Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,8 +501,12 @@ The sandbox has no path to any browser, so browser control is served **from this
LaunchAgent from [`poc/pw-mcp`](poc/pw-mcp/README.md) is installed) the daemon re-serves
Playwright's tools under its own `tools/list`, filtered by the connection's profile:

- `owner` sees all 32 `browser_*` tools, and any that Playwright adds later; `observe` sees none
(#59). The browser is this computer's persistent profile, with its cookies, so browser access is
- `owner` sees the 32 `browser_*` tools the catalog lists; `observe` sees none (#59). On macOS the
tools go through a router ([ADR](docs/adr/browser-tiers.md)); a tool Playwright adds later is
filtered out until the catalog, the vector file and the tests are updated together. Port 8794 is
loopback, not private: any process on this Mac can reach it, and the daemon uses it only when
you pass `--upstream browser=…` (literal `127.0.0.1` or `::1`, no redirects) and the listener
runs as your user. The browser is this computer's persistent profile, with its cookies, so browser access is
owner-level: `browser_evaluate` and `browser_run_code_unsafe` run arbitrary JavaScript there.
- `browser_navigate` + `browser_snapshot` returns the page as an accessibility tree — the first
video title on a channel page is one text line, no screenshot, no OCR. The browser is a real
Expand Down
68 changes: 68 additions & 0 deletions Sources/InstanceMCPCore/Browser/BrowserBackend.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import Foundation

public enum BrowserBackendID: String, Sendable, Equatable {
case appleEvents
case playwright
}

/// `needsSetup` and `unavailable` carry a fixed class string, never upstream or page text.
public enum BrowserBackendStatus: Equatable, Sendable {
case ready
case needsSetup(String)
case unavailable(String)

public var isReady: Bool { if case .ready = self { return true }; return false }
}

/// A backend failure with a fixed class. `setupLost` means the backend stopped being usable;
/// anything else fails that call only (R2.5).
public struct BrowserBackendError: Error, Equatable, Sendable {
public let errorClass: String
public let setupLost: Bool
public init(_ errorClass: String, setupLost: Bool = false) {
self.errorClass = errorClass; self.setupLost = setupLost
}
}

/// What serves the `browser_*` tools. Implemented by the Playwright tier now and by the Apple
/// Events tier in Phase 2; the CDP tier stays reserved and is never selected by `auto` (R2.7).
public protocol BrowserBackend: Sendable {
var id: BrowserBackendID { get }
/// Cheap. Never prompts and never starts anything.
func status() async -> BrowserBackendStatus
/// Descriptors of the tools this backend serves right now.
func tools() async -> [JSONValue]
/// A `tools/call` result, relayed as the backend produced it.
func call(_ name: String, arguments: JSONValue) async throws -> JSONValue
}

public enum BrowserPreference: String, Sendable, CaseIterable {
case auto, appleEvents, playwright
}

public protocol BrowserPreferenceStore: Sendable {
func load() -> BrowserPreference
func save(_ p: BrowserPreference)
}

/// `UserDefaults` key `browserTools.backend.v1` (R5.10). An unknown stored value resets to
/// `auto` with one log line naming the key.
public struct UserDefaultsBrowserPreferenceStore: BrowserPreferenceStore, @unchecked Sendable {
public static let key = "browserTools.backend.v1"
private let defaults: UserDefaults
private let log: @Sendable (String) -> Void

public init(defaults: UserDefaults = .standard, log: @escaping @Sendable (String) -> Void = { _ in }) {
self.defaults = defaults; self.log = log
}

public func load() -> BrowserPreference {
guard let raw = defaults.string(forKey: Self.key) else { return .auto }
if let p = BrowserPreference(rawValue: raw) { return p }
log("\(Self.key): unknown value, reset to auto")
defaults.removeObject(forKey: Self.key)
return .auto
}

public func save(_ p: BrowserPreference) { defaults.set(p.rawValue, forKey: Self.key) }
}
116 changes: 116 additions & 0 deletions Sources/InstanceMCPCore/Browser/BrowserRouter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import Foundation

/// Per-request connection context: a key that identifies the connection (MCP session id, or a
/// fixed key on the attach paths) and the optional `OAB-Browser-Backend` header override.
public struct BrowserConnection: Sendable, Equatable {
public var key: String
public var override: BrowserPreference?
public init(key: String, override: BrowserPreference? = nil) { self.key = key; self.override = override }
}

/// Serves the `browser_*` tools from one active backend per connection (requirements R2).
/// Phase 1 fills only the Playwright slot; the Apple Events slot is left open.
public actor BrowserRouter {
public static let guardWindow: TimeInterval = 30 * 60
public static let notSetUp = "Browser tools are not set up. Open OpenAB Computer, then Browser tools."

private let appleEvents: (any BrowserBackend)?
private let playwright: (any BrowserBackend)?
private let store: any BrowserPreferenceStore
private let clock: @Sendable () -> Date
private let log: @Sendable (String) -> Void
private var last: [String: (id: BrowserBackendID, at: Date)] = [:]

public init(appleEvents: (any BrowserBackend)? = nil, playwright: (any BrowserBackend)? = nil,
store: any BrowserPreferenceStore, clock: @escaping @Sendable () -> Date = { Date() },
log: @escaping @Sendable (String) -> Void = { _ in }) {
self.appleEvents = appleEvents; self.playwright = playwright
self.store = store; self.clock = clock; self.log = log
}

enum Selection {
case active(any BrowserBackend)
/// Auto mode, inside the guard window: the last backend is gone and the other one is
/// ready but is not used by itself.
case guarded(other: BrowserBackendID)
case none
}

private func backend(_ id: BrowserBackendID) -> (any BrowserBackend)? {
id == .appleEvents ? appleEvents : playwright
}

func select(_ conn: BrowserConnection) async -> Selection {
let pref = conn.override ?? store.load()
let aReady = await appleEvents?.status().isReady ?? false
let pReady = await playwright?.status().isReady ?? false
switch pref {
case .appleEvents: return aReady ? .active(appleEvents!) : .none
case .playwright: return pReady ? .active(playwright!) : .none
case .auto:
if let l = last[conn.key], clock().timeIntervalSince(l.at) < Self.guardWindow {
let lastReady = l.id == .appleEvents ? aReady : pReady
if lastReady, let b = backend(l.id) { return .active(b) }
let other: BrowserBackendID = l.id == .appleEvents ? .playwright : .appleEvents
if (other == .appleEvents ? aReady : pReady) { return .guarded(other: other) }
return .none
}
if pReady { return .active(playwright!) }
if aReady { return .active(appleEvents!) }
return .none
}
}

/// The catalog names the active backend serves now. Empty with no ready backend.
public func tools(_ conn: BrowserConnection) async -> [JSONValue] {
guard case .active(let b) = await select(conn) else { return [] }
return await b.tools().filter {
guard let n = $0["name"]?.stringValue else { return false }
return BrowserToolCatalog.allNameSet.contains(n)
}
}

public func call(_ name: String, arguments: JSONValue, _ conn: BrowserConnection) async -> JSONValue {
let backend: any BrowserBackend
switch await select(conn) {
case .none: return Self.errorResult(Self.notSetUp, backend: nil)
case .guarded(let other):
return Self.errorResult("The browser backend used earlier in this conversation is no longer "
+ "ready. The \(other.rawValue) backend is ready but is not used automatically in the "
+ "middle of a task. Select it in OpenAB Computer, Browser tools, or send the "
+ "OAB-Browser-Backend header.", backend: nil)
case .active(let b): backend = b
}
let served = await backend.tools().compactMap { $0["name"]?.stringValue }
guard served.contains(name) else {
return Self.errorResult("\(name) is not available with the \(backend.id.rawValue) backend. "
+ "Install Playwright or select it in OpenAB Computer, Browser tools.", backend: backend.id)
}
last[conn.key] = (backend.id, clock())
do {
let r = try await backend.call(name, arguments: arguments)
return Self.tag(r, backend: backend.id)
} catch let e as BrowserBackendError {
if e.setupLost { log("browser backend \(backend.id.rawValue): setup lost (\(e.errorClass))") }
return Self.errorResult("Browser call failed: \(e.errorClass)", backend: backend.id)
} catch {
return Self.errorResult("Browser call failed: backend_error", backend: backend.id)
}
}

// MARK: result shaping

static func errorResult(_ text: String, backend: BrowserBackendID?) -> JSONValue {
tag(["content": [["type": "text", "text": .string(text)]], "isError": true], backend: backend)
}

/// Adds `structuredContent.backend`, merging into an existing object and keeping every
/// content block.
static func tag(_ result: JSONValue, backend: BrowserBackendID?) -> JSONValue {
guard let backend, var o = result.objectValue else { return result }
var sc = result["structuredContent"]?.objectValue ?? [:]
sc["backend"] = .string(backend.rawValue)
o["structuredContent"] = .object(sc)
return .object(o)
}
}
30 changes: 30 additions & 0 deletions Sources/InstanceMCPCore/Browser/BrowserToolCatalog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Foundation

/// The 32 real `browser_*` tool names and, for Tier A (Apple Events), how well each is served
/// (requirements R3). One source for the router filter, the Phase 2 backend and the tests.
/// A name an upstream adds later is not in this list and is filtered out on macOS (R1.5).
public enum BrowserToolCatalog {
public enum Support: String, Sendable { case full, limited, no }

/// Tier A support, in the order of the requirements table.
public static let tierA: [(name: String, support: Support)] = [
("browser_navigate", .full), ("browser_navigate_back", .full), ("browser_snapshot", .limited),
("browser_find", .limited), ("browser_click", .limited), ("browser_type", .limited),
("browser_fill_form", .full), ("browser_press_key", .limited), ("browser_hover", .limited),
("browser_select_option", .full), ("browser_wait_for", .full), ("browser_tabs", .limited),
("browser_take_screenshot", .no), ("browser_console_messages", .no), ("browser_resize", .limited),
("browser_evaluate", .limited), ("browser_run_code_unsafe", .no), ("browser_file_upload", .no),
("browser_drop", .no), ("browser_pdf_save", .no), ("browser_network_requests", .no),
("browser_network_request", .no), ("browser_handle_dialog", .no), ("browser_emulate_media", .no),
("browser_close", .full), ("browser_drag", .no), ("browser_mouse_move_xy", .no),
("browser_mouse_click_xy", .no), ("browser_mouse_drag_xy", .no), ("browser_mouse_down", .no),
("browser_mouse_up", .no), ("browser_mouse_wheel", .limited),
]

public static let allNames: [String] = tierA.map(\.name)
public static let allNameSet: Set<String> = Set(allNames)

public static func tierASupport(_ name: String) -> Support? {
tierA.first { $0.name == name }?.support
}
}
17 changes: 17 additions & 0 deletions Sources/InstanceMCPCore/Browser/BrowserUpstreamURL.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation

/// The only URL shape an external Playwright may have: `http` and a literal loopback address.
/// No name resolution, so `localhost` and DNS names are refused (R7.5.2).
public enum BrowserUpstreamURL {
public struct Invalid: Error, CustomStringConvertible, Equatable {
public let description: String
}

public static func validate(_ url: URL) throws {
guard url.scheme?.lowercased() == "http" else { throw Invalid(description: "browser upstream must use http") }
let host = url.host ?? ""
guard host == "127.0.0.1" || host == "::1" else {
throw Invalid(description: "browser upstream host must be the literal 127.0.0.1 or ::1")
}
}
}
121 changes: 121 additions & 0 deletions Sources/InstanceMCPCore/Browser/ExternalPlaywrightClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import Foundation

/// A failure from the external Playwright path. `errorClass` is one of the fixed strings of
/// R7.5.4 and never carries upstream-supplied text.
public struct ExternalPlaywrightError: Error, Equatable, Sendable, CustomStringConvertible {
public let errorClass: String
public var description: String { errorClass }
}

/// What the Playwright backend needs from a transport. Phase 1 has the external HTTP client;
/// the managed stdio client arrives next.
public protocol PlaywrightTransport: Sendable {
func healthCheck() async throws
func listTools() async throws -> [JSONValue]
func callTool(_ name: String, arguments: JSONValue) async throws -> JSONValue
}

private final class RefuseRedirects: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil) // deliver the 3xx as the response; no second request
}
}

/// Streamable-HTTP client for an operator-supplied loopback Playwright MCP (R7.5.3, R7.5.4).
/// Unlike `UpstreamMCP` it follows no redirects and turns every failure into a fixed class.
/// Successful tool results are returned verbatim.
public actor ExternalPlaywrightClient: PlaywrightTransport {
public let url: URL
private let session: URLSession
private var sessionID: String?

public init(url: URL, timeout: TimeInterval = 90) {
self.url = url
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = timeout
self.session = URLSession(configuration: cfg, delegate: RefuseRedirects(), delegateQueue: nil)
}

public func healthCheck() async throws { try await initialize() }

public func listTools() async throws -> [JSONValue] {
let r = try await rpc("tools/list", params: [:])
return r["tools"]?.arrayValue ?? []
}

public func callTool(_ name: String, arguments: JSONValue) async throws -> JSONValue {
try await rpc("tools/call", params: ["name": .string(name), "arguments": arguments])
}

// MARK: wire

private func rpc(_ method: String, params: JSONValue) async throws -> JSONValue {
if sessionID == nil { try await initialize() }
var (status, body, _) = try await post(Self.message(method, params))
if status == 400 || status == 404 {
sessionID = nil
try await initialize()
(status, body, _) = try await post(Self.message(method, params))
}
try Self.check(status)
return try Self.result(body)
}

private func initialize() async throws {
let (status, body, headers) = try await post(Self.message("initialize", [
"protocolVersion": "2025-06-18", "capabilities": [:],
"clientInfo": ["name": "oab-computer", "version": "browser"],
], id: 0))
try Self.check(status)
_ = try Self.result(body)
sessionID = headers.first { $0.key.lowercased() == "mcp-session-id" }?.value
_ = try? await post(["jsonrpc": "2.0", "method": "notifications/initialized"])
}

private static func message(_ method: String, _ params: JSONValue, id: Int = Int.random(in: 1...1_000_000)) -> JSONValue {
["jsonrpc": "2.0", "id": .number(Double(id)), "method": .string(method), "params": params]
}

private func post(_ msg: JSONValue) async throws -> (Int, Data, [String: String]) {
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.httpBody = try JSONCoding.encoder.encode(msg)
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept")
// Bare host: Playwright MCP's allowed-hosts list compares the header verbatim.
if let h = url.host { req.setValue(h, forHTTPHeaderField: "Host") }
if let sid = sessionID { req.setValue(sid, forHTTPHeaderField: "Mcp-Session-Id") }
do {
let (data, resp) = try await session.data(for: req)
let http = resp as? HTTPURLResponse
var headers: [String: String] = [:]
for (k, v) in http?.allHeaderFields ?? [:] { if let k = k as? String, let v = v as? String { headers[k] = v } }
return (http?.statusCode ?? 0, data, headers)
} catch let e as URLError where e.code == .timedOut {
throw ExternalPlaywrightError(errorClass: "upstream_timeout")
} catch {
throw ExternalPlaywrightError(errorClass: "upstream_unreachable")
}
}

private static func check(_ status: Int) throws {
if (200..<300).contains(status) { return }
if (300..<400).contains(status) { throw ExternalPlaywrightError(errorClass: "upstream_redirect") }
throw ExternalPlaywrightError(errorClass: "upstream_http_\(status)")
}

/// The JSON-RPC `result`; a JSON-RPC `error` or an unreadable body becomes a fixed class.
private static func result(_ data: Data) throws -> JSONValue {
var parsed = try? JSONCoding.decoder.decode(JSONValue.self, from: data)
if parsed == nil {
for line in String(decoding: data, as: UTF8.self).split(separator: "\n") where line.hasPrefix("data:") {
let payload = line.dropFirst(5).trimmingCharacters(in: .whitespaces)
if let v = try? JSONCoding.decoder.decode(JSONValue.self, from: Data(payload.utf8)) { parsed = v; break }
}
}
guard let v = parsed, v.objectValue != nil else { throw ExternalPlaywrightError(errorClass: "upstream_bad_body") }
if v["error"] != nil { throw ExternalPlaywrightError(errorClass: "upstream_rpc_error") }
return v["result"] ?? .null
}
}
Loading
Loading