From 9e02bf1bfd1f6ba75cf541c8ae62bd0f30dd0051 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Sat, 26 Sep 2026 14:10:06 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20re-serve=20a=20loopback=20MCP=20server'?= =?UTF-8?q?s=20tools=20(Playwright=20=E2=86=92=20browser=5F*)=20under=20th?= =?UTF-8?q?e=20tool=20profile=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UpstreamMCP: Streamable-HTTP client to a loopback MCP server — initialize, hold the Mcp-Session-Id, re-init once on 400/404, parse JSON or SSE `data:` bodies, send Host as the bare host (Playwright's --allowed-hosts compares it verbatim; with the port it 403s). tools/list cached 30 s; down → empty, so the merged list simply lacks them. MCPServer gains `upstreams` + `upstreamFilter`: upstream tools are appended to tools/list and routed on tools/call, local names win on collision, results are relayed verbatim (image blocks survive). scoped(to:) carries the upstreams and the profile, so the filter travels with the lent server. ToolProfile.sandbox: browser_* is allowlisted (navigate/back/snapshot/find/click/ type/fill_form/press_key/hover/select_option/wait_for/tabs/take_screenshot/ console_messages/resize/evaluate); everything else browser_* — run_code_unsafe, file_upload, pdf_save, network_*, mouse_*_xy, drag/drop, dialogs, emulate_media, close, and anything new — is denied. CLI: --upstream name=url (repeatable); instructions mention the browser when one is configured; deploy.sh adds the Playwright upstream when its LaunchAgent exists. Tests (88 pass): fake SSE upstream — sandbox list/deny/shadowing, owner sees all, call forwarding + verbatim passthrough, upstream down, session re-establishment, body parsing; allowlist shape. Verified from a lent pod session against the real pw-mcp on macmini: navigate → snapshot returned a YouTube channel's first video title as text. 0.6.0. --- README.md | 20 ++ Sources/InstanceMCPCore/AttachManager.swift | 6 +- Sources/InstanceMCPCore/MCPServer.swift | 36 +++- Sources/InstanceMCPCore/Tool.swift | 4 + Sources/InstanceMCPCore/ToolProfile.swift | 22 +- Sources/InstanceMCPCore/UpstreamMCP.swift | 175 ++++++++++++++++ Sources/oab-instance-mcp/main.swift | 25 ++- .../UpstreamMCPTests.swift | 194 ++++++++++++++++++ scripts/deploy.sh | 9 + 9 files changed, 479 insertions(+), 12 deletions(-) create mode 100644 Sources/InstanceMCPCore/UpstreamMCP.swift create mode 100644 Tests/InstanceMCPCoreTests/UpstreamMCPTests.swift diff --git a/README.md b/README.md index e0fef52..ca135f5 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,26 @@ curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/ grant is the identity; `Tailscale-User-Login` there would be pod-supplied. - `--no-attach` disables the plane (`/attach` → 404). +### Browser tools for lent sessions (`--upstream`) + +The sandbox has no path to any browser, so browser control is served **from this Mac**: with +`--upstream browser=http://127.0.0.1:8794/mcp` (deploy.sh adds it when the Playwright MCP +LaunchAgent from [`poc/pw-mcp`](poc/pw-mcp/README.md) is installed) the daemon re-serves +Playwright's tools under its own `tools/list`, filtered by the connection's profile: + +- `owner` sees all 32 `browser_*` tools; `sandbox` sees the navigate / read / interact subset + (`ToolProfile.sandboxBrowserTools`) and **not** `browser_run_code_unsafe`, file upload / PDF, + network inspection, raw mouse-by-coordinate, dialogs, `browser_close`. New Playwright tools are + denied under sandbox until listed. +- `browser_navigate` + `browser_snapshot` returns the page as an accessibility tree — the first + video title on a channel page is one text line, no screenshot, no OCR. The browser is a real + 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. + +Verified 2026-09-26 from a lent pod session (sandbox): 16 `browser_*` tools listed, `run_code_unsafe` +unknown, navigate → snapshot on a YouTube channel returned the first video's title as text. + Verified 2026-09-26 end to end on macmini against the openab-pty runtime (PR #38): mint via `admin_credential`, dial, the session shell's `$OPENAB_TOOLS_MCP_URL` listed `sys_info screenshot mouse key osascript instance_status`, `exec` refused, `sys_info` answered, diff --git a/Sources/InstanceMCPCore/AttachManager.swift b/Sources/InstanceMCPCore/AttachManager.swift index 64a3da6..d86b964 100644 --- a/Sources/InstanceMCPCore/AttachManager.swift +++ b/Sources/InstanceMCPCore/AttachManager.swift @@ -161,8 +161,10 @@ public actor AttachManager { You reached this Mac through OpenAB Connect: a human lent it to your sandbox session for a \ limited time and is likely watching the screen. This is the `sandbox` profile — there is no \ `exec` tool here (you already have a shell in your own session); drive the Mac through \ - `screenshot`, `mouse`, `key` and `osascript`. If a tool starts failing with "not attached", \ - the grant ended; ask the human to lend the Mac again. + `screenshot`, `mouse`, `key` and `osascript`, and — when `browser_*` tools are listed — through \ + the browser directly: `browser_navigate` then `browser_snapshot` gives you the page as text, \ + no screenshot needed. If a tool starts failing with "not attached", the grant ended; ask the \ + human to lend the Mac again. """ } diff --git a/Sources/InstanceMCPCore/MCPServer.swift b/Sources/InstanceMCPCore/MCPServer.swift index 3f5e9ac..88a147a 100644 --- a/Sources/InstanceMCPCore/MCPServer.swift +++ b/Sources/InstanceMCPCore/MCPServer.swift @@ -12,8 +12,17 @@ public struct MCPServer: Sendable { public let instructions: String? private let tools: [String: any Tool] private let toolOrder: [String] + /// Loopback MCP servers whose tools are merged into `tools/list` (after the + /// local ones) and routed on `tools/call`. Resolved at request time, so an + /// upstream that is down simply contributes nothing. See `UpstreamMCP`. + public let upstreams: [UpstreamMCP] + /// Applied to upstream tool names at list/call time. Local tools are already + /// filtered by `scoped(to:)`; upstream lists are dynamic, so the filter has + /// to travel with the server. + public let upstreamFilter: ToolProfile? - public init(name: String, version: String, instructions: String? = nil, tools: [any Tool]) { + public init(name: String, version: String, instructions: String? = nil, tools: [any Tool], + upstreams: [UpstreamMCP] = [], upstreamFilter: ToolProfile? = nil) { self.serverName = name self.serverVersion = version self.instructions = instructions @@ -21,6 +30,21 @@ public struct MCPServer: Sendable { for t in tools { map[t.name] = t } self.tools = map self.toolOrder = tools.map(\.name) + self.upstreams = upstreams + self.upstreamFilter = upstreamFilter + } + + /// Upstream tools visible under the current filter, as `Tool`s. + func upstreamTools() async -> [UpstreamTool] { + var out: [UpstreamTool] = [] + for u in upstreams { + for d in await u.tools() { + let t = UpstreamTool(descriptorValue: d, upstream: u) + if upstreamFilter?.allows(t.name) ?? true { out.append(t) } + } + } + // Local names win on collision: an upstream cannot shadow `screenshot`. + return out.filter { tools[$0.name] == nil } } /// Tools in declaration order. Used by `scoped(to:)`. @@ -86,13 +110,19 @@ public struct MCPServer: Sendable { return [:] case "tools/list": - return ["tools": .array(toolOrder.compactMap { tools[$0]?.descriptor })] + var list = toolOrder.compactMap { tools[$0]?.descriptor } + list += await upstreamTools().map(\.descriptor) + return ["tools": .array(list)] case "tools/call": guard let name = req.params?["name"]?.stringValue else { throw JSONRPCError.invalidParams("missing tool name") } - guard let tool = tools[name] else { + var resolved: (any Tool)? = tools[name] + if resolved == nil, !upstreams.isEmpty { + resolved = await upstreamTools().first { $0.name == name } + } + guard let tool = resolved else { throw JSONRPCError.invalidParams("unknown tool: \(name)") } let args = req.params?["arguments"] ?? [:] diff --git a/Sources/InstanceMCPCore/Tool.swift b/Sources/InstanceMCPCore/Tool.swift index b2e3af3..8e862ae 100644 --- a/Sources/InstanceMCPCore/Tool.swift +++ b/Sources/InstanceMCPCore/Tool.swift @@ -20,6 +20,9 @@ public struct ToolResult: Equatable, Sendable { public var isError: Bool /// Optional machine-readable payload, surfaced as `structuredContent` (MCP 2025-06-18). public var structured: JSONValue? + /// When set, `json` is this value verbatim: an upstream MCP server's own + /// `tools/call` result, relayed without re-encoding (keeps image blocks etc.). + public var rawPassthrough: JSONValue? = nil public init(content: [ToolContent], isError: Bool = false, structured: JSONValue? = nil) { self.content = content; self.isError = isError; self.structured = structured @@ -33,6 +36,7 @@ public struct ToolResult: Equatable, Sendable { } public var json: JSONValue { + if let raw = rawPassthrough { return raw } var o: [String: JSONValue] = ["content": .array(content.map(\.json))] if isError { o["isError"] = true } if let s = structured { o["structuredContent"] = s } diff --git a/Sources/InstanceMCPCore/ToolProfile.swift b/Sources/InstanceMCPCore/ToolProfile.swift index 623635b..2843bb3 100644 --- a/Sources/InstanceMCPCore/ToolProfile.swift +++ b/Sources/InstanceMCPCore/ToolProfile.swift @@ -14,13 +14,27 @@ public enum ToolProfile: String, Codable, Sendable, CaseIterable { case owner case sandbox - /// `nil` means "no filter". Match is on the tool name. + /// Match is on the tool name. public func allows(_ toolName: String) -> Bool { switch self { case .owner: return true - case .sandbox: return !toolName.hasPrefix("exec") + case .sandbox: + if toolName.hasPrefix("exec") { return false } + if toolName.hasPrefix("browser_") { return Self.sandboxBrowserTools.contains(toolName) } + return true } } + + /// Playwright MCP tools a lent sandbox may use: navigate, read, interact. + /// Not: arbitrary code in the browser process, the filesystem (upload / PDF), + /// raw network inspection, dialogs, media emulation, closing the browser. + /// Anything Playwright adds later is denied until listed here. + public static let sandboxBrowserTools: Set = [ + "browser_navigate", "browser_navigate_back", "browser_snapshot", "browser_find", + "browser_click", "browser_type", "browser_fill_form", "browser_press_key", "browser_hover", + "browser_select_option", "browser_wait_for", "browser_tabs", "browser_take_screenshot", + "browser_console_messages", "browser_resize", "browser_evaluate", + ] } extension MCPServer { @@ -33,7 +47,9 @@ extension MCPServer { name: serverName, version: serverVersion, instructions: instructions ?? self.instructions, - tools: allTools.filter { profile.allows($0.name) } + tools: allTools.filter { profile.allows($0.name) }, + upstreams: upstreams, + upstreamFilter: profile ) } } diff --git a/Sources/InstanceMCPCore/UpstreamMCP.swift b/Sources/InstanceMCPCore/UpstreamMCP.swift new file mode 100644 index 0000000..84c8a96 --- /dev/null +++ b/Sources/InstanceMCPCore/UpstreamMCP.swift @@ -0,0 +1,175 @@ +import Foundation + +/// A loopback MCP server whose tools this daemon re-serves under its own roof — +/// first case: `@playwright/mcp` on `127.0.0.1:8794`, so a lent sandbox session +/// gets `browser_*` tools without any path from the pod to the browser (issue #10). +/// +/// Streamable HTTP client, request/response only. The upstream's `Mcp-Session-Id` +/// is held here and re-established when the upstream forgets it (400/404). Replies +/// may arrive as `application/json` or as an SSE frame (`data: {…}`); both parsed. +/// +/// `Host` is sent as the bare host without the port: Playwright MCP's +/// `--allowed-hosts` compares the header verbatim, and `127.0.0.1:8794` is not on +/// its list while `127.0.0.1` is. +public actor UpstreamMCP { + public let name: String + public let url: URL + /// Prefix every tool name is exposed under. Playwright's tools already carry + /// `browser_`, so the default is empty; set it for upstreams that do not. + public let prefix: String + private let log: @Sendable (String) -> Void + private let session: URLSession + private var sessionID: String? + private var cachedTools: (at: Date, tools: [JSONValue])? + private let cacheTTL: TimeInterval = 30 + public private(set) var lastError: String? + + public init(name: String, url: URL, prefix: String = "", timeout: TimeInterval = 90, + log: @escaping @Sendable (String) -> Void = { _ in }) { + self.name = name; self.url = url; self.prefix = prefix; self.log = log + let cfg = URLSessionConfiguration.ephemeral + cfg.timeoutIntervalForRequest = timeout + self.session = URLSession(configuration: cfg) + } + + // MARK: public surface + + /// Tool descriptors with `name` prefixed. Empty when the upstream is down — + /// the merged `tools/list` then simply lacks them (issue #10 acceptance). + public func tools() async -> [JSONValue] { + if let c = cachedTools, Date().timeIntervalSince(c.at) < cacheTTL { return c.tools } + do { + let r = try await rpc("tools/list", params: nil) + let list = (r["result"]?["tools"]?.arrayValue ?? []).map { t -> JSONValue in + guard var o = t.objectValue, let n = o["name"]?.stringValue else { return t } + o["name"] = .string(prefix + n) + return .object(o) + } + cachedTools = (Date(), list) + lastError = nil + return list + } catch { + lastError = "\(error)" + log("upstream \(name): tools/list failed: \(error)") + cachedTools = (Date(), []) + return [] + } + } + + /// Forward a `tools/call`. `name` is the prefixed name the caller used. + public func call(_ name: String, arguments: JSONValue) async throws -> JSONValue { + let upstreamName = name.hasPrefix(prefix) ? String(name.dropFirst(prefix.count)) : name + let r = try await rpc("tools/call", params: ["name": .string(upstreamName), "arguments": arguments]) + if let e = r["error"] { throw JSONRPCError(code: e["code"]?.intValue ?? -32000, message: e["message"]?.stringValue ?? "upstream error") } + return r["result"] ?? .null + } + + public func status() async -> JSONValue { + let n = await tools().count + return ["name": .string(name), "url": .string(url.absoluteString), "tools": .number(Double(n)), + "session": .bool(sessionID != nil), "error": lastError.map { .string($0) } ?? .null] + } + + /// Does not hit the network; for the `sys_info` line. + public func isKnownHealthy() -> Bool { lastError == nil && (cachedTools?.tools.isEmpty == false) } + + // MARK: wire + + private func rpc(_ method: String, params: JSONValue?) async throws -> JSONValue { + if sessionID == nil { try await initialize() } + let (status, body) = try await post(["jsonrpc": "2.0", "id": .number(Double(Int.random(in: 1...1_000_000))), "method": .string(method), "params": params ?? [:]]) + if status == 404 || status == 400 { + // Upstream lost our session (restart). One re-init, one retry. + sessionID = nil + try await initialize() + let (s2, b2) = try await post(["jsonrpc": "2.0", "id": 1, "method": .string(method), "params": params ?? [:]]) + guard (200..<300).contains(s2) else { throw UpstreamError.http(s2, b2) } + return try Self.parseBody(b2) + } + guard (200..<300).contains(status) else { throw UpstreamError.http(status, body) } + return try Self.parseBody(body) + } + + private func initialize() async throws { + let (status, body, headers) = try await postRaw([ + "jsonrpc": "2.0", "id": 0, "method": "initialize", + "params": ["protocolVersion": "2025-06-18", "capabilities": [:], + "clientInfo": ["name": "oab-instance-mcp", "version": "upstream"]], + ]) + guard (200..<300).contains(status) else { throw UpstreamError.http(status, body) } + sessionID = headers.first { $0.key.lowercased() == "mcp-session-id" }?.value + _ = try? await post(["jsonrpc": "2.0", "method": "notifications/initialized"]) + let info = (try? Self.parseBody(body))?["result"]?["serverInfo"] + log("upstream \(name): connected to \(info?["name"]?.stringValue ?? "?") \(info?["version"]?.stringValue ?? "")") + cachedTools = nil + } + + private func post(_ msg: JSONValue) async throws -> (Int, Data) { + let (s, b, _) = try await postRaw(msg); return (s, b) + } + + private func postRaw(_ msg: JSONValue) async throws -> (Int, Data, [String: String]) { + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.httpBody = try JSONCoding.encoder.encode(msg) + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept") + if let h = url.host { req.setValue(h, forHTTPHeaderField: "Host") } + if let sid = sessionID { req.setValue(sid, forHTTPHeaderField: "Mcp-Session-Id") } + let (data, resp) = try await session.data(for: req) + let http = resp as? HTTPURLResponse + var headers: [String: String] = [:] + for (k, v) in http?.allHeaderFields ?? [:] { if let k = k as? String, let v = v as? String { headers[k] = v } } + return (http?.statusCode ?? 0, data, headers) + } + + /// JSON, or the first `data:` line of an SSE body. + static func parseBody(_ data: Data) throws -> JSONValue { + if let v = try? JSONCoding.decoder.decode(JSONValue.self, from: data) { return v } + let text = String(decoding: data, as: UTF8.self) + for line in text.split(separator: "\n") where line.hasPrefix("data:") { + let payload = line.dropFirst(5).trimmingCharacters(in: .whitespaces) + if let v = try? JSONCoding.decoder.decode(JSONValue.self, from: Data(payload.utf8)) { return v } + } + throw UpstreamError.badBody(String(text.prefix(200))) + } + + public enum UpstreamError: Error, CustomStringConvertible { + case http(Int, Data) + case badBody(String) + public var description: String { + switch self { + case .http(let s, let d): return "upstream HTTP \(s): \(String(decoding: d.prefix(200), as: UTF8.self))" + case .badBody(let s): return "upstream returned neither JSON nor SSE: \(s)" + } + } + } +} + +/// A `Tool` that forwards to an upstream. One per upstream tool, built from the +/// upstream's descriptor at list time, so the schema the agent sees is the +/// upstream's own. +struct UpstreamTool: Tool { + let descriptorValue: JSONValue + let upstream: UpstreamMCP + + var name: String { descriptorValue["name"]?.stringValue ?? "?" } + var description: String { descriptorValue["description"]?.stringValue ?? "" } + var inputSchema: JSONValue { descriptorValue["inputSchema"] ?? ["type": "object"] } + var descriptor: JSONValue { descriptorValue } + + func call(arguments: JSONValue) async throws -> ToolResult { + let r = try await upstream.call(name, arguments: arguments) + // Pass the upstream's result through verbatim: content blocks, isError, + // structuredContent. Re-wrapping would lose image blocks. + return ToolResult(passthrough: r) + } +} + +extension ToolResult { + /// A result whose JSON is exactly `raw` (an upstream's `tools/call` result). + init(passthrough raw: JSONValue) { + self.init(content: [], isError: raw["isError"]?.boolValue ?? false, structured: nil) + self.rawPassthrough = raw + } +} diff --git a/Sources/oab-instance-mcp/main.swift b/Sources/oab-instance-mcp/main.swift index cde6854..0c03dd1 100644 --- a/Sources/oab-instance-mcp/main.swift +++ b/Sources/oab-instance-mcp/main.swift @@ -4,7 +4,7 @@ import CoreGraphics import Foundation import InstanceMCPCore -let version = "0.5.0" +let version = "0.6.0" struct Options { var host = "127.0.0.1" @@ -18,6 +18,8 @@ struct Options { var menuBar = false var publicURL: String? = nil var attach = true + /// name=url pairs; each is a loopback MCP server whose tools are re-served. + var upstreams: [(String, URL)] = [] } func usage() -> Never { @@ -35,6 +37,10 @@ func usage() -> Never { --insecure-local Allow unauthenticated requests that arrive on loopback *without* Tailscale headers. For local debugging only. + --upstream Re-serve the tools of a loopback MCP server (e.g. the Playwright MCP + 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; sandbox sees an allowlisted subset of browser_*. --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). @@ -69,6 +75,11 @@ while !args.isEmpty { case "--menu-bar": opts.menuBar = true case "--public-url": opts.publicURL = next(a) case "--no-attach": opts.attach = false + case "--upstream": + let v = next(a) + guard let eq = v.firstIndex(of: "="), let u = URL(string: String(v[v.index(after: eq)...])), + u.host != nil else { fputs("--upstream wants name=http://host:port/path\n", stderr); usage() } + opts.upstreams.append((String(v[.. HTTPResponse { + let body = "event: message\ndata: " + String(decoding: try! JSONCoding.encoder.encode(v), as: UTF8.self) + "\n\n" + return HTTPResponse(status: 200, headers: [("Content-Type", "text/event-stream")] + extra, body: Data(body.utf8)) + } + + private func handle(_ req: HTTPRequest) -> HTTPResponse { + hostHeaders.append(req.header("host") ?? "") + let rpc = (try? JSONCoding.decoder.decode(JSONRPCRequest.self, from: req.body)) + guard let rpc else { return .text(400, "bad") } + let id = rpc.id ?? .null + switch rpc.method { + case "initialize": + initializeCount += 1 + return sse(["jsonrpc": "2.0", "id": id, "result": ["protocolVersion": "2025-06-18", "capabilities": [:], "serverInfo": ["name": "FakePlaywright", "version": "0"]]], + extra: [("Mcp-Session-Id", sid)]) + case "notifications/initialized": + return .init(status: 202) + default: + guard req.header("mcp-session-id") == sid else { return .text(400, "no session") } + } + switch rpc.method { + case "tools/list": + return sse(["jsonrpc": "2.0", "id": id, "result": ["tools": [ + ["name": "browser_navigate", "description": "go", "inputSchema": ["type": "object", "properties": ["url": ["type": "string"]]]], + ["name": "browser_snapshot", "description": "read", "inputSchema": ["type": "object"]], + ["name": "browser_run_code_unsafe", "description": "danger", "inputSchema": ["type": "object"]], + ["name": "screenshot", "description": "collides with a local tool", "inputSchema": ["type": "object"]], + ]]]) + case "tools/call": + calls.append(rpc.params ?? .null) + let name = rpc.params?["name"]?.stringValue ?? "?" + if name == "browser_snapshot" { + return sse(["jsonrpc": "2.0", "id": id, "result": ["content": [["type": "text", "text": "- heading \"First video title\""], ["type": "image", "data": "AAAA", "mimeType": "image/png"]]]]) + } + let text = "did \(name) with \(rpc.params?["arguments"]?["url"]?.stringValue ?? "-")" + return sse(["jsonrpc": "2.0", "id": id, "result": ["content": [["type": "text", "text": .string(text)]], "isError": false]]) + default: + return sse(["jsonrpc": "2.0", "id": id, "error": ["code": -32601, "message": "nope"]]) + } + } +} + +final class UpstreamMCPTests: XCTestCase { + func makeServer(_ up: FakeUpstream, profile: ToolProfile?) -> MCPServer { + let u = UpstreamMCP(name: "browser", url: URL(string: "http://127.0.0.1:\(up.port)/mcp")!) + let base = MCPServer(name: "t", version: "0", tools: [EchoTool(), FakeExecTool(name: "exec"), FakeExecTool(name: "screenshot")], upstreams: [u]) + return profile.map { base.scoped(to: $0) } ?? base + } + + func names(_ r: JSONRPCResponse?) -> [String] { + r?.result?["tools"]?.arrayValue?.compactMap { $0["name"]?.stringValue } ?? [] + } + + func testSandboxSeesOnlyAllowlistedBrowserToolsAndLocalNamesWin() async throws { + let up = try FakeUpstream(); defer { up.stop() } + let s = makeServer(up, profile: .sandbox) + let list = await s.handle(try JSONCoding.decoder.decode(JSONRPCRequest.self, from: rpc("tools/list"))) + XCTAssertEqual(names(list), ["echo", "screenshot", "browser_navigate", "browser_snapshot"], + "no exec, no browser_run_code_unsafe, upstream 'screenshot' shadowed by the local one") + XCTAssertEqual(up.hostHeaders.first, "127.0.0.1", "Host must be the bare host — Playwright's allowed-hosts check") + } + + func testOwnerSeesEverythingUpstreamOffers() async throws { + let up = try FakeUpstream(); defer { up.stop() } + let s = makeServer(up, profile: .owner) + let list = await s.handle(try JSONCoding.decoder.decode(JSONRPCRequest.self, from: rpc("tools/list"))) + XCTAssertTrue(names(list).contains("browser_run_code_unsafe")) + } + + func testCallIsForwardedAndResultPassedThroughVerbatim() async throws { + let up = try FakeUpstream(); defer { up.stop() } + let s = makeServer(up, profile: .sandbox) + let nav = await s.handle(try JSONCoding.decoder.decode(JSONRPCRequest.self, + from: rpc("tools/call", params: ["name": "browser_navigate", "arguments": ["url": "https://x"]]))) + XCTAssertEqual(nav?.result?["content"]?.arrayValue?.first?["text"]?.stringValue, "did browser_navigate with https://x") + XCTAssertEqual(up.calls.last?["name"]?.stringValue, "browser_navigate") + XCTAssertEqual(up.calls.last?["arguments"]?["url"]?.stringValue, "https://x") + + let snap = await s.handle(try JSONCoding.decoder.decode(JSONRPCRequest.self, + from: rpc("tools/call", params: ["name": "browser_snapshot", "arguments": [:]]))) + let content = snap?.result?["content"]?.arrayValue + XCTAssertEqual(content?.count, 2, "image block survives the relay") + XCTAssertEqual(content?[1]["mimeType"]?.stringValue, "image/png") + } + + func testDeniedUpstreamToolIsUnknownUnderSandbox() async throws { + let up = try FakeUpstream(); defer { up.stop() } + let s = makeServer(up, profile: .sandbox) + let r = await s.handle(try JSONCoding.decoder.decode(JSONRPCRequest.self, + from: rpc("tools/call", params: ["name": "browser_run_code_unsafe", "arguments": [:]]))) + XCTAssertEqual(r?.error?.code, JSONRPCError.invalidParams) + XCTAssertTrue(up.calls.isEmpty, "the denied call never reached the upstream") + } + + func testUpstreamDownMeansNoBrowserToolsAndLocalStillWorks() async throws { + let up = try FakeUpstream() + up.stop() + let s = makeServer(up, profile: .sandbox) + let list = await s.handle(try JSONCoding.decoder.decode(JSONRPCRequest.self, from: rpc("tools/list"))) + XCTAssertEqual(names(list), ["echo", "screenshot"]) + let echo = await s.handle(try JSONCoding.decoder.decode(JSONRPCRequest.self, + from: rpc("tools/call", params: ["name": "echo", "arguments": ["msg": "ok"]]))) + XCTAssertEqual(echo?.result?["content"]?.arrayValue?.first?["text"]?.stringValue, "ok") + } + + func testSessionIsReestablishedWhenUpstreamForgetsIt() async throws { + let up = try FakeUpstream(); defer { up.stop() } + let u = UpstreamMCP(name: "b", url: URL(string: "http://127.0.0.1:\(up.port)/mcp")!) + _ = await u.tools() + XCTAssertEqual(up.initializeCount, 1) + // A second UpstreamMCP against the same fake has no session: the fake 400s, + // the client re-inits once and retries. + let u2 = UpstreamMCP(name: "b2", url: URL(string: "http://127.0.0.1:\(up.port)/mcp")!) + let r = try await u2.call("browser_navigate", arguments: ["url": "https://y"]) + XCTAssertEqual(r["content"]?.arrayValue?.first?["text"]?.stringValue, "did browser_navigate with https://y") + XCTAssertEqual(up.initializeCount, 2) + } + + func testSSEAndPlainJSONBodiesBothParse() throws { + let plain = try UpstreamMCP.parseBody(Data(#"{"jsonrpc":"2.0","id":1,"result":{}}"#.utf8)) + XCTAssertEqual(plain["id"]?.intValue, 1) + let sse = try UpstreamMCP.parseBody(Data("event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{}}\n\n".utf8)) + XCTAssertEqual(sse["id"]?.intValue, 2) + XCTAssertThrowsError(try UpstreamMCP.parseBody(Data("Access denied".utf8))) + } +} + +final class SandboxBrowserAllowlistTests: XCTestCase { + func testAllowlistShapesMatchTheThreatModel() { + let p = ToolProfile.sandbox + for ok in ["browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_evaluate", "browser_take_screenshot", "browser_tabs"] { + XCTAssertTrue(p.allows(ok), ok) + } + for no in ["browser_run_code_unsafe", "browser_file_upload", "browser_pdf_save", "browser_network_requests", "browser_mouse_click_xy", "browser_close", "browser_handle_dialog", "browser_something_new"] { + XCTAssertFalse(p.allows(no), no) + } + XCTAssertTrue(p.allows("screenshot")) + XCTAssertFalse(p.allows("exec_start")) + XCTAssertTrue(ToolProfile.owner.allows("browser_run_code_unsafe")) + } +} diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 0bad503..6eeceaf 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -88,6 +88,14 @@ fi codesign --force --options runtime --timestamp=none ${KC_ARGS[@]+"${KC_ARGS[@]}"} --sign "$IDENTITY" --identifier "$BUNDLE_ID" "$APP" codesign --verify --deep --strict "$APP" && echo "signed: $(codesign -dv "$APP" 2>&1 | grep -E '^(Authority=Apple Dev|TeamIdentifier)' | tr '\n' ' ')" +# Re-serve the Playwright MCP (poc/pw-mcp) as browser_* tools when it is installed, +# so a lent sandbox session can read pages as text instead of screenshots (#10). +UPSTREAM_ARGS="" +if launchctl print "gui/$(id -u)/dev.openab.instance-mcp.pw-mcp" >/dev/null 2>&1; then + UPSTREAM_ARGS=' --upstreambrowser=http://127.0.0.1:8794/mcp' + echo "pw-mcp LaunchAgent present: re-serving it as browser_* tools" +fi + echo "--- LaunchAgent $LABEL ---" PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" cat >"$PLIST" <"$PLIST" <--token-file$TOKEN_FILE --menu-bar --public-urlhttps://$DNSNAME:$HTTPS_PORT/mcp +$UPSTREAM_ARGS RunAtLoad KeepAlive