Restore Chrome loopback debugging and document accepted local risks
This commit is contained in:
@@ -62,16 +62,7 @@ final class Chain {
|
||||
}
|
||||
}
|
||||
|
||||
final class ChromeContext {
|
||||
let pid: pid_t
|
||||
var tabs: [String: Task<TabState, Error>] = [:]
|
||||
var current: String?
|
||||
|
||||
init(pid: pid_t) { self.pid = pid }
|
||||
}
|
||||
|
||||
final class TabState {
|
||||
weak var context: ChromeContext?
|
||||
let session: String
|
||||
let target: String
|
||||
var seq = 0
|
||||
@@ -107,59 +98,50 @@ final class TabState {
|
||||
}
|
||||
|
||||
final class Chrome: @unchecked Sendable {
|
||||
let input: FileHandle
|
||||
let output: FileHandle
|
||||
let writes = DispatchQueue(label: "run.fc.byre.chrome-pipe")
|
||||
let ws: URLSessionWebSocketTask
|
||||
let lock = NSLock()
|
||||
var next = 0
|
||||
var pending: [Int: CheckedContinuation<[String: Any], Error>] = [:]
|
||||
var tabs: [String: Task<TabState, Error>] = [:]
|
||||
var sessions: [String: TabState] = [:]
|
||||
var current: String?
|
||||
var closed = false
|
||||
|
||||
init(input: FileHandle, output: FileHandle, pid: pid_t) {
|
||||
self.input = input
|
||||
self.output = output
|
||||
Thread.detachNewThread { [self] in
|
||||
var buffer = Data()
|
||||
do {
|
||||
while let chunk = try readChromeChunk(output), !chunk.isEmpty {
|
||||
buffer.append(chunk)
|
||||
while let end = buffer.firstIndex(of: 0) {
|
||||
dispatch(Data(buffer[..<end]))
|
||||
buffer.removeSubrange(...end)
|
||||
}
|
||||
guard buffer.count <= 256 << 20 else {
|
||||
throw ToolError("Chrome's message is too large")
|
||||
}
|
||||
init(_ url: URL) {
|
||||
ws = URLSession.shared.webSocketTask(with: url)
|
||||
ws.maximumMessageSize = 1 << 28
|
||||
ws.resume()
|
||||
receive()
|
||||
}
|
||||
|
||||
func receive() {
|
||||
ws.receive { [self] result in
|
||||
switch result {
|
||||
case .failure(let e):
|
||||
fail(e)
|
||||
case .success(let msg):
|
||||
switch msg {
|
||||
case .string(let s):
|
||||
dispatch(Data(s.utf8))
|
||||
case .data(let d):
|
||||
dispatch(d)
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
fail(ToolError("Chrome closed its debugging pipe; if byre's Chrome was already open before this update, quit that browser once and retry"))
|
||||
} catch {
|
||||
fail(error)
|
||||
receive()
|
||||
}
|
||||
}
|
||||
Thread.detachNewThread {
|
||||
var status: Int32 = 0
|
||||
while waitpid(pid, &status, 0) < 0, errno == EINTR {}
|
||||
// Finish replies already in flight, then retire the browser owner.
|
||||
// The next call launches the current hands binary, including updates.
|
||||
chromeBrokerCalls.notify(queue: .global()) { exit(0) }
|
||||
}
|
||||
}
|
||||
|
||||
func fail(_ e: Error) {
|
||||
let waiting = lock.withLock {
|
||||
if closed { return [Int: CheckedContinuation<[String: Any], Error>]() }
|
||||
closed = true
|
||||
let p = pending
|
||||
pending = [:]
|
||||
return p
|
||||
}
|
||||
let message = (e as? ToolError)?.message ?? e.localizedDescription
|
||||
for c in waiting.values {
|
||||
c.resume(throwing: ToolError("lost the connection to Chrome: \(message)"))
|
||||
}
|
||||
writes.async { [self] in
|
||||
try? input.close()
|
||||
c.resume(throwing: ToolError("lost the connection to Chrome: \(e.localizedDescription)"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,14 +174,9 @@ final class Chrome: @unchecked Sendable {
|
||||
c.resume(throwing: ToolError("lost the connection to Chrome"))
|
||||
return
|
||||
}
|
||||
writes.async { [self] in
|
||||
guard !lock.withLock({ closed }) else { return }
|
||||
do {
|
||||
var frame = data
|
||||
frame.append(0)
|
||||
try input.write(contentsOf: frame)
|
||||
} catch {
|
||||
fail(error)
|
||||
ws.send(.string(String(decoding: data, as: UTF8.self))) { [self] err in
|
||||
if let err {
|
||||
resolve(id, .failure(err))
|
||||
}
|
||||
}
|
||||
Task {
|
||||
@@ -225,7 +202,7 @@ final class Chrome: @unchecked Sendable {
|
||||
let p = m.dict("params") ?? [:]
|
||||
lock.withLock {
|
||||
if method == "Target.detachedFromTarget", let s = p.string("sessionId"), let t = sessions.removeValue(forKey: s) {
|
||||
t.context?.tabs.removeValue(forKey: t.target)
|
||||
tabs.removeValue(forKey: t.target)
|
||||
return
|
||||
}
|
||||
guard let s = m.string("sessionId"), let t = sessions[s] else {
|
||||
@@ -320,43 +297,42 @@ final class Chrome: @unchecked Sendable {
|
||||
return (r["targetInfos"] as? [[String: Any]] ?? []).filter { $0.string("type") == "page" }
|
||||
}
|
||||
|
||||
func tab(_ id: String?, context: ChromeContext) async throws -> (String, TabState) {
|
||||
func tab(_ id: String?) async throws -> (String, TabState) {
|
||||
var target = id
|
||||
if target == nil {
|
||||
let open = try await pages().compactMap { $0.string("targetId") }
|
||||
target = lock.withLock { context.current }.flatMap { open.contains($0) ? $0 : nil } ?? open.first
|
||||
target = lock.withLock { current }.flatMap { open.contains($0) ? $0 : nil } ?? open.first
|
||||
}
|
||||
guard let target else {
|
||||
throw ToolError("Chrome has no tabs; navigate with new_tab to open one")
|
||||
}
|
||||
let task = lock.withLock {
|
||||
context.current = target
|
||||
if let a = context.tabs[target] {
|
||||
current = target
|
||||
if let a = tabs[target] {
|
||||
return a
|
||||
}
|
||||
let a = Task { try await attach(target, context: context) }
|
||||
context.tabs[target] = a
|
||||
let a = Task { try await attach(target) }
|
||||
tabs[target] = a
|
||||
return a
|
||||
}
|
||||
do {
|
||||
return (target, try await task.value)
|
||||
} catch {
|
||||
lock.withLock {
|
||||
if context.tabs[target] == task {
|
||||
context.tabs.removeValue(forKey: target)
|
||||
if tabs[target] == task {
|
||||
tabs.removeValue(forKey: target)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func attach(_ target: String, context: ChromeContext) async throws -> TabState {
|
||||
func attach(_ target: String) async throws -> TabState {
|
||||
let r = try await send("Target.attachToTarget", ["targetId": target, "flatten": true])
|
||||
guard let s = r.string("sessionId") else {
|
||||
throw ToolError("Chrome attached to \(target) without a session")
|
||||
}
|
||||
let t = TabState(session: s, target: target)
|
||||
t.context = context
|
||||
lock.withLock { sessions[s] = t }
|
||||
for m in ["Page.enable", "Network.enable", "Runtime.enable", "Log.enable"] {
|
||||
_ = try await send(m, session: s)
|
||||
@@ -390,54 +366,63 @@ func consoleLine(_ level: String, _ text: String, _ url: String?, _ line: Int?)
|
||||
return "\(level) \(text) (\(url):\((line ?? 0) + 1))"
|
||||
}
|
||||
|
||||
// Chrome writes both its ephemeral port and browser-specific WebSocket path
|
||||
// into this profile. A live port alone does not identify our browser.
|
||||
func chromeFromProfile() async -> Chrome? {
|
||||
let activePort = URL(filePath: chromeProfile).appending(path: "DevToolsActivePort")
|
||||
guard let text = try? String(contentsOf: activePort, encoding: .utf8) else { return nil }
|
||||
let lines = text.split(whereSeparator: \.isNewline)
|
||||
guard lines.count == 2, let port = Int(lines[0]), (1...65535).contains(port),
|
||||
lines[1].hasPrefix("/devtools/browser/") else { return nil }
|
||||
var endpoint = URLComponents()
|
||||
endpoint.scheme = "ws"
|
||||
endpoint.host = "127.0.0.1"
|
||||
endpoint.port = port
|
||||
endpoint.path = String(lines[1])
|
||||
guard let url = endpoint.url else { return nil }
|
||||
let chrome = Chrome(url)
|
||||
do {
|
||||
_ = try await chrome.send("Browser.getVersion", timeout: 2)
|
||||
return chrome
|
||||
} catch {
|
||||
chrome.ws.cancel(with: .goingAway, reason: nil)
|
||||
chrome.fail(error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func openChrome() async throws -> (Chrome, String) {
|
||||
if let chrome = await chromeFromProfile() { return (chrome, "") }
|
||||
let lock = try await lockInput()
|
||||
defer { close(lock) }
|
||||
guard let app = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.google.Chrome"),
|
||||
let executable = Bundle(url: app)?.executablePath else {
|
||||
throw ToolError("Google Chrome is not installed on this Mac")
|
||||
}
|
||||
// Another helper may have started Chrome while this one waited.
|
||||
if let chrome = await chromeFromProfile() { return (chrome, "") }
|
||||
let fm = FileManager.default
|
||||
try fm.createDirectory(atPath: chromeProfile, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
try fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: chromeProfile)
|
||||
let commands = Pipe()
|
||||
let replies = Pipe()
|
||||
guard fcntl(commands.fileHandleForWriting.fileDescriptor, F_SETNOSIGPIPE, 1) == 0 else {
|
||||
throw ToolError("configure Chrome command pipe: errno \(errno)")
|
||||
let p = Process()
|
||||
p.executableURL = URL(filePath: "/usr/bin/open")
|
||||
p.arguments = ["-na", "Google Chrome", "--args", "--user-data-dir=\(chromeProfile)", "--remote-debugging-port=0", "--no-first-run", "--no-default-browser-check"]
|
||||
try p.run()
|
||||
p.waitUntilExit()
|
||||
if p.terminationStatus != 0 {
|
||||
throw ToolError("could not open Google Chrome (open exited \(p.terminationStatus)); is it installed on this Mac?")
|
||||
}
|
||||
// Keep sources out of Chrome's fd 3/4 destinations during posix_spawn.
|
||||
let readFD = fcntl(commands.fileHandleForReading.fileDescriptor, F_DUPFD_CLOEXEC, 10)
|
||||
guard readFD >= 0 else { throw ToolError("create Chrome command pipe: errno \(errno)") }
|
||||
defer { close(readFD) }
|
||||
let writeFD = fcntl(replies.fileHandleForWriting.fileDescriptor, F_DUPFD_CLOEXEC, 10)
|
||||
guard writeFD >= 0 else { throw ToolError("create Chrome reply pipe: errno \(errno)") }
|
||||
defer { close(writeFD) }
|
||||
let pid = try spawnChromeProcess(executable, ["--user-data-dir=\(chromeProfile)", "--remote-debugging-pipe", "--no-first-run", "--no-default-browser-check"], pipes: (readFD, writeFD))
|
||||
try commands.fileHandleForReading.close()
|
||||
try replies.fileHandleForWriting.close()
|
||||
let chrome = Chrome(input: commands.fileHandleForWriting, output: replies.fileHandleForReading, pid: pid)
|
||||
// Prove the private connection works; never discover or fall back to a TCP debugger.
|
||||
do {
|
||||
_ = try await chrome.send("Browser.getVersion", timeout: 15)
|
||||
} catch {
|
||||
// Only the newly launched process group belongs to this attempt. An
|
||||
// existing browser using this profile must be quit by the human.
|
||||
kill(-pid, SIGTERM)
|
||||
chrome.fail(error)
|
||||
let message = (error as? ToolError)?.message ?? error.localizedDescription
|
||||
throw ToolError("Chrome did not open its private debugging pipe: \(message). If byre's Chrome was already running, quit that browser once and retry.")
|
||||
let start = Date()
|
||||
while Date().timeIntervalSince(start) < 15 {
|
||||
if let chrome = await chromeFromProfile() {
|
||||
return (chrome, "started Chrome with byre's profile\n")
|
||||
}
|
||||
await pause(200)
|
||||
}
|
||||
return (chrome, "started Chrome with byre's profile\n")
|
||||
throw ToolError("Chrome's debugger for byre's profile didn't answer within 15s; quit any already-running byre-profile Chrome once and retry")
|
||||
}
|
||||
|
||||
actor ChromeLink {
|
||||
var conn: Task<(Chrome, String), Error>?
|
||||
|
||||
func get() async throws -> (Chrome, String) {
|
||||
if let conn, let (c, _) = try? await conn.value {
|
||||
guard !c.lock.withLock({ c.closed }) else {
|
||||
throw ToolError("Chrome closed its debugging pipe; retry the call after quitting any old byre-profile Chrome browser")
|
||||
}
|
||||
if let conn, let (c, _) = try? await conn.value, !c.lock.withLock({ c.closed }) {
|
||||
return (c, "")
|
||||
}
|
||||
let t = Task { try await openChrome() }
|
||||
@@ -660,7 +645,7 @@ func shrink(_ png: Data, _ maxEdge: Double) throws -> (Data, String) {
|
||||
return (data as Data, "page viewport, image \(img.width)x\(img.height) scaled from \(full.width)x\(full.height)")
|
||||
}
|
||||
|
||||
func chromeTool(_ args: [String: Any], saveDir: String?, context: ChromeContext) async throws -> [Content] {
|
||||
func chromeTool(_ args: [String: Any]) async throws -> [Content] {
|
||||
let op = try args.require("op", args.string)
|
||||
let maxEdge = args.double("max_edge") ?? 1568
|
||||
if !maxEdge.isFinite || maxEdge < 16 || maxEdge > 8192 {
|
||||
@@ -690,7 +675,7 @@ func chromeTool(_ args: [String: Any], saveDir: String?, context: ChromeContext)
|
||||
let r = try await c.send("Target.createTarget", ["url": "about:blank"])
|
||||
tabId = r.string("targetId")
|
||||
}
|
||||
let (target, t) = try await c.tab(tabId, context: context)
|
||||
let (target, t) = try await c.tab(tabId)
|
||||
switch op {
|
||||
case "tabs":
|
||||
break
|
||||
@@ -757,7 +742,7 @@ func chromeTool(_ args: [String: Any], saveDir: String?, context: ChromeContext)
|
||||
let (png, shotNote) = try shrink(Data(base64Encoded: shot.string("data") ?? "") ?? Data(), maxEdge)
|
||||
log.append(shotNote)
|
||||
if args.bool("save") {
|
||||
log.append("saved to \(try save(png, in: saveDir))")
|
||||
log.append("saved to \(try save(png))")
|
||||
}
|
||||
return [.text(log.joined(separator: "\n")), .png(png)]
|
||||
}
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,8 +142,8 @@ func captureTool(_ args: [String: Any]) async throws -> [Content] {
|
||||
return [.text("\(shot.note); saved to \(try save(shot.png))"), .png(shot.png)]
|
||||
}
|
||||
|
||||
func save(_ png: Data, in directory: String? = mediaDir) throws -> String {
|
||||
guard let dir = directory else {
|
||||
func save(_ png: Data) throws -> String {
|
||||
guard let dir = mediaDir else {
|
||||
throw ToolError("save needs byre's media folder, and this byre-hands has none")
|
||||
}
|
||||
let url = URL(filePath: dir).appending(path: "capture-\(UUID().uuidString.prefix(8).lowercased()).png")
|
||||
|
||||
@@ -49,19 +49,6 @@ struct Server {
|
||||
runCheck()
|
||||
RunLoop.main.run()
|
||||
}
|
||||
if CommandLine.arguments.dropFirst().first == "chrome-broker" {
|
||||
Thread.detachNewThread {
|
||||
do {
|
||||
try serveChromeBroker()
|
||||
exit(0)
|
||||
} catch {
|
||||
FileHandle.standardError.write(Data("Chrome helper: \(error)\n".utf8))
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
RunLoop.main.run()
|
||||
return
|
||||
}
|
||||
Thread.detachNewThread {
|
||||
let inFlight = DispatchGroup()
|
||||
while let line = readLine() {
|
||||
|
||||
@@ -127,6 +127,6 @@ let tools: [Tool] = [
|
||||
"save": {"type": "boolean", "description": "With capture, also save the screenshot as a PNG in the task's media folder; the reply gives its path, which a Markdown image in your final message can show."}
|
||||
}, "required": ["op"]}
|
||||
""",
|
||||
run: sharedChromeTool
|
||||
run: chromeTool
|
||||
),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user