diff --git a/Package.swift b/Package.swift
index 624025b..2c8f46f 100644
--- a/Package.swift
+++ b/Package.swift
@@ -27,11 +27,16 @@ let package = Package(
dependencies: ["InstanceMCPCore"],
path: "Sources/oab-computer"
),
+ // Test helper only (not a product): a stand-in MCP stdio server for the managed Playwright tests.
+ .executableTarget(
+ name: "FakeMCPStdioServer",
+ path: "Tests/FakeMCPStdioServer"
+ ),
.testTarget(
name: "InstanceMCPCoreTests",
// The executable is a dependency only so it is built beside the tests: one test runs it with the hidden
// permission-check argument.
- dependencies: ["InstanceMCPCore", "oab-computer"],
+ dependencies: ["InstanceMCPCore", "oab-computer", "FakeMCPStdioServer"],
path: "Tests/InstanceMCPCoreTests"
),
]
diff --git a/README.md b/README.md
index f1ae25a..76ca533 100644
--- a/README.md
+++ b/README.md
@@ -513,6 +513,14 @@ Playwright's tools under its own `tools/list`, filtered by the connection's prof
window on this Mac's desktop, so Connect's Screens pane shows what the agent is doing.
- Upstream down → its tools are absent from `tools/list`; everything else works. The upstream's
`Mcp-Session-Id` is re-established automatically. Local tool names win on collision.
+- **Managed Playwright (no listener).** Without `--upstream browser=…` the app can run an installed
+ Playwright MCP itself, as a child process over stdio: nothing listens on any port. It looks under
+ `~/Library/Application Support/oab-computer/playwright/` (`--playwright-root
` changes it;
+ `install.json` there names the install). The child starts on first use, is restarted with a
+ backoff (1 s, 2 s, 4 s, up to 30 s) and is marked unavailable after 5 failures in 2 minutes. Its
+ stderr is kept in memory only and is never written to a log. With nothing installed, the browser
+ tools are not listed, a call says Playwright is not installed, and `sys_info` and the menu show
+ the same. This build has no installer for it yet.
## Download and install
diff --git a/Sources/InstanceMCPCore/Browser/BrowserBackend.swift b/Sources/InstanceMCPCore/Browser/BrowserBackend.swift
index 036b5d7..8a3eabb 100644
--- a/Sources/InstanceMCPCore/Browser/BrowserBackend.swift
+++ b/Sources/InstanceMCPCore/Browser/BrowserBackend.swift
@@ -28,14 +28,28 @@ public struct BrowserBackendError: Error, Equatable, Sendable {
/// 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.
+ /// Never prompts. A managed backend may start its child here (and the first call after an
+ /// install does), so callers must not poll it; the router asks only the backends it needs.
func status() async -> BrowserBackendStatus
+ /// The last known state without starting or probing anything, for menus and `sys_info`.
+ /// `nil` means nothing is known.
+ func peekStatus() async -> BrowserBackendStatus?
+ /// `managed` or `external`, no URLs. `nil` for a backend with no such notion.
+ var mode: String? { get async }
+ /// Process name and uid of an external listener, names only.
+ var ownerSummary: String? { get async }
/// 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
}
+extension BrowserBackend {
+ public func peekStatus() async -> BrowserBackendStatus? { nil }
+ public var mode: String? { get async { nil } }
+ public var ownerSummary: String? { get async { nil } }
+}
+
public enum BrowserPreference: String, Sendable, CaseIterable {
case auto, appleEvents, playwright
}
diff --git a/Sources/InstanceMCPCore/Browser/BrowserRouter.swift b/Sources/InstanceMCPCore/Browser/BrowserRouter.swift
index be5aee8..7c85fd9 100644
--- a/Sources/InstanceMCPCore/Browser/BrowserRouter.swift
+++ b/Sources/InstanceMCPCore/Browser/BrowserRouter.swift
@@ -12,6 +12,7 @@ public struct BrowserConnection: Sendable, Equatable {
/// Phase 1 fills only the Playwright slot; the Apple Events slot is left open.
public actor BrowserRouter {
public static let guardWindow: TimeInterval = 30 * 60
+ static let maxTracked = 1024
public static let notSetUp = "Browser tools are not set up. Open OpenAB Computer, then Browser tools."
private let appleEvents: (any BrowserBackend)?
@@ -40,27 +41,36 @@ public actor BrowserRouter {
id == .appleEvents ? appleEvents : playwright
}
+ /// Statuses are asked lazily, in the order the rules need them, because asking a managed
+ /// backend may start its child (R2, R2.1).
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
+ func ready(_ id: BrowserBackendID) async -> Bool { await backend(id)?.status().isReady ?? false }
switch pref {
- case .appleEvents: return aReady ? .active(appleEvents!) : .none
- case .playwright: return pReady ? .active(playwright!) : .none
+ case .appleEvents: return await ready(.appleEvents) ? .active(appleEvents!) : .none
+ case .playwright: return await ready(.playwright) ? .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) }
+ if await ready(l.id), 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
+ return await ready(other) ? .guarded(other: other) : .none
}
- if pReady { return .active(playwright!) }
- if aReady { return .active(appleEvents!) }
+ if await ready(.playwright) { return .active(playwright!) }
+ if await ready(.appleEvents) { return .active(appleEvents!) }
return .none
}
}
+ private func prune() {
+ let now = clock()
+ last = last.filter { now.timeIntervalSince($0.value.at) < Self.guardWindow }
+ if last.count > Self.maxTracked {
+ for (k, _) in last.sorted(by: { $0.value.at < $1.value.at }).prefix(last.count - Self.maxTracked) { last[k] = nil }
+ }
+ }
+
+ var trackedConnections: Int { last.count }
+
/// 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 [] }
@@ -72,8 +82,14 @@ public actor BrowserRouter {
public func call(_ name: String, arguments: JSONValue, _ conn: BrowserConnection) async -> JSONValue {
let backend: any BrowserBackend
+ prune()
switch await select(conn) {
- case .none: return Self.errorResult(Self.notSetUp, backend: nil)
+ case .none:
+ var text = Self.notSetUp
+ if let p = playwright, case .needsSetup("playwright_not_installed")? = await p.peekStatus() {
+ text += " Playwright is not installed."
+ }
+ return Self.errorResult(text, 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 "
@@ -114,3 +130,76 @@ public actor BrowserRouter {
return .object(o)
}
}
+
+// MARK: reporting (R1.6, R5.9)
+
+/// A non-sensitive snapshot of the browser tiers: states and fixed classes, never a URL, tab
+/// title or page text.
+public struct BrowserReport: Equatable, Sendable {
+ public struct Entry: Equatable, Sendable {
+ public let id: BrowserBackendID
+ public let state: String // ready | needs_setup | unavailable
+ public let errorClass: String?
+ public let mode: String? // managed | external
+ public let ownerSummary: String?
+ }
+ public let preference: BrowserPreference
+ public let active: BrowserBackendID?
+ public let backends: [Entry]
+
+ /// One line for `sys_info` text.
+ public var line: String {
+ let parts = backends.map { e -> String in
+ let name = e.id == .playwright ? "playwright" : "apple events"
+ let m = e.mode.map { " (\($0))" } ?? ""
+ switch e.state {
+ case "ready": return "\(name)\(m) ready"
+ case "needs_setup" where e.errorClass == "playwright_not_installed": return "\(name) not installed"
+ default: return "\(name)\(m) \(e.state.replacingOccurrences(of: "_", with: " "))\(e.errorClass.map { " (\($0))" } ?? "")"
+ }
+ }
+ return "browser tools: " + (parts.isEmpty ? "none" : parts.joined(separator: ", "))
+ }
+
+ public var json: JSONValue {
+ [
+ "preference": .string(preference.rawValue),
+ "active": active.map { .string($0.rawValue) } ?? .null,
+ "backends": .array(backends.map { e in
+ var o: [String: JSONValue] = ["id": .string(e.id.rawValue), "state": .string(e.state)]
+ if let c = e.errorClass { o["class"] = .string(c) }
+ if let m = e.mode { o["mode"] = .string(m) }
+ if let w = e.ownerSummary { o["owner"] = .string(w) }
+ return .object(o)
+ }),
+ ]
+ }
+}
+
+extension BrowserRouter {
+ /// Built from `peekStatus`, so reading it never starts a child or probes a listener. The
+ /// active backend is the one `auto` would pick from what is known.
+ public func report(_ conn: BrowserConnection? = nil) async -> BrowserReport {
+ let pref = conn?.override ?? store.load()
+ var entries: [BrowserReport.Entry] = []
+ var firstReady: BrowserBackendID?
+ for b in [playwright, appleEvents].compactMap({ $0 }) {
+ let st = await b.peekStatus() ?? .unavailable("not_checked")
+ let (state, cls): (String, String?)
+ switch st {
+ case .ready: (state, cls) = ("ready", nil); if firstReady == nil { firstReady = b.id }
+ case .needsSetup(let c): (state, cls) = ("needs_setup", c)
+ case .unavailable(let c): (state, cls) = ("unavailable", c)
+ }
+ entries.append(.init(id: b.id, state: state, errorClass: cls, mode: await b.mode, ownerSummary: await b.ownerSummary))
+ }
+ var active: BrowserBackendID?
+ switch pref {
+ case .auto: active = firstReady
+ case .appleEvents, .playwright:
+ let want: BrowserBackendID = pref == .appleEvents ? .appleEvents : .playwright
+ active = entries.first { $0.id == want && $0.state == "ready" }?.id
+ }
+ return BrowserReport(preference: pref, active: active, backends: entries)
+ }
+}
diff --git a/Sources/InstanceMCPCore/Browser/BrowserUpstreamURL.swift b/Sources/InstanceMCPCore/Browser/BrowserUpstreamURL.swift
index 3bf2cf1..21ee29a 100644
--- a/Sources/InstanceMCPCore/Browser/BrowserUpstreamURL.swift
+++ b/Sources/InstanceMCPCore/Browser/BrowserUpstreamURL.swift
@@ -14,4 +14,20 @@ public enum BrowserUpstreamURL {
throw Invalid(description: "browser upstream host must be the literal 127.0.0.1 or ::1")
}
}
+
+ /// A validated loopback listener address: the family follows the literal host.
+ public struct Endpoint: Equatable, Sendable {
+ public enum Family: Equatable, Sendable { case v4, v6 }
+ public let family: Family
+ public let port: UInt16
+ public init(family: Family, port: UInt16) { self.family = family; self.port = port }
+ }
+
+ /// `validate`, then the family and a range-checked port (no port means 80).
+ public static func endpoint(_ url: URL) throws -> Endpoint {
+ try validate(url)
+ let p = url.port ?? 80
+ guard (1...65535).contains(p) else { throw Invalid(description: "browser upstream port must be 1-65535") }
+ return Endpoint(family: url.host == "::1" ? .v6 : .v4, port: UInt16(p))
+ }
}
diff --git a/Sources/InstanceMCPCore/Browser/ExternalPlaywrightClient.swift b/Sources/InstanceMCPCore/Browser/ExternalPlaywrightClient.swift
index ec25768..6ee3dc2 100644
--- a/Sources/InstanceMCPCore/Browser/ExternalPlaywrightClient.swift
+++ b/Sources/InstanceMCPCore/Browser/ExternalPlaywrightClient.swift
@@ -1,11 +1,13 @@
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 {
+/// A failure from a Playwright transport, external or managed. `errorClass` is one of the fixed
+/// strings (R7.5.4 for the external path) and never carries upstream-supplied text.
+public struct PlaywrightTransportError: Error, Equatable, Sendable, CustomStringConvertible {
public let errorClass: String
+ public init(errorClass: String) { self.errorClass = errorClass }
public var description: String { errorClass }
}
+public typealias ExternalPlaywrightError = PlaywrightTransportError
/// What the Playwright backend needs from a transport. Phase 1 has the external HTTP client;
/// the managed stdio client arrives next.
@@ -13,6 +15,15 @@ public protocol PlaywrightTransport: Sendable {
func healthCheck() async throws
func listTools() async throws -> [JSONValue]
func callTool(_ name: String, arguments: JSONValue) async throws -> JSONValue
+ /// Stops anything the transport started. A no-op for an external listener.
+ func shutdown() async
+ /// True once a managed child failed too often and waits for Retry.
+ var isLatched: Bool { get async }
+}
+
+extension PlaywrightTransport {
+ public func shutdown() async {}
+ public var isLatched: Bool { get async { false } }
}
private final class RefuseRedirects: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
@@ -30,14 +41,31 @@ public actor ExternalPlaywrightClient: PlaywrightTransport {
private let session: URLSession
private var sessionID: String?
+ /// Health requests and the best-effort session DELETE (R2.1).
+ public static let healthTimeout: TimeInterval = 2
+ private let timeout: TimeInterval
+
public init(url: URL, timeout: TimeInterval = 90) {
self.url = url
+ self.timeout = timeout
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = timeout
self.session = URLSession(configuration: cfg, delegate: RefuseRedirects(), delegateQueue: nil)
}
- public func healthCheck() async throws { try await initialize() }
+ /// With a session: a `ping` (no new `initialize`, so the session id is not replaced). Without
+ /// one, or when the upstream forgot it: one `initialize`. Always 2 s.
+ public func healthCheck() async throws {
+ guard sessionID != nil else { try await initialize(timeout: Self.healthTimeout); return }
+ let (status, body, _) = try await post(Self.message("ping", [:]), timeout: Self.healthTimeout)
+ if status == 400 || status == 404 {
+ if status == 400 { await closeSession() } else { sessionID = nil } // 404: already gone upstream
+ try await initialize(timeout: Self.healthTimeout)
+ return
+ }
+ try Self.check(status)
+ _ = try Self.result(body)
+ }
public func listTools() async throws -> [JSONValue] {
let r = try await rpc("tools/list", params: [:])
@@ -54,7 +82,7 @@ public actor ExternalPlaywrightClient: PlaywrightTransport {
if sessionID == nil { try await initialize() }
var (status, body, _) = try await post(Self.message(method, params))
if status == 400 || status == 404 {
- sessionID = nil
+ if status == 400 { await closeSession() } else { sessionID = nil } // 404: already gone upstream
try await initialize()
(status, body, _) = try await post(Self.message(method, params))
}
@@ -62,29 +90,50 @@ public actor ExternalPlaywrightClient: PlaywrightTransport {
return try Self.result(body)
}
- private func initialize() async throws {
+ private func initialize(timeout: TimeInterval? = nil) async throws {
+ if sessionID != nil { await closeSession() }
let (status, body, headers) = try await post(Self.message("initialize", [
"protocolVersion": "2025-06-18", "capabilities": [:],
"clientInfo": ["name": "oab-computer", "version": "browser"],
- ], id: 0))
+ ], id: 0), timeout: timeout)
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"])
+ _ = try? await post(["jsonrpc": "2.0", "method": "notifications/initialized"], timeout: timeout)
+ }
+
+ /// Best-effort `DELETE` of the current session; errors are ignored and the id is cleared.
+ private func closeSession() async {
+ guard let sid = sessionID else { return }
+ sessionID = nil
+ var req = URLRequest(url: url)
+ req.httpMethod = "DELETE"
+ req.timeoutInterval = Self.healthTimeout
+ req.setValue(hostHeader, forHTTPHeaderField: "Host")
+ req.setValue(sid, forHTTPHeaderField: "Mcp-Session-Id")
+ _ = try? await session.data(for: req)
+ }
+
+ var hostHeaderForTests: String { hostHeader }
+
+ /// No port, the same as the IPv4 form has always sent; the allowed-hosts list compares verbatim.
+ private var hostHeader: String {
+ let h = url.host ?? ""
+ return h.contains(":") ? "[\(h)]" : h
}
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]) {
+ private func post(_ msg: JSONValue, timeout: TimeInterval? = nil) 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") }
+ req.timeoutInterval = timeout ?? self.timeout
+ req.setValue(hostHeader, forHTTPHeaderField: "Host")
if let sid = sessionID { req.setValue(sid, forHTTPHeaderField: "Mcp-Session-Id") }
do {
let (data, resp) = try await session.data(for: req)
diff --git a/Sources/InstanceMCPCore/Browser/ManagedPlaywright.swift b/Sources/InstanceMCPCore/Browser/ManagedPlaywright.swift
new file mode 100644
index 0000000..09494e8
--- /dev/null
+++ b/Sources/InstanceMCPCore/Browser/ManagedPlaywright.swift
@@ -0,0 +1,219 @@
+import Foundation
+import Darwin
+
+/// Everything needed to start the managed Playwright MCP (R7.4). There is no `--port`, `--host`
+/// or `--allowed-hosts`: stdio is the transport and nothing listens.
+public struct ManagedLaunchSpec: Equatable, Sendable {
+ public let executable: String
+ public let arguments: [String]
+ public let environment: [String: String]
+ public let workingDirectory: String
+}
+
+/// Finds an installed Playwright under `root`. The install itself (download and verify) is not
+/// part of this code; `install.json` is read, never written:
+/// `{ "version": "", "chromiumExecutable": "/>" }`.
+/// Inside `//`: `node/bin/node` and `pkg/node_modules/@playwright/mcp/cli.js`.
+public struct ManagedPlaywrightLocator: Sendable {
+ public let root: URL
+
+ public init(root: URL) { self.root = root }
+
+ public static var defaultRoot: URL {
+ URL(fileURLWithPath: NSHomeDirectory())
+ .appendingPathComponent("Library/Application Support/oab-computer/playwright", isDirectory: true)
+ }
+
+ private struct Manifest: Decodable { let version: String; let chromiumExecutable: String }
+
+ public func locate() -> Result {
+ let fm = FileManager.default
+ let manifestURL = root.appendingPathComponent("install.json")
+ guard fm.fileExists(atPath: manifestURL.path) else {
+ return .failure(.init(errorClass: "playwright_not_installed"))
+ }
+ let invalid = Result.failure(.init(errorClass: "playwright_install_invalid"))
+ guard let data = try? Data(contentsOf: manifestURL),
+ let m = try? JSONDecoder().decode(Manifest.self, from: data),
+ !m.version.isEmpty, !m.version.contains("/"), m.version != ".", m.version != ".." else { return invalid }
+ let base = root.appendingPathComponent(m.version, isDirectory: true).resolvingSymlinksInPath()
+ func inside(_ rel: String, executable: Bool) -> String? {
+ let u = base.appendingPathComponent(rel).resolvingSymlinksInPath()
+ guard u.path.hasPrefix(base.path + "/") else { return nil }
+ var isDir: ObjCBool = false
+ guard fm.fileExists(atPath: u.path, isDirectory: &isDir), !isDir.boolValue else { return nil }
+ if executable && !fm.isExecutableFile(atPath: u.path) { return nil }
+ return u.path
+ }
+ guard let node = inside("node/bin/node", executable: true),
+ let cli = inside("pkg/node_modules/@playwright/mcp/cli.js", executable: false),
+ let chromium = inside(m.chromiumExecutable, executable: true) else { return invalid }
+ let profile = root.appendingPathComponent("profile").path
+ let output = root.appendingPathComponent("output").path
+ for d in [profile, output] {
+ do { try fm.createDirectory(atPath: d, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) }
+ catch { return .failure(.init(errorClass: "playwright_install_invalid")) }
+ }
+ let nodeDir = (node as NSString).deletingLastPathComponent
+ let env: [String: String] = [
+ "HOME": NSHomeDirectory(), "TMPDIR": NSTemporaryDirectory(),
+ "LANG": ProcessInfo.processInfo.environment["LANG"] ?? "en_US.UTF-8",
+ "PATH": "\(nodeDir):/usr/bin:/bin",
+ ]
+ let args = [cli, "--user-data-dir", profile, "--output-dir", output, "--idle-timeout", "1800000",
+ "--browser", "chromium", "--executable-path", chromium, "--shared-browser-context",
+ "--caps", "vision,pdf"]
+ return .success(ManagedLaunchSpec(executable: node, arguments: args, environment: env,
+ workingDirectory: root.path))
+ }
+}
+
+/// A running child, as the transport sees it.
+public protocol ManagedChild: AnyObject, Sendable {
+ var channel: any StdioChannel { get }
+ var hasExited: Bool { get }
+ var exitInfo: ExitInfo? { get }
+ var stderrClass: StderrClass? { get }
+ func terminate() async
+}
+
+extension ManagedProcess: ManagedChild {
+ public var channel: any StdioChannel { self }
+ public var stderrClass: StderrClass? { stderrRing.classify() }
+}
+
+public protocol ChildLauncher: Sendable {
+ func launch(_ spec: ManagedLaunchSpec) throws -> any ManagedChild
+}
+
+public struct ProcessChildLauncher: ChildLauncher {
+ public init() {}
+ public func launch(_ spec: ManagedLaunchSpec) throws -> any ManagedChild {
+ try ManagedProcess.spawn(executable: spec.executable, arguments: spec.arguments,
+ environment: spec.environment, workingDirectory: spec.workingDirectory)
+ }
+}
+
+/// The managed Playwright transport: starts the child on demand, speaks stdio MCP to it and
+/// restarts it lazily, with backoff, after an unexpected exit. There is no timer: a child that
+/// died is started again by the next call, never by itself.
+public actor ManagedPlaywrightTransport: PlaywrightTransport {
+ public static let backoff: [TimeInterval] = [1, 2, 4, 8, 16, 30]
+ public static let failureWindow: TimeInterval = 120
+ public static let failureLimit = 5
+
+ private let spec: ManagedLaunchSpec
+ private let launcher: any ChildLauncher
+ private let clock: @Sendable () -> Date
+ private let log: @Sendable (String) -> Void
+ private let initTimeout: TimeInterval
+
+ private var child: (any ManagedChild)?
+ private var client: StdioMCPClient?
+ private var starting: Task?
+ private var failures: [Date] = []
+ private var step = 0
+ private var notBefore: Date?
+ public private(set) var latched = false
+
+ public init(spec: ManagedLaunchSpec, launcher: any ChildLauncher = ProcessChildLauncher(),
+ initializeTimeout: TimeInterval = StdioMCPClient.initializeTimeout,
+ clock: @escaping @Sendable () -> Date = { Date() }, log: @escaping @Sendable (String) -> Void = { _ in }) {
+ self.spec = spec; self.launcher = launcher; self.clock = clock; self.log = log
+ self.initTimeout = initializeTimeout
+ }
+
+ /// The delay the next failure would impose; for tests.
+ var nextBackoff: TimeInterval { Self.backoff[min(step, Self.backoff.count - 1)] }
+
+ public var isLatched: Bool { latched }
+
+ public func retry() {
+ latched = false; failures = []; step = 0; notBefore = nil
+ }
+
+ public func shutdown() async {
+ let c = child
+ child = nil; client = nil
+ await c?.terminate()
+ }
+
+ // MARK: PlaywrightTransport
+
+ public func healthCheck() async throws {
+ let fresh = child == nil
+ try await ensureRunning()
+ if fresh { resetBackoff(); return }
+ do { try await client!.ping(); resetBackoff() }
+ catch { await noteFailureIfDead(); throw error }
+ }
+
+ public func listTools() async throws -> [JSONValue] {
+ try await ensureRunning()
+ do { return try await client!.listTools() }
+ catch { await noteFailureIfDead(); throw error }
+ }
+
+ public func callTool(_ name: String, arguments: JSONValue) async throws -> JSONValue {
+ try await ensureRunning()
+ do { let r = try await client!.callTool(name, arguments: arguments); resetBackoff(); return r }
+ catch { await noteFailureIfDead(); throw error }
+ }
+
+ // MARK: supervision
+
+ private func resetBackoff() { step = 0 }
+
+ private func recordFailure() {
+ let now = clock()
+ failures = failures.filter { now.timeIntervalSince($0) < Self.failureWindow } + [now]
+ notBefore = now.addingTimeInterval(Self.backoff[min(step, Self.backoff.count - 1)])
+ step += 1
+ if failures.count >= Self.failureLimit { latched = true; log("browser: playwright_unavailable failures=\(failures.count)") }
+ }
+
+ /// If the child is gone (or its channel closed), log one classified line, record the failure
+ /// and drop it. A live child that merely timed out is left alone.
+ private func noteFailureIfDead() async {
+ guard let c = child else { return }
+ let closed = await client?.isClosed ?? true
+ guard c.hasExited || closed else { return }
+ let cls = c.stderrClass?.rawValue ?? "none"
+ log("browser: playwright_exit class=\(cls) \(c.exitInfo?.summary ?? "status=unknown")")
+ child = nil; client = nil
+ recordFailure()
+ await c.terminate()
+ }
+
+ private func ensureRunning() async throws {
+ if latched { throw PlaywrightTransportError(errorClass: "playwright_unavailable") }
+ if let t = starting { try await t.value; return }
+ await noteFailureIfDead()
+ if latched { throw PlaywrightTransportError(errorClass: "playwright_unavailable") }
+ if child != nil { return }
+ if let nb = notBefore, clock() < nb { throw PlaywrightTransportError(errorClass: "playwright_restarting") }
+ let t = Task { try await self.start() }
+ starting = t
+ defer { starting = nil }
+ try await t.value
+ }
+
+ private func start() async throws {
+ let c: any ManagedChild
+ do { c = try launcher.launch(spec) }
+ catch { recordFailure(); throw PlaywrightTransportError(errorClass: "playwright_spawn_failed") }
+ let cl = StdioMCPClient(channel: c.channel)
+ do {
+ try await cl.initialize(timeout: initTimeout)
+ } catch {
+ let cls = c.stderrClass
+ log("browser: playwright_start_failed class=\(cls?.rawValue ?? "none") \(c.exitInfo?.summary ?? "status=running")")
+ await cl.close()
+ await c.terminate()
+ recordFailure()
+ if let cls { throw PlaywrightTransportError(errorClass: cls.rawValue) }
+ throw (error as? PlaywrightTransportError) ?? PlaywrightTransportError(errorClass: "playwright_exited")
+ }
+ child = c; client = cl
+ }
+}
diff --git a/Sources/InstanceMCPCore/Browser/ManagedProcess.swift b/Sources/InstanceMCPCore/Browser/ManagedProcess.swift
new file mode 100644
index 0000000..abab20f
--- /dev/null
+++ b/Sources/InstanceMCPCore/Browser/ManagedProcess.swift
@@ -0,0 +1,235 @@
+import Foundation
+import Darwin
+
+/// What the child's stderr said, as an app-defined class. The text itself is never kept past the
+/// in-memory ring, never logged and never returned (R7.4).
+public enum StderrClass: String, Sendable, Equatable {
+ case profileLocked = "profile_locked"
+ case executableNotFound = "executable_not_found"
+ case moduleNotFound = "module_not_found"
+ case launchRefused = "launch_refused"
+}
+
+/// A 64 KiB in-memory ring of the newest stderr bytes. There is no accessor that returns the
+/// text: the only way out is `classify()`, which yields a fixed class.
+public final class StderrRing: @unchecked Sendable {
+ public static let capacity = 64 * 1024
+ private let lock = NSLock()
+ private var data = Data()
+
+ public init() {}
+
+ public func append(_ chunk: Data) {
+ lock.withLock {
+ data.append(chunk)
+ if data.count > Self.capacity { data = Data(data.suffix(Self.capacity)) }
+ }
+ }
+
+ /// The signature that occurs last in the buffer wins, so a stale message does not mask a new one.
+ public func classify() -> StderrClass? {
+ let text = lock.withLock { String(decoding: data, as: UTF8.self) }.lowercased()
+ let table: [(String, StderrClass)] = [
+ ("singleton lock", .profileLocked), ("processsingleton", .profileLocked),
+ ("executable doesn't exist", .executableNotFound), ("cannot find module", .moduleNotFound),
+ ("eacces", .launchRefused), ("operation not permitted", .launchRefused),
+ ]
+ var best: (Int, StderrClass)?
+ for (sig, cls) in table {
+ if let r = text.range(of: sig, options: .backwards) {
+ let at = text.distance(from: text.startIndex, to: r.lowerBound)
+ if best == nil || at > best!.0 { best = (at, cls) }
+ }
+ }
+ return best?.1
+ }
+
+ var byteCount: Int { lock.withLock { data.count } }
+ func snapshotForTests() -> String { lock.withLock { String(decoding: data, as: UTF8.self) } }
+}
+
+public enum ExitInfo: Equatable, Sendable {
+ case exited(Int32)
+ case signaled(Int32)
+ var summary: String {
+ switch self { case .exited(let c): return "status=\(c)"; case .signaled(let s): return "signal=\(s)" }
+ }
+}
+
+/// Process groups of live managed children, in memory only. `terminateAllSync` runs while the app
+/// exits, so it blocks briefly by design.
+public final class ManagedChildRegistry: @unchecked Sendable {
+ public static let shared = ManagedChildRegistry()
+ private let lock = NSLock()
+ private var groups: Set = []
+
+ func add(_ g: pid_t) { lock.withLock { _ = groups.insert(g) } }
+ func remove(_ g: pid_t) { lock.withLock { _ = groups.remove(g) } }
+ var count: Int { lock.withLock { groups.count } }
+
+ public func terminateAllSync() {
+ let gs = lock.withLock { groups }
+ guard !gs.isEmpty else { return }
+ for g in gs { killpg(g, SIGTERM) }
+ for _ in 0..<40 {
+ if lock.withLock({ groups.isEmpty }) { return }
+ usleep(50_000)
+ }
+ for g in lock.withLock({ groups }) { killpg(g, SIGKILL) }
+ for _ in 0..<20 {
+ if lock.withLock({ groups.isEmpty }) { return }
+ usleep(50_000)
+ }
+ }
+}
+
+/// A child process that speaks MCP over stdio. It leads its own process group, so Chromium and
+/// other helpers can be signalled together. Limitation: a helper that calls `setsid` leaves the
+/// group, and an orphan left by a crashed app is not killed because the app cannot prove it is
+/// ours (R7.4).
+public final class ManagedProcess: StdioChannel, @unchecked Sendable {
+ public let pid: pid_t
+ public let stderrRing = StderrRing()
+ public let incoming: AsyncStream
+
+ private let continuation: AsyncStream.Continuation
+ private let lock = NSLock()
+ private var stdinFD: Int32
+ private var exitStatus: ExitInfo?
+ private let writeQueue = DispatchQueue(label: "oab.managed.stdin")
+ private var onExit: (@Sendable (ExitInfo) -> Void)?
+ private let stdoutDone = DispatchSemaphore(value: 0)
+
+ public var exitInfo: ExitInfo? { lock.withLock { exitStatus } }
+ public var hasExited: Bool { exitInfo != nil }
+
+ private init(pid: pid_t, stdinFD: Int32, stdoutFD: Int32, stderrFD: Int32) {
+ self.pid = pid; self.stdinFD = stdinFD
+ var c: AsyncStream.Continuation!
+ incoming = AsyncStream(bufferingPolicy: .unbounded) { c = $0 }
+ continuation = c
+ ManagedChildRegistry.shared.add(pid)
+ Self.startReader(fd: stdoutFD) { [weak self] chunk in self?.continuation.yield(chunk) } done: { [weak self] in
+ self?.stdoutDone.signal()
+ }
+ Self.startReader(fd: stderrFD) { [weak self] chunk in self?.stderrRing.append(chunk) } done: {}
+ let t = Thread { [weak self] in self?.waitForChild() }
+ t.stackSize = 256 * 1024
+ t.start()
+ }
+
+ /// `environment` is used as given: nothing is inherited.
+ public static func spawn(executable: String, arguments: [String], environment: [String: String],
+ workingDirectory: String,
+ onExit: (@Sendable (ExitInfo) -> Void)? = nil) throws -> ManagedProcess {
+ var inP = [Int32](repeating: -1, count: 2), outP = inP, errP = inP
+ guard pipe(&inP) == 0, pipe(&outP) == 0, pipe(&errP) == 0 else {
+ throw PlaywrightTransportError(errorClass: "playwright_spawn_failed")
+ }
+ var attr: posix_spawnattr_t?; posix_spawnattr_init(&attr)
+ var fa: posix_spawn_file_actions_t?; posix_spawn_file_actions_init(&fa)
+ defer { posix_spawnattr_destroy(&attr); posix_spawn_file_actions_destroy(&fa) }
+ posix_spawnattr_setpgroup(&attr, 0)
+ var all = sigset_t(); sigfillset(&all)
+ var none = sigset_t(); sigemptyset(&none)
+ posix_spawnattr_setsigdefault(&attr, &all)
+ posix_spawnattr_setsigmask(&attr, &none)
+ posix_spawnattr_setflags(&attr, Int16(POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_SETSIGDEF
+ | POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_CLOEXEC_DEFAULT))
+ posix_spawn_file_actions_adddup2(&fa, inP[0], 0)
+ posix_spawn_file_actions_adddup2(&fa, outP[1], 1)
+ posix_spawn_file_actions_adddup2(&fa, errP[1], 2)
+ posix_spawn_file_actions_addchdir_np(&fa, workingDirectory)
+
+ let argv = ([executable] + arguments).map { strdup($0) } + [nil]
+ let envp = environment.map { strdup("\($0.key)=\($0.value)") } + [nil]
+ defer { argv.forEach { free($0) }; envp.forEach { free($0) } }
+ var pid: pid_t = 0
+ let rc = posix_spawn(&pid, executable, &fa, &attr, argv, envp)
+ Darwin.close(inP[0]); Darwin.close(outP[1]); Darwin.close(errP[1])
+ guard rc == 0 else {
+ Darwin.close(inP[1]); Darwin.close(outP[0]); Darwin.close(errP[0])
+ throw PlaywrightTransportError(errorClass: "playwright_spawn_failed")
+ }
+ _ = fcntl(inP[1], F_SETNOSIGPIPE, 1)
+ let p = ManagedProcess(pid: pid, stdinFD: inP[1], stdoutFD: outP[0], stderrFD: errP[0])
+ p.lock.withLock { p.onExit = onExit }
+ return p
+ }
+
+ private static func startReader(fd: Int32, _ chunk: @escaping @Sendable (Data) -> Void, done: @escaping @Sendable () -> Void) {
+ let t = Thread {
+ var buf = [UInt8](repeating: 0, count: 16 * 1024)
+ while true {
+ let n = read(fd, &buf, buf.count)
+ if n > 0 { chunk(Data(buf[0..> 8) & 0xff) : .signaled(status & 0x7f)
+ // Give trailing stdout a moment; a surviving grandchild could hold the pipe open.
+ _ = stdoutDone.wait(timeout: .now() + 0.3)
+ let cb = lock.withLock { () -> (@Sendable (ExitInfo) -> Void)? in exitStatus = e; return onExit }
+ continuation.finish()
+ ManagedChildRegistry.shared.remove(pid)
+ cb?(e)
+ }
+
+ // MARK: StdioChannel
+
+ public func send(_ data: Data) {
+ writeQueue.async { [self] in
+ let fd = lock.withLock { stdinFD }
+ guard fd >= 0 else { return }
+ var off = 0
+ data.withUnsafeBytes { raw in
+ while off < raw.count {
+ let n = write(fd, raw.baseAddress! + off, raw.count - off)
+ if n > 0 { off += n } else if n < 0 && errno == EINTR { continue } else { return }
+ }
+ }
+ }
+ }
+
+ public func closeStdin() {
+ writeQueue.async { [self] in
+ let fd = lock.withLock { () -> Int32 in let f = stdinFD; stdinFD = -1; return f }
+ if fd >= 0 { Darwin.close(fd) }
+ }
+ }
+
+ public func close() { closeStdin() }
+
+ private func waitExit(_ seconds: Double) async -> Bool {
+ let deadline = Date().addingTimeInterval(seconds)
+ while !hasExited {
+ if Date() >= deadline { return false }
+ try? await Task.sleep(nanoseconds: 20_000_000)
+ }
+ return true
+ }
+
+ /// Close stdin and wait 1 s, then SIGTERM the group and wait 3 s, then SIGKILL.
+ public func terminate() async {
+ closeStdin()
+ if await waitExit(1) { return }
+ killpg(pid, SIGTERM)
+ if await waitExit(3) { return }
+ killpg(pid, SIGKILL)
+ _ = await waitExit(2)
+ }
+}
diff --git a/Sources/InstanceMCPCore/Browser/PlaywrightBackend.swift b/Sources/InstanceMCPCore/Browser/PlaywrightBackend.swift
index ba676d0..d765cb8 100644
--- a/Sources/InstanceMCPCore/Browser/PlaywrightBackend.swift
+++ b/Sources/InstanceMCPCore/Browser/PlaywrightBackend.swift
@@ -9,74 +9,161 @@ public enum BrowserTrust: Sendable, Equatable {
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.
+/// Tier P. Managed mode runs the installed `@playwright/mcp` as a stdio child of the app; with
+/// nothing installed it reports `needsSetup("playwright_not_installed")`. External mode serves an
+/// operator-supplied loopback listener and needs a `trust`.
public actor PlaywrightBackend: BrowserBackend {
public nonisolated let id: BrowserBackendID = .playwright
public static let healthTTL: TimeInterval = 30
+ public static let toolsTTL: TimeInterval = 30
+ public static let ownerTTL: TimeInterval = 2
private struct External {
let transport: any PlaywrightTransport
- let port: UInt16
+ let endpoint: BrowserUpstreamURL.Endpoint
let probe: any PortOwnerProbe
let trust: BrowserTrust
}
+ private struct Managed {
+ let locator: ManagedPlaywrightLocator
+ let makeTransport: @Sendable (ManagedLaunchSpec, @escaping @Sendable (String) -> Void) -> any PlaywrightTransport
+ }
private let external: External?
+ private let managed: Managed?
private let euid: UInt32
private let clock: @Sendable () -> Date
private let log: @Sendable (String) -> Void
private var health: (at: Date, status: BrowserBackendStatus)?
+ private var toolCache: (at: Date, list: [JSONValue])?
+ private var ownerOK: Date?
+ private var managedTransport: (spec: ManagedLaunchSpec, transport: any PlaywrightTransport)?
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
+ /// Managed mode: the child under `locator`'s root, started on demand.
+ public init(managed locator: ManagedPlaywrightLocator,
+ makeTransport: @escaping @Sendable (ManagedLaunchSpec, @escaping @Sendable (String) -> Void) -> any PlaywrightTransport
+ = { spec, log in ManagedPlaywrightTransport(spec: spec, log: log) },
+ clock: @escaping @Sendable () -> Date = { Date() }, log: @escaping @Sendable (String) -> Void = { _ in }) {
+ external = nil; managed = Managed(locator: locator, makeTransport: makeTransport)
+ euid = geteuid(); self.clock = clock; self.log = log
}
/// External mode; `trust` is required.
- public init(external transport: any PlaywrightTransport, port: UInt16, probe: any PortOwnerProbe,
+ public init(external transport: any PlaywrightTransport, endpoint: BrowserUpstreamURL.Endpoint, 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)
+ external = External(transport: transport, endpoint: endpoint, probe: probe, trust: trust)
+ managed = nil
self.euid = euid; self.clock = clock; self.log = log
}
- public var mode: String { external == nil ? "managed" : "external" }
+ 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))" } }
+ /// Stops the managed child, if one was started. Call on app quit.
+ public func shutdown() async { await managedTransport?.transport.shutdown(); managedTransport = nil }
+
+ // MARK: external
+
+ /// The owner check runs on every use, but a success is reused for `ownerTTL` so one tool call
+ /// does not walk every process several times. A failure is never cached.
private func ownerCheck(_ e: External) -> BrowserBackendStatus? {
- guard let o = e.probe.owner(ofLoopbackPort: e.port) else { return .unavailable("listener_owner_unknown") }
+ if let ok = ownerOK, clock().timeIntervalSince(ok) < Self.ownerTTL { return nil }
+ ownerOK = nil
+ guard let o = e.probe.owner(of: e.endpoint) else { return .unavailable("listener_owner_unknown") }
lastOwner = o
- return o.uid == euid ? nil : .unavailable("listener_owner_mismatch")
+ guard o.uid == euid else { return .unavailable("listener_owner_mismatch") }
+ ownerOK = clock()
+ return nil
}
+ // MARK: managed
+
+ private func managedTransportFor() -> Result {
+ guard let m = managed else { return .failure(.init(errorClass: "playwright_not_installed")) }
+ switch m.locator.locate() {
+ case .failure(let e):
+ if managedTransport != nil { Task { await self.shutdown() } }
+ health = nil; toolCache = nil
+ return .failure(e)
+ case .success(let spec):
+ if let t = managedTransport, t.spec == spec { return .success(t.transport) }
+ let t = m.makeTransport(spec, log)
+ managedTransport = (spec, t)
+ return .success(t)
+ }
+ }
+
+ // MARK: BrowserBackend
+
public func status() async -> BrowserBackendStatus {
- guard let e = external else { return .needsSetup("playwright_not_installed") }
- if let bad = ownerCheck(e) { health = nil; return bad }
+ let transport: any PlaywrightTransport
+ if let e = external {
+ if let bad = ownerCheck(e) { health = nil; return bad }
+ transport = e.transport
+ } else {
+ switch managedTransportFor() {
+ case .failure(let e): return .needsSetup(e.errorClass)
+ case .success(let t): transport = t
+ }
+ }
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)
+ do { try await transport.healthCheck(); s = .ready }
+ catch { s = .unavailable((error as? PlaywrightTransportError)?.errorClass ?? "upstream_unreachable") }
+ // A restart gate is short; do not hold it for the whole TTL.
+ if external != nil || s.isReady { health = (clock(), s) } else { health = nil }
return s
}
+ public func peekStatus() async -> BrowserBackendStatus? {
+ if external != nil { return health?.status }
+ switch managedTransportFor() {
+ case .failure(let e): return .needsSetup(e.errorClass)
+ case .success(let t):
+ if await t.isLatched { return .unavailable("playwright_unavailable") }
+ return health?.status ?? .ready
+ }
+ }
+
public func tools() async -> [JSONValue] {
- guard let e = external, ownerCheck(e) == nil else { return [] }
- return (try? await e.transport.listTools()) ?? []
+ let transport: any PlaywrightTransport
+ if let e = external {
+ guard ownerCheck(e) == nil else { return [] } // every use, even with a cached list
+ transport = e.transport
+ } else {
+ guard case .success(let t) = managedTransportFor() else { return [] }
+ transport = t
+ }
+ if let c = toolCache, clock().timeIntervalSince(c.at) < Self.toolsTTL { return c.list }
+ if let list = try? await transport.listTools() {
+ toolCache = (clock(), list)
+ return list
+ }
+ return toolCache?.list ?? [] // a transient failure keeps the last known list
}
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)
+ let transport: any PlaywrightTransport
+ if let e = external {
+ 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)
+ }
+ transport = e.transport
+ } else {
+ switch managedTransportFor() {
+ case .failure(let e): throw BrowserBackendError(e.errorClass, setupLost: true)
+ case .success(let t): transport = t
+ }
}
- do { return try await e.transport.callTool(name, arguments: arguments) }
- catch let err as ExternalPlaywrightError { throw BrowserBackendError(err.errorClass) }
- catch { throw BrowserBackendError("upstream_unreachable") }
+ do { return try await transport.callTool(name, arguments: arguments) }
+ catch let err as PlaywrightTransportError {
+ if err.errorClass == "playwright_unavailable" { health = nil; throw BrowserBackendError(err.errorClass, setupLost: true) }
+ throw BrowserBackendError(err.errorClass)
+ } catch { throw BrowserBackendError("upstream_unreachable") }
}
}
diff --git a/Sources/InstanceMCPCore/Browser/PortOwnerProbe.swift b/Sources/InstanceMCPCore/Browser/PortOwnerProbe.swift
index e30ec47..a242515 100644
--- a/Sources/InstanceMCPCore/Browser/PortOwnerProbe.swift
+++ b/Sources/InstanceMCPCore/Browser/PortOwnerProbe.swift
@@ -11,7 +11,7 @@ public struct PortOwner: Equatable, Sendable {
/// 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?
+ func owner(of endpoint: BrowserUpstreamURL.Endpoint) -> PortOwner?
}
/// libproc walk: every process we may inspect, every socket fd, the listening TCP ones. Other
@@ -20,7 +20,7 @@ public protocol PortOwnerProbe: Sendable {
public struct LibprocPortOwnerProbe: PortOwnerProbe {
public init() {}
- public func owner(ofLoopbackPort port: UInt16) -> PortOwner? {
+ public func owner(of endpoint: BrowserUpstreamURL.Endpoint) -> 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)
@@ -29,7 +29,7 @@ public struct LibprocPortOwnerProbe: PortOwnerProbe {
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 }
+ guard listens(pid: pid, endpoint: endpoint) 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 }
@@ -41,7 +41,7 @@ public struct LibprocPortOwnerProbe: PortOwnerProbe {
return found.first { $0.uid != me } ?? found.first
}
- private func listens(pid: pid_t, port: UInt16) -> Bool {
+ private func listens(pid: pid_t, endpoint: BrowserUpstreamURL.Endpoint) -> 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)
@@ -54,8 +54,36 @@ public struct LibprocPortOwnerProbe: PortOwnerProbe {
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 }
+ let ini = s.soi_proto.pri_tcp.tcpsi_ini
+ guard UInt16(truncatingIfNeeded: Int(ini.insi_lport)).bigEndian == endpoint.port else { continue }
+ let a4 = ini.insi_laddr.ina_46.i46a_addr4.s_addr
+ let a6 = ini.insi_laddr.ina_6
+ let bytes6 = withUnsafeBytes(of: a6) { Array($0) }
+ if ListenerMatch.matches(vflag: ini.insi_vflag, addr4: UInt32(bigEndian: a4), addr6: bytes6, endpoint: endpoint) {
+ return true
+ }
}
return false
}
}
+
+/// Whether a listening socket's local address can accept a connection to `endpoint`. A wildcard
+/// or loopback address of the endpoint's family matches; any other address (a LAN address) does
+/// not. A dual-stack wildcard socket (both family bits, all-zero v6 address) matches both.
+enum ListenerMatch {
+ static func matches(vflag: UInt8, addr4: UInt32, addr6: [UInt8], endpoint: BrowserUpstreamURL.Endpoint) -> Bool {
+ let isV4 = vflag & UInt8(INI_IPV4) != 0
+ let isV6 = vflag & UInt8(INI_IPV6) != 0
+ switch endpoint.family {
+ case .v4:
+ guard isV4 else { return false }
+ return addr4 == 0x7f00_0001 || addr4 == 0
+ case .v6:
+ guard isV6 else { return false }
+ guard addr6.count == 16 else { return false }
+ let zero = addr6.allSatisfy { $0 == 0 }
+ let loop = addr6.prefix(15).allSatisfy { $0 == 0 } && addr6[15] == 1
+ return zero || loop
+ }
+ }
+}
diff --git a/Sources/InstanceMCPCore/Browser/StdioMCPClient.swift b/Sources/InstanceMCPCore/Browser/StdioMCPClient.swift
new file mode 100644
index 0000000..12db618
--- /dev/null
+++ b/Sources/InstanceMCPCore/Browser/StdioMCPClient.swift
@@ -0,0 +1,159 @@
+import Foundation
+
+/// A byte channel to a child MCP server. `incoming` carries chunks of the child's stdout and
+/// finishes when the child's stdout closes. `send` queues bytes for the child's stdin and never
+/// blocks the caller. `close` ends the channel (and, for a process, asks it to stop).
+public protocol StdioChannel: Sendable {
+ var incoming: AsyncStream { get }
+ func send(_ data: Data)
+ func close()
+}
+
+/// MCP over stdio (transport spec 2025-06-18): newline-delimited UTF-8 JSON-RPC, one message per
+/// line. stdout is the protocol channel and is never logged. Every failure is a fixed class with
+/// no child-supplied text (R7.4).
+public actor StdioMCPClient {
+ public static let maxLineBytes = 8 * 1024 * 1024
+ public static let initializeTimeout: TimeInterval = 15
+ public static let healthTimeout: TimeInterval = 2
+ public static let callTimeout: TimeInterval = 90
+
+ private let channel: any StdioChannel
+ private var buffer = Data()
+ private var nextID = 1
+ private var pending: [Int: CheckedContinuation] = [:]
+ private var closedError: PlaywrightTransportError?
+ private var reader: Task?
+ /// Lines that were not JSON. Counted, never logged.
+ public private(set) var droppedLines = 0
+
+ public init(channel: any StdioChannel) {
+ self.channel = channel
+ }
+
+ /// Begin reading. Must be called once, before any request.
+ public func startReading() {
+ guard reader == nil else { return }
+ let stream = channel.incoming
+ reader = Task { [weak self] in
+ for await chunk in stream { await self?.feed(chunk) }
+ await self?.channelEnded(PlaywrightTransportError(errorClass: "playwright_exited"))
+ }
+ }
+
+ public var isClosed: Bool { closedError != nil }
+
+ // MARK: lifecycle
+
+ /// `initialize` then `notifications/initialized`.
+ public func initialize(timeout: TimeInterval = StdioMCPClient.initializeTimeout) async throws {
+ startReading()
+ _ = try await request("initialize", [
+ "protocolVersion": "2025-06-18", "capabilities": [:],
+ "clientInfo": ["name": "oab-computer", "version": "browser"],
+ ], timeout: timeout)
+ try sendLine(["jsonrpc": "2.0", "method": "notifications/initialized"])
+ }
+
+ public func ping(timeout: TimeInterval = StdioMCPClient.healthTimeout) async throws {
+ _ = try await request("ping", [:], timeout: timeout)
+ }
+
+ public func listTools(timeout: TimeInterval = StdioMCPClient.callTimeout) async throws -> [JSONValue] {
+ let r = try await request("tools/list", [:], timeout: timeout)
+ return r["tools"]?.arrayValue ?? []
+ }
+
+ public func callTool(_ name: String, arguments: JSONValue,
+ timeout: TimeInterval = StdioMCPClient.callTimeout) async throws -> JSONValue {
+ try await request("tools/call", ["name": .string(name), "arguments": arguments], timeout: timeout)
+ }
+
+ public func close() {
+ channel.close()
+ channelEnded(PlaywrightTransportError(errorClass: "playwright_exited"))
+ }
+
+ // MARK: requests
+
+ private func request(_ method: String, _ params: JSONValue, timeout: TimeInterval) async throws -> JSONValue {
+ startReading()
+ if let e = closedError { throw e }
+ let id = nextID; nextID += 1
+ let line = try Self.frame(["jsonrpc": "2.0", "id": .number(Double(id)), "method": .string(method), "params": params])
+ return try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in
+ pending[id] = cont
+ channel.send(line)
+ Task { [weak self] in
+ try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
+ await self?.timedOut(id)
+ }
+ }
+ }
+
+ private func timedOut(_ id: Int) {
+ guard let cont = pending.removeValue(forKey: id) else { return }
+ try? sendLine(["jsonrpc": "2.0", "method": "notifications/cancelled",
+ "params": ["requestId": .number(Double(id)), "reason": "timeout"]])
+ cont.resume(throwing: PlaywrightTransportError(errorClass: "playwright_timeout"))
+ }
+
+ /// Compact JSON plus `\n`. The encoder is not pretty-printing and escapes control characters,
+ /// so the line holds no raw newline.
+ static func frame(_ v: JSONValue) throws -> Data {
+ var d = try JSONCoding.encoder.encode(v)
+ guard !d.contains(0x0A) else { throw PlaywrightTransportError(errorClass: "playwright_bad_frame") }
+ d.append(0x0A)
+ return d
+ }
+
+ private func sendLine(_ v: JSONValue) throws {
+ if let e = closedError { throw e }
+ channel.send(try Self.frame(v))
+ }
+
+ // MARK: inbound
+
+ private func feed(_ chunk: Data) {
+ guard closedError == nil else { return }
+ buffer.append(chunk)
+ while let nl = buffer.firstIndex(of: 0x0A) {
+ var line = buffer[buffer.startIndex.. Self.maxLineBytes { overflow(); return }
+ if !line.isEmpty { handle(Data(line)) }
+ }
+ if buffer.count > Self.maxLineBytes { overflow() }
+ }
+
+ private func overflow() {
+ buffer = Data()
+ channel.close()
+ channelEnded(PlaywrightTransportError(errorClass: "stdio_line_too_long"))
+ }
+
+ private func handle(_ line: Data) {
+ guard let v = try? JSONCoding.decoder.decode(JSONValue.self, from: line), v.objectValue != nil else {
+ droppedLines += 1; return
+ }
+ let hasID = v["id"] != nil && v["id"] != .null
+ if let method = v["method"]?.stringValue {
+ guard hasID else { return } // notification
+ let reply: JSONValue = method == "ping"
+ ? ["jsonrpc": "2.0", "id": v["id"]!, "result": [:]]
+ : ["jsonrpc": "2.0", "id": v["id"]!, "error": ["code": -32601, "message": "method not found"]]
+ try? sendLine(reply)
+ return
+ }
+ guard hasID, let id = v["id"]?.intValue, let cont = pending.removeValue(forKey: id) else { return }
+ if v["error"] != nil { cont.resume(throwing: PlaywrightTransportError(errorClass: "playwright_rpc_error")) }
+ else { cont.resume(returning: v["result"] ?? .null) }
+ }
+
+ private func channelEnded(_ error: PlaywrightTransportError) {
+ if closedError == nil { closedError = error }
+ let all = pending; pending = [:]
+ for (_, c) in all { c.resume(throwing: closedError ?? error) }
+ }
+}
diff --git a/Sources/InstanceMCPCore/MenuModel.swift b/Sources/InstanceMCPCore/MenuModel.swift
index bde04c1..69ffaba 100644
--- a/Sources/InstanceMCPCore/MenuModel.swift
+++ b/Sources/InstanceMCPCore/MenuModel.swift
@@ -145,6 +145,33 @@ public struct NetworkUpstream: Equatable, Sendable {
public init(name: String, url: URL) { self.name = name; self.url = url }
}
+/// The Browser tools row's input: the last known report, text only (R5.9). Nothing here is a URL,
+/// tab title or page text.
+public struct NetworkBrowser: Equatable, Sendable {
+ public let report: BrowserReport
+ public init(report: BrowserReport) { self.report = report }
+
+ private var playwright: BrowserReport.Entry? { report.backends.first { $0.id == .playwright } }
+
+ /// `Playwright ready`, `Playwright not installed`, and so on.
+ public var playwrightText: String? {
+ guard let e = playwright else { return nil }
+ switch (e.state, e.errorClass) {
+ case ("ready", _): return "Playwright ready"
+ case ("needs_setup", "playwright_not_installed"?): return "Playwright not installed"
+ case ("needs_setup", "playwright_install_invalid"?): return "Playwright install not usable"
+ case (_, let c?): return "Playwright unavailable (\(c))"
+ default: return "Playwright unavailable"
+ }
+ }
+
+ /// For the external row: ` · Playwright external (node (uid 501))` once an owner is known.
+ public var externalSuffix: String? {
+ guard let e = playwright, e.mode == "external", let o = e.ownerSummary else { return nil }
+ return " · Playwright external (\(o))"
+ }
+}
+
/// The Switchboard row's input: the supervisor's status (nil until it reports) and how to print a time.
public struct NetworkSwitchboard {
public let status: SwitchboardStatus?
@@ -224,7 +251,7 @@ extension MenuModel {
public static func networkSection(bindHost: String, bindPort: Int, path: String,
upstreams: [NetworkUpstream], tailnet: NetworkTailnetState,
switchboard: NetworkSwitchboard?, liveGrants: Int?, attachEnabled: Bool,
- publicURL: String? = nil) -> NetworkSection {
+ publicURL: String? = nil, browser: NetworkBrowser? = nil) -> NetworkSection {
var rows: [NetworkRow] = []
let localURL = "http://\(hostForURL(bindHost)):\(bindPort)\(path)"
@@ -253,11 +280,13 @@ extension MenuModel {
copyValue: publicURL))
}
- if let browser = upstreams.first(where: { $0.name == "browser" }) {
- let host = browser.url.host ?? ""
- let address = browser.url.port.map { "\(hostForURL(host)):\($0)" } ?? hostForURL(host)
+ if let up = upstreams.first(where: { $0.name == "browser" }) {
+ let host = up.url.host ?? ""
+ let address = up.url.port.map { "\(hostForURL(host)):\($0)" } ?? hostForURL(host)
let who = isLoopbackHost(host) ? "(separate process, this Mac only)" : "(remote)"
- rows.append(NetworkRow(kind: .browser, text: "Browser tools: \(address) \(who)", copyValue: nil))
+ rows.append(NetworkRow(kind: .browser, text: "Browser tools: \(address) \(who)" + (browser?.externalSuffix ?? ""), copyValue: nil))
+ } else if let text = browser?.playwrightText {
+ rows.append(NetworkRow(kind: .browser, text: "Browser tools: \(text)", copyValue: nil))
}
if let switchboard {
diff --git a/Sources/InstanceMCPCore/ToolCatalog.swift b/Sources/InstanceMCPCore/ToolCatalog.swift
index 3d11bb7..70b872a 100644
--- a/Sources/InstanceMCPCore/ToolCatalog.swift
+++ b/Sources/InstanceMCPCore/ToolCatalog.swift
@@ -4,8 +4,8 @@ import Foundation
/// list and `ProfileBoundaryTests` checks it, so a tool cannot be served without
/// also being classified (`ToolProfile.localToolClass`).
public enum ToolCatalog {
- public static func local(agentVersion: String) -> [any Tool] {
- [SysInfoTool(agentVersion: agentVersion), ExecTool(), ExecStartTool(), ExecPollTool(),
+ public static func local(agentVersion: String, browser: (@Sendable () async -> BrowserReport)? = nil) -> [any Tool] {
+ [SysInfoTool(agentVersion: agentVersion, browser: browser), ExecTool(), ExecStartTool(), ExecPollTool(),
ExecListTool(), ExecCancelTool(), ScreenshotTool(), UISnapshotTool(), UIActionTool(), MouseTool(), KeyTool(), OsascriptTool()]
}
}
diff --git a/Sources/InstanceMCPCore/Tools/SysInfoTool.swift b/Sources/InstanceMCPCore/Tools/SysInfoTool.swift
index e946b04..657aeff 100644
--- a/Sources/InstanceMCPCore/Tools/SysInfoTool.swift
+++ b/Sources/InstanceMCPCore/Tools/SysInfoTool.swift
@@ -14,7 +14,12 @@ public struct SysInfoTool: Tool {
public let inputSchema: JSONValue = ["type": "object", "properties": [:]]
public let agentVersion: String
- public init(agentVersion: String) { self.agentVersion = agentVersion }
+ /// Browser tier state (R1.6). Non-sensitive: states, fixed classes and a managed/external mode,
+ /// never a URL, tab title or page text, so `sys_info` can stay visible to `observe`.
+ public let browser: (@Sendable () async -> BrowserReport)?
+ public init(agentVersion: String, browser: (@Sendable () async -> BrowserReport)? = nil) {
+ self.agentVersion = agentVersion; self.browser = browser
+ }
public func call(arguments: JSONValue) async throws -> ToolResult {
let pi = ProcessInfo.processInfo
@@ -58,7 +63,8 @@ public struct SysInfoTool: Tool {
let console = consoleUser()
let tail = tailnetAddresses()
- let structured: JSONValue = [
+ let browserReport = await browser?()
+ var structured: JSONValue = [
"agent": ["name": "oab-computer", "version": .string(agentVersion), "pid": .number(Double(pi.processIdentifier))],
"host": .string(host),
"os": .string("macOS \(os.majorVersion).\(os.minorVersion).\(os.patchVersion)"),
@@ -77,6 +83,11 @@ public struct SysInfoTool: Tool {
"uptime_secs": .number(pi.systemUptime.rounded()),
]
+ if let browserReport, var o = structured.objectValue {
+ o["browser"] = browserReport.json
+ structured = .object(o)
+ }
+
var lines: [String] = []
lines.append("\(host) — macOS \(os.majorVersion).\(os.minorVersion).\(os.patchVersion), \(hw["model"]?.stringValue ?? "?"), \(hw["chip"]?.stringValue ?? "?"), \(Int(hw["memory_gb"]?.doubleValue ?? 0)) GB")
lines.append("user \(NSUserName()); console user \(console ?? "none")\(console == NSUserName() ? " (agent is in the GUI session)" : " (agent NOT in GUI session — screenshot/input will fail)")")
@@ -88,6 +99,7 @@ public struct SysInfoTool: Tool {
if !screenRecording { lines.append("→ screenshot will fail until Screen Recording is granted to OpenAB Computer (oab-computer)") }
if !accessibility { lines.append("→ mouse/key/ui_snapshot/ui_action will fail until Accessibility is granted to OpenAB Computer (oab-computer)") }
if !fullDiskAccess { lines.append("→ protected Mail/Messages/Safari files will fail until Full Disk Access is granted to OpenAB Computer (oab-computer)") }
+ if let browserReport { lines.append(browserReport.line) }
lines.append("agent \(agentVersion)")
return ToolResult(content: [.text(lines.joined(separator: "\n"))], structured: structured)
}
diff --git a/Sources/oab-computer/StatusItemController.swift b/Sources/oab-computer/StatusItemController.swift
index af9191f..45f13ad 100644
--- a/Sources/oab-computer/StatusItemController.swift
+++ b/Sources/oab-computer/StatusItemController.swift
@@ -73,6 +73,10 @@ final class StatusItemController: NSObject, NSMenuDelegate {
private let path: String
private let upstreams: [NetworkUpstream]
private let attachEnabled: Bool
+ private let browserReport: (@Sendable () async -> BrowserReport)?
+ /// Last known browser state. Refreshed from `peekStatus` (never starts the child) when the menu opens.
+ private var browserState: NetworkBrowser?
+ private var browserRowItem: NSMenuItem?
private var grantRowItems: [NSMenuItem] = []
private var grantTimer: Timer?
private static let timeFormatter: DateFormatter = {
@@ -91,6 +95,7 @@ final class StatusItemController: NSObject, NSMenuDelegate {
exposure: TailnetExposure? = nil,
bindHost: String = "127.0.0.1", bindPort: Int = 8795, path: String = "/mcp",
upstreams: [NetworkUpstream] = [], attachEnabled: Bool = true,
+ browserReport: (@Sendable () async -> BrowserReport)? = nil,
environment: [String: String] = ProcessInfo.processInfo.environment) {
self.exposure = exposure
self.bindHost = bindHost
@@ -98,6 +103,7 @@ final class StatusItemController: NSObject, NSMenuDelegate {
self.path = path
self.upstreams = upstreams
self.attachEnabled = attachEnabled
+ self.browserReport = browserReport
self.launchdJobLabel = LaunchdJob.jobLabel(environment: environment)
self.version = version
self.url = url
@@ -349,8 +355,22 @@ final class StatusItemController: NSObject, NSMenuDelegate {
// MARK: menu
+ /// Reads the browser report (last known state; it never starts the child) and redraws the Browser tools row.
+ func refreshBrowserState() {
+ guard let browserReport else { return }
+ Task { [weak self] in
+ let r = await browserReport()
+ await MainActor.run {
+ guard let self else { return }
+ self.browserState = NetworkBrowser(report: r)
+ self.refreshSwitchboardTitle()
+ }
+ }
+ }
+
func menuWillOpen(_ menu: NSMenu) {
menu.removeAllItems()
+ refreshBrowserState()
refreshPermissionStatus()
menu.addItem(label("OpenAB Computer \(version)"))
@@ -370,6 +390,7 @@ final class StatusItemController: NSObject, NSMenuDelegate {
switchboardTitleItem = nil
attachRowItem = nil
tailnetRowItem = nil
+ browserRowItem = nil
for row in section.rows {
if row.kind == .reverseAttach && attachEnabled && attach == nil { continue }
let item = label(row.text, action: row.copyValue == nil ? nil : #selector(copyRow(_:)),
@@ -379,6 +400,7 @@ final class StatusItemController: NSObject, NSMenuDelegate {
switch row.kind {
case .switchboard: switchboardTitleItem = item
case .reverseAttach: attachRowItem = item
+ case .browser: browserRowItem = item
case .tailnet: tailnetRowItem = item; addTailnetActions(tailnet)
default: break
}
@@ -526,7 +548,7 @@ final class StatusItemController: NSObject, NSMenuDelegate {
return MenuModel.networkSection(
bindHost: bindHost, bindPort: bindPort, path: path, upstreams: upstreams, tailnet: tailnetState,
switchboard: switchboardSupervisor == nil ? nil : switchboardRowInput(),
- liveGrants: lastGrantCount, attachEnabled: attachEnabled)
+ liveGrants: lastGrantCount, attachEnabled: attachEnabled, browser: browserState)
}
/// Update the Tailnet, Switchboard and Reverse attach rows in place from the single model function.
@@ -542,6 +564,7 @@ final class StatusItemController: NSObject, NSMenuDelegate {
tailnetRowItem?.toolTip = row.copyValue == nil ? nil : "Click to copy"
case .switchboard: switchboardTitleItem?.title = row.text
case .reverseAttach: attachRowItem?.title = row.text
+ case .browser: browserRowItem?.title = row.text
default: break
}
}
diff --git a/Sources/oab-computer/main.swift b/Sources/oab-computer/main.swift
index a7116e3..2c13562 100644
--- a/Sources/oab-computer/main.swift
+++ b/Sources/oab-computer/main.swift
@@ -27,6 +27,7 @@ struct Options {
var persistGrants = true
/// name=url pairs; each is a loopback MCP server whose tools are re-served.
var upstreams: [(String, URL)] = []
+ var playwrightRoot: String?
/// openab-sb `…/vm/attach` to dial, its secret file, and the profile served there.
var switchboard: String? = nil
var switchboardSecretFile: String? = nil
@@ -61,6 +62,11 @@ func usage() -> Never {
at browser=http://127.0.0.1:8794/mcp) under this daemon, subject to the
connection's tool profile. Repeatable. Tools appear with the upstream's
own names; owner sees every upstream tool, observe none.
+ --playwright-root
+ Where the managed Playwright is installed (absolute path; default
+ ~/Library/Application Support/oab-computer/playwright). The app runs it as
+ a child process over stdio, with no listener. Without an install the browser
+ tools stay unlisted and the menu says Playwright is not installed.
--no-attach Disable the reverse-attach plane (POST/GET /attach, DELETE /attach/{id}):
the human-credentialed endpoint through which Connect / Remote lends this
Mac to one openab-pty session (this Mac dials the pod; see the ADR).
@@ -140,11 +146,18 @@ while !args.isEmpty {
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)
-}
+// because the operator passed the flag (R1.7, R7.8); the managed child is then not used. Without
+// the flag the managed tier serves, or reports that Playwright is not installed. Other upstreams
+// stay generic.
+let playwrightLocator = ManagedPlaywrightLocator(
+ root: opts.playwrightRoot.map { URL(fileURLWithPath: $0, isDirectory: true) } ?? ManagedPlaywrightLocator.defaultRoot)
+let playwrightBackend: PlaywrightBackend = {
+ if let b = opts.upstreams.first(where: { $0.0 == "browser" }), let ep = try? BrowserUpstreamURL.endpoint(b.1) {
+ return PlaywrightBackend(external: ExternalPlaywrightClient(url: b.1), endpoint: ep,
+ probe: LibprocPortOwnerProbe(), trust: .operatorFlag, log: log)
+ }
+ return PlaywrightBackend(managed: playwrightLocator, log: log)
+}()
+let browserRouter = BrowserRouter(playwright: playwrightBackend,
+ store: UserDefaultsBrowserPreferenceStore(log: log), log: log)
+let browserServed: Bool = opts.upstreams.contains(where: { $0.0 == "browser" })
+ || { if case .success = playwrightLocator.locate() { return true }; return false }()
let server = MCPServer(
name: "oab-computer",
version: version,
@@ -290,14 +311,14 @@ 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 && browserRouter == nil ? "" : """
+ content.\(opts.upstreams.isEmpty && !browserServed ? "" : """
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 \
no screenshots. Fall back to `screenshot` only for things outside the browser.
""")
""",
- tools: ToolCatalog.local(agentVersion: version),
+ tools: ToolCatalog.local(agentVersion: version, browser: { await browserRouter.report() }),
upstreams: opts.upstreams.filter { $0.0 != "browser" }.map { UpstreamMCP(name: $0.0, url: $0.1, log: log) },
browser: browserRouter
)
@@ -443,9 +464,11 @@ Task {
}
signal(SIGPIPE, SIG_IGN)
+// Menu Quit ends in exit(), which runs this: the managed browser child's process group is stopped.
+atexit { ManagedChildRegistry.shared.terminateAllSync() }
let stop = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
signal(SIGTERM, SIG_IGN)
-stop.setEventHandler { log("SIGTERM, exiting"); exit(0) }
+stop.setEventHandler { log("SIGTERM, exiting"); ManagedChildRegistry.shared.terminateAllSync(); exit(0) }
stop.resume()
if opts.menuBar {
@@ -460,8 +483,9 @@ if opts.menuBar {
attach: attachManager, switchboard: switchboardSupervisor, notice: firstRunNotice,
exposure: exposure, bindHost: opts.host, bindPort: Int(opts.port), path: opts.path,
upstreams: opts.upstreams.map { NetworkUpstream(name: $0.0, url: $0.1) },
- attachEnabled: opts.attach)
+ attachEnabled: opts.attach, browserReport: { await browserRouter.report() })
}
+ MainActor.assumeIsolated { statusItem?.refreshBrowserState() }
// A stored opt-in is re-applied once per launch, off the main thread, and only when the mapping is missing.
if let exposure {
DispatchQueue.global(qos: .utility).async {
diff --git a/Tests/FakeMCPStdioServer/main.swift b/Tests/FakeMCPStdioServer/main.swift
new file mode 100644
index 0000000..61a0bed
--- /dev/null
+++ b/Tests/FakeMCPStdioServer/main.swift
@@ -0,0 +1,80 @@
+import Foundation
+import Darwin
+
+// A stand-in MCP stdio server for tests. Mode is argv[1]; see the cases below.
+setvbuf(stdout, nil, _IONBF, 0)
+let mode = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "normal"
+let arg2 = CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : ""
+
+func emit(_ s: String, chunky: Bool = false) {
+ let d = Array((s + "\n").utf8)
+ if chunky {
+ var i = 0
+ while i < d.count { let n = min(3, d.count - i); _ = d[i..<(i + n)].withUnsafeBytes { write(1, $0.baseAddress, n) }; i += n; usleep(1000) }
+ } else { d.withUnsafeBytes { _ = write(1, $0.baseAddress, d.count) } }
+}
+func err(_ s: String) { FileHandle.standardError.write(Data(s.utf8)) }
+
+if mode == "listen4" || mode == "listen6" {
+ let v6 = mode == "listen6"
+ let fd = socket(v6 ? AF_INET6 : AF_INET, SOCK_STREAM, 0)
+ var yes: Int32 = 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, 4)
+ let port = UInt16(arg2) ?? 0
+ var ok = false
+ if v6 {
+ var a = sockaddr_in6(); a.sin6_len = UInt8(MemoryLayout.size); a.sin6_family = sa_family_t(AF_INET6)
+ a.sin6_port = port.bigEndian; a.sin6_addr = in6addr_loopback
+ ok = withUnsafePointer(to: &a) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(fd, $0, socklen_t(MemoryLayout.size)) } } == 0
+ } else {
+ var a = sockaddr_in(); a.sin_len = UInt8(MemoryLayout.size); a.sin_family = sa_family_t(AF_INET)
+ a.sin_port = port.bigEndian; a.sin_addr.s_addr = UInt32(0x7f000001).bigEndian
+ ok = withUnsafePointer(to: &a) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(fd, $0, socklen_t(MemoryLayout.size)) } } == 0
+ }
+ guard ok, listen(fd, 4) == 0 else { emit("failed"); exit(1) }
+ emit("ready")
+ sleep(600); exit(0)
+}
+
+if mode == "ignore-term" { signal(SIGTERM, SIG_IGN) }
+if mode == "stderr-canary" || mode == "stderr-signature" {
+ err("CANARY-token-abc https://example.invalid/?token=CANARY-secret\n")
+ if mode == "stderr-signature" { err(String(repeating: "x", count: 70_000)); err("\nProcessSingleton: profile locked\n") }
+ else { err(String(repeating: "filler ", count: 20_000)) }
+ exit(3)
+}
+if mode == "env" { emit(ProcessInfo.processInfo.environment.keys.sorted().joined(separator: ",")); exit(0) }
+if mode == "argv" { emit(CommandLine.arguments.dropFirst().joined(separator: " ")); exit(0) }
+if mode == "grandchild" {
+ // A same-group grandchild that outlives us unless the group is signalled.
+ var gpid: pid_t = 0
+ let argv: [UnsafeMutablePointer?] = [strdup("sleep"), strdup("300"), nil]
+ posix_spawn(&gpid, "/bin/sleep", nil, nil, argv, environ) // inherits our process group
+ emit("grandchild \(gpid)")
+}
+if mode == "hang-init" { while true { sleep(60) } }
+
+var toolCalls = 0
+let dieAfter = Int(arg2) ?? 0
+while let line = readLine(strippingNewline: true) {
+ guard let obj = (try? JSONSerialization.jsonObject(with: Data(line.utf8))) as? [String: Any] else { continue }
+ guard let method = obj["method"] as? String, let id = obj["id"] else { continue }
+ func reply(_ result: String) {
+ let idJSON = (try? String(decoding: JSONSerialization.data(withJSONObject: id, options: .fragmentsAllowed), as: UTF8.self)) ?? "0"
+ if mode == "noise" { emit("this is not json") }
+ emit("{\"jsonrpc\":\"2.0\",\"id\":\(idJSON),\"result\":\(result)}", chunky: mode == "chunky")
+ }
+ switch method {
+ case "initialize": reply("{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{\"tools\":{}},\"serverInfo\":{\"name\":\"fake\",\"version\":\"0\"}}")
+ case "ping": reply("{}")
+ case "tools/list": reply("{\"tools\":[{\"name\":\"browser_navigate\"},{\"name\":\"browser_snapshot\"}]}")
+ case "tools/call":
+ toolCalls += 1
+ let name = ((obj["params"] as? [String: Any])?["name"] as? String) ?? "?"
+ if mode == "die-mid-call" { exit(9) }
+ reply("{\"content\":[{\"type\":\"text\",\"text\":\"fake did \(name)\"}]}")
+ if mode == "die-after" && toolCalls >= dieAfter { exit(7) }
+ default: break
+ }
+}
+if mode == "ignore-term" { while true { sleep(60) } }
+exit(0)
diff --git a/Tests/InstanceMCPCoreTests/BrowserReportingTests.swift b/Tests/InstanceMCPCoreTests/BrowserReportingTests.swift
new file mode 100644
index 0000000..c4adab8
--- /dev/null
+++ b/Tests/InstanceMCPCoreTests/BrowserReportingTests.swift
@@ -0,0 +1,51 @@
+import Foundation
+import XCTest
+@testable import InstanceMCPCore
+
+final class BrowserReportingTests: XCTestCase {
+ private func report(state: String, cls: String?, mode: String = "managed", owner: String? = nil) -> BrowserReport {
+ BrowserReport(preference: .auto, active: state == "ready" ? .playwright : nil,
+ backends: [.init(id: .playwright, state: state, errorClass: cls, mode: mode, ownerSummary: owner)])
+ }
+ private func section(upstreams: [NetworkUpstream] = [], browser: NetworkBrowser?) -> NetworkSection {
+ MenuModel.networkSection(bindHost: "127.0.0.1", bindPort: 8795, path: "/mcp", upstreams: upstreams, tailnet: .off,
+ switchboard: nil, liveGrants: 0, attachEnabled: true, browser: browser)
+ }
+ private func row(_ s: NetworkSection) -> String? { s.rows.first { $0.kind == .browser }?.text }
+
+ func testSysInfoReportsManagedNotInstalled() async throws {
+ let tool = SysInfoTool(agentVersion: "t", browser: { self.report(state: "needs_setup", cls: "playwright_not_installed") })
+ let r = try await tool.call(arguments: [:])
+ let b = try XCTUnwrap(r.structured?["browser"])
+ XCTAssertEqual(b["backends"]?.arrayValue?.first?["state"]?.stringValue, "needs_setup")
+ XCTAssertEqual(b["backends"]?.arrayValue?.first?["mode"]?.stringValue, "managed")
+ XCTAssertEqual(b["backends"]?.arrayValue?.first?["class"]?.stringValue, "playwright_not_installed")
+ let text = r.content.compactMap { c -> String? in if case .text(let t) = c { return t }; return nil }.joined()
+ XCTAssertTrue(text.contains("browser tools: playwright not installed"), text)
+ XCTAssertFalse(String(decoding: jsonBody(b), as: UTF8.self).contains("http"))
+ }
+
+ func testSysInfoWithoutARouterHasNoBrowserBlock() async throws {
+ let r = try await SysInfoTool(agentVersion: "t").call(arguments: [:])
+ XCTAssertNil(r.structured?["browser"])
+ }
+
+ func testMenuRowWording() {
+ XCTAssertEqual(row(section(browser: NetworkBrowser(report: report(state: "ready", cls: nil)))), "Browser tools: Playwright ready")
+ XCTAssertEqual(row(section(browser: NetworkBrowser(report: report(state: "needs_setup", cls: "playwright_not_installed")))),
+ "Browser tools: Playwright not installed")
+ XCTAssertEqual(row(section(browser: NetworkBrowser(report: report(state: "unavailable", cls: "playwright_unavailable")))),
+ "Browser tools: Playwright unavailable (playwright_unavailable)")
+ XCTAssertNil(row(section(browser: nil)), "no row until a report is known")
+ }
+
+ func testExternalRowKeepsTheAddressAndAppendsTheOwner() {
+ let up = [NetworkUpstream(name: "browser", url: URL(string: "http://127.0.0.1:8794/mcp")!)]
+ let plain = row(section(upstreams: up, browser: nil))
+ XCTAssertEqual(plain, "Browser tools: 127.0.0.1:8794 (separate process, this Mac only)")
+ let withOwner = row(section(upstreams: up, browser: NetworkBrowser(report: report(state: "ready", cls: nil, mode: "external", owner: "node (uid 501)"))))
+ XCTAssertEqual(withOwner, plain! + " · Playwright external (node (uid 501))")
+ let noOwner = row(section(upstreams: up, browser: NetworkBrowser(report: report(state: "unavailable", cls: "x", mode: "external"))))
+ XCTAssertEqual(noOwner, plain)
+ }
+}
diff --git a/Tests/InstanceMCPCoreTests/BrowserRouterTests.swift b/Tests/InstanceMCPCoreTests/BrowserRouterTests.swift
index e0b26a1..94ec908 100644
--- a/Tests/InstanceMCPCoreTests/BrowserRouterTests.swift
+++ b/Tests/InstanceMCPCoreTests/BrowserRouterTests.swift
@@ -122,6 +122,45 @@ final class BrowserRouterTests: XCTestCase {
}
}
+final class BrowserRouterLazinessTests: XCTestCase {
+ let conn = BrowserConnection(key: "c1")
+
+ func testPinnedPreferenceAsksOnlyThatBackend() async {
+ let a = FakeBrowserBackend(.appleEvents), p = FakeBrowserBackend(.playwright)
+ let r = BrowserRouter(appleEvents: a, playwright: p, store: MemoryPreferenceStore(.appleEvents))
+ _ = await r.call("browser_navigate", arguments: [:], conn)
+ XCTAssertEqual(p.statusCalls, 0)
+ XCTAssertGreaterThan(a.statusCalls, 0)
+ }
+
+ func testAutoAsksAppleEventsOnlyWhenPlaywrightIsNotReady() async {
+ let a = FakeBrowserBackend(.appleEvents), p = FakeBrowserBackend(.playwright)
+ let r = BrowserRouter(appleEvents: a, playwright: p, store: MemoryPreferenceStore())
+ _ = await r.call("browser_navigate", arguments: [:], conn)
+ XCTAssertEqual(a.statusCalls, 0)
+ }
+
+ func testLastMapShrinksAfterTheGuardWindow() async {
+ let clock = TestClock()
+ let r = BrowserRouter(playwright: FakeBrowserBackend(.playwright), store: MemoryPreferenceStore(), clock: { clock.date() })
+ for i in 0..<10 { _ = await r.call("browser_navigate", arguments: [:], BrowserConnection(key: "k\(i)")) }
+ let before = await r.trackedConnections
+ XCTAssertEqual(before, 10)
+ clock.advance(31 * 60)
+ _ = await r.call("browser_navigate", arguments: [:], BrowserConnection(key: "fresh"))
+ let after = await r.trackedConnections
+ XCTAssertEqual(after, 1)
+ }
+
+ func testReportNeverIncludesAURLAndNamesTheActiveBackend() async {
+ let r = BrowserRouter(appleEvents: FakeBrowserBackend(.appleEvents, status: .needsSetup("x")),
+ playwright: FakeBrowserBackend(.playwright), store: MemoryPreferenceStore())
+ let rep = await r.report()
+ XCTAssertNil(rep.active, "fakes report no peek state, so nothing is claimed active")
+ XCTAssertFalse(String(decoding: jsonBody(rep.json), as: UTF8.self).contains("http"))
+ }
+}
+
final class BrowserServerWiringTests: XCTestCase {
func server(_ profile: ToolProfile, backend: FakeBrowserBackend) -> MCPServer {
let router = BrowserRouter(playwright: backend, store: MemoryPreferenceStore())
diff --git a/Tests/InstanceMCPCoreTests/BrowserTestSupport.swift b/Tests/InstanceMCPCoreTests/BrowserTestSupport.swift
index adc850b..d25c7b7 100644
--- a/Tests/InstanceMCPCoreTests/BrowserTestSupport.swift
+++ b/Tests/InstanceMCPCoreTests/BrowserTestSupport.swift
@@ -11,6 +11,8 @@ final class FakeBrowserBackend: BrowserBackend, @unchecked Sendable {
private var _names: [String]
private var _calls: [String] = []
var failure: BrowserBackendError?
+ private var _statusCalls = 0
+ var statusCalls: Int { lock.withLock { _statusCalls } }
init(_ id: BrowserBackendID, status: BrowserBackendStatus = .ready, names: [String] = BrowserToolCatalog.allNames) {
self.id = id; _status = status; _names = names
@@ -21,7 +23,7 @@ final class FakeBrowserBackend: BrowserBackend, @unchecked Sendable {
}
var calls: [String] { lock.withLock { _calls } }
- func status() async -> BrowserBackendStatus { status_ }
+ func status() async -> BrowserBackendStatus { lock.withLock { _statusCalls += 1 }; return status_ }
func tools() async -> [JSONValue] {
lock.withLock { _names }.map { ["name": .string($0), "description": "fake", "inputSchema": ["type": "object"]] }
}
@@ -47,9 +49,57 @@ final class TestClock: @unchecked Sendable {
func advance(_ s: TimeInterval) { lock.withLock { now = now.addingTimeInterval(s) } }
}
-struct FakePortOwnerProbe: PortOwnerProbe {
- var result: PortOwner?
- func owner(ofLoopbackPort port: UInt16) -> PortOwner? { result }
+final class FakePortOwnerProbe: PortOwnerProbe, @unchecked Sendable {
+ private let lock = NSLock()
+ private var _result: PortOwner?
+ private var _asked: [BrowserUpstreamURL.Endpoint] = []
+ init(result: PortOwner?) { _result = result }
+ var result: PortOwner? {
+ get { lock.withLock { _result } }
+ set { lock.withLock { _result = newValue } }
+ }
+ var asked: [BrowserUpstreamURL.Endpoint] { lock.withLock { _asked } }
+ func owner(of endpoint: BrowserUpstreamURL.Endpoint) -> PortOwner? {
+ lock.withLock { _asked.append(endpoint); return _result }
+ }
+}
+
+/// A BSD loopback listener on a chosen family and port, held open by the test process.
+final class BSDListener {
+ let fd: Int32
+ let port: UInt16
+ init?(v6: Bool, port: UInt16) {
+ let fd = socket(v6 ? AF_INET6 : AF_INET, SOCK_STREAM, 0)
+ guard fd >= 0 else { return nil }
+ var ok = false
+ if v6 {
+ var a = sockaddr_in6(); a.sin6_len = UInt8(MemoryLayout.size); a.sin6_family = sa_family_t(AF_INET6)
+ a.sin6_port = port.bigEndian; a.sin6_addr = in6addr_loopback
+ ok = withUnsafePointer(to: &a) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(fd, $0, socklen_t(MemoryLayout.size)) } } == 0
+ } else {
+ var a = sockaddr_in(); a.sin_len = UInt8(MemoryLayout.size); a.sin_family = sa_family_t(AF_INET)
+ a.sin_port = port.bigEndian; a.sin_addr.s_addr = UInt32(0x7f000001).bigEndian
+ ok = withUnsafePointer(to: &a) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(fd, $0, socklen_t(MemoryLayout.size)) } } == 0
+ }
+ guard ok, listen(fd, 4) == 0 else { Darwin.close(fd); return nil }
+ self.fd = fd; self.port = port
+ }
+ deinit { Darwin.close(fd) }
+
+ /// A free port on both families (v4 listener returned, v6 left to the caller), or nil.
+ static func freePort() -> UInt16? {
+ for _ in 0..<50 {
+ let p = UInt16.random(in: 40000...60000)
+ if let a = BSDListener(v6: false, port: p), let b = BSDListener(v6: true, port: p) { _ = (a, b); return p }
+ }
+ return nil
+ }
+}
+
+/// The built helper next to the test bundle.
+func fakeServerPath() -> String {
+ let bundle = Bundle.allBundles.first { $0.bundlePath.hasSuffix(".xctest") }!
+ return bundle.bundleURL.deletingLastPathComponent().appendingPathComponent("FakeMCPStdioServer").path
}
/// A loopback HTTP server whose handler decides every reply; counts requests and keeps bodies.
diff --git a/Tests/InstanceMCPCoreTests/ExternalPlaywrightTests.swift b/Tests/InstanceMCPCoreTests/ExternalPlaywrightTests.swift
index 115d639..416c564 100644
--- a/Tests/InstanceMCPCoreTests/ExternalPlaywrightTests.swift
+++ b/Tests/InstanceMCPCoreTests/ExternalPlaywrightTests.swift
@@ -80,7 +80,7 @@ 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)
+ endpoint: .init(family: .v4, port: s.port), probe: FakePortOwnerProbe(result: owner), trust: trust, euid: euid)
}
private func fake() throws -> StubHTTPServer {
try StubHTTPServer { req in
@@ -114,20 +114,97 @@ final class PortOwnerProbeTests: XCTestCase {
XCTAssertTrue(s.requests.isEmpty, "a request reached a listener that failed the ownership check")
}
- func testManagedModeIsNotSetUpYet() async {
- let b = PlaywrightBackend()
+ func testManagedModeWithNothingInstalledIsNotSetUp() async throws {
+ let dir = FileManager.default.temporaryDirectory.appendingPathComponent("oab-pw-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: dir) }
+ let b = PlaywrightBackend(managed: ManagedPlaywrightLocator(root: dir))
let st = await b.status()
- XCTAssertEqual(st, .needsSetup("playwright_not_installed"))
+ XCTAssertEqual(st, BrowserBackendStatus.needsSetup("playwright_not_installed"))
let mode = await b.mode
XCTAssertEqual(mode, "managed")
+ let peek = await b.peekStatus()
+ XCTAssertEqual(peek, BrowserBackendStatus.needsSetup("playwright_not_installed"))
}
- func testLibprocFindsOurOwnListener() throws {
+ func testBackendPassesTheValidatedEndpointFamilyToTheProbe() async throws {
let s = try fake(); defer { s.stop() }
- let o = LibprocPortOwnerProbe().owner(ofLoopbackPort: s.port)
+ let probe = FakePortOwnerProbe(result: PortOwner(pid: 7, uid: 501, name: "node"))
+ let b = PlaywrightBackend(external: ExternalPlaywrightClient(url: URL(string: "http://[::1]:\(s.port)/mcp")!, timeout: 5),
+ endpoint: .init(family: .v6, port: s.port), probe: probe, trust: .operatorFlag, euid: 501)
+ _ = await b.status()
+ XCTAssertEqual(probe.asked.first, BrowserUpstreamURL.Endpoint(family: .v6, port: s.port))
+ }
+
+ func testOwnerSuccessIsReusedBrieflyAndFailureIsNeverCached() async throws {
+ let s = try fake(); defer { s.stop() }
+ let clock = TestClock()
+ let probe = FakePortOwnerProbe(result: PortOwner(pid: 7, uid: 501, name: "node"))
+ let b = PlaywrightBackend(external: ExternalPlaywrightClient(url: URL(string: "http://127.0.0.1:\(s.port)/mcp")!, timeout: 5),
+ endpoint: .init(family: .v4, port: s.port), probe: probe, trust: .operatorFlag, euid: 501,
+ clock: { clock.date() })
+ _ = await b.status(); _ = await b.tools(); _ = await b.tools()
+ XCTAssertEqual(probe.asked.count, 1)
+ clock.advance(3)
+ _ = await b.tools()
+ XCTAssertEqual(probe.asked.count, 2)
+ probe.result = nil; clock.advance(3)
+ _ = await b.tools(); _ = await b.tools()
+ XCTAssertEqual(probe.asked.count, 4, "a failed check must not be cached")
+ }
+
+ func testToolListKeepsLastKnownOnATransientFailure() async throws {
+ let s = try fake()
+ let clock = TestClock()
+ let b = PlaywrightBackend(external: ExternalPlaywrightClient(url: URL(string: "http://127.0.0.1:\(s.port)/mcp")!, timeout: 2),
+ endpoint: .init(family: .v4, port: s.port), probe: FakePortOwnerProbe(result: PortOwner(pid: 7, uid: 501, name: "node")),
+ trust: .operatorFlag, euid: 501, clock: { clock.date() })
+ let first = await b.tools()
+ XCTAssertEqual(first.count, 1)
+ s.stop()
+ try await Task.sleep(nanoseconds: 100_000_000)
+ clock.advance(60)
+ let second = await b.tools()
+ XCTAssertEqual(second.count, 1, "the previous list is kept")
+ }
+
+ func testLibprocFindsOurOwnListener() throws {
+ guard let p = BSDListener.freePort() else { throw XCTSkip("no free port") }
+ let l = try XCTUnwrap(BSDListener(v6: false, port: p))
+ _ = l
+ let o = LibprocPortOwnerProbe().owner(of: .init(family: .v4, port: p))
XCTAssertEqual(o?.pid, getpid())
XCTAssertEqual(o?.uid, geteuid())
}
+
+ func testSimultaneousV4AndV6ListenersAreToldApart() throws {
+ guard let p = BSDListener.freePort() else { throw XCTSkip("no free port") }
+ let v4 = try XCTUnwrap(BSDListener(v6: false, port: p))
+ _ = v4
+ // Only v4 is open: the v6 endpoint has no owner.
+ XCTAssertNil(LibprocPortOwnerProbe().owner(of: .init(family: .v6, port: p)))
+ // The v6 listener is held by a different process.
+ let child = Process()
+ child.executableURL = URL(fileURLWithPath: fakeServerPath())
+ child.arguments = ["listen6", String(p)]
+ let out = Pipe(); child.standardOutput = out
+ try child.run()
+ defer { child.terminate(); child.waitUntilExit() }
+ let ready = out.fileHandleForReading.availableData
+ XCTAssertEqual(String(decoding: ready, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines), "ready")
+ let o4 = LibprocPortOwnerProbe().owner(of: .init(family: .v4, port: p))
+ let o6 = LibprocPortOwnerProbe().owner(of: .init(family: .v6, port: p))
+ XCTAssertEqual(o4?.pid, getpid())
+ XCTAssertEqual(o6?.pid, child.processIdentifier)
+ }
+
+ func testSameProcessOwnsBothFamilies() throws {
+ guard let p = BSDListener.freePort() else { throw XCTSkip("no free port") }
+ let a = try XCTUnwrap(BSDListener(v6: false, port: p)), b = try XCTUnwrap(BSDListener(v6: true, port: p))
+ _ = (a, b)
+ XCTAssertEqual(LibprocPortOwnerProbe().owner(of: .init(family: .v4, port: p))?.pid, getpid())
+ XCTAssertEqual(LibprocPortOwnerProbe().owner(of: .init(family: .v6, port: p))?.pid, getpid())
+ }
}
final class BrowserSecretHygieneTests: XCTestCase {
@@ -137,7 +214,7 @@ final class BrowserSecretHygieneTests: XCTestCase {
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")),
+ endpoint: .init(family: .v4, 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) } })
@@ -146,3 +223,108 @@ final class BrowserSecretHygieneTests: XCTestCase {
XCTAssertFalse(all.contains("CANARY"), all)
}
}
+
+final class ListenerMatchTests: XCTestCase {
+ private let zero = [UInt8](repeating: 0, count: 16)
+ private var loop6: [UInt8] { var a = zero; a[15] = 1; return a }
+ private let v4 = BrowserUpstreamURL.Endpoint(family: .v4, port: 1)
+ private let v6 = BrowserUpstreamURL.Endpoint(family: .v6, port: 1)
+
+ func testTable() {
+ let I4 = UInt8(INI_IPV4), I6 = UInt8(INI_IPV6)
+ // v4 loopback and wildcard
+ XCTAssertTrue(ListenerMatch.matches(vflag: I4, addr4: 0x7f00_0001, addr6: zero, endpoint: v4))
+ XCTAssertTrue(ListenerMatch.matches(vflag: I4, addr4: 0, addr6: zero, endpoint: v4))
+ XCTAssertFalse(ListenerMatch.matches(vflag: I4, addr4: 0x7f00_0001, addr6: zero, endpoint: v6))
+ // v6 loopback and wildcard
+ XCTAssertTrue(ListenerMatch.matches(vflag: I6, addr4: 0, addr6: loop6, endpoint: v6))
+ XCTAssertTrue(ListenerMatch.matches(vflag: I6, addr4: 0, addr6: zero, endpoint: v6))
+ XCTAssertFalse(ListenerMatch.matches(vflag: I6, addr4: 0, addr6: loop6, endpoint: v4))
+ // dual-stack wildcard serves both
+ XCTAssertTrue(ListenerMatch.matches(vflag: I4 | I6, addr4: 0, addr6: zero, endpoint: v4))
+ XCTAssertTrue(ListenerMatch.matches(vflag: I4 | I6, addr4: 0, addr6: zero, endpoint: v6))
+ // a LAN address does not match
+ XCTAssertFalse(ListenerMatch.matches(vflag: I4, addr4: 0xc000_0201, addr6: zero, endpoint: v4))
+ var lan6 = zero; lan6[0] = 0x20; lan6[1] = 0x01; lan6[15] = 5
+ XCTAssertFalse(ListenerMatch.matches(vflag: I6, addr4: 0, addr6: lan6, endpoint: v6))
+ }
+}
+
+final class ExternalPlaywrightHealthTests: XCTestCase {
+ private func methodOf(_ r: HTTPRequest) -> String? { (try? JSONCoding.decoder.decode(JSONRPCRequest.self, from: r.body))?.method }
+
+ private func stub() throws -> StubHTTPServer {
+ try StubHTTPServer { [self] req in
+ if req.method == "DELETE" { return .init(status: 200) }
+ switch methodOf(req) {
+ case "initialize": return sse(["jsonrpc": "2.0", "id": 0, "result": [:]], sid: "sid-\(Int.random(in: 1...1_000_000))")
+ case "ping": return sse(["jsonrpc": "2.0", "id": 1, "result": [:]])
+ default: return .init(status: 202)
+ }
+ }
+ }
+
+ func testRepeatedHealthChecksInitializeOnceAndPing() async throws {
+ let s = try stub(); defer { s.stop() }
+ let c = ExternalPlaywrightClient(url: URL(string: "http://127.0.0.1:\(s.port)/mcp")!, timeout: 90)
+ try await c.healthCheck(); try await c.healthCheck(); try await c.healthCheck()
+ let methods = s.requests.compactMap { methodOf($0) }
+ XCTAssertEqual(methods.filter { $0 == "initialize" }.count, 1)
+ XCTAssertEqual(methods.filter { $0 == "ping" }.count, 2)
+ XCTAssertTrue(s.requests.allSatisfy { $0.method != "DELETE" })
+ }
+
+ func testRejectedSessionIsDeletedBeforeANewInitialize() async throws {
+ let lock = NSLock(); nonisolated(unsafe) var pings = 0; nonisolated(unsafe) var n = 0
+ let s = try StubHTTPServer { [self] req in
+ if req.method == "DELETE" { return .init(status: 200) }
+ switch methodOf(req) {
+ case "initialize": return sse(["jsonrpc": "2.0", "id": 0, "result": [:]], sid: "sid-\(lock.withLock { n += 1; return n })")
+ case "ping":
+ let k = lock.withLock { pings += 1; return pings }
+ return k == 1 ? .init(status: 400) : sse(["jsonrpc": "2.0", "id": 1, "result": [:]])
+ default: return .init(status: 202)
+ }
+ }
+ defer { s.stop() }
+ let c = ExternalPlaywrightClient(url: URL(string: "http://127.0.0.1:\(s.port)/mcp")!, timeout: 90)
+ try await c.healthCheck() // initialize (sid-1)
+ try await c.healthCheck() // ping -> 400 -> DELETE sid-1 -> initialize (sid-2)
+ let seq = s.requests.map { $0.method == "DELETE" ? "DELETE:" + ($0.header("mcp-session-id") ?? "") : (methodOf($0) ?? "?") }
+ let del = try XCTUnwrap(seq.firstIndex(of: "DELETE:sid-1"))
+ XCTAssertEqual(seq.filter { $0 == "initialize" }.count, 2)
+ XCTAssertEqual(seq[del + 1], "initialize")
+ }
+
+ func testHealthCheckUsesTwoSecondsEvenWithALongClientTimeout() async throws {
+ let s = try StubHTTPServer { _ in Thread.sleep(forTimeInterval: 3.5); return .text(200, "") }
+ defer { s.stop() }
+ let c = ExternalPlaywrightClient(url: URL(string: "http://127.0.0.1:\(s.port)/mcp")!, timeout: 90)
+ let t0 = Date()
+ do { try await c.healthCheck(); XCTFail("no error") }
+ catch let e as PlaywrightTransportError { XCTAssertEqual(e.errorClass, "upstream_timeout") }
+ XCTAssertLessThan(Date().timeIntervalSince(t0), 3.2)
+ }
+
+ func testHostHeaderIsBracketedForIPv6() async throws {
+ guard let p = BSDListener.freePort() else { throw XCTSkip("no free port") }
+ _ = p
+ let c = ExternalPlaywrightClient(url: URL(string: "http://[::1]:9/mcp")!)
+ let h = await c.hostHeaderForTests
+ XCTAssertEqual(h, "[::1]")
+ let c4 = ExternalPlaywrightClient(url: URL(string: "http://127.0.0.1:9/mcp")!)
+ let h4 = await c4.hostHeaderForTests
+ XCTAssertEqual(h4, "127.0.0.1")
+ }
+}
+
+final class BrowserUpstreamEndpointTests: XCTestCase {
+ func testFamilyPortAndRange() throws {
+ XCTAssertEqual(try BrowserUpstreamURL.endpoint(URL(string: "http://127.0.0.1:8794/mcp")!), .init(family: .v4, port: 8794))
+ XCTAssertEqual(try BrowserUpstreamURL.endpoint(URL(string: "http://[::1]:8794/mcp")!), .init(family: .v6, port: 8794))
+ XCTAssertEqual(try BrowserUpstreamURL.endpoint(URL(string: "http://127.0.0.1/mcp")!).port, 80)
+ XCTAssertThrowsError(try BrowserUpstreamURL.endpoint(URL(string: "http://127.0.0.1:70000/mcp")!))
+ XCTAssertThrowsError(try BrowserUpstreamURL.endpoint(URL(string: "http://127.0.0.1:0/mcp")!))
+ XCTAssertThrowsError(try BrowserUpstreamURL.endpoint(URL(string: "http://localhost:8794/mcp")!))
+ }
+}
diff --git a/Tests/InstanceMCPCoreTests/ManagedPlaywrightTests.swift b/Tests/InstanceMCPCoreTests/ManagedPlaywrightTests.swift
new file mode 100644
index 0000000..d16ba10
--- /dev/null
+++ b/Tests/InstanceMCPCoreTests/ManagedPlaywrightTests.swift
@@ -0,0 +1,277 @@
+import Foundation
+import XCTest
+@testable import InstanceMCPCore
+
+/// A child driven by the test, so the supervisor can be exercised without processes.
+final class FakeChild: ManagedChild, @unchecked Sendable {
+ let script = ScriptedChannel()
+ var channel: any StdioChannel { script }
+ private let lock = NSLock()
+ private var _exit: ExitInfo?
+ var stderrClass: StderrClass?
+ var hasExited: Bool { lock.withLock { _exit != nil } }
+ var exitInfo: ExitInfo? { lock.withLock { _exit } }
+ func die(_ code: Int32 = 1) { lock.withLock { _exit = .exited(code) }; script.finish() }
+ func terminate() async { if !hasExited { lock.withLock { _exit = .exited(0) }; script.finish() } }
+ func healthy() {
+ script.onSend = { [script] v in
+ guard let id = v["id"]?.intValue else { return }
+ let result = v["method"]?.stringValue == "tools/call" ? "{\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]}" : "{}"
+ script.feed("{\"jsonrpc\":\"2.0\",\"id\":\(id),\"result\":\(result)}\n")
+ }
+ }
+}
+
+final class FakeLauncher: ChildLauncher, @unchecked Sendable {
+ enum Behavior { case healthy, dieAtStart }
+ private let lock = NSLock()
+ private var _launches = 0
+ private var _behavior: Behavior
+ private var _last: FakeChild?
+ init(_ b: Behavior) { _behavior = b }
+ var behavior: Behavior { get { lock.withLock { _behavior } } set { lock.withLock { _behavior = newValue } } }
+ var launches: Int { lock.withLock { _launches } }
+ var last: FakeChild? { lock.withLock { _last } }
+ func launch(_ spec: ManagedLaunchSpec) throws -> any ManagedChild {
+ let c = FakeChild()
+ lock.withLock { _launches += 1; _last = c }
+ switch behavior {
+ case .healthy: c.healthy()
+ case .dieAtStart: c.die(1)
+ }
+ return c
+ }
+}
+
+private let dummySpec = ManagedLaunchSpec(executable: "/bin/false", arguments: [], environment: [:], workingDirectory: "/tmp")
+
+final class ManagedPlaywrightSupervisionTests: XCTestCase {
+ private func klass(_ t: ManagedPlaywrightTransport) async -> String {
+ do { try await t.healthCheck(); return "ok" }
+ catch let e as PlaywrightTransportError { return e.errorClass }
+ catch { return "other" }
+ }
+
+ func testColdStartAndRoundTrip() async throws {
+ let l = FakeLauncher(.healthy)
+ let t = ManagedPlaywrightTransport(spec: dummySpec, launcher: l, initializeTimeout: 2)
+ try await t.healthCheck()
+ try await t.healthCheck() // a ping, not a second launch
+ XCTAssertEqual(l.launches, 1)
+ let r = try await t.callTool("browser_navigate", arguments: [:])
+ XCTAssertEqual(r["content"]?.arrayValue?.first?["text"]?.stringValue, "ok")
+ await t.shutdown()
+ XCTAssertTrue(l.last?.hasExited == true)
+ }
+
+ func testBackoffDoublesAndCaps() async throws {
+ let clock = TestClock()
+ let l = FakeLauncher(.dieAtStart)
+ let t = ManagedPlaywrightTransport(spec: dummySpec, launcher: l, initializeTimeout: 2, clock: { clock.date() })
+ var delays: [TimeInterval] = []
+ for _ in 0..<4 {
+ delays.append(await t.nextBackoff)
+ let k = await klass(t); XCTAssertEqual(k, "playwright_exited")
+ let gated = await klass(t); XCTAssertEqual(gated, "playwright_restarting")
+ clock.advance(delays.last! + 0.01)
+ }
+ XCTAssertEqual(delays, [1, 2, 4, 8])
+ XCTAssertEqual(l.launches, 4, "calls inside the gate do not launch")
+ XCTAssertEqual(ManagedPlaywrightTransport.backoff.last, 30)
+ }
+
+ func testLatchesAfterFiveFailuresInTwoMinutesAndRetryClears() async throws {
+ let clock = TestClock()
+ let l = FakeLauncher(.dieAtStart)
+ let t = ManagedPlaywrightTransport(spec: dummySpec, launcher: l, initializeTimeout: 2, clock: { clock.date() })
+ for d in [1.0, 2, 4, 8] { _ = await klass(t); clock.advance(d + 0.01) }
+ _ = await klass(t) // fifth failure
+ let latched = await t.isLatched
+ XCTAssertTrue(latched)
+ let launches = l.launches
+ let fast = await klass(t); XCTAssertEqual(fast, "playwright_unavailable")
+ XCTAssertEqual(l.launches, launches, "no launch while latched")
+ l.behavior = .healthy
+ await t.retry()
+ let ok = await klass(t); XCTAssertEqual(ok, "ok")
+ }
+
+ func testFailuresOutsideTheWindowDoNotLatch() async throws {
+ let clock = TestClock()
+ let l = FakeLauncher(.dieAtStart)
+ let t = ManagedPlaywrightTransport(spec: dummySpec, launcher: l, initializeTimeout: 2, clock: { clock.date() })
+ for _ in 0..<6 { _ = await klass(t); clock.advance(100) }
+ let latched = await t.isLatched
+ XCTAssertFalse(latched)
+ }
+
+ func testCrashMidCallThenRestartAfterBackoff() async throws {
+ let clock = TestClock()
+ let l = FakeLauncher(.healthy)
+ let t = ManagedPlaywrightTransport(spec: dummySpec, launcher: l, initializeTimeout: 2, clock: { clock.date() })
+ try await t.healthCheck()
+ let child = try XCTUnwrap(l.last)
+ child.script.onSend = { [child] v in if v["method"]?.stringValue == "tools/call" { child.die(9) } }
+ do { _ = try await t.callTool("browser_navigate", arguments: [:]); XCTFail() }
+ catch let e as PlaywrightTransportError { XCTAssertEqual(e.errorClass, "playwright_exited") }
+ let gated = await klass(t); XCTAssertEqual(gated, "playwright_restarting")
+ clock.advance(1.1)
+ let back = await klass(t); XCTAssertEqual(back, "ok")
+ XCTAssertEqual(l.launches, 2)
+ }
+
+ func testExitIsLoggedAsAClassOnly() async throws {
+ let clock = TestClock()
+ nonisolated(unsafe) var lines: [String] = []
+ let lock = NSLock()
+ let l = FakeLauncher(.healthy)
+ let t = ManagedPlaywrightTransport(spec: dummySpec, launcher: l, initializeTimeout: 2, clock: { clock.date() },
+ log: { s in lock.withLock { lines.append(s) } })
+ try await t.healthCheck()
+ let child = try XCTUnwrap(l.last)
+ child.stderrClass = .profileLocked
+ child.die(1)
+ _ = await klass(t)
+ XCTAssertTrue(lines.contains("browser: playwright_exit class=profile_locked status=1"), "\(lines)")
+ }
+}
+
+/// A temporary install tree whose `node` is a shell shim that runs the fake MCP server.
+final class InstallTree {
+ let root: URL
+ init(mode: String = "normal", chromium: Bool = true) throws {
+ root = FileManager.default.temporaryDirectory.appendingPathComponent("oab-pw-\(UUID().uuidString)")
+ let v = root.appendingPathComponent("1.0")
+ let fm = FileManager.default
+ try fm.createDirectory(at: v.appendingPathComponent("node/bin"), withIntermediateDirectories: true)
+ try fm.createDirectory(at: v.appendingPathComponent("pkg/node_modules/@playwright/mcp"), withIntermediateDirectories: true)
+ try fm.createDirectory(at: v.appendingPathComponent("chromium"), withIntermediateDirectories: true)
+ let node = v.appendingPathComponent("node/bin/node")
+ try "#!/bin/sh\nexec \"\(fakeServerPath())\" \(mode)\n".write(to: node, atomically: true, encoding: .utf8)
+ try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: node.path)
+ try "// cli".write(to: v.appendingPathComponent("pkg/node_modules/@playwright/mcp/cli.js"), atomically: true, encoding: .utf8)
+ if chromium {
+ let c = v.appendingPathComponent("chromium/chrome")
+ try "#!/bin/sh\n".write(to: c, atomically: true, encoding: .utf8)
+ try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: c.path)
+ }
+ try "{\"version\":\"1.0\",\"chromiumExecutable\":\"chromium/chrome\"}".write(to: root.appendingPathComponent("install.json"), atomically: true, encoding: .utf8)
+ }
+ var locator: ManagedPlaywrightLocator { .init(root: root) }
+ deinit { try? FileManager.default.removeItem(at: root) }
+}
+
+final class ManagedPlaywrightLocatorTests: XCTestCase {
+ func testMissingInvalidAndEscapingInstalls() throws {
+ let empty = FileManager.default.temporaryDirectory.appendingPathComponent("oab-pw-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: empty, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: empty) }
+ guard case .failure(let e0) = ManagedPlaywrightLocator(root: empty).locate() else { return XCTFail() }
+ XCTAssertEqual(e0.errorClass, "playwright_not_installed")
+
+ let noChromium = try InstallTree(chromium: false)
+ guard case .failure(let e1) = noChromium.locator.locate() else { return XCTFail() }
+ XCTAssertEqual(e1.errorClass, "playwright_install_invalid")
+
+ let bad = try InstallTree()
+ try "{ nope".write(to: bad.root.appendingPathComponent("install.json"), atomically: true, encoding: .utf8)
+ guard case .failure(let e2) = bad.locator.locate() else { return XCTFail() }
+ XCTAssertEqual(e2.errorClass, "playwright_install_invalid")
+
+ let esc = try InstallTree()
+ let outside = FileManager.default.temporaryDirectory.appendingPathComponent("oab-outside-\(UUID().uuidString)")
+ try "#!/bin/sh\n".write(to: outside, atomically: true, encoding: .utf8)
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: outside.path)
+ defer { try? FileManager.default.removeItem(at: outside) }
+ let link = esc.root.appendingPathComponent("1.0/chromium/evil")
+ try FileManager.default.createSymbolicLink(at: link, withDestinationURL: outside)
+ try "{\"version\":\"1.0\",\"chromiumExecutable\":\"chromium/evil\"}".write(to: esc.root.appendingPathComponent("install.json"), atomically: true, encoding: .utf8)
+ guard case .failure(let e3) = esc.locator.locate() else { return XCTFail() }
+ XCTAssertEqual(e3.errorClass, "playwright_install_invalid")
+
+ let dots = try InstallTree()
+ try "{\"version\":\"../x\",\"chromiumExecutable\":\"chromium/chrome\"}".write(to: dots.root.appendingPathComponent("install.json"), atomically: true, encoding: .utf8)
+ guard case .failure = dots.locator.locate() else { return XCTFail() }
+ }
+
+ func testLaunchVectorHasNoListenerFlagsAndAnAllowListEnvironment() throws {
+ let t = try InstallTree()
+ guard case .success(let spec) = t.locator.locate() else { return XCTFail() }
+ let a = spec.arguments
+ for banned in ["--port", "--host", "--allowed-hosts"] { XCTAssertFalse(a.contains(banned)) }
+ XCTAssertTrue(a[0].hasSuffix("pkg/node_modules/@playwright/mcp/cli.js"))
+ XCTAssertEqual(Array(a.dropFirst()).filter { $0.hasPrefix("--") },
+ ["--user-data-dir", "--output-dir", "--idle-timeout", "--browser", "--executable-path",
+ "--shared-browser-context", "--caps"])
+ XCTAssertEqual(a[a.firstIndex(of: "--idle-timeout")! + 1], "1800000")
+ XCTAssertEqual(a[a.firstIndex(of: "--caps")! + 1], "vision,pdf")
+ XCTAssertEqual(Set(spec.environment.keys), ["HOME", "TMPDIR", "LANG", "PATH"])
+ XCTAssertTrue(spec.environment["PATH"]!.hasSuffix(":/usr/bin:/bin"))
+ let attrs = try FileManager.default.attributesOfItem(atPath: t.root.appendingPathComponent("profile").path)
+ XCTAssertEqual((attrs[.posixPermissions] as? Int ?? 0) & 0o777, 0o700)
+ }
+}
+
+final class ManagedPlaywrightEndToEndTests: XCTestCase {
+ func testRouterServesTheManagedChildThroughTheMCPServer() async throws {
+ let t = try InstallTree()
+ let backend = PlaywrightBackend(managed: t.locator)
+ let router = BrowserRouter(playwright: backend, store: MemoryPreferenceStore())
+ let server = MCPServer(name: "t", version: "0", tools: ToolCatalog.local(agentVersion: "test"), browser: router)
+ let owner = server.scoped(to: .owner)
+ let names = Set(listedNames(try await serverRequest(owner, "tools/list")))
+ XCTAssertTrue(names.isSuperset(of: ["browser_navigate", "browser_snapshot"]))
+ let res = try await serverRequest(owner, "tools/call", params: ["name": "browser_navigate", "arguments": [:]])
+ XCTAssertEqual(res?.result?["structuredContent"]?["backend"]?.stringValue, "playwright")
+ XCTAssertEqual(res?.result?["content"]?.arrayValue?.first?["text"]?.stringValue, "fake did browser_navigate")
+ // observe sees and reaches no browser tool.
+ let observe = Set(listedNames(try await serverRequest(server.scoped(to: .observe), "tools/list")))
+ XCTAssertTrue(observe.filter { $0.hasPrefix("browser_") }.isEmpty)
+ // sys_info-style report names the mode without a URL.
+ let rep = await router.report()
+ XCTAssertEqual(rep.backends.first?.mode, "managed")
+ XCTAssertEqual(rep.active, .playwright)
+ await backend.shutdown()
+ }
+
+ func testNotInstalledListsNothingAndSaysSo() async throws {
+ let empty = FileManager.default.temporaryDirectory.appendingPathComponent("oab-pw-\(UUID().uuidString)")
+ let backend = PlaywrightBackend(managed: ManagedPlaywrightLocator(root: empty))
+ let apple = FakeBrowserBackend(.appleEvents)
+ let router = BrowserRouter(appleEvents: apple, playwright: backend, store: MemoryPreferenceStore())
+ // Router falls back to Apple Events when it is ready.
+ let r = await router.call("browser_navigate", arguments: [:], BrowserConnection(key: "a"))
+ XCTAssertEqual(r["structuredContent"]?["backend"]?.stringValue, "appleEvents")
+ // With nothing else, the call says Playwright is not installed.
+ let solo = BrowserRouter(playwright: backend, store: MemoryPreferenceStore())
+ let tools = await solo.tools(BrowserConnection(key: "b"))
+ XCTAssertTrue(tools.isEmpty)
+ let n = await solo.call("browser_navigate", arguments: [:], BrowserConnection(key: "b"))
+ XCTAssertEqual(n["content"]?.arrayValue?.first?["text"]?.stringValue, BrowserRouter.notSetUp + " Playwright is not installed.")
+ let rep = await solo.report()
+ XCTAssertEqual(rep.line, "browser tools: playwright not installed")
+ XCTAssertNil(rep.active)
+ XCTAssertFalse(FileManager.default.fileExists(atPath: empty.path), "reading state must not create the install dir")
+ }
+
+ func testManagedPreferredOverAppleEventsWhenBothReady() async throws {
+ let t = try InstallTree()
+ let backend = PlaywrightBackend(managed: t.locator)
+ let router = BrowserRouter(appleEvents: FakeBrowserBackend(.appleEvents), playwright: backend, store: MemoryPreferenceStore())
+ let r = await router.call("browser_navigate", arguments: [:], BrowserConnection(key: "a"))
+ XCTAssertEqual(r["structuredContent"]?["backend"]?.stringValue, "playwright")
+ await backend.shutdown()
+ }
+
+ func testChildStderrCanaryNeverReachesLogsOrResults() async throws {
+ let t = try InstallTree(mode: "stderr-canary")
+ nonisolated(unsafe) var lines: [String] = []
+ let lock = NSLock()
+ let backend = PlaywrightBackend(managed: t.locator, log: { s in lock.withLock { lines.append(s) } })
+ let router = BrowserRouter(playwright: backend, store: MemoryPreferenceStore(), log: { s in lock.withLock { lines.append(s) } })
+ let r = await router.call("browser_navigate", arguments: [:], BrowserConnection(key: "a"))
+ let rep = await router.report()
+ let all = String(decoding: jsonBody(r), as: UTF8.self) + String(decoding: jsonBody(rep.json), as: UTF8.self) + lines.joined()
+ XCTAssertFalse(all.contains("CANARY"), all)
+ }
+}
diff --git a/Tests/InstanceMCPCoreTests/ManagedProcessTests.swift b/Tests/InstanceMCPCoreTests/ManagedProcessTests.swift
new file mode 100644
index 0000000..ef72848
--- /dev/null
+++ b/Tests/InstanceMCPCoreTests/ManagedProcessTests.swift
@@ -0,0 +1,119 @@
+import Foundation
+import XCTest
+@testable import InstanceMCPCore
+
+final class StderrRingTests: XCTestCase {
+ func testRingKeepsNewestAndClassifiesOnlyWhatIsKept() {
+ let r = StderrRing()
+ r.append(Data("ProcessSingleton: old\n".utf8))
+ r.append(Data(String(repeating: "x", count: 70_000).utf8))
+ XCTAssertLessThanOrEqual(r.byteCount, StderrRing.capacity)
+ XCTAssertNil(r.classify(), "the evicted signature must not be classified")
+ r.append(Data("\nError: Cannot find module 'x'\n".utf8))
+ XCTAssertEqual(r.classify(), .moduleNotFound)
+ r.append(Data("\nSingleton lock held\n".utf8))
+ XCTAssertEqual(r.classify(), .profileLocked, "the newest signature wins")
+ }
+}
+
+final class ManagedProcessTests: XCTestCase {
+ private func spawn(_ mode: String, _ extra: [String] = [], env: [String: String] = [:]) throws -> ManagedProcess {
+ try ManagedProcess.spawn(executable: fakeServerPath(), arguments: [mode] + extra, environment: env,
+ workingDirectory: NSTemporaryDirectory())
+ }
+ private func read(_ p: ManagedProcess) async -> String {
+ var out = Data()
+ for await c in p.incoming { out.append(c) }
+ return String(decoding: out, as: UTF8.self)
+ }
+
+ func testSpawnRoundTripThroughTheClient() async throws {
+ let p = try spawn("normal")
+ let c = StdioMCPClient(channel: p)
+ try await c.initialize(timeout: 5)
+ let tools = try await c.listTools(timeout: 5)
+ XCTAssertEqual(tools.compactMap { $0["name"]?.stringValue }, ["browser_navigate", "browser_snapshot"])
+ let r = try await c.callTool("browser_navigate", arguments: [:], timeout: 5)
+ XCTAssertEqual(r["content"]?.arrayValue?.first?["text"]?.stringValue, "fake did browser_navigate")
+ XCTAssertEqual(getpgid(p.pid), p.pid, "the child leads its own group")
+ await p.terminate()
+ XCTAssertTrue(p.hasExited)
+ }
+
+ func testChunkyAndNoisyOutputStillWorks() async throws {
+ for mode in ["chunky", "noise"] {
+ let p = try spawn(mode)
+ let c = StdioMCPClient(channel: p)
+ try await c.initialize(timeout: 5)
+ _ = try await c.listTools(timeout: 5)
+ await p.terminate()
+ }
+ }
+
+ func testEnvironmentIsExactlyTheAllowList() async throws {
+ let p = try spawn("env", env: ["HOME": "/tmp", "LANG": "C", "PATH": "/usr/bin:/bin", "TMPDIR": "/tmp/"])
+ let out = await read(p)
+ // Foundation in the child adds __CF_USER_TEXT_ENCODING itself; everything else is ours.
+ let keys = out.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: ",").map(String.init)
+ .filter { $0 != "__CF_USER_TEXT_ENCODING" }
+ XCTAssertEqual(keys, ["HOME", "LANG", "PATH", "TMPDIR"])
+ }
+
+ func testStdinEOFEndsTheChildGracefully() async throws {
+ let p = try spawn("normal")
+ let c = StdioMCPClient(channel: p)
+ try await c.initialize(timeout: 5)
+ let t0 = Date()
+ await p.terminate()
+ XCTAssertLessThan(Date().timeIntervalSince(t0), 1.5)
+ XCTAssertEqual(p.exitInfo, .exited(0))
+ }
+
+ func testChildThatIgnoresTermIsKilled() async throws {
+ let p = try spawn("ignore-term")
+ try await Task.sleep(nanoseconds: 200_000_000)
+ let t0 = Date()
+ await p.terminate()
+ XCTAssertTrue(p.hasExited)
+ XCTAssertLessThan(Date().timeIntervalSince(t0), 7)
+ XCTAssertEqual(p.exitInfo, .signaled(SIGKILL))
+ }
+
+ func testGrandchildInTheGroupIsKilled() async throws {
+ let p = try spawn("grandchild")
+ var line = ""
+ for await c in p.incoming { line += String(decoding: c, as: UTF8.self); if line.contains("\n") { break } }
+ let gc = try XCTUnwrap(Int32(line.split(separator: " ").last.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? ""))
+ XCTAssertEqual(kill(gc, 0), 0)
+ XCTAssertEqual(getpgid(gc), p.pid)
+ await p.terminate()
+ try await Task.sleep(nanoseconds: 300_000_000)
+ XCTAssertEqual(kill(gc, 0), -1)
+ XCTAssertEqual(errno, ESRCH)
+ }
+
+ func testRegistryTerminatesRegisteredChildren() async throws {
+ let p = try spawn("ignore-term")
+ try await Task.sleep(nanoseconds: 200_000_000)
+ XCTAssertGreaterThan(ManagedChildRegistry.shared.count, 0)
+ ManagedChildRegistry.shared.terminateAllSync()
+ XCTAssertTrue(p.hasExited)
+ XCTAssertEqual(ManagedChildRegistry.shared.count, 0)
+ }
+
+ func testStderrNeverLeavesTheRing() async throws {
+ let home = FileManager.default.temporaryDirectory.appendingPathComponent("oab-home-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: home) }
+ let p = try ManagedProcess.spawn(executable: fakeServerPath(), arguments: ["stderr-signature"],
+ environment: ["HOME": home.path], workingDirectory: home.path)
+ _ = await read(p)
+ try await Task.sleep(nanoseconds: 300_000_000)
+ XCTAssertLessThanOrEqual(p.stderrRing.byteCount, StderrRing.capacity)
+ XCTAssertEqual(p.stderrRing.classify(), .profileLocked)
+ XCTAssertFalse(p.stderrRing.snapshotForTests().contains("CANARY"), "the oldest text was evicted")
+ XCTAssertEqual(p.exitInfo, .exited(3))
+ let files = FileManager.default.enumerator(atPath: home.path)?.allObjects ?? []
+ XCTAssertTrue(files.isEmpty, "nothing was written under HOME: \(files)")
+ }
+}
diff --git a/Tests/InstanceMCPCoreTests/StdioMCPClientTests.swift b/Tests/InstanceMCPCoreTests/StdioMCPClientTests.swift
new file mode 100644
index 0000000..8f6aba4
--- /dev/null
+++ b/Tests/InstanceMCPCoreTests/StdioMCPClientTests.swift
@@ -0,0 +1,154 @@
+import Foundation
+import XCTest
+@testable import InstanceMCPCore
+
+/// A channel the test drives by hand.
+final class ScriptedChannel: StdioChannel, @unchecked Sendable {
+ let incoming: AsyncStream
+ private let cont: AsyncStream.Continuation
+ private let lock = NSLock()
+ private var _sent: [Data] = []
+ private var _closed = false
+ var onSend: (@Sendable (JSONValue) -> Void)?
+
+ init() {
+ var c: AsyncStream.Continuation!
+ incoming = AsyncStream(bufferingPolicy: .unbounded) { c = $0 }
+ cont = c
+ }
+ var sent: [JSONValue] {
+ lock.withLock { _sent }.flatMap { d in
+ String(decoding: d, as: UTF8.self).split(separator: "\n").compactMap { try? JSONCoding.decoder.decode(JSONValue.self, from: Data($0.utf8)) }
+ }
+ }
+ var rawSent: [Data] { lock.withLock { _sent } }
+ var closed: Bool { lock.withLock { _closed } }
+ func send(_ data: Data) {
+ lock.withLock { _sent.append(data) }
+ if let v = try? JSONCoding.decoder.decode(JSONValue.self, from: data.dropLast()) { onSend?(v) }
+ }
+ func close() { lock.withLock { _closed = true }; cont.finish() }
+ func feed(_ s: String) { cont.yield(Data(s.utf8)) }
+ func feed(_ d: Data) { cont.yield(d) }
+ func finish() { cont.finish() }
+}
+
+private func okReply(_ id: JSONValue?) -> String {
+ "{\"jsonrpc\":\"2.0\",\"id\":\(id?.intValue ?? 0),\"result\":{}}\n"
+}
+
+final class StdioMCPClientTests: XCTestCase {
+ func testOutboundIsOneCompactLinePerMessage() async throws {
+ let ch = ScriptedChannel()
+ ch.onSend = { v in if v["id"] != nil { ch.feed(okReply(v["id"])) } }
+ let c = StdioMCPClient(channel: ch)
+ try await c.initialize(timeout: 2)
+ for d in ch.rawSent {
+ XCTAssertEqual(d.last, 0x0A)
+ XCTAssertEqual(d.filter { $0 == 0x0A }.count, 1, "exactly one newline, at the end")
+ }
+ XCTAssertEqual(ch.sent.map { $0["method"]?.stringValue }, ["initialize", "notifications/initialized"])
+ }
+
+ func testReplySplitAcrossWritesAndTwoRepliesInOneRead() async throws {
+ let ch = ScriptedChannel()
+ let c = StdioMCPClient(channel: ch)
+ await c.startReading()
+ ch.onSend = { v in
+ guard let id = v["id"] else { return }
+ let line = okReply(id)
+ let mid = line.index(line.startIndex, offsetBy: 7)
+ ch.feed(String(line[..