Protect Chrome debugging from other local OS accounts
This commit is contained in:
@@ -115,6 +115,7 @@ Task agents use the Macs the human has byre's app open on.
|
||||
- **Display sleep.** While the display sleeps, ScreenCaptureKit lists no displays. Every call holds a `PreventUserIdleDisplaySleep` assertion and, when the display is asleep, declares user activity and waits for ScreenCaptureKit to list it again. A declared activity doesn't reset the keyboard and mouse idle time, so the display sleeps again after powerd's grace period (`com.apple.powermanagement.delayDisplayOff` in `pmset -g assertions`), not after the full display-sleep delay. `pmset -g log` shows each wake as `Display is turned on` beside `byre-hands` assertions.
|
||||
- **Orion.** Orion ignores key events that carry only a Unicode string, so typing posts each ASCII character as its key code. Some apps list `confirm` but ignore it, Orion's address bar among them; send Return instead. `do JavaScript` needs Orion's Develop → "Allow JavaScript from Apple Events".
|
||||
- **Chrome.** Chrome refuses remote debugging on its default profile, so byre's Chrome is its own profile (`~/Library/Application Support/byre/chrome`), a separate Chrome beside any the human runs, and it keeps its logins: the human signs in there once. Chrome comes from `brew install --cask google-chrome`; macOS's "downloaded from the Internet" prompt on its first launch must be answered Open (or `osascript -e 'tell application "System Events" to tell process "CoreServicesUIAgent" to click button "Open" of window 1'`), and until it is, Chrome runs without opening its debugging port.
|
||||
- **Chrome's trust boundary.** Other OS accounts on the same Mac are not trusted with byre's logged-in browser. Chrome uses inherited debugging pipes, shared by agents through an owner-only Unix socket; never enable a debugging TCP port or fall back to one. After upgrading from the TCP transport, quit the old byre-profile Chrome once before using the tool; an already-running browser cannot acquire the new pipes. The saved profile and logins stay in place.
|
||||
- **Permissions.** Agents' processes run as byre's descendants, so macOS privacy controls (TCC) hold byre responsible for them: the Screen Recording, Accessibility and Automation grants in System Settings → Privacy & Security are byre's, made by clicking at the Mac's screen, and each app scripted for the first time asks for its own Automation grant. Without Accessibility an untrusted process just sees no windows and no menu bar. Accessibility can list byre switched off; only its switch being on counts. TCC keys grants on the signing identifier and team, and every build is signed with Developer ID, so grants carry over from build to build. On a client Mac the grants belong to byre's app; `NSAppleEventsUsageDescription` in `mac/Info.plist` is what lets macOS ask for the Automation grant. `byre-hands check` prints the grants of the process that runs it; the Server and Settings tabs run it.
|
||||
|
||||
## Terminals
|
||||
|
||||
@@ -2,7 +2,6 @@ import AppKit
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
let chromePort = 9222
|
||||
let chromeProfile = FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/Application Support/byre/chrome").path
|
||||
|
||||
final class Req {
|
||||
@@ -63,7 +62,16 @@ 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
|
||||
@@ -99,50 +107,59 @@ final class TabState {
|
||||
}
|
||||
|
||||
final class Chrome: @unchecked Sendable {
|
||||
let ws: URLSessionWebSocketTask
|
||||
let input: FileHandle
|
||||
let output: FileHandle
|
||||
let writes = DispatchQueue(label: "run.fc.byre.chrome-pipe")
|
||||
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(_ 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
|
||||
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")
|
||||
}
|
||||
}
|
||||
receive()
|
||||
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)
|
||||
}
|
||||
}
|
||||
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: \(e.localizedDescription)"))
|
||||
c.resume(throwing: ToolError("lost the connection to Chrome: \(message)"))
|
||||
}
|
||||
writes.async { [self] in
|
||||
try? input.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,9 +192,14 @@ final class Chrome: @unchecked Sendable {
|
||||
c.resume(throwing: ToolError("lost the connection to Chrome"))
|
||||
return
|
||||
}
|
||||
ws.send(.string(String(decoding: data, as: UTF8.self))) { [self] err in
|
||||
if let err {
|
||||
resolve(id, .failure(err))
|
||||
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)
|
||||
}
|
||||
}
|
||||
Task {
|
||||
@@ -203,7 +225,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) {
|
||||
tabs.removeValue(forKey: t.target)
|
||||
t.context?.tabs.removeValue(forKey: t.target)
|
||||
return
|
||||
}
|
||||
guard let s = m.string("sessionId"), let t = sessions[s] else {
|
||||
@@ -298,42 +320,43 @@ final class Chrome: @unchecked Sendable {
|
||||
return (r["targetInfos"] as? [[String: Any]] ?? []).filter { $0.string("type") == "page" }
|
||||
}
|
||||
|
||||
func tab(_ id: String?) async throws -> (String, TabState) {
|
||||
func tab(_ id: String?, context: ChromeContext) async throws -> (String, TabState) {
|
||||
var target = id
|
||||
if target == nil {
|
||||
let open = try await pages().compactMap { $0.string("targetId") }
|
||||
target = lock.withLock { current }.flatMap { open.contains($0) ? $0 : nil } ?? open.first
|
||||
target = lock.withLock { context.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 {
|
||||
current = target
|
||||
if let a = tabs[target] {
|
||||
context.current = target
|
||||
if let a = context.tabs[target] {
|
||||
return a
|
||||
}
|
||||
let a = Task { try await attach(target) }
|
||||
tabs[target] = a
|
||||
let a = Task { try await attach(target, context: context) }
|
||||
context.tabs[target] = a
|
||||
return a
|
||||
}
|
||||
do {
|
||||
return (target, try await task.value)
|
||||
} catch {
|
||||
lock.withLock {
|
||||
if tabs[target] == task {
|
||||
tabs.removeValue(forKey: target)
|
||||
if context.tabs[target] == task {
|
||||
context.tabs.removeValue(forKey: target)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func attach(_ target: String) async throws -> TabState {
|
||||
func attach(_ target: String, context: ChromeContext) 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)
|
||||
@@ -367,49 +390,54 @@ func consoleLine(_ level: String, _ text: String, _ url: String?, _ line: Int?)
|
||||
return "\(level) \(text) (\(url):\((line ?? 0) + 1))"
|
||||
}
|
||||
|
||||
func chromeVersion() async -> [String: Any]? {
|
||||
var req = URLRequest(url: URL(string: "http://127.0.0.1:\(chromePort)/json/version")!)
|
||||
req.timeoutInterval = 2
|
||||
guard let (d, _) = try? await URLSession.shared.data(for: req) else {
|
||||
return nil
|
||||
}
|
||||
return try? JSONSerialization.jsonObject(with: d) as? [String: Any]
|
||||
}
|
||||
|
||||
func openChrome() async throws -> (Chrome, String) {
|
||||
var note = ""
|
||||
var info = await chromeVersion()
|
||||
if info == nil {
|
||||
let lock = try await lockInput()
|
||||
defer {
|
||||
close(lock)
|
||||
}
|
||||
let p = Process()
|
||||
p.executableURL = URL(filePath: "/usr/bin/open")
|
||||
p.arguments = ["-na", "Google Chrome", "--args", "--user-data-dir=\(chromeProfile)", "--remote-debugging-port=\(chromePort)", "--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?")
|
||||
}
|
||||
let start = Date()
|
||||
while info == nil, Date().timeIntervalSince(start) < 15 {
|
||||
await pause(200)
|
||||
info = await chromeVersion()
|
||||
}
|
||||
note = "started Chrome with byre's profile\n"
|
||||
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")
|
||||
}
|
||||
guard let ws = info?.string("webSocketDebuggerUrl").flatMap(URL.init(string:)) else {
|
||||
throw ToolError("Chrome's debugging port \(chromePort) didn't answer within 15s")
|
||||
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)")
|
||||
}
|
||||
return (Chrome(ws), note)
|
||||
// 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.")
|
||||
}
|
||||
return (chrome, "started Chrome with byre's profile\n")
|
||||
}
|
||||
|
||||
actor ChromeLink {
|
||||
var conn: Task<(Chrome, String), Error>?
|
||||
|
||||
func get() async throws -> (Chrome, String) {
|
||||
if let conn, let (c, _) = try? await conn.value, !c.lock.withLock({ c.closed }) {
|
||||
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")
|
||||
}
|
||||
return (c, "")
|
||||
}
|
||||
let t = Task { try await openChrome() }
|
||||
@@ -632,7 +660,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]) async throws -> [Content] {
|
||||
func chromeTool(_ args: [String: Any], saveDir: String?, context: ChromeContext) 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 {
|
||||
@@ -662,7 +690,7 @@ func chromeTool(_ args: [String: Any]) async throws -> [Content] {
|
||||
let r = try await c.send("Target.createTarget", ["url": "about:blank"])
|
||||
tabId = r.string("targetId")
|
||||
}
|
||||
let (target, t) = try await c.tab(tabId)
|
||||
let (target, t) = try await c.tab(tabId, context: context)
|
||||
switch op {
|
||||
case "tabs":
|
||||
break
|
||||
@@ -729,7 +757,7 @@ func chromeTool(_ args: [String: Any]) async throws -> [Content] {
|
||||
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))")
|
||||
log.append("saved to \(try save(png, in: saveDir))")
|
||||
}
|
||||
return [.text(log.joined(separator: "\n")), .png(png)]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
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) throws -> String {
|
||||
guard let dir = mediaDir else {
|
||||
func save(_ png: Data, in directory: String? = mediaDir) throws -> String {
|
||||
guard let dir = directory 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,6 +49,19 @@ 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: chromeTool
|
||||
run: sharedChromeTool
|
||||
),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user