Protect Chrome debugging from other local OS accounts

This commit is contained in:
Ian Gulliver
2026-10-20 23:32:07 -07:00
parent 73689f0f2d
commit 4f88f8a4f4
6 changed files with 358 additions and 79 deletions
+104 -76
View File
@@ -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)]
}