From c93e6739f9ab1b3dced8e58add27ca7902f70743 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Sat, 14 Nov 2026 14:17:15 -0500 Subject: [PATCH] feat: stdio MCP client and managed Playwright tier (browser tiers phase 1, PR 2) Adds StdioMCPClient (newline-delimited JSON-RPC, 8 MiB line cap, per-request timeouts), ManagedProcess (posix_spawn, own process group, allow-list env, 64 KiB in-memory stderr ring that is only ever classified, never logged, quit-time group termination), ManagedPlaywrightTransport (lazy start, backoff 1/2/4..30 s, latch after 5 failures in 2 min, Retry) and the managed mode of PlaywrightBackend. The install location is configuration (--playwright-root, default under Application Support); with nothing installed the router falls back and sys_info/menu say so. No listener. Reporting: sys_info gains a browser block and one text line; the menu's Browser tools row shows Playwright ready / not installed from the last known state, never starting the child to draw a menu. Review follow-ups from #122 in this PR: PortOwnerProbe takes the validated loopback endpoint (family and address) and is tested with simultaneous IPv4 and IPv6 listeners; healthCheck pings the live session with a 2 s timeout instead of re-running initialize, and DELETEs a session it replaces; port range check; repeated --upstream browser= rejected; lazy router status calls; tool list and owner check caching; pruned guard map; bracketed IPv6 Host header. Tested on macmini (macOS 15.7) in /private/tmp: swift build --build-tests, swift test (583 tests, 0 failures, 2 pre-existing skips), the new suites re-run alone (68 tests, 0 failures), swift build -c release, flag checks (exit 64 for repeated browser upstream, port 70000, relative root), scripts/check-no-private-identifiers.sh, test-pw-mcp-check.sh, test-name-consistency.sh. --- Package.swift | 7 +- README.md | 8 + .../Browser/BrowserBackend.swift | 16 +- .../Browser/BrowserRouter.swift | 111 ++++++- .../Browser/BrowserUpstreamURL.swift | 16 + .../Browser/ExternalPlaywrightClient.swift | 71 ++++- .../Browser/ManagedPlaywright.swift | 219 ++++++++++++++ .../Browser/ManagedProcess.swift | 235 +++++++++++++++ .../Browser/PlaywrightBackend.swift | 139 +++++++-- .../Browser/PortOwnerProbe.swift | 38 ++- .../Browser/StdioMCPClient.swift | 159 ++++++++++ Sources/InstanceMCPCore/MenuModel.swift | 39 ++- Sources/InstanceMCPCore/ToolCatalog.swift | 4 +- .../InstanceMCPCore/Tools/SysInfoTool.swift | 16 +- .../oab-computer/StatusItemController.swift | 25 +- Sources/oab-computer/main.swift | 50 +++- Tests/FakeMCPStdioServer/main.swift | 80 +++++ .../BrowserReportingTests.swift | 51 ++++ .../BrowserRouterTests.swift | 39 +++ .../BrowserTestSupport.swift | 58 +++- .../ExternalPlaywrightTests.swift | 196 ++++++++++++- .../ManagedPlaywrightTests.swift | 277 ++++++++++++++++++ .../ManagedProcessTests.swift | 119 ++++++++ .../StdioMCPClientTests.swift | 154 ++++++++++ 24 files changed, 2038 insertions(+), 89 deletions(-) create mode 100644 Sources/InstanceMCPCore/Browser/ManagedPlaywright.swift create mode 100644 Sources/InstanceMCPCore/Browser/ManagedProcess.swift create mode 100644 Sources/InstanceMCPCore/Browser/StdioMCPClient.swift create mode 100644 Tests/FakeMCPStdioServer/main.swift create mode 100644 Tests/InstanceMCPCoreTests/BrowserReportingTests.swift create mode 100644 Tests/InstanceMCPCoreTests/ManagedPlaywrightTests.swift create mode 100644 Tests/InstanceMCPCoreTests/ManagedProcessTests.swift create mode 100644 Tests/InstanceMCPCoreTests/StdioMCPClientTests.swift 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[..