Restore Chrome loopback debugging and document accepted local risks

This commit is contained in:
Ian Gulliver
2026-10-21 08:44:03 -07:00
parent 8126c7b4af
commit f8e67836c3
7 changed files with 92 additions and 353 deletions
+84 -99
View File
@@ -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)]
}
-237
View File
@@ -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)
}
}
}
}
+2 -2
View File
@@ -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")
-13
View File
@@ -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() {
+1 -1
View File
@@ -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
),
]