From 72308488a0f4b3aa45fa36cc48b5965a78513c10 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:46:01 -0500 Subject: [PATCH 1/2] feat: browser tool router and external Playwright tier (phase 1, PR 1) BrowserBackend interface, BrowserToolCatalog (32 names, Tier A table), BrowserRouter (R2 table, 30 min identity guard, no fallback), MCPServer/scoped/mappingTools wiring, OAB-Browser-Backend header, external Playwright backend (loopback-only URL, no redirects, fixed error classes, listener uid check, explicit trust), no-tailscale guard, docs. Managed stdio child is PR 2; Apple Events tier is Phase 2. Ran on macmini (/private/tmp/oab-computer-p1): swift build ok; swift test all green incl. new Browser*/PortOwner/ExternalPlaywright suites (31 tests); oab-computer --upstream browser=http://localhost:8794/mcp exits 64; scripts/check-no-private-identifiers.sh ok; scripts/test-pw-mcp-check.sh ok. --- README.md | 8 +- .../Browser/BrowserBackend.swift | 68 +++++++ .../Browser/BrowserRouter.swift | 116 ++++++++++++ .../Browser/BrowserToolCatalog.swift | 30 +++ .../Browser/BrowserUpstreamURL.swift | 17 ++ .../Browser/ExternalPlaywrightClient.swift | 121 +++++++++++++ .../Browser/PlaywrightBackend.swift | 82 +++++++++ .../Browser/PortOwnerProbe.swift | 61 +++++++ Sources/InstanceMCPCore/MCPHTTPEndpoint.swift | 4 +- Sources/InstanceMCPCore/MCPServer.swift | 31 +++- .../InstanceMCPCore/ReverseAttachClient.swift | 2 +- Sources/InstanceMCPCore/ToolProfile.swift | 8 +- Sources/oab-computer/main.swift | 20 +- .../BrowserCatalogTests.swift | 56 ++++++ .../BrowserProfileTests.swift | 28 ++- .../BrowserRouterTests.swift | 171 ++++++++++++++++++ .../BrowserTestSupport.swift | 109 +++++++++++ .../ExternalPlaywrightTests.swift | 148 +++++++++++++++ docs/parity-checklist.md | 4 +- docs/tool-profiles.md | 8 +- scripts/test-pw-mcp-check.sh | 8 + 21 files changed, 1082 insertions(+), 18 deletions(-) create mode 100644 Sources/InstanceMCPCore/Browser/BrowserBackend.swift create mode 100644 Sources/InstanceMCPCore/Browser/BrowserRouter.swift create mode 100644 Sources/InstanceMCPCore/Browser/BrowserToolCatalog.swift create mode 100644 Sources/InstanceMCPCore/Browser/BrowserUpstreamURL.swift create mode 100644 Sources/InstanceMCPCore/Browser/ExternalPlaywrightClient.swift create mode 100644 Sources/InstanceMCPCore/Browser/PlaywrightBackend.swift create mode 100644 Sources/InstanceMCPCore/Browser/PortOwnerProbe.swift create mode 100644 Tests/InstanceMCPCoreTests/BrowserCatalogTests.swift create mode 100644 Tests/InstanceMCPCoreTests/BrowserRouterTests.swift create mode 100644 Tests/InstanceMCPCoreTests/BrowserTestSupport.swift create mode 100644 Tests/InstanceMCPCoreTests/ExternalPlaywrightTests.swift diff --git a/README.md b/README.md index 82220da..f1ae25a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/InstanceMCPCore/Browser/BrowserBackend.swift b/Sources/InstanceMCPCore/Browser/BrowserBackend.swift new file mode 100644 index 0000000..036b5d7 --- /dev/null +++ b/Sources/InstanceMCPCore/Browser/BrowserBackend.swift @@ -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) } +} diff --git a/Sources/InstanceMCPCore/Browser/BrowserRouter.swift b/Sources/InstanceMCPCore/Browser/BrowserRouter.swift new file mode 100644 index 0000000..be5aee8 --- /dev/null +++ b/Sources/InstanceMCPCore/Browser/BrowserRouter.swift @@ -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) + } +} diff --git a/Sources/InstanceMCPCore/Browser/BrowserToolCatalog.swift b/Sources/InstanceMCPCore/Browser/BrowserToolCatalog.swift new file mode 100644 index 0000000..f8f2f68 --- /dev/null +++ b/Sources/InstanceMCPCore/Browser/BrowserToolCatalog.swift @@ -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 = Set(allNames) + + public static func tierASupport(_ name: String) -> Support? { + tierA.first { $0.name == name }?.support + } +} diff --git a/Sources/InstanceMCPCore/Browser/BrowserUpstreamURL.swift b/Sources/InstanceMCPCore/Browser/BrowserUpstreamURL.swift new file mode 100644 index 0000000..3bf2cf1 --- /dev/null +++ b/Sources/InstanceMCPCore/Browser/BrowserUpstreamURL.swift @@ -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") + } + } +} diff --git a/Sources/InstanceMCPCore/Browser/ExternalPlaywrightClient.swift b/Sources/InstanceMCPCore/Browser/ExternalPlaywrightClient.swift new file mode 100644 index 0000000..ec25768 --- /dev/null +++ b/Sources/InstanceMCPCore/Browser/ExternalPlaywrightClient.swift @@ -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 + } +} diff --git a/Sources/InstanceMCPCore/Browser/PlaywrightBackend.swift b/Sources/InstanceMCPCore/Browser/PlaywrightBackend.swift new file mode 100644 index 0000000..ba676d0 --- /dev/null +++ b/Sources/InstanceMCPCore/Browser/PlaywrightBackend.swift @@ -0,0 +1,82 @@ +import Foundation + +/// Why an external Playwright may be used. There is no way to build an external backend +/// without one; nothing discovers port 8794 by itself (R7.8). +public enum BrowserTrust: Sendable, Equatable { + /// The operator passed `--upstream browser=URL`. + case operatorFlag + /// Reserved for the Phase 2 "Use it" prompt. + case userConsent +} + +/// Tier P. Phase 1 serves an external Playwright MCP; the managed stdio child is the next step +/// and reports `needsSetup("playwright_not_installed")` until then. +public actor PlaywrightBackend: BrowserBackend { + public nonisolated let id: BrowserBackendID = .playwright + public static let healthTTL: TimeInterval = 30 + + private struct External { + let transport: any PlaywrightTransport + let port: UInt16 + let probe: any PortOwnerProbe + let trust: BrowserTrust + } + private let external: External? + private let euid: UInt32 + private let clock: @Sendable () -> Date + private let log: @Sendable (String) -> Void + private var health: (at: Date, status: BrowserBackendStatus)? + public private(set) var lastOwner: PortOwner? + + /// Managed mode. Nothing is installed yet, so it is never ready. + public init(log: @escaping @Sendable (String) -> Void = { _ in }) { + external = nil; euid = geteuid(); clock = { Date() }; self.log = log + } + + /// External mode; `trust` is required. + public init(external transport: any PlaywrightTransport, port: UInt16, probe: any PortOwnerProbe, + trust: BrowserTrust, euid: UInt32 = geteuid(), clock: @escaping @Sendable () -> Date = { Date() }, + log: @escaping @Sendable (String) -> Void = { _ in }) { + external = External(transport: transport, port: port, probe: probe, trust: trust) + self.euid = euid; self.clock = clock; self.log = log + } + + public var mode: String { external == nil ? "managed" : "external" } + + /// Process name and uid of the external listener, names only (R7.5.5). + public var ownerSummary: String? { lastOwner.map { "\($0.name) (uid \($0.uid))" } } + + private func ownerCheck(_ e: External) -> BrowserBackendStatus? { + guard let o = e.probe.owner(ofLoopbackPort: e.port) else { return .unavailable("listener_owner_unknown") } + lastOwner = o + return o.uid == euid ? nil : .unavailable("listener_owner_mismatch") + } + + public func status() async -> BrowserBackendStatus { + guard let e = external else { return .needsSetup("playwright_not_installed") } + if let bad = ownerCheck(e) { health = nil; return bad } + if let h = health, clock().timeIntervalSince(h.at) < Self.healthTTL { return h.status } + let s: BrowserBackendStatus + do { try await e.transport.healthCheck(); s = .ready } + catch { s = .unavailable((error as? ExternalPlaywrightError)?.errorClass ?? "upstream_unreachable") } + health = (clock(), s) + return s + } + + public func tools() async -> [JSONValue] { + guard let e = external, ownerCheck(e) == nil else { return [] } + return (try? await e.transport.listTools()) ?? [] + } + + public func call(_ name: String, arguments: JSONValue) async throws -> JSONValue { + guard let e = external else { throw BrowserBackendError("playwright_not_installed", setupLost: true) } + if let bad = ownerCheck(e) { + health = nil + if case .unavailable(let c) = bad { throw BrowserBackendError(c, setupLost: true) } + throw BrowserBackendError("listener_owner_unknown", setupLost: true) + } + do { return try await e.transport.callTool(name, arguments: arguments) } + catch let err as ExternalPlaywrightError { throw BrowserBackendError(err.errorClass) } + catch { throw BrowserBackendError("upstream_unreachable") } + } +} diff --git a/Sources/InstanceMCPCore/Browser/PortOwnerProbe.swift b/Sources/InstanceMCPCore/Browser/PortOwnerProbe.swift new file mode 100644 index 0000000..e30ec47 --- /dev/null +++ b/Sources/InstanceMCPCore/Browser/PortOwnerProbe.swift @@ -0,0 +1,61 @@ +import Foundation +import Darwin + +public struct PortOwner: Equatable, Sendable { + public var pid: Int32 + public var uid: UInt32 + public var name: String + public init(pid: Int32, uid: UInt32, name: String) { self.pid = pid; self.uid = uid; self.name = name } +} + +/// Who listens on a loopback TCP port (R7.5.5). nil means "could not tell", which callers treat +/// as a refusal. +public protocol PortOwnerProbe: Sendable { + func owner(ofLoopbackPort port: UInt16) -> PortOwner? +} + +/// libproc walk: every process we may inspect, every socket fd, the listening TCP ones. Other +/// users' processes are not visible without elevated rights, which also reads as "unknown". +/// If several owners listen on the port, a foreign-uid one is reported first (fail closed). +public struct LibprocPortOwnerProbe: PortOwnerProbe { + public init() {} + + public func owner(ofLoopbackPort port: UInt16) -> PortOwner? { + let bytes = proc_listpids(UInt32(PROC_ALL_PIDS), 0, nil, 0) + guard bytes > 0 else { return nil } + var pids = [pid_t](repeating: 0, count: Int(bytes) / MemoryLayout.size + 32) + let got = proc_listpids(UInt32(PROC_ALL_PIDS), 0, &pids, Int32(pids.count * MemoryLayout.size)) + guard got > 0 else { return nil } + let me = geteuid() + var found: [PortOwner] = [] + for pid in pids.prefix(Int(got) / MemoryLayout.size) where pid > 0 { + guard listens(pid: pid, port: port) else { continue } + var info = proc_bsdinfo() + let n = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, Int32(MemoryLayout.size)) + guard n == Int32(MemoryLayout.size) else { continue } + let name = withUnsafePointer(to: &info.pbi_comm) { + $0.withMemoryRebound(to: CChar.self, capacity: Int(MAXCOMLEN) + 1) { String(cString: $0) } + } + found.append(PortOwner(pid: pid, uid: info.pbi_uid, name: name)) + } + return found.first { $0.uid != me } ?? found.first + } + + private func listens(pid: pid_t, port: UInt16) -> Bool { + let size = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0) + guard size > 0 else { return false } + var fds = [proc_fdinfo](repeating: proc_fdinfo(), count: Int(size) / MemoryLayout.size + 8) + let got = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, &fds, Int32(fds.count * MemoryLayout.size)) + guard got > 0 else { return false } + for fd in fds.prefix(Int(got) / MemoryLayout.size) + where fd.proc_fdtype == UInt32(PROX_FDTYPE_SOCKET) { + var si = socket_fdinfo() + let r = proc_pidfdinfo(pid, fd.proc_fd, PROC_PIDFDSOCKETINFO, &si, Int32(MemoryLayout.size)) + guard r == Int32(MemoryLayout.size) else { continue } + let s = si.psi + guard s.soi_kind == Int32(SOCKINFO_TCP), s.soi_proto.pri_tcp.tcpsi_state == Int32(TSI_S_LISTEN) else { continue } + if UInt16(truncatingIfNeeded: Int(s.soi_proto.pri_tcp.tcpsi_ini.insi_lport)).bigEndian == port { return true } + } + return false + } +} diff --git a/Sources/InstanceMCPCore/MCPHTTPEndpoint.swift b/Sources/InstanceMCPCore/MCPHTTPEndpoint.swift index 2389371..4cc53e4 100644 --- a/Sources/InstanceMCPCore/MCPHTTPEndpoint.swift +++ b/Sources/InstanceMCPCore/MCPHTTPEndpoint.swift @@ -75,7 +75,9 @@ public actor MCPHTTPEndpoint { // Be lenient: stateless clients (curl probes) still work; the daemon has no per-session state yet. } - guard let response = await server.handle(rpc) else { + guard let response = await server.withBrowserConnection( + key: sid ?? "http:\(principal)", + override: req.header("oab-browser-backend").flatMap { BrowserPreference(rawValue: $0) }).handle(rpc) else { return .init(status: 202, headers: extraHeaders) // notification } if rpc.method == "tools/call" { diff --git a/Sources/InstanceMCPCore/MCPServer.swift b/Sources/InstanceMCPCore/MCPServer.swift index 88a147a..e2b4259 100644 --- a/Sources/InstanceMCPCore/MCPServer.swift +++ b/Sources/InstanceMCPCore/MCPServer.swift @@ -20,9 +20,15 @@ public struct MCPServer: Sendable { /// filtered by `scoped(to:)`; upstream lists are dynamic, so the filter has /// to travel with the server. public let upstreamFilter: ToolProfile? + /// Serves the `browser_*` tools from the active tier, after local tools and generic + /// upstreams. Names outside `BrowserToolCatalog` are never routed. + public let browser: BrowserRouter? + /// Set per request by `withBrowserConnection`. + public let browserConnection: BrowserConnection public init(name: String, version: String, instructions: String? = nil, tools: [any Tool], - upstreams: [UpstreamMCP] = [], upstreamFilter: ToolProfile? = nil) { + upstreams: [UpstreamMCP] = [], upstreamFilter: ToolProfile? = nil, + browser: BrowserRouter? = nil, browserConnection: BrowserConnection = .init(key: "default")) { self.serverName = name self.serverVersion = version self.instructions = instructions @@ -32,6 +38,25 @@ public struct MCPServer: Sendable { self.toolOrder = tools.map(\.name) self.upstreams = upstreams self.upstreamFilter = upstreamFilter + self.browser = browser + self.browserConnection = browserConnection + } + + /// The same server for one connection: the key identifies it for the router's identity guard, + /// `override` is the `OAB-Browser-Backend` header (nil on paths that carry none). + public func withBrowserConnection(key: String, override: BrowserPreference? = nil) -> MCPServer { + MCPServer(name: serverName, version: serverVersion, instructions: instructions, tools: allTools, + upstreams: upstreams, upstreamFilter: upstreamFilter, browser: browser, + browserConnection: BrowserConnection(key: key, override: override)) + } + + /// Router tools visible under the profile filter. + private func browserTools() async -> [JSONValue] { + guard let browser else { return [] } + return await browser.tools(browserConnection).filter { + guard let n = $0["name"]?.stringValue else { return false } + return tools[n] == nil && (upstreamFilter?.allows(n) ?? true) + } } /// Upstream tools visible under the current filter, as `Tool`s. @@ -112,6 +137,7 @@ public struct MCPServer: Sendable { case "tools/list": var list = toolOrder.compactMap { tools[$0]?.descriptor } list += await upstreamTools().map(\.descriptor) + list += await browserTools() return ["tools": .array(list)] case "tools/call": @@ -123,6 +149,9 @@ public struct MCPServer: Sendable { resolved = await upstreamTools().first { $0.name == name } } guard let tool = resolved else { + if let browser, BrowserToolCatalog.allNameSet.contains(name), upstreamFilter?.allows(name) ?? true { + return await browser.call(name, arguments: req.params?["arguments"] ?? [:], browserConnection) + } throw JSONRPCError.invalidParams("unknown tool: \(name)") } let args = req.params?["arguments"] ?? [:] diff --git a/Sources/InstanceMCPCore/ReverseAttachClient.swift b/Sources/InstanceMCPCore/ReverseAttachClient.swift index 72bf5be..a0142c6 100644 --- a/Sources/InstanceMCPCore/ReverseAttachClient.swift +++ b/Sources/InstanceMCPCore/ReverseAttachClient.swift @@ -417,7 +417,7 @@ public actor ReverseAttachClient { case .success(let r): rpc = r } if let refusal = refuseLongExec(rpc) { return encode(refusal) } - guard let response = await server.handle(rpc) else { return nil } + guard let response = await server.withBrowserConnection(key: "attach:\(config.session)").handle(rpc) else { return nil } if rpc.method == "tools/call" { let name = rpc.params?["name"]?.stringValue ?? "?" log("sandbox[\(config.session)] tools/call \(name)\(response.error != nil ? " -> rpc error" : "")") diff --git a/Sources/InstanceMCPCore/ToolProfile.swift b/Sources/InstanceMCPCore/ToolProfile.swift index 86574df..03b9f7c 100644 --- a/Sources/InstanceMCPCore/ToolProfile.swift +++ b/Sources/InstanceMCPCore/ToolProfile.swift @@ -143,7 +143,9 @@ extension MCPServer { instructions: instructions ?? self.instructions, tools: allTools.filter { profile.allows($0.name) }, upstreams: upstreams, - upstreamFilter: profile + upstreamFilter: profile, + browser: browser, + browserConnection: browserConnection ) } @@ -155,7 +157,9 @@ extension MCPServer { instructions: instructions, tools: allTools.map(transform), upstreams: upstreams, - upstreamFilter: upstreamFilter + upstreamFilter: upstreamFilter, + browser: browser, + browserConnection: browserConnection ) } } diff --git a/Sources/oab-computer/main.swift b/Sources/oab-computer/main.swift index 980fc1c..a7116e3 100644 --- a/Sources/oab-computer/main.swift +++ b/Sources/oab-computer/main.swift @@ -138,6 +138,12 @@ while !args.isEmpty { let v = next(a) guard let eq = v.firstIndex(of: "="), let u = URL(string: String(v[v.index(after: eq)...])), u.host != nil else { fputs("--upstream wants name=http://host:port/path\n", stderr); usage() } + // The upstream named `browser` is an external Playwright: literal loopback only (R7.5.2). + if String(v[.. Void = { msg in } } +// `--upstream browser=URL` is handed to the Playwright tier as an external Playwright, trusted +// because the operator passed the flag (R1.7, R7.8). Other upstreams stay generic. +let browserRouter: BrowserRouter? = opts.upstreams.first(where: { $0.0 == "browser" }).map { b in + let playwright = PlaywrightBackend(external: ExternalPlaywrightClient(url: b.1), + port: UInt16(b.1.port ?? 80), probe: LibprocPortOwnerProbe(), + trust: .operatorFlag, log: log) + return BrowserRouter(playwright: playwright, + store: UserDefaultsBrowserPreferenceStore(log: log), log: log) +} let server = MCPServer( name: "oab-computer", version: version, @@ -275,7 +290,7 @@ let server = MCPServer( back to `screenshot` for custom-drawn content. To act on a node, call `ui_action` with its \ `snapshot_id`, `ref` and `action` (press, set_value, focus, …): no pointer movement, no focus \ stealing; then take a new `ui_snapshot` to confirm. Use `mouse` for drags and custom-drawn \ - content.\(opts.upstreams.isEmpty ? "" : """ + content.\(opts.upstreams.isEmpty && browserRouter == nil ? "" : """ A browser is available through the `browser_*` tools (Playwright, a real window on this Mac's \ desktop): prefer `browser_navigate` + `browser_snapshot` (accessibility tree as text) to read a \ page, and `browser_click` / `browser_type` / `browser_fill_form` to act — they are exact and need \ @@ -283,7 +298,8 @@ let server = MCPServer( """) """, tools: ToolCatalog.local(agentVersion: version), - upstreams: opts.upstreams.map { UpstreamMCP(name: $0.0, url: $0.1, log: log) } + upstreams: opts.upstreams.filter { $0.0 != "browser" }.map { UpstreamMCP(name: $0.0, url: $0.1, log: log) }, + browser: browserRouter ) /// Every `desktop` → `observe` mapping is recorded here as well as in the log (#59). let auditLog = FileAuditLog(log: log) diff --git a/Tests/InstanceMCPCoreTests/BrowserCatalogTests.swift b/Tests/InstanceMCPCoreTests/BrowserCatalogTests.swift new file mode 100644 index 0000000..ef97070 --- /dev/null +++ b/Tests/InstanceMCPCoreTests/BrowserCatalogTests.swift @@ -0,0 +1,56 @@ +import Foundation +import XCTest +@testable import InstanceMCPCore + +final class BrowserToolCatalogTests: XCTestCase { + func testThirtyTwoNamesMatchTheVectorFile() throws { + let doc = try loadConformanceVector("profile_vectors.json") + let fixture = try XCTUnwrap(doc["upstream_fixture"] as? [String]) + XCTAssertEqual(BrowserToolCatalog.allNames.count, 32) + XCTAssertEqual(Set(BrowserToolCatalog.allNames).count, 32) + XCTAssertEqual(Set(BrowserToolCatalog.allNames), Set(fixture)) + let unlisted = try XCTUnwrap(doc["unlisted_upstream"] as? [String]) + XCTAssertTrue(unlisted.contains("browser_something_new")) + XCTAssertFalse(BrowserToolCatalog.allNameSet.contains("browser_something_new")) + } + + func testTierASplitIsSixTenSixteen() { + let counts = Dictionary(grouping: BrowserToolCatalog.tierA, by: \.support).mapValues(\.count) + XCTAssertEqual(counts[.full], 6) + XCTAssertEqual(counts[.limited], 10) + XCTAssertEqual(counts[.no], 16) + XCTAssertEqual(BrowserToolCatalog.tierASupport("browser_close"), .full) + XCTAssertEqual(BrowserToolCatalog.tierASupport("browser_drag"), .no) + } + + func testObserveAllowsNoBrowserTool() { + for n in BrowserToolCatalog.allNames + ["browser_something_new"] { + XCTAssertTrue(ToolProfile.owner.allows(n), n) + XCTAssertFalse(ToolProfile.observe.allows(n), n) + } + } +} + +final class BrowserUpstreamURLTests: XCTestCase { + func testOnlyLiteralLoopbackIsAccepted() throws { + XCTAssertNoThrow(try BrowserUpstreamURL.validate(URL(string: "http://127.0.0.1:8794/mcp")!)) + XCTAssertNoThrow(try BrowserUpstreamURL.validate(URL(string: "http://[::1]:8794/mcp")!)) + for bad in ["http://localhost:8794/mcp", "http://example.com:8794/mcp", "http://0.0.0.0:8794/mcp", + "http://192.168.0.5:8794/mcp", "https://127.0.0.1:8794/mcp"] { + XCTAssertThrowsError(try BrowserUpstreamURL.validate(URL(string: bad)!), bad) + } + } +} + +final class BrowserNoTailscaleTests: XCTestCase { + func testNoFileUnderBrowserMentionsTailscale() throws { + let dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().appendingPathComponent("Sources/InstanceMCPCore/Browser") + let files = try FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) + XCTAssertFalse(files.isEmpty) + for f in files { + let text = try String(contentsOf: f, encoding: .utf8).lowercased() + XCTAssertFalse(text.contains("tailscale"), f.lastPathComponent) + } + } +} diff --git a/Tests/InstanceMCPCoreTests/BrowserProfileTests.swift b/Tests/InstanceMCPCoreTests/BrowserProfileTests.swift index a18bcbf..3e0faf6 100644 --- a/Tests/InstanceMCPCoreTests/BrowserProfileTests.swift +++ b/Tests/InstanceMCPCoreTests/BrowserProfileTests.swift @@ -2,10 +2,11 @@ import Foundation import XCTest @testable import InstanceMCPCore -/// #59: the browser is owner-level. Against an upstream that serves the full -/// `browser_*` fixture from `conformance/profile_vectors.json` (plus tools that are -/// not in it), `owner` lists and reaches every one through the real `tools/list` / -/// `tools/call` path, and `observe` lists none and reaches none. +/// #59: the browser is owner-level. The `browser_*` tools now go through the `BrowserRouter`: +/// against a backend that serves the full fixture from `conformance/profile_vectors.json` (plus +/// a name that is not in the catalog), `owner` lists and reaches the 32 catalog names through the +/// real `tools/list` / `tools/call` path, the unknown name is filtered (R1.5), and `observe` +/// lists none and reaches none. A generic upstream not named `browser` keeps the old rule. final class BrowserProfileTests: XCTestCase { private func fixture() throws -> (listed: [String], unlisted: [String]) { let doc = try loadConformanceVector("profile_vectors.json") @@ -14,7 +15,7 @@ final class BrowserProfileTests: XCTestCase { } private func server(_ up: FakeUpstream, _ profile: ToolProfile) -> MCPServer { - let u = UpstreamMCP(name: "browser", url: URL(string: "http://127.0.0.1:\(up.port)/mcp")!) + let u = UpstreamMCP(name: "generic", url: URL(string: "http://127.0.0.1:\(up.port)/mcp")!) return MCPServer(name: "t", version: "0", tools: ToolCatalog.local(agentVersion: "test"), upstreams: [u]) .scoped(to: profile) } @@ -59,4 +60,21 @@ final class BrowserProfileTests: XCTestCase { } XCTAssertTrue(up.calls.isEmpty, "no observe call reached the upstream: \(up.calls)") } + + func testRouterServesTheFixtureToOwnerAndFiltersUnknownNames() async throws { + let (listed, unlisted) = try fixture() + let b = FakeBrowserBackend(.playwright, names: listed + unlisted) + let router = BrowserRouter(playwright: b, store: MemoryPreferenceStore()) + let owner = MCPServer(name: "t", version: "0", tools: ToolCatalog.local(agentVersion: "test"), browser: router) + .scoped(to: .owner) + let names = Set(try await list(owner)) + XCTAssertTrue(Set(listed).isSubset(of: names)) + XCTAssertTrue(names.isDisjoint(with: unlisted), "unknown browser_* names must be filtered") + for tool in listed { let r = try await call(owner, tool); XCTAssertNil(r?.error, tool) } + for tool in unlisted { + let r = try await call(owner, tool) + XCTAssertEqual(r?.error?.code, JSONRPCError.invalidParams, tool) + } + XCTAssertEqual(Set(b.calls), Set(listed)) + } } diff --git a/Tests/InstanceMCPCoreTests/BrowserRouterTests.swift b/Tests/InstanceMCPCoreTests/BrowserRouterTests.swift new file mode 100644 index 0000000..e0b26a1 --- /dev/null +++ b/Tests/InstanceMCPCoreTests/BrowserRouterTests.swift @@ -0,0 +1,171 @@ +import Foundation +import XCTest +@testable import InstanceMCPCore + +final class BrowserRouterTests: XCTestCase { + let conn = BrowserConnection(key: "c1") + + func router(a: FakeBrowserBackend?, p: FakeBrowserBackend?, pref: BrowserPreference = .auto, + clock: TestClock = TestClock()) -> BrowserRouter { + BrowserRouter(appleEvents: a, playwright: p, store: MemoryPreferenceStore(pref), clock: { clock.date() }) + } + func text(_ r: JSONValue) -> String { r["content"]?.arrayValue?.first?["text"]?.stringValue ?? "" } + func names(_ t: [JSONValue]) -> Set { Set(t.compactMap { $0["name"]?.stringValue }) } + + func testAutoPrefersPlaywright() async { + let a = FakeBrowserBackend(.appleEvents), p = FakeBrowserBackend(.playwright) + let r = router(a: a, p: p) + let res = await r.call("browser_navigate", arguments: [:], conn) + XCTAssertEqual(res["structuredContent"]?["backend"]?.stringValue, "playwright") + XCTAssertEqual(p.calls, ["browser_navigate"]); XCTAssertTrue(a.calls.isEmpty) + } + + func testAutoFallsToAppleEventsWhenPlaywrightNotReady() async { + let a = FakeBrowserBackend(.appleEvents), p = FakeBrowserBackend(.playwright, status: .needsSetup("x")) + let res = await router(a: a, p: p).call("browser_navigate", arguments: [:], conn) + XCTAssertEqual(res["structuredContent"]?["backend"]?.stringValue, "appleEvents") + } + + func testPreferenceTable() async { + let ready = FakeBrowserBackend(.appleEvents), down = FakeBrowserBackend(.playwright, status: .unavailable("x")) + // appleEvents pinned, ready: A. playwright pinned, not ready: none, no silent switch. + var r = router(a: ready, p: down, pref: .appleEvents) + let t1 = await r.tools(conn); XCTAssertFalse(t1.isEmpty) + r = router(a: ready, p: down, pref: .playwright) + let none = await r.call("browser_navigate", arguments: [:], conn) + XCTAssertEqual(text(none), BrowserRouter.notSetUp) + XCTAssertEqual(none["isError"]?.boolValue, true) + XCTAssertTrue(ready.calls.isEmpty) + let r2 = router(a: down2(), p: FakeBrowserBackend(.playwright), pref: .appleEvents) + let t2 = await r2.tools(conn); XCTAssertTrue(t2.isEmpty) + } + func down2() -> FakeBrowserBackend { FakeBrowserBackend(.appleEvents, status: .needsSetup("x")) } + + func testNothingReadyListsNothingAndSaysNotSetUp() async { + let r = router(a: nil, p: FakeBrowserBackend(.playwright, status: .needsSetup("playwright_not_installed"))) + let t3 = await r.tools(conn); XCTAssertTrue(t3.isEmpty) + let c3 = await r.call("browser_click", arguments: [:], conn) + XCTAssertEqual(text(c3), BrowserRouter.notSetUp) + } + + func testHeaderOverridesStoredPreferenceAndStoresNothing() async { + let a = FakeBrowserBackend(.appleEvents), p = FakeBrowserBackend(.playwright) + let store = MemoryPreferenceStore(.playwright) + let r = BrowserRouter(appleEvents: a, playwright: p, store: store) + let res = await r.call("browser_navigate", arguments: [:], BrowserConnection(key: "h", override: .appleEvents)) + XCTAssertEqual(res["structuredContent"]?["backend"]?.stringValue, "appleEvents") + XCTAssertEqual(store.load(), .playwright) + } + + func testIdentityGuardBothDirectionsAndExpiry() async { + let clock = TestClock() + // Playwright conversation; Playwright goes away; Apple Events is ready but not used. + let a = FakeBrowserBackend(.appleEvents), p = FakeBrowserBackend(.playwright) + let r = router(a: a, p: p, clock: clock) + _ = await r.call("browser_navigate", arguments: [:], conn) + p.status_ = .unavailable("gone") + let t4 = await r.tools(conn); XCTAssertTrue(t4.isEmpty) + let blocked = await r.call("browser_navigate", arguments: [:], conn) + XCTAssertEqual(blocked["isError"]?.boolValue, true) + XCTAssertTrue(text(blocked).contains("not used automatically")) + XCTAssertTrue(a.calls.isEmpty, "no fallback") + clock.advance(31 * 60) + let t5 = await r.tools(conn); XCTAssertFalse(t5.isEmpty, "recomputed after 30 minutes") + // Reverse: Apple Events conversation; Playwright becomes ready later and does not take over. + let a2 = FakeBrowserBackend(.appleEvents), p2 = FakeBrowserBackend(.playwright, status: .needsSetup("x")) + let r2 = router(a: a2, p: p2, clock: clock) + _ = await r2.call("browser_navigate", arguments: [:], conn) + p2.status_ = .ready + let again = await r2.call("browser_navigate", arguments: [:], conn) + XCTAssertEqual(again["structuredContent"]?["backend"]?.stringValue, "appleEvents") + // A header pin recomputes. + let pinned = await r2.call("browser_navigate", arguments: [:], BrowserConnection(key: "c1", override: .playwright)) + XCTAssertEqual(pinned["structuredContent"]?["backend"]?.stringValue, "playwright") + } + + func testNoFallbackOnCallFailureAndFixedErrorText() async { + let a = FakeBrowserBackend(.appleEvents), p = FakeBrowserBackend(.playwright) + p.failure = BrowserBackendError("upstream_timeout") + let res = await router(a: a, p: p).call("browser_navigate", arguments: [:], conn) + XCTAssertEqual(res["isError"]?.boolValue, true) + XCTAssertEqual(text(res), "Browser call failed: upstream_timeout") + XCTAssertTrue(a.calls.isEmpty) + } + + func testUnsupportedToolIsAToolErrorAndListIsFilteredToCatalog() async { + let p = FakeBrowserBackend(.playwright, names: ["browser_navigate", "browser_something_new"]) + let r = router(a: nil, p: p) + let t6 = await r.tools(conn); XCTAssertEqual(names(t6), ["browser_navigate"]) + let res = await r.call("browser_click", arguments: [:], conn) + XCTAssertEqual(res["isError"]?.boolValue, true) + XCTAssertTrue(text(res).contains("not available")) + XCTAssertTrue(p.calls.isEmpty) + } + + func testStructuredContentIsMergedAndContentKept() { + let r = BrowserRouter.tag(["content": [["type": "image", "data": "AA", "mimeType": "image/png"]], + "structuredContent": ["k": 1]], backend: .playwright) + XCTAssertEqual(r["content"]?.arrayValue?.count, 1) + XCTAssertEqual(r["structuredContent"]?["k"]?.intValue, 1) + XCTAssertEqual(r["structuredContent"]?["backend"]?.stringValue, "playwright") + } + + func testPreferenceStoreResetsUnknownValue() { + let d = UserDefaults(suiteName: "oab.test.\(UUID().uuidString)")! + d.set("bogus", forKey: UserDefaultsBrowserPreferenceStore.key) + let lines = NSLock(); nonisolated(unsafe) var logged: [String] = [] + let s = UserDefaultsBrowserPreferenceStore(defaults: d, log: { line in lines.withLock { logged.append(line) } }) + XCTAssertEqual(s.load(), .auto) + XCTAssertEqual(logged.count, 1) + XCTAssertTrue(logged[0].contains("browserTools.backend.v1")) + s.save(.playwright); XCTAssertEqual(s.load(), .playwright) + } +} + +final class BrowserServerWiringTests: XCTestCase { + func server(_ profile: ToolProfile, backend: FakeBrowserBackend) -> MCPServer { + let router = BrowserRouter(playwright: backend, store: MemoryPreferenceStore()) + return MCPServer(name: "t", version: "0", tools: ToolCatalog.local(agentVersion: "test"), browser: router) + .scoped(to: profile) + } + func call(_ s: MCPServer, _ name: String) async throws -> JSONRPCResponse? { + try await serverRequest(s, "tools/call", params: ["name": .string(name), "arguments": [:]]) + } + + func testOwnerListsAndCallsCatalogToolsOnly() async throws { + let b = FakeBrowserBackend(.playwright, names: BrowserToolCatalog.allNames + ["browser_something_new"]) + let s = server(.owner, backend: b) + let names = Set(listedNames(try await serverRequest(s, "tools/list"))) + XCTAssertTrue(BrowserToolCatalog.allNameSet.isSubset(of: names)) + XCTAssertFalse(names.contains("browser_something_new")) + let ok = try await call(s, "browser_navigate") + XCTAssertNil(ok?.error) + XCTAssertEqual(ok?.result?["structuredContent"]?["backend"]?.stringValue, "playwright") + let unknown = try await call(s, "browser_something_new") + XCTAssertEqual(unknown?.error?.code, JSONRPCError.invalidParams) + XCTAssertTrue(unknown?.error?.message.contains("unknown tool") == true) + } + + func testObserveSeesAndReachesNone() async throws { + let b = FakeBrowserBackend(.playwright) + let s = server(.observe, backend: b) + let lr = try await serverRequest(s, "tools/list") + XCTAssertEqual(Set(listedNames(lr)), ["sys_info", "screenshot"]) + for n in BrowserToolCatalog.allNames + ["browser_something_new"] { + let r = try await call(s, n) + XCTAssertEqual(r?.error?.code, JSONRPCError.invalidParams, n) + XCTAssertTrue(r?.error?.message.contains("unknown tool") == true, n) + } + XCTAssertTrue(b.calls.isEmpty) + } + + func testScopedAndMappingForwardTheRouter() async throws { + let b = FakeBrowserBackend(.playwright) + let base = MCPServer(name: "t", version: "0", tools: ToolCatalog.local(agentVersion: "test"), + browser: BrowserRouter(playwright: b, store: MemoryPreferenceStore())) + XCTAssertNotNil(base.scoped(to: .owner).browser) + XCTAssertNotNil(base.mappingTools { $0 }.browser) + XCTAssertNotNil(base.withBrowserConnection(key: "k").browser) + XCTAssertNotNil(Switchboard.server(base, profile: .owner).browser) + } +} diff --git a/Tests/InstanceMCPCoreTests/BrowserTestSupport.swift b/Tests/InstanceMCPCoreTests/BrowserTestSupport.swift new file mode 100644 index 0000000..adc850b --- /dev/null +++ b/Tests/InstanceMCPCoreTests/BrowserTestSupport.swift @@ -0,0 +1,109 @@ +import Foundation +import Network +import XCTest +@testable import InstanceMCPCore + +/// A backend with canned behavior, for router and wiring tests. +final class FakeBrowserBackend: BrowserBackend, @unchecked Sendable { + let id: BrowserBackendID + private let lock = NSLock() + private var _status: BrowserBackendStatus + private var _names: [String] + private var _calls: [String] = [] + var failure: BrowserBackendError? + + init(_ id: BrowserBackendID, status: BrowserBackendStatus = .ready, names: [String] = BrowserToolCatalog.allNames) { + self.id = id; _status = status; _names = names + } + var status_: BrowserBackendStatus { + get { lock.withLock { _status } } + set { lock.withLock { _status = newValue } } + } + var calls: [String] { lock.withLock { _calls } } + + func status() async -> BrowserBackendStatus { status_ } + func tools() async -> [JSONValue] { + lock.withLock { _names }.map { ["name": .string($0), "description": "fake", "inputSchema": ["type": "object"]] } + } + func call(_ name: String, arguments: JSONValue) async throws -> JSONValue { + lock.withLock { _calls.append(name) } + if let failure { throw failure } + return ["content": [["type": "text", "text": .string("\(id.rawValue) did \(name)")]]] + } +} + +final class MemoryPreferenceStore: BrowserPreferenceStore, @unchecked Sendable { + private let lock = NSLock() + private var value: BrowserPreference + init(_ v: BrowserPreference = .auto) { value = v } + func load() -> BrowserPreference { lock.withLock { value } } + func save(_ p: BrowserPreference) { lock.withLock { value = p } } +} + +final class TestClock: @unchecked Sendable { + private let lock = NSLock() + private var now = Date(timeIntervalSince1970: 1_000_000) + func date() -> Date { lock.withLock { now } } + func advance(_ s: TimeInterval) { lock.withLock { now = now.addingTimeInterval(s) } } +} + +struct FakePortOwnerProbe: PortOwnerProbe { + var result: PortOwner? + func owner(ofLoopbackPort port: UInt16) -> PortOwner? { result } +} + +/// A loopback HTTP server whose handler decides every reply; counts requests and keeps bodies. +final class StubHTTPServer: @unchecked Sendable { + private(set) var port: UInt16 = 0 + private let listener: NWListener + private let queue = DispatchQueue(label: "stub-http") + private let lock = NSLock() + private var _requests: [HTTPRequest] = [] + private let handler: @Sendable (HTTPRequest) -> HTTPResponse + + var requests: [HTTPRequest] { lock.withLock { _requests } } + + init(_ handler: @escaping @Sendable (HTTPRequest) -> HTTPResponse) throws { + self.handler = handler + let params = NWParameters.tcp + params.requiredInterfaceType = .loopback + listener = try NWListener(using: params, on: .any) + let sem = DispatchSemaphore(value: 0) + listener.stateUpdateHandler = { [weak self] st in + if case .ready = st { self?.port = self?.listener.port?.rawValue ?? 0; sem.signal() } + if case .failed = st { sem.signal() } + } + listener.newConnectionHandler = { [weak self] c in self?.accept(c) } + listener.start(queue: queue) + sem.wait() + } + func stop() { listener.cancel() } + + private func accept(_ conn: NWConnection) { + conn.stateUpdateHandler = { [weak self] st in if case .ready = st { self?.read(conn, Data()) } } + conn.start(queue: queue) + } + private func read(_ conn: NWConnection, _ buf: Data) { + conn.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, done, _ in + guard let self else { return } + var b = buf; if let data { b.append(data) } + switch HTTPParser.parse(b) { + case .incomplete: if done { conn.cancel() } else { self.read(conn, b) } + case .invalid: conn.cancel() + case .request(let req, _): + self.lock.withLock { self._requests.append(req) } + conn.send(content: self.handler(req).serialize(keepAlive: false), completion: .contentProcessed { _ in conn.cancel() }) + } + } + } +} + +func jsonBody(_ v: JSONValue) -> Data { try! JSONCoding.encoder.encode(v) } + +func serverRequest(_ s: MCPServer, _ method: String, params: JSONValue? = nil) async throws -> JSONRPCResponse? { + await s.handle(try JSONCoding.decoder.decode(JSONRPCRequest.self, from: rpc(method, params: params))) +} + +func listedNames(_ r: JSONRPCResponse?) -> [String] { + r?.result?["tools"]?.arrayValue?.compactMap { $0["name"]?.stringValue } ?? [] +} diff --git a/Tests/InstanceMCPCoreTests/ExternalPlaywrightTests.swift b/Tests/InstanceMCPCoreTests/ExternalPlaywrightTests.swift new file mode 100644 index 0000000..115d639 --- /dev/null +++ b/Tests/InstanceMCPCoreTests/ExternalPlaywrightTests.swift @@ -0,0 +1,148 @@ +import Foundation +import XCTest +@testable import InstanceMCPCore + +private func sse(_ v: JSONValue, sid: String? = "s1") -> HTTPResponse { + let body = "event: message\ndata: " + String(decoding: jsonBody(v), as: UTF8.self) + "\n\n" + return HTTPResponse(status: 200, headers: [("Content-Type", "text/event-stream")] + (sid.map { [("Mcp-Session-Id", $0)] } ?? []), + body: Data(body.utf8)) +} + +final class ExternalPlaywrightRedirectTests: XCTestCase { + func testRedirectsAreRefusedAndNoSecondRequestIsSent() async throws { + for status in [301, 302, 307, 308] { + let target = try StubHTTPServer { _ in .text(200, "should not be reached\n") } + defer { target.stop() } + let tport = target.port + let origin = try StubHTTPServer { _ in + HTTPResponse(status: status, headers: [("Location", "http://127.0.0.1:\(tport)/mcp")], body: Data()) + } + defer { origin.stop() } + let client = ExternalPlaywrightClient(url: URL(string: "http://127.0.0.1:\(origin.port)/mcp")!, timeout: 5) + do { _ = try await client.listTools(); XCTFail("\(status): no error") } + catch let e as ExternalPlaywrightError { XCTAssertEqual(e.errorClass, "upstream_redirect", "\(status)") } + XCTAssertTrue(target.requests.isEmpty, "\(status): the redirect target received a request") + XCTAssertEqual(origin.requests.count, 1, "\(status): body re-sent or retried") + } + } +} + +final class ExternalPlaywrightErrorTests: XCTestCase { + static let canary = "CANARY-9f3a-secret-page-text" + + func klass(_ handler: @escaping @Sendable (HTTPRequest) -> HTTPResponse) async throws -> String { + let s = try StubHTTPServer(handler); defer { s.stop() } + let client = ExternalPlaywrightClient(url: URL(string: "http://127.0.0.1:\(s.port)/mcp")!, timeout: 5) + do { _ = try await client.callTool("browser_navigate", arguments: [:]); return "no error" } + catch let e as ExternalPlaywrightError { return e.errorClass } + } + + func testFixedClassesCarryNoUpstreamText() async throws { + let c = Self.canary + let http = try await klass { _ in .text(500, "\(c)\n") } + XCTAssertEqual(http, "upstream_http_500") + let bad = try await klass { _ in .text(200, "\(c) not json\n") } + XCTAssertEqual(bad, "upstream_bad_body") + let rpcErr = try await klass { req in + let m = (try? JSONCoding.decoder.decode(JSONRPCRequest.self, from: req.body))?.method + if m == "initialize" { return sse(["jsonrpc": "2.0", "id": 0, "result": [:]]) } + if m == "tools/call" { return sse(["jsonrpc": "2.0", "id": 1, "error": ["code": -1, "message": .string(c)]]) } + return .init(status: 202) + } + XCTAssertEqual(rpcErr, "upstream_rpc_error") + for k in [http, bad, rpcErr] { XCTAssertFalse(k.contains("CANARY")) } + } + + func testUnreachableAndSuccessPassthrough() async throws { + let dead = try StubHTTPServer { _ in .text(200, "") }; let p = dead.port; dead.stop() + try await Task.sleep(nanoseconds: 100_000_000) + let c1 = ExternalPlaywrightClient(url: URL(string: "http://127.0.0.1:\(p)/mcp")!, timeout: 2) + do { _ = try await c1.listTools(); XCTFail() } catch let e as ExternalPlaywrightError { + XCTAssertEqual(e.errorClass, "upstream_unreachable") + } + let s = try StubHTTPServer { req in + let m = (try? JSONCoding.decoder.decode(JSONRPCRequest.self, from: req.body))?.method + if m == "initialize" { return sse(["jsonrpc": "2.0", "id": 0, "result": [:]]) } + if m == "tools/call" { + return sse(["jsonrpc": "2.0", "id": 1, "result": ["content": [["type": "text", "text": "page text"]], "isError": true]]) + } + return .init(status: 202) + } + defer { s.stop() } + let ok = try await ExternalPlaywrightClient(url: URL(string: "http://127.0.0.1:\(s.port)/mcp")!) + .callTool("browser_navigate", arguments: [:]) + XCTAssertEqual(ok["content"]?.arrayValue?.first?["text"]?.stringValue, "page text") + XCTAssertEqual(ok["isError"]?.boolValue, true) + } +} + +final class PortOwnerProbeTests: XCTestCase { + private func backend(_ owner: PortOwner?, _ s: StubHTTPServer, euid: UInt32 = 501, + trust: BrowserTrust = .operatorFlag) -> PlaywrightBackend { + PlaywrightBackend(external: ExternalPlaywrightClient(url: URL(string: "http://127.0.0.1:\(s.port)/mcp")!, timeout: 5), + port: s.port, probe: FakePortOwnerProbe(result: owner), trust: trust, euid: euid) + } + private func fake() throws -> StubHTTPServer { + try StubHTTPServer { req in + let m = (try? JSONCoding.decoder.decode(JSONRPCRequest.self, from: req.body))?.method + if m == "initialize" { return sse(["jsonrpc": "2.0", "id": 0, "result": [:]]) } + if m == "tools/list" { return sse(["jsonrpc": "2.0", "id": 1, "result": ["tools": [["name": "browser_navigate"]]]]) } + if m == "tools/call" { return sse(["jsonrpc": "2.0", "id": 1, "result": ["content": []]]) } + return .init(status: 202) + } + } + + func testSameUidIsReady() async throws { + let s = try fake(); defer { s.stop() } + let b = backend(PortOwner(pid: 7, uid: 501, name: "node"), s) + let st = await b.status() + XCTAssertEqual(st, .ready) + let summary = await b.ownerSummary + XCTAssertEqual(summary, "node (uid 501)") + } + + func testUnknownAndForeignOwnersFailClosedWithNoRequest() async throws { + let s = try fake(); defer { s.stop() } + for owner in [nil, PortOwner(pid: 7, uid: 0, name: "node")] { + let b = backend(owner, s) + let st = await b.status() + if case .unavailable = st {} else { XCTFail("not unavailable: \(st)") } + let t = await b.tools() + XCTAssertTrue(t.isEmpty) + do { _ = try await b.call("browser_navigate", arguments: [:]); XCTFail("call went through") } catch {} + } + XCTAssertTrue(s.requests.isEmpty, "a request reached a listener that failed the ownership check") + } + + func testManagedModeIsNotSetUpYet() async { + let b = PlaywrightBackend() + let st = await b.status() + XCTAssertEqual(st, .needsSetup("playwright_not_installed")) + let mode = await b.mode + XCTAssertEqual(mode, "managed") + } + + func testLibprocFindsOurOwnListener() throws { + let s = try fake(); defer { s.stop() } + let o = LibprocPortOwnerProbe().owner(ofLoopbackPort: s.port) + XCTAssertEqual(o?.pid, getpid()) + XCTAssertEqual(o?.uid, geteuid()) + } +} + +final class BrowserSecretHygieneTests: XCTestCase { + func testBackendErrorsAndLogsCarryOnlyClasses() async throws { + let canary = "CANARY-token-abc123" + let s = try StubHTTPServer { _ in .text(502, "\(canary)\n") }; defer { s.stop() } + nonisolated(unsafe) var lines: [String] = [] + let lock = NSLock() + let b = PlaywrightBackend(external: ExternalPlaywrightClient(url: URL(string: "http://127.0.0.1:\(s.port)/mcp")!, timeout: 5), + port: s.port, probe: FakePortOwnerProbe(result: PortOwner(pid: 1, uid: 501, name: "node")), + trust: .operatorFlag, euid: 501, log: { l in lock.withLock { lines.append(l) } }) + let router = BrowserRouter(playwright: b, store: MemoryPreferenceStore(.playwright), + log: { l in lock.withLock { lines.append(l) } }) + let res = await router.call("browser_navigate", arguments: ["url": .string(canary)], BrowserConnection(key: "k")) + let all = String(decoding: jsonBody(res), as: UTF8.self) + lines.joined() + XCTAssertFalse(all.contains("CANARY"), all) + } +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 6af1951..4a1f42a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -120,7 +120,7 @@ local tool is served under `owner` only, except `sys_info` and `screenshot`, whi | `ui_snapshot` | `UISnapshotTool` (`UISnapshot/`, AXUIElement) | no (Linux AT-SPI deferred, #102) | `observe`-class on macOS; `owner` only (not in `observe` in phase 1) | `profile_vectors.json` `macos` lists; Swift `UISnapshotWalkerTests`/`UISnapshotTargetTests`/`UISnapshotToolTests` (fake AX provider, no GUI); Rust `profile_conformance::macos_only_tools_are_not_served_on_linux` | | `ui_action` | `UIActionTool` (`UISnapshot/UIAction.swift`, `UISnapshotCache.swift`; AX actions, no pointer movement) | no (Linux AT-SPI deferred, #102) | `shell` on macOS; `owner` only | `profile_vectors.json` `macos` lists; Swift `UIActionToolTests`/`UISnapshotCacheTests` (fake AX provider, no GUI), `ProfileBoundaryTests.testUIActionIsOwnerOnlyShellClass`; Rust `profile_conformance::macos_only_tools_are_not_served_on_linux` | | `exec_start`, `exec_poll`, `exec_list`, `exec_cancel` | `AsyncExecTools`, `JobRegistry` | yes (`src/jobs/`, #32 item 4): same names, arguments and `structuredContent`; `bash -c` instead of `zsh -f -c`; logs under `$XDG_STATE_HOME/oab-computer/jobs/`; `shell`-class, `owner` only (Swift classes `exec_start` as `shell`, `exec_poll`/`exec_list` as `observe` and `exec_cancel` as `act`; both expose the four to `owner` only, so the gating is the same) | Swift: `exec_start` `shell`, `exec_poll`/`exec_list` `observe`, `exec_cancel` `act`; Rust: all `shell`. `owner` only on both | Rust unit tests `jobs::tests` and `smoke.sh` (start, offset polls, survives an attach drop, cancel → `killed`); no shared vectors. Differences: Rust keeps the 10 most recent finished jobs (Swift: finished metadata for ~10 min), pages `exec_poll` at 1 MiB per stream, and refuses a `signal` other than `KILL`/`TERM`. A job stays `running` until its shell has exited and its process group is empty (Swift: until its output pipes close), so a child the shell left behind can still be cancelled or timed out | -| `browser_*` (upstream Playwright) | `UpstreamMCP` | yes (`mcp.rs` `Upstream`, `MCP_UPSTREAM`) | not classified locally; every upstream tool is `owner` only on both, `observe` gets none (#59) | `profile_vectors.json` membership on both; the full 32-tool fixture through a fake upstream: `BrowserProfileTests` (Swift), `profile_conformance::owner_reaches_every_upstream_tool_and_observe_none` (Rust) | +| `browser_*` (upstream Playwright) | `BrowserRouter` (macOS), `UpstreamMCP` (Linux) | yes (`mcp.rs` `Upstream`, `MCP_UPSTREAM`) | `owner` only on both, `observe` gets none (#59). **Gap:** macOS serves only the 32 catalog names and filters unknown `browser_*` names; Linux passes every upstream tool through (#119) | `profile_vectors.json` membership on both; the full 32-tool fixture through a fake upstream: `BrowserProfileTests` (Swift), `profile_conformance::owner_reaches_every_upstream_tool_and_observe_none` (Rust) | ## 6. Profile rows @@ -134,7 +134,7 @@ runner is `profile_conformance`. | exactly two profiles, `owner` and `observe`; one source constant | `ToolProfile.allCases`, `ProfileBoundaryTests.testOnlyOwnerAndObserveExist` | `mcp::PROFILES`, `profile_tests` | pass (vectors `profiles`) | | per-connection enforcement at dispatch: `observe` serves exactly `sys_info` and `screenshot`, `owner` every local tool | `MCPServer.scoped(to:)`, `ProfileBoundaryTests` | `local_tool_allowed` in `tool_list` / `handle_tool_call`, `profile_tests` | pass (vectors `served`, `local_tools`) | | every local tool classified; an unclassified tool fails | `testEveryServedToolIsClassified` | `every_served_tool_is_classified_and_the_lists_agree` | pass (vectors `tool_class`) | -| browser tools: `owner` gets the full upstream set, including tools not in the fixture; `observe` none | `BrowserProfileTests` | `profile_conformance::owner_reaches_every_upstream_tool_and_observe_none` | pass | +| browser tools: `owner` gets the 32 catalog names (macOS filters names not in the fixture, Linux passes them through); `observe` none | `BrowserProfileTests`, `BrowserServerWiringTests` | `profile_conformance::owner_reaches_every_upstream_tool_and_observe_none` | pass | | a hidden tool on `tools/call` is *unknown tool* (local and upstream) | `ToolProfileTests`, `BrowserProfileTests` | `job_tools_are_shell_class_and_owner_only`, `profile_conformance` | pass (#78 + #59) | | the long-call guard on a reverse attach does not name a hidden tool | `testLongExecUnderObserveIsAnUnknownTool` | `long_work_advice_follows_the_job_tools` | pass | | bare attach (no or non-string `profile`) gets `observe` | `AttachEndpointTests.testCreateListRevoke` | `attach_profile_tests` | pass (vectors `attach_requests`) | diff --git a/docs/tool-profiles.md b/docs/tool-profiles.md index 88f2833..17020a1 100644 --- a/docs/tool-profiles.md +++ b/docs/tool-profiles.md @@ -74,8 +74,12 @@ Linux has no `osascript`, and no `ui_snapshot` or `ui_action` yet (AT-SPI versio [#102](https://github.com/openabdev/openab-computer/issues/102)). `bash` is the counterpart of macOS `exec`; the four job tools have the same names and contract on both (#32 item 4). -`owner` gets every upstream tool, so a tool that Playwright adds later appears under `owner` -without a code change. The browser runs with the computer's **persistent profile**, including +On macOS, `owner` gets the 32 `browser_*` tools in `BrowserToolCatalog`, served by the browser +router (`--upstream browser=URL` is its external Playwright; the URL host must be the literal +`127.0.0.1` or `::1`, redirects are refused, and the listener's owner uid must equal the daemon's). +A tool Playwright adds later is filtered out until the catalog, `profile_vectors.json` and the +tests are updated together (R1.5). Linux still passes every upstream tool through. Other +`--upstream name=URL` upstreams keep the old rule: `owner` gets every tool they offer. The browser runs with the computer's **persistent profile**, including cookies for sites it is logged in to, and it reaches whatever network the computer reaches, localhost and the tailnet included. `browser_evaluate` and `browser_run_code_unsafe` run arbitrary JavaScript in that profile. diff --git a/scripts/test-pw-mcp-check.sh b/scripts/test-pw-mcp-check.sh index c33bddf..1571dae 100755 --- a/scripts/test-pw-mcp-check.sh +++ b/scripts/test-pw-mcp-check.sh @@ -28,6 +28,14 @@ fi echo "guard ok: no tailscale serve call under $DIR" [ -n "${1:-}" ] && exit 0 +# R7.5.6: the browser backend code never mentions tailscale (it must not map 8794 or 8443). +BROWSER_DIR="$ROOT/Sources/InstanceMCPCore/Browser" +if [ -d "$BROWSER_DIR" ] && grep -rIil tailscale "$BROWSER_DIR" >/dev/null; then + echo "FAIL: tailscale mentioned under $BROWSER_DIR (R7.5.6):" >&2 + grep -rIil tailscale "$BROWSER_DIR" >&2; exit 1 +fi +echo "guard ok: no tailscale under Sources/InstanceMCPCore/Browser" + T=$(mktemp -d); trap 'rm -rf "$T"' EXIT mkdir "$T/bin" stub() { # $1 = mode From 171058db9ea3e96dfa49f9c80fcb06724690b904 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Sat, 14 Nov 2026 13:14:29 -0500 Subject: [PATCH 2/2] fix: use documentation placeholder IP in BrowserCatalogTests Ran: scripts/check-no-private-identifiers.sh (ok); swift test --filter Browser on macmini (24 tests, 0 failures). --- Tests/InstanceMCPCoreTests/BrowserCatalogTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/InstanceMCPCoreTests/BrowserCatalogTests.swift b/Tests/InstanceMCPCoreTests/BrowserCatalogTests.swift index ef97070..acfe92c 100644 --- a/Tests/InstanceMCPCoreTests/BrowserCatalogTests.swift +++ b/Tests/InstanceMCPCoreTests/BrowserCatalogTests.swift @@ -36,7 +36,7 @@ final class BrowserUpstreamURLTests: XCTestCase { XCTAssertNoThrow(try BrowserUpstreamURL.validate(URL(string: "http://127.0.0.1:8794/mcp")!)) XCTAssertNoThrow(try BrowserUpstreamURL.validate(URL(string: "http://[::1]:8794/mcp")!)) for bad in ["http://localhost:8794/mcp", "http://example.com:8794/mcp", "http://0.0.0.0:8794/mcp", - "http://192.168.0.5:8794/mcp", "https://127.0.0.1:8794/mcp"] { + "http://192.0.2.5:8794/mcp", "https://127.0.0.1:8794/mcp"] { XCTAssertThrowsError(try BrowserUpstreamURL.validate(URL(string: bad)!), bad) } }