From 11c0b2c42a331b72c60f115aaa4666131ecf3c8c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 11:27:12 -0700 Subject: [PATCH 01/14] feat(browser): add native window receipts --- .../Browser/BrowserNativeWindowReceipt.swift | 250 ++++++++++++++++++ .../BrowserNativeWindowReceiptTests.swift | 220 +++++++++++++++ 2 files changed, 470 insertions(+) create mode 100644 Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserNativeWindowReceipt.swift create mode 100644 Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserNativeWindowReceiptTests.swift diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserNativeWindowReceipt.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserNativeWindowReceipt.swift new file mode 100644 index 000000000..cbb1f11c9 --- /dev/null +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserNativeWindowReceipt.swift @@ -0,0 +1,250 @@ +import CoreGraphics +import Darwin +import Foundation +import PeekabooAutomationKit + +/// The native browser window selected by an authenticated browser connection. +/// +/// A PID alone is not authority because macOS recycles process identifiers. Callers must carry the +/// process-start generation and one exact WindowServer identifier obtained from trusted discovery. +struct BrowserNativeWindowTarget: Equatable, Sendable { + let processIdentifier: pid_t + let processStartIdentity: UInt64 + let windowID: CGWindowID +} + +/// Immutable capture-time authority for one native browser window. +struct BrowserNativeWindowReceipt: Equatable, Sendable { + let target: BrowserNativeWindowTarget + let windowIdentity: WindowMutationIdentity + let bounds: CGRect +} + +/// Typed reasons why native browser window authority could not be established or retained. +enum BrowserNativeWindowReceiptFailure: Error, Equatable, Sendable { + case invalidProcessIdentifier(pid_t) + case invalidProcessStartIdentity(UInt64) + case invalidWindowIdentifier(CGWindowID) + case processUnavailable(pid_t) + case processGenerationChanged(processIdentifier: pid_t, expected: UInt64, actual: UInt64) + case windowUnavailable(CGWindowID) + case windowReplaced(windowID: CGWindowID, expectedOwner: pid_t, actualOwner: pid_t) + case boundsChanged(windowID: CGWindowID, expected: CGRect, actual: CGRect) + case receiptMalformed(CGWindowID) + case identityChangedDuringCapture(CGWindowID) + case identityChangedDuringRevalidation(CGWindowID) +} + +/// Captures and revalidates native browser window authority using public process and WindowServer identity only. +/// +/// Descriptive metadata such as titles is deliberately excluded. Revalidation never repins: a bounds change, +/// owner change, disappearance, or PID-generation change invalidates the original receipt. +enum BrowserNativeWindowReceiptResolver { + private enum IdentityCheckPhase { + case capture + case revalidation + + func identityChangedFailure(_ windowID: CGWindowID) -> BrowserNativeWindowReceiptFailure { + switch self { + case .capture: + .identityChangedDuringCapture(windowID) + case .revalidation: + .identityChangedDuringRevalidation(windowID) + } + } + } + + struct Providers: Sendable { + let processStartIdentity: @Sendable (pid_t) -> UInt64? + let windowIdentity: @Sendable (CGWindowID) -> SystemWindowIdentity? + let windowMutationIdentity: @Sendable (CGWindowID) -> WindowMutationIdentity? + let validateWindowMutationIdentity: @Sendable (WindowMutationIdentity) -> Bool + + static let live = Providers( + processStartIdentity: SystemIdentityResolver.processStartIdentity, + windowIdentity: SystemIdentityResolver.windowIdentity, + windowMutationIdentity: SystemIdentityResolver.windowMutationIdentity, + validateWindowMutationIdentity: SystemIdentityResolver.validateWindowMutationIdentity) + } + + static func capture( + target: BrowserNativeWindowTarget, + providers: Providers = .live) -> Result + { + if let failure = self.invalidTargetFailure(target) { + return .failure(failure) + } + if let failure = self.currentIdentityFailure( + target: target, + expectedBounds: nil, + phase: .capture, + providers: providers) + { + return .failure(failure) + } + guard let initialWindow = providers.windowIdentity(target.windowID) else { + return .failure(.windowUnavailable(target.windowID)) + } + guard initialWindow.windowID == target.windowID else { + return .failure(.identityChangedDuringCapture(target.windowID)) + } + guard initialWindow.ownerProcessIdentifier == target.processIdentifier else { + return .failure(.windowReplaced( + windowID: target.windowID, + expectedOwner: target.processIdentifier, + actualOwner: initialWindow.ownerProcessIdentifier)) + } + + guard let identity = providers.windowMutationIdentity(target.windowID) else { + return .failure(self.currentIdentityFailure( + target: target, + expectedBounds: initialWindow.bounds, + phase: .capture, + providers: providers) ?? .identityChangedDuringCapture(target.windowID)) + } + guard identity.windowID == Int(target.windowID), + identity.ownerProcessIdentifier == target.processIdentifier, + identity.ownerProcessStartIdentity == target.processStartIdentity, + let capturedBounds = identity.capturedBounds + else { + return .failure(self.identityMismatchFailure( + identity, + target: target, + expectedBounds: initialWindow.bounds) ?? .receiptMalformed(target.windowID)) + } + guard capturedBounds == initialWindow.bounds else { + return .failure(.boundsChanged( + windowID: target.windowID, + expected: initialWindow.bounds, + actual: capturedBounds)) + } + guard providers.validateWindowMutationIdentity(identity) else { + return .failure(self.currentIdentityFailure( + target: target, + expectedBounds: capturedBounds, + phase: .capture, + providers: providers) ?? .identityChangedDuringCapture(target.windowID)) + } + + return .success(BrowserNativeWindowReceipt( + target: target, + windowIdentity: identity, + bounds: capturedBounds)) + } + + /// Returns the original receipt on success. It never creates or substitutes a new receipt. + static func revalidate( + _ receipt: BrowserNativeWindowReceipt, + providers: Providers = .live) -> Result + { + let target = receipt.target + if let failure = self.invalidTargetFailure(target) { + return .failure(failure) + } + guard receipt.windowIdentity.windowID == Int(target.windowID), + receipt.windowIdentity.ownerProcessIdentifier == target.processIdentifier, + receipt.windowIdentity.ownerProcessStartIdentity == target.processStartIdentity, + receipt.windowIdentity.capturedBounds == receipt.bounds + else { + return .failure(.receiptMalformed(target.windowID)) + } + if let failure = self.currentIdentityFailure( + target: target, + expectedBounds: receipt.bounds, + phase: .revalidation, + providers: providers) + { + return .failure(failure) + } + guard providers.validateWindowMutationIdentity(receipt.windowIdentity) else { + return .failure(self.currentIdentityFailure( + target: target, + expectedBounds: receipt.bounds, + phase: .revalidation, + providers: providers) ?? .identityChangedDuringRevalidation(target.windowID)) + } + return .success(receipt) + } + + private static func invalidTargetFailure( + _ target: BrowserNativeWindowTarget) -> BrowserNativeWindowReceiptFailure? + { + guard target.processIdentifier > 0 else { + return .invalidProcessIdentifier(target.processIdentifier) + } + guard target.processStartIdentity > 0 else { + return .invalidProcessStartIdentity(target.processStartIdentity) + } + guard target.windowID != kCGNullWindowID else { + return .invalidWindowIdentifier(target.windowID) + } + return nil + } + + private static func currentIdentityFailure( + target: BrowserNativeWindowTarget, + expectedBounds: CGRect?, + phase: IdentityCheckPhase, + providers: Providers) -> BrowserNativeWindowReceiptFailure? + { + guard let currentGeneration = providers.processStartIdentity(target.processIdentifier) else { + return .processUnavailable(target.processIdentifier) + } + guard currentGeneration == target.processStartIdentity else { + return .processGenerationChanged( + processIdentifier: target.processIdentifier, + expected: target.processStartIdentity, + actual: currentGeneration) + } + guard let currentWindow = providers.windowIdentity(target.windowID) else { + return .windowUnavailable(target.windowID) + } + guard currentWindow.windowID == target.windowID else { + return phase.identityChangedFailure(target.windowID) + } + guard currentWindow.ownerProcessIdentifier == target.processIdentifier else { + return .windowReplaced( + windowID: target.windowID, + expectedOwner: target.processIdentifier, + actualOwner: currentWindow.ownerProcessIdentifier) + } + if let expectedBounds, currentWindow.bounds != expectedBounds { + return .boundsChanged( + windowID: target.windowID, + expected: expectedBounds, + actual: currentWindow.bounds) + } + return nil + } + + private static func identityMismatchFailure( + _ identity: WindowMutationIdentity, + target: BrowserNativeWindowTarget, + expectedBounds: CGRect) -> BrowserNativeWindowReceiptFailure? + { + guard identity.windowID == Int(target.windowID) else { + return .identityChangedDuringCapture(target.windowID) + } + guard identity.ownerProcessIdentifier == target.processIdentifier else { + return .windowReplaced( + windowID: target.windowID, + expectedOwner: target.processIdentifier, + actualOwner: identity.ownerProcessIdentifier) + } + guard identity.ownerProcessStartIdentity == target.processStartIdentity else { + return .processGenerationChanged( + processIdentifier: target.processIdentifier, + expected: target.processStartIdentity, + actual: identity.ownerProcessStartIdentity) + } + if let capturedBounds = identity.capturedBounds, + capturedBounds != expectedBounds + { + return .boundsChanged( + windowID: target.windowID, + expected: expectedBounds, + actual: capturedBounds) + } + return nil + } +} diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserNativeWindowReceiptTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserNativeWindowReceiptTests.swift new file mode 100644 index 000000000..d89509aff --- /dev/null +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserNativeWindowReceiptTests.swift @@ -0,0 +1,220 @@ +import CoreGraphics +import Darwin +import Foundation +import PeekabooAutomationKit +import Testing +@testable import PeekabooAgentRuntime + +struct BrowserNativeWindowReceiptTests { + private static let target = BrowserNativeWindowTarget( + processIdentifier: 4242, + processStartIdentity: 9001, + windowID: 313) + private static let bounds = CGRect(x: 40, y: 80, width: 1200, height: 800) + + @Test + func `capture binds exact process generation window and bounds`() throws { + let providers = Self.providers() + + let receipt = try BrowserNativeWindowReceiptResolver.capture( + target: Self.target, + providers: providers).get() + + #expect(receipt.target == Self.target) + #expect(receipt.windowIdentity == Self.mutationIdentity()) + #expect(receipt.bounds == Self.bounds) + } + + @Test + func `capture refuses pid reuse`() { + let providers = Self.providers(processStartIdentity: Self.target.processStartIdentity + 1) + + #expect(BrowserNativeWindowReceiptResolver.capture( + target: Self.target, + providers: providers) == .failure(.processGenerationChanged( + processIdentifier: Self.target.processIdentifier, + expected: Self.target.processStartIdentity, + actual: Self.target.processStartIdentity + 1))) + } + + @Test + func `capture refuses replacement owner`() { + let replacementPID: pid_t = 8888 + let providers = Self.providers(windowOwner: replacementPID) + + #expect(BrowserNativeWindowReceiptResolver.capture( + target: Self.target, + providers: providers) == .failure(.windowReplaced( + windowID: Self.target.windowID, + expectedOwner: Self.target.processIdentifier, + actualOwner: replacementPID))) + } + + @Test + func `capture refuses a window that disappears`() { + let providers = Self.providers(windowAvailable: false) + + #expect(BrowserNativeWindowReceiptResolver.capture( + target: Self.target, + providers: providers) == .failure(.windowUnavailable(Self.target.windowID))) + } + + @Test + func `capture refuses bounds drift during receipt acquisition`() { + let drifted = Self.bounds.offsetBy(dx: 10, dy: 0) + let providers = Self.providers(receiptBounds: drifted) + + #expect(BrowserNativeWindowReceiptResolver.capture( + target: Self.target, + providers: providers) == .failure(.boundsChanged( + windowID: Self.target.windowID, + expected: Self.bounds, + actual: drifted))) + } + + @Test + func `capture refuses malformed target and receipt evidence`() { + let invalidTarget = BrowserNativeWindowTarget( + processIdentifier: 0, + processStartIdentity: Self.target.processStartIdentity, + windowID: Self.target.windowID) + #expect(BrowserNativeWindowReceiptResolver.capture( + target: invalidTarget, + providers: Self.providers()) == .failure(.invalidProcessIdentifier(0))) + + let missingBounds = Self.providers(receiptIncludesBounds: false) + #expect(BrowserNativeWindowReceiptResolver.capture( + target: Self.target, + providers: missingBounds) == .failure(.receiptMalformed(Self.target.windowID))) + } + + @Test + func `capture refuses a final identity race`() { + let providers = Self.providers(validatesIdentity: false) + + #expect(BrowserNativeWindowReceiptResolver.capture( + target: Self.target, + providers: providers) == .failure(.identityChangedDuringCapture(Self.target.windowID))) + } + + @Test + func `revalidation returns the original immutable receipt`() throws { + let receipt = Self.receipt() + + let revalidated = try BrowserNativeWindowReceiptResolver.revalidate( + receipt, + providers: Self.providers(windowMutationIdentity: { _ in + Issue.record("Revalidation must not recapture or repin window authority") + return nil + })).get() + + #expect(revalidated == receipt) + } + + @Test + func `revalidation refuses pid reuse disappearance replacement and bounds drift`() { + let receipt = Self.receipt() + let reusedGeneration = Self.target.processStartIdentity + 1 + #expect(BrowserNativeWindowReceiptResolver.revalidate( + receipt, + providers: Self.providers(processStartIdentity: reusedGeneration)) == + .failure(.processGenerationChanged( + processIdentifier: Self.target.processIdentifier, + expected: Self.target.processStartIdentity, + actual: reusedGeneration))) + + #expect(BrowserNativeWindowReceiptResolver.revalidate( + receipt, + providers: Self.providers(windowAvailable: false)) == + .failure(.windowUnavailable(Self.target.windowID))) + + let replacementPID: pid_t = 8888 + #expect(BrowserNativeWindowReceiptResolver.revalidate( + receipt, + providers: Self.providers(windowOwner: replacementPID)) == + .failure(.windowReplaced( + windowID: Self.target.windowID, + expectedOwner: Self.target.processIdentifier, + actualOwner: replacementPID))) + + let drifted = Self.bounds.offsetBy(dx: 0, dy: 10) + #expect(BrowserNativeWindowReceiptResolver.revalidate( + receipt, + providers: Self.providers(windowBounds: drifted)) == + .failure(.boundsChanged( + windowID: Self.target.windowID, + expected: Self.bounds, + actual: drifted))) + } + + @Test + func `revalidation refuses malformed authority and final race without repinning`() { + let malformed = BrowserNativeWindowReceipt( + target: Self.target, + windowIdentity: Self.mutationIdentity(bounds: Self.bounds.offsetBy(dx: 1, dy: 0)), + bounds: Self.bounds) + #expect(BrowserNativeWindowReceiptResolver.revalidate( + malformed, + providers: Self.providers()) == .failure(.receiptMalformed(Self.target.windowID))) + + #expect(BrowserNativeWindowReceiptResolver.revalidate( + Self.receipt(), + providers: Self.providers(validatesIdentity: false)) == + .failure(.identityChangedDuringRevalidation(Self.target.windowID))) + } + + private static func providers( + processStartIdentity: UInt64? = target.processStartIdentity, + windowAvailable: Bool = true, + windowOwner: pid_t = target.processIdentifier, + windowBounds: CGRect = bounds, + receiptBounds: CGRect = bounds, + receiptIncludesBounds: Bool = true, + validatesIdentity: Bool = true, + windowMutationIdentity: (@Sendable (CGWindowID) -> WindowMutationIdentity?)? = nil) + -> BrowserNativeWindowReceiptResolver.Providers + { + BrowserNativeWindowReceiptResolver.Providers( + processStartIdentity: { _ in processStartIdentity }, + windowIdentity: { windowID in + guard windowAvailable else { return nil } + return SystemWindowIdentity( + windowID: windowID, + ownerProcessIdentifier: windowOwner, + ownerProcessStartIdentity: processStartIdentity, + title: "ignored title", + bounds: windowBounds, + layer: 0, + alpha: 1, + isOnScreen: true, + sharingState: .readOnly) + }, + windowMutationIdentity: windowMutationIdentity ?? { _ in + Self.mutationIdentity( + owner: windowOwner, + generation: processStartIdentity ?? 0, + bounds: receiptIncludesBounds ? receiptBounds : nil) + }, + validateWindowMutationIdentity: { _ in validatesIdentity }) + } + + private static func mutationIdentity( + owner: pid_t = target.processIdentifier, + generation: UInt64 = target.processStartIdentity, + bounds: CGRect? = bounds) -> WindowMutationIdentity + { + WindowMutationIdentity( + windowID: Int(self.target.windowID), + ownerProcessIdentifier: owner, + ownerProcessStartIdentity: generation, + capturedBounds: bounds, + isMinimized: false) + } + + private static func receipt() -> BrowserNativeWindowReceipt { + BrowserNativeWindowReceipt( + target: self.target, + windowIdentity: self.mutationIdentity(), + bounds: self.bounds) + } +} From 4a285f0e182b2c730c797ccdadb3ba8d830b3050 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 11:28:19 -0700 Subject: [PATCH 02/14] feat(browser): retain native DevTools control session --- .../BrowserMCPDevToolsControlSession.swift | 695 ++++++++++++++++++ ...rowserMCPDevToolsControlSessionTests.swift | 537 ++++++++++++++ 2 files changed, 1232 insertions(+) create mode 100644 Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPDevToolsControlSession.swift create mode 100644 Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPDevToolsControlSessionTests.swift diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPDevToolsControlSession.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPDevToolsControlSession.swift new file mode 100644 index 000000000..8ebbc1cf7 --- /dev/null +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPDevToolsControlSession.swift @@ -0,0 +1,695 @@ +import Foundation + +struct BrowserMCPDevToolsTargetInfo: Sendable, Equatable { + let targetID: String + let type: String + let title: String + let url: String +} + +struct BrowserMCPDevToolsWindowID: RawRepresentable, Sendable, Equatable, Hashable { + let rawValue: Int +} + +enum BrowserMCPDevToolsWindowState: String, Sendable, Equatable { + case normal + case minimized + case maximized + case fullscreen +} + +struct BrowserMCPDevToolsWindowBounds: Sendable, Equatable { + let left: Int? + let top: Int? + let width: Int? + let height: Int? + let state: BrowserMCPDevToolsWindowState +} + +enum BrowserMCPDevToolsControlError: LocalizedError, Sendable, Equatable { + case invalidEndpoint(String) + case cancelled + case timedOut(method: String) + case closed + case controlDied(String) + case malformedResponse(String) + case cdpError(method: String, code: Int?, message: String) + case staleTarget(String) + case staleWindow(BrowserMCPDevToolsWindowID) + + var errorDescription: String? { + switch self { + case let .invalidEndpoint(reason): + "the DevTools control WebSocket endpoint was invalid: \(reason)" + case .cancelled: + "the retained DevTools control session was cancelled" + case let .timedOut(method): + "the retained DevTools control session did not complete \(method) before its deadline" + case .closed: + "the retained DevTools control session is closed" + case let .controlDied(reason): + "the retained DevTools control WebSocket died: \(reason)" + case let .malformedResponse(reason): + "the retained DevTools control WebSocket returned an invalid response: \(reason)" + case let .cdpError(method, code, message): + if let code { + "Chrome rejected \(method) with CDP error \(code): \(message)" + } else { + "Chrome rejected \(method): \(message)" + } + case let .staleTarget(targetID): + "the DevTools target is stale: \(targetID)" + case let .staleWindow(windowID): + "the DevTools browser window is stale: \(windowID.rawValue)" + } + } +} + +enum BrowserMCPDevToolsControlState: Sendable, Equatable { + case open + case closed + case failed(BrowserMCPDevToolsControlError) +} + +struct BrowserMCPDevToolsControlConnection: Sendable { + let session: BrowserMCPDevToolsControlSession + let version: BrowserMCPDevToolsVersion +} + +protocol BrowserMCPDevToolsControlTransport: Sendable { + func send(_ data: Data) async throws + func receive(maximumPayloadBytes: Int) async throws -> Data + func cancel() +} + +struct BrowserMCPDevToolsControlTransportFactory: Sendable { + typealias Open = @Sendable ( + URLRequest, + @escaping @Sendable () -> Void) async throws -> any BrowserMCPDevToolsControlTransport + + let open: Open + + static let live = BrowserMCPDevToolsControlTransportFactory { request, onDispatch in + let task = BrowserMCPNoRedirectURLSession.shared.webSocketTask(with: request) + let transport = BrowserMCPURLSessionControlTransport(task: task) + try Task.checkCancellation() + onDispatch() + task.resume() + return transport + } +} + +actor BrowserMCPDevToolsControlSession { + private static let maximumPayloadBytes = 64 * 1024 + private static let maximumTargetIDBytes = 1024 + private static let approvalTimeout: Duration = .seconds(60) + + private struct PendingRequest { + let continuation: CheckedContinuation + var timeoutTask: Task? + } + + private struct Request { + let id: Int + let method: String + let payload: Data + let deadline: ContinuousClock.Instant + } + + private struct MessageHeader: Decodable { + let id: Int? + let method: String? + } + + private struct Command: Encodable { + let id: Int + let method: String + let params: Parameters + } + + private struct EmptyParameters: Encodable {} + + private struct TargetParameters: Encodable { + let targetId: String + } + + private struct WindowParameters: Encodable { + let windowId: Int + } + + private struct ResponseEnvelope: Decodable { + let id: Int + let result: Result? + let error: ResponseError? + } + + private struct ResponseError: Decodable { + let code: Int? + let message: String + } + + private struct VersionResult: Decodable { + let product: String + let protocolVersion: String + } + + private struct TargetsResult: Decodable { + let targetInfos: [TargetInfo] + } + + private struct TargetInfo: Decodable { + let targetId: String + let type: String + let title: String + let url: String + } + + private struct WindowResult: Decodable { + let windowId: Int + } + + private struct BoundsResult: Decodable { + let bounds: Bounds + } + + private struct Bounds: Decodable { + let left: Int? + let top: Int? + let width: Int? + let height: Int? + let windowState: String + } + + private let transport: any BrowserMCPDevToolsControlTransport + private var controlState: BrowserMCPDevToolsControlState = .open + private var nextRequestID = 1 + private var pendingRequests: [Int: PendingRequest] = [:] + private var receiveTask: Task? + + private init(transport: any BrowserMCPDevToolsControlTransport) { + self.transport = transport + } + + deinit { + self.transport.cancel() + } + + static func connect( + _ webSocketURL: URL, + expectedBrowserID: String, + deadline: ContinuousClock.Instant, + onDispatch: @escaping @Sendable () -> Void = {}, + transportFactory: BrowserMCPDevToolsControlTransportFactory = .live) async throws + -> BrowserMCPDevToolsControlConnection + { + try self.validate(webSocketURL, expectedBrowserID: expectedBrowserID) + try Task.checkCancellation() + let approvalDeadline = min( + deadline, + ContinuousClock.now.advanced(by: Self.approvalTimeout)) + let remaining = ContinuousClock.now.duration(to: approvalDeadline) + guard remaining > .zero else { + throw BrowserMCPDevToolsControlError.timedOut(method: "Browser.getVersion") + } + + var request = URLRequest(url: webSocketURL) + request.cachePolicy = .reloadIgnoringLocalCacheData + request.timeoutInterval = self.timeInterval(remaining) + let dispatchMarker = BrowserMCPDevToolsControlDispatchMarker(onDispatch: onDispatch) + let transport: any BrowserMCPDevToolsControlTransport + do { + transport = try await transportFactory.open(request, dispatchMarker.markDispatched) + } catch is CancellationError { + if dispatchMarker.didDispatch { + throw BrowserMCPDevToolsControlError.cancelled + } + throw CancellationError() + } catch { + if dispatchMarker.didDispatch { + throw BrowserMCPDevToolsControlError.controlDied(error.localizedDescription) + } + throw error + } + + let session = BrowserMCPDevToolsControlSession(transport: transport) + await session.startReceiving() + do { + let version = try await session.getVersion(deadline: approvalDeadline) + return BrowserMCPDevToolsControlConnection(session: session, version: version) + } catch is CancellationError { + await session.fail(.cancelled, pendingError: CancellationError()) + throw BrowserMCPDevToolsControlError.cancelled + } catch { + await session.closeAfterFailedConnect() + throw error + } + } + + func state() -> BrowserMCPDevToolsControlState { + self.controlState + } + + func close() { + self.terminate(state: .closed, pendingError: BrowserMCPDevToolsControlError.closed) + } + + func getTargets(deadline: ContinuousClock.Instant) async throws -> [BrowserMCPDevToolsTargetInfo] { + let request = try self.makeRequest( + method: "Target.getTargets", + parameters: EmptyParameters(), + deadline: deadline) + let result: TargetsResult = try await self.perform(request) + var targetIDs = Set() + return try result.targetInfos.map { target in + guard !target.targetId.isEmpty, + target.targetId.utf8.count <= Self.maximumTargetIDBytes, + !target.type.isEmpty, + targetIDs.insert(target.targetId).inserted + else { + throw self.malformedResponse( + "Target.getTargets returned an empty, oversized, or duplicate target identity") + } + return BrowserMCPDevToolsTargetInfo( + targetID: target.targetId, + type: target.type, + title: target.title, + url: target.url) + } + } + + func getWindowForTarget( + targetID: String, + deadline: ContinuousClock.Instant) async throws -> BrowserMCPDevToolsWindowID + { + guard !targetID.isEmpty, targetID.utf8.count <= Self.maximumTargetIDBytes else { + throw BrowserMCPDevToolsControlError.staleTarget(targetID) + } + let request = try self.makeRequest( + method: "Browser.getWindowForTarget", + parameters: TargetParameters(targetId: targetID), + deadline: deadline) + do { + let result: WindowResult = try await self.perform(request) + guard result.windowId >= 0 else { + throw self.malformedResponse( + "Browser.getWindowForTarget returned a negative window identity") + } + return BrowserMCPDevToolsWindowID(rawValue: result.windowId) + } catch let error as BrowserMCPDevToolsControlError where Self.isStaleTargetError(error) { + throw BrowserMCPDevToolsControlError.staleTarget(targetID) + } + } + + func getWindowBounds( + windowID: BrowserMCPDevToolsWindowID, + deadline: ContinuousClock.Instant) async throws -> BrowserMCPDevToolsWindowBounds + { + guard windowID.rawValue >= 0 else { + throw BrowserMCPDevToolsControlError.staleWindow(windowID) + } + let request = try self.makeRequest( + method: "Browser.getWindowBounds", + parameters: WindowParameters(windowId: windowID.rawValue), + deadline: deadline) + do { + let result: BoundsResult = try await self.perform(request) + guard let state = BrowserMCPDevToolsWindowState(rawValue: result.bounds.windowState), + result.bounds.width.map({ $0 >= 0 }) ?? true, + result.bounds.height.map({ $0 >= 0 }) ?? true + else { + throw self.malformedResponse( + "Browser.getWindowBounds returned an invalid bounds record") + } + return BrowserMCPDevToolsWindowBounds( + left: result.bounds.left, + top: result.bounds.top, + width: result.bounds.width, + height: result.bounds.height, + state: state) + } catch let error as BrowserMCPDevToolsControlError where Self.isStaleWindowError(error) { + throw BrowserMCPDevToolsControlError.staleWindow(windowID) + } + } + + private func getVersion(deadline: ContinuousClock.Instant) async throws -> BrowserMCPDevToolsVersion { + let request = try self.makeRequest( + method: "Browser.getVersion", + parameters: EmptyParameters(), + deadline: deadline) + let result: VersionResult = try await self.perform(request) + guard result.product.hasPrefix("Chrome/"), + result.product.count > "Chrome/".count, + !result.protocolVersion.isEmpty + else { + throw self.malformedResponse( + "Browser.getVersion omitted the Chrome product or protocol version") + } + return BrowserMCPDevToolsVersion( + browserVersion: result.product, + protocolVersion: result.protocolVersion) + } + + private func startReceiving() { + guard self.receiveTask == nil, self.controlState == .open else { return } + let transport = self.transport + self.receiveTask = Task { [weak self] in + while !Task.isCancelled { + do { + let data = try await transport.receive(maximumPayloadBytes: Self.maximumPayloadBytes) + guard let self else { return } + await self.receive(data) + } catch is CancellationError { + guard let self else { return } + await self.transportEnded("the WebSocket closed") + return + } catch let error as BrowserMCPDevToolsControlError { + guard let self else { return } + await self.transportFailed(error) + return + } catch { + guard let self else { return } + await self.transportEnded(error.localizedDescription) + return + } + } + } + } + + private func makeRequest( + method: String, + parameters: some Encodable, + deadline: ContinuousClock.Instant) throws -> Request + { + try self.requireOpen() + guard ContinuousClock.now < deadline else { + throw BrowserMCPDevToolsControlError.timedOut(method: method) + } + let id = self.nextRequestID + guard id < Int.max else { + self.terminate( + state: .failed(.malformedResponse("the CDP request identity space was exhausted")), + pendingError: BrowserMCPDevToolsControlError.malformedResponse( + "the CDP request identity space was exhausted")) + throw BrowserMCPDevToolsControlError.malformedResponse( + "the CDP request identity space was exhausted") + } + self.nextRequestID += 1 + let payload = try JSONEncoder().encode(Command(id: id, method: method, params: parameters)) + guard payload.count <= Self.maximumPayloadBytes else { + throw BrowserMCPDevToolsControlError.malformedResponse( + "the \(method) request exceeded 64 KiB") + } + return Request(id: id, method: method, payload: payload, deadline: deadline) + } + + private func perform(_ request: Request) async throws -> Result { + let data = try await withTaskCancellationHandler { + try Task.checkCancellation() + return try await self.awaitResponse(to: request) + } onCancel: { [weak self] in + Task { + await self?.fail(.cancelled, pendingError: CancellationError()) + } + } + try Task.checkCancellation() + let response: ResponseEnvelope + do { + response = try JSONDecoder().decode(ResponseEnvelope.self, from: data) + } catch { + let failure = BrowserMCPDevToolsControlError.malformedResponse( + "\(request.method) returned a response with the wrong shape") + self.terminate(state: .failed(failure), pendingError: failure) + throw failure + } + guard response.id == request.id else { + let failure = BrowserMCPDevToolsControlError.malformedResponse( + "\(request.method) returned the wrong response identity") + self.terminate(state: .failed(failure), pendingError: failure) + throw failure + } + if let error = response.error { + guard response.result == nil else { + let failure = BrowserMCPDevToolsControlError.malformedResponse( + "\(request.method) returned both result and error") + self.terminate(state: .failed(failure), pendingError: failure) + throw failure + } + throw BrowserMCPDevToolsControlError.cdpError( + method: request.method, + code: error.code, + message: error.message) + } + guard let result = response.result else { + let failure = BrowserMCPDevToolsControlError.malformedResponse( + "\(request.method) returned neither result nor error") + self.terminate(state: .failed(failure), pendingError: failure) + throw failure + } + return result + } + + private func awaitResponse(to request: Request) async throws -> Data { + try self.requireOpen() + return try await withCheckedThrowingContinuation { continuation in + self.pendingRequests[request.id] = PendingRequest( + continuation: continuation) + let remaining = ContinuousClock.now.duration(to: request.deadline) + let timeoutTask = Task { [weak self] in + if remaining > .zero { + try? await Task.sleep(for: remaining) + } + guard !Task.isCancelled else { return } + await self?.requestTimedOut(id: request.id, method: request.method) + } + self.pendingRequests[request.id]?.timeoutTask = timeoutTask + Task { [weak self] in + do { + try await self?.transport.send(request.payload) + } catch is CancellationError { + await self?.sendFailed(id: request.id, reason: "the WebSocket send was cancelled") + } catch { + await self?.sendFailed(id: request.id, reason: error.localizedDescription) + } + } + } + } + + private func receive(_ data: Data) { + guard self.controlState == .open else { return } + guard data.count <= Self.maximumPayloadBytes else { + let failure = BrowserMCPDevToolsControlError.malformedResponse( + "a WebSocket payload exceeded 64 KiB") + self.terminate(state: .failed(failure), pendingError: failure) + return + } + let header: MessageHeader + do { + header = try JSONDecoder().decode(MessageHeader.self, from: data) + } catch { + let failure = BrowserMCPDevToolsControlError.malformedResponse( + "a WebSocket payload was not a CDP response or event") + self.terminate(state: .failed(failure), pendingError: failure) + return + } + if let id = header.id { + guard header.method == nil else { + let failure = BrowserMCPDevToolsControlError.malformedResponse( + "a CDP response also claimed to be an event") + self.terminate(state: .failed(failure), pendingError: failure) + return + } + guard var pending = self.pendingRequests.removeValue(forKey: id) else { + let failure = BrowserMCPDevToolsControlError.malformedResponse( + "response ID \(id) did not match an outstanding request") + self.terminate(state: .failed(failure), pendingError: failure) + return + } + pending.timeoutTask?.cancel() + pending.timeoutTask = nil + pending.continuation.resume(returning: data) + return + } + guard let method = header.method, !method.isEmpty else { + let failure = BrowserMCPDevToolsControlError.malformedResponse( + "an unsolicited WebSocket payload had no CDP event method") + self.terminate(state: .failed(failure), pendingError: failure) + return + } + } + + private func requestTimedOut(id: Int, method: String) { + guard self.pendingRequests[id] != nil else { return } + let failure = BrowserMCPDevToolsControlError.timedOut(method: method) + self.terminate(state: .failed(failure), pendingError: failure) + } + + private func sendFailed(id: Int, reason: String) { + guard self.pendingRequests[id] != nil else { return } + let failure = BrowserMCPDevToolsControlError.controlDied(reason) + self.terminate(state: .failed(failure), pendingError: failure) + } + + private func transportEnded(_ reason: String) { + guard self.controlState == .open else { return } + let failure = BrowserMCPDevToolsControlError.controlDied(reason) + self.terminate(state: .failed(failure), pendingError: failure) + } + + private func transportFailed(_ failure: BrowserMCPDevToolsControlError) { + guard self.controlState == .open else { return } + self.terminate(state: .failed(failure), pendingError: failure) + } + + private func closeAfterFailedConnect() { + guard self.controlState == .open else { return } + self.terminate(state: .closed, pendingError: BrowserMCPDevToolsControlError.closed) + } + + private func fail( + _ failure: BrowserMCPDevToolsControlError, + pendingError: any Error) + { + self.terminate(state: .failed(failure), pendingError: pendingError) + } + + private func terminate( + state: BrowserMCPDevToolsControlState, + pendingError: any Error) + { + guard self.controlState == .open else { return } + self.controlState = state + self.receiveTask?.cancel() + self.receiveTask = nil + self.transport.cancel() + let pending = self.pendingRequests.values + self.pendingRequests.removeAll() + for var request in pending { + request.timeoutTask?.cancel() + request.timeoutTask = nil + request.continuation.resume(throwing: pendingError) + } + } + + private func requireOpen() throws { + switch self.controlState { + case .open: + return + case .closed: + throw BrowserMCPDevToolsControlError.closed + case let .failed(error): + throw error + } + } + + private func malformedResponse(_ reason: String) -> BrowserMCPDevToolsControlError { + let failure = BrowserMCPDevToolsControlError.malformedResponse(reason) + self.terminate(state: .failed(failure), pendingError: failure) + return failure + } + + private nonisolated static func isStaleTargetError(_ error: BrowserMCPDevToolsControlError) -> Bool { + guard case let .cdpError(method, _, message) = error, + method == "Browser.getWindowForTarget" + else { return false } + let normalized = message.lowercased() + return normalized.contains("no target") || + normalized.contains("target not found") || + normalized.contains("web contents") + } + + private nonisolated static func isStaleWindowError(_ error: BrowserMCPDevToolsControlError) -> Bool { + guard case let .cdpError(method, _, message) = error, + method == "Browser.getWindowBounds" + else { return false } + let normalized = message.lowercased() + return normalized.contains("window") && + (normalized.contains("not found") || normalized.contains("no browser")) + } + + private nonisolated static func validate(_ url: URL, expectedBrowserID: String) throws { + guard !expectedBrowserID.isEmpty, + url.scheme == "ws", + url.user == nil, + url.password == nil, + url.query == nil, + url.fragment == nil, + let host = url.host, + BrowserMCPDevToolsWebSocketProber.isLoopbackHost(host), + url.port != nil, + url.path == "/devtools/browser/\(expectedBrowserID)" + else { + throw BrowserMCPDevToolsControlError.invalidEndpoint( + "expected the exact published loopback browser identity") + } + } + + private nonisolated static func timeInterval(_ duration: Duration) -> TimeInterval { + let components = duration.components + return TimeInterval(components.seconds) + TimeInterval(components.attoseconds) / 1e18 + } +} + +private final class BrowserMCPURLSessionControlTransport: BrowserMCPDevToolsControlTransport, @unchecked Sendable { + private let task: URLSessionWebSocketTask + + init(task: URLSessionWebSocketTask) { + self.task = task + } + + deinit { + self.cancel() + } + + func send(_ data: Data) async throws { + guard let text = String(data: data, encoding: .utf8) else { + throw BrowserMCPDevToolsControlError.malformedResponse( + "a CDP command could not be encoded as UTF-8") + } + try await self.task.send(.string(text)) + } + + func receive(maximumPayloadBytes: Int) async throws -> Data { + let message = try await self.task.receive() + let data = switch message { + case let .data(data): data + case let .string(string): Data(string.utf8) + @unknown default: + throw BrowserMCPDevToolsControlError.malformedResponse( + "Chrome returned an unsupported WebSocket message") + } + guard data.count <= maximumPayloadBytes else { + throw BrowserMCPDevToolsControlError.malformedResponse( + "a WebSocket payload exceeded 64 KiB") + } + return data + } + + func cancel() { + self.task.cancel(with: .normalClosure, reason: nil) + } +} + +private final class BrowserMCPDevToolsControlDispatchMarker: @unchecked Sendable { + private let lock = NSLock() + private let onDispatch: @Sendable () -> Void + private var dispatched = false + + init(onDispatch: @escaping @Sendable () -> Void) { + self.onDispatch = onDispatch + } + + var didDispatch: Bool { + self.lock.withLock { self.dispatched } + } + + func markDispatched() { + let shouldNotify = self.lock.withLock { + guard !self.dispatched else { return false } + self.dispatched = true + return true + } + if shouldNotify { + self.onDispatch() + } + } +} diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPDevToolsControlSessionTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPDevToolsControlSessionTests.swift new file mode 100644 index 000000000..4dcbd281f --- /dev/null +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPDevToolsControlSessionTests.swift @@ -0,0 +1,537 @@ +import Foundation +import Testing +@testable import PeekabooAgentRuntime + +struct BrowserMCPDevToolsControlSessionTests { + @Test + func `one explicit connection retains one socket across all read commands`() async throws { + let transport = FakeControlTransport { command in + let request = try Self.decodeCommand(command) + switch request.method { + case "Browser.getVersion": + return [ + .success(Self.json(["method": "Target.targetCreated", "params": [:]])), + .success(Self.response( + id: request.id, + result: ["product": "Chrome/151.0", "protocolVersion": "1.3"])), + ] + case "Target.getTargets": + return [.success(Self.response( + id: request.id, + result: [ + "targetInfos": [[ + "targetId": "target-a", + "type": "page", + "title": "Example", + "url": "https://example.com/", + ]], + ]))] + case "Browser.getWindowForTarget": + #expect(request.params["targetId"] as? String == "target-a") + return [.success(Self.response(id: request.id, result: ["windowId": 41]))] + case "Browser.getWindowBounds": + #expect(request.params["windowId"] as? Int == 41) + return [.success(Self.response( + id: request.id, + result: [ + "bounds": [ + "left": 12, + "top": 34, + "width": 1200, + "height": 800, + "windowState": "normal", + ], + ]))] + default: + Issue.record("Unexpected CDP method \(request.method)") + return [] + } + } + let opener = FakeControlTransportOpener(transport: transport) + let dispatches = LockedInteger() + let connection = try await BrowserMCPDevToolsControlSession.connect( + Self.webSocketURL, + expectedBrowserID: "browser-a", + deadline: Self.deadline(seconds: 2), + onDispatch: { dispatches.increment() }, + transportFactory: opener.factory) + + #expect(connection.version == .init(browserVersion: "Chrome/151.0", protocolVersion: "1.3")) + let targets = try await connection.session.getTargets(deadline: Self.deadline(seconds: 1)) + #expect(targets == [BrowserMCPDevToolsTargetInfo( + targetID: "target-a", + type: "page", + title: "Example", + url: "https://example.com/")]) + let windowID = try await connection.session.getWindowForTarget( + targetID: "target-a", + deadline: Self.deadline(seconds: 1)) + #expect(windowID == BrowserMCPDevToolsWindowID(rawValue: 41)) + let bounds = try await connection.session.getWindowBounds( + windowID: windowID, + deadline: Self.deadline(seconds: 1)) + #expect(bounds == BrowserMCPDevToolsWindowBounds( + left: 12, + top: 34, + width: 1200, + height: 800, + state: .normal)) + #expect(await connection.session.state() == .open) + #expect(opener.openCount == 1) + #expect(dispatches.value == 1) + #expect(try transport.sentCommands().map(Self.decodeCommand).map(\.id) == [1, 2, 3, 4]) + #expect(try transport.sentCommands().map(Self.decodeCommand).map(\.method) == [ + "Browser.getVersion", + "Target.getTargets", + "Browser.getWindowForTarget", + "Browser.getWindowBounds", + ]) + await connection.session.close() + } + + @Test + func `unexpected response identity kills control without reopening`() async throws { + let transport = FakeControlTransport { command in + let request = try Self.decodeCommand(command) + let id = request.method == "Browser.getVersion" ? request.id : request.id + 100 + let result: [String: Any] = if request.method == "Browser.getVersion" { + ["product": "Chrome/151.0", "protocolVersion": "1.3"] + } else { + ["targetInfos": []] + } + return [.success(Self.response(id: id, result: result))] + } + let opener = FakeControlTransportOpener(transport: transport) + let connection = try await self.connect(opener) + + await #expect(throws: BrowserMCPDevToolsControlError.self) { + _ = try await connection.session.getTargets(deadline: Self.deadline(seconds: 1)) + } + guard case let .failed(.malformedResponse(reason)) = await connection.session.state() else { + Issue.record("Expected a terminal response-correlation failure") + return + } + #expect(reason.contains("did not match an outstanding request")) + await #expect(throws: BrowserMCPDevToolsControlError.self) { + _ = try await connection.session.getTargets(deadline: Self.deadline(seconds: 1)) + } + #expect(opener.openCount == 1) + #expect(transport.sentCommands().count == 2) + } + + @Test + func `oversized response kills control at the payload bound`() async throws { + let transport = FakeControlTransport { command in + let request = try Self.decodeCommand(command) + if request.method == "Browser.getVersion" { + return [.success(Self.response( + id: request.id, + result: ["product": "Chrome/151.0", "protocolVersion": "1.3"]))] + } + return [.success(Data(repeating: 0x20, count: 64 * 1024 + 1))] + } + let opener = FakeControlTransportOpener(transport: transport) + let connection = try await self.connect(opener) + + await #expect(throws: BrowserMCPDevToolsControlError.self) { + _ = try await connection.session.getTargets(deadline: Self.deadline(seconds: 1)) + } + guard case .failed(.malformedResponse) = await connection.session.state() else { + Issue.record("Expected a terminal payload-bound failure") + return + } + #expect(opener.openCount == 1) + #expect(transport.cancelCount == 1) + } + + @Test + func `request deadline closes retained control instead of leaving a late response`() async throws { + let transport = FakeControlTransport { command in + let request = try Self.decodeCommand(command) + guard request.method == "Browser.getVersion" else { return [] } + return [.success(Self.response( + id: request.id, + result: ["product": "Chrome/151.0", "protocolVersion": "1.3"]))] + } + let opener = FakeControlTransportOpener(transport: transport) + let connection = try await self.connect(opener) + + await #expect(throws: BrowserMCPDevToolsControlError.timedOut(method: "Target.getTargets")) { + _ = try await connection.session.getTargets(deadline: Self.deadline(milliseconds: 30)) + } + #expect(await connection.session.state() == .failed(.timedOut(method: "Target.getTargets"))) + #expect(opener.openCount == 1) + #expect(transport.cancelCount == 1) + } + + @Test + func `caller cancellation closes retained control and remains cancellation`() async throws { + let transport = FakeControlTransport { command in + let request = try Self.decodeCommand(command) + guard request.method == "Browser.getVersion" else { return [] } + return [.success(Self.response( + id: request.id, + result: ["product": "Chrome/151.0", "protocolVersion": "1.3"]))] + } + let opener = FakeControlTransportOpener(transport: transport) + let connection = try await self.connect(opener) + let query = Task { + try await connection.session.getTargets(deadline: Self.deadline(seconds: 30)) + } + try await transport.waitForSentCommandCount(2) + query.cancel() + + await #expect(throws: CancellationError.self) { + _ = try await query.value + } + #expect(await connection.session.state() == .failed(.cancelled)) + #expect(opener.openCount == 1) + #expect(transport.cancelCount == 1) + } + + @Test + func `transport death is typed persistent state and never reconnects`() async throws { + let transport = FakeControlTransport { command in + let request = try Self.decodeCommand(command) + guard request.method == "Browser.getVersion" else { + return [.failure(FakeControlTransportError.connectionLost)] + } + return [.success(Self.response( + id: request.id, + result: ["product": "Chrome/151.0", "protocolVersion": "1.3"]))] + } + let opener = FakeControlTransportOpener(transport: transport) + let connection = try await self.connect(opener) + + await #expect(throws: BrowserMCPDevToolsControlError.controlDied("fixture connection lost")) { + _ = try await connection.session.getTargets(deadline: Self.deadline(seconds: 1)) + } + #expect(await connection.session.state() == .failed(.controlDied("fixture connection lost"))) + await #expect(throws: BrowserMCPDevToolsControlError.controlDied("fixture connection lost")) { + _ = try await connection.session.getTargets(deadline: Self.deadline(seconds: 1)) + } + #expect(opener.openCount == 1) + #expect(transport.sentCommands().count == 2) + } + + @Test + func `idle transport death becomes observable without a query or reopen`() async throws { + let transport = FakeControlTransport.respondingNormally + let opener = FakeControlTransportOpener(transport: transport) + let connection = try await self.connect(opener) + + transport.inject(.failure(FakeControlTransportError.connectionLost)) + try await connection.session.waitForState( + .failed(.controlDied("fixture connection lost")), + deadline: Self.deadline(seconds: 1)) + + #expect(await connection.session.state() == .failed(.controlDied("fixture connection lost"))) + #expect(opener.openCount == 1) + #expect(transport.sentCommands().count == 1) + } + + @Test + func `stale target and window are typed without killing healthy control`() async throws { + let transport = FakeControlTransport { command in + let request = try Self.decodeCommand(command) + switch request.method { + case "Browser.getVersion": + return [.success(Self.response( + id: request.id, + result: ["product": "Chrome/151.0", "protocolVersion": "1.3"]))] + case "Browser.getWindowForTarget": + return [.success(Self.errorResponse( + id: request.id, + code: -32000, + message: "No target with given id found"))] + case "Browser.getWindowBounds": + return [.success(Self.errorResponse( + id: request.id, + code: -32000, + message: "Browser window not found"))] + case "Target.getTargets": + return [.success(Self.response(id: request.id, result: ["targetInfos": []]))] + default: + return [] + } + } + let opener = FakeControlTransportOpener(transport: transport) + let connection = try await self.connect(opener) + + await #expect(throws: BrowserMCPDevToolsControlError.staleTarget("gone")) { + _ = try await connection.session.getWindowForTarget( + targetID: "gone", + deadline: Self.deadline(seconds: 1)) + } + let windowID = BrowserMCPDevToolsWindowID(rawValue: 99) + await #expect(throws: BrowserMCPDevToolsControlError.staleWindow(windowID)) { + _ = try await connection.session.getWindowBounds( + windowID: windowID, + deadline: Self.deadline(seconds: 1)) + } + #expect(try await connection.session.getTargets(deadline: Self.deadline(seconds: 1)).isEmpty) + #expect(await connection.session.state() == .open) + #expect(opener.openCount == 1) + #expect(transport.cancelCount == 0) + await connection.session.close() + } + + @Test + func `explicit close is observable and refuses commands without reopening`() async throws { + let transport = FakeControlTransport.respondingNormally + let opener = FakeControlTransportOpener(transport: transport) + let connection = try await self.connect(opener) + + await connection.session.close() + #expect(await connection.session.state() == .closed) + await #expect(throws: BrowserMCPDevToolsControlError.closed) { + _ = try await connection.session.getTargets(deadline: Self.deadline(seconds: 1)) + } + #expect(opener.openCount == 1) + #expect(transport.sentCommands().count == 1) + #expect(transport.cancelCount == 1) + } + + @Test + func `invalid endpoint is rejected before transport open`() async throws { + let transport = FakeControlTransport.respondingNormally + let opener = FakeControlTransportOpener(transport: transport) + let wrongURL = try #require(URL(string: "ws://127.0.0.1:9222/devtools/browser/browser-b")) + + await #expect(throws: BrowserMCPDevToolsControlError.invalidEndpoint( + "expected the exact published loopback browser identity")) + { + _ = try await BrowserMCPDevToolsControlSession.connect( + wrongURL, + expectedBrowserID: "browser-a", + deadline: Self.deadline(seconds: 1), + transportFactory: opener.factory) + } + #expect(opener.openCount == 0) + #expect(transport.sentCommands().isEmpty) + } + + private func connect(_ opener: FakeControlTransportOpener) async throws + -> BrowserMCPDevToolsControlConnection + { + try await BrowserMCPDevToolsControlSession.connect( + Self.webSocketURL, + expectedBrowserID: "browser-a", + deadline: Self.deadline(seconds: 2), + transportFactory: opener.factory) + } + + static let webSocketURL = URL( + string: "ws://127.0.0.1:9222/devtools/browser/browser-a")! + + static func response(id: Int, result: [String: Any]) -> Data { + self.json(["id": id, "result": result]) + } + + static func errorResponse(id: Int, code: Int, message: String) -> Data { + self.json(["id": id, "error": ["code": code, "message": message]]) + } + + static func json(_ object: [String: Any]) -> Data { + do { + return try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + } catch { + preconditionFailure("Invalid fixture JSON: \(error)") + } + } + + static func decodeCommand(_ data: Data) throws -> DecodedControlCommand { + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + return try DecodedControlCommand( + id: #require(object["id"] as? Int), + method: #require(object["method"] as? String), + params: #require(object["params"] as? [String: Any])) + } + + private static func deadline(seconds: TimeInterval) -> ContinuousClock.Instant { + ContinuousClock.now.advanced(by: .seconds(seconds)) + } + + private static func deadline(milliseconds: Int) -> ContinuousClock.Instant { + ContinuousClock.now.advanced(by: .milliseconds(milliseconds)) + } +} + +extension BrowserMCPDevToolsControlSession { + fileprivate func waitForState( + _ expected: BrowserMCPDevToolsControlState, + deadline: ContinuousClock.Instant) async throws + { + while self.state() != expected { + guard ContinuousClock.now < deadline else { + throw FakeControlTransportWaitError.timedOut + } + await Task.yield() + } + } +} + +struct DecodedControlCommand { + let id: Int + let method: String + let params: [String: Any] +} + +enum FakeControlTransportError: LocalizedError { + case connectionLost + + var errorDescription: String? { + "fixture connection lost" + } +} + +final class FakeControlTransport: BrowserMCPDevToolsControlTransport, @unchecked Sendable { + typealias Handler = @Sendable (Data) throws -> [Result] + + private struct State { + var sent: [Data] = [] + var queued: [Result] = [] + var waiter: CheckedContinuation? + var cancelled = false + var cancelCount = 0 + } + + private let lock = NSLock() + private let handler: Handler + private var state = State() + + init(handler: @escaping Handler) { + self.handler = handler + } + + static var respondingNormally: FakeControlTransport { + FakeControlTransport { command in + let request = try BrowserMCPDevToolsControlSessionTests.decodeCommand(command) + guard request.method == "Browser.getVersion" else { + return [.success(BrowserMCPDevToolsControlSessionTests.response( + id: request.id, + result: ["targetInfos": []]))] + } + return [.success(BrowserMCPDevToolsControlSessionTests.response( + id: request.id, + result: ["product": "Chrome/151.0", "protocolVersion": "1.3"]))] + } + } + + var cancelCount: Int { + self.lock.withLock { self.state.cancelCount } + } + + func sentCommands() -> [Data] { + self.lock.withLock { self.state.sent } + } + + func send(_ data: Data) async throws { + let isCancelled = self.lock.withLock { + self.state.sent.append(data) + return self.state.cancelled + } + guard !isCancelled else { throw CancellationError() } + for result in try self.handler(data) { + self.enqueue(result) + } + } + + func receive(maximumPayloadBytes _: Int) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + let immediate: Result? = self.lock.withLock { + if self.state.cancelled { + return .failure(CancellationError()) + } + if !self.state.queued.isEmpty { + return self.state.queued.removeFirst() + } + precondition(self.state.waiter == nil, "The control session must have one receive loop") + self.state.waiter = continuation + return nil + } + if let immediate { + continuation.resume(with: immediate) + } + } + } + + func cancel() { + let waiter: CheckedContinuation? = self.lock.withLock { + guard !self.state.cancelled else { return nil } + self.state.cancelled = true + self.state.cancelCount += 1 + let waiter = self.state.waiter + self.state.waiter = nil + return waiter + } + waiter?.resume(throwing: CancellationError()) + } + + func waitForSentCommandCount(_ count: Int) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(1)) + while self.sentCommands().count < count { + guard ContinuousClock.now < deadline else { + throw FakeControlTransportWaitError.timedOut + } + await Task.yield() + } + } + + func inject(_ result: Result) { + self.enqueue(result) + } + + private func enqueue(_ result: Result) { + let waiter: CheckedContinuation? = self.lock.withLock { + guard !self.state.cancelled else { return nil } + guard let waiter = self.state.waiter else { + self.state.queued.append(result) + return nil + } + self.state.waiter = nil + return waiter + } + waiter?.resume(with: result) + } +} + +private enum FakeControlTransportWaitError: Error { + case timedOut +} + +final class FakeControlTransportOpener: @unchecked Sendable { + private let lock = NSLock() + private let transport: FakeControlTransport + private var count = 0 + + init(transport: FakeControlTransport) { + self.transport = transport + } + + var openCount: Int { + self.lock.withLock { self.count } + } + + var factory: BrowserMCPDevToolsControlTransportFactory { + BrowserMCPDevToolsControlTransportFactory { [self] request, onDispatch in + #expect(request.url == BrowserMCPDevToolsControlSessionTests.webSocketURL) + self.lock.withLock { self.count += 1 } + onDispatch() + return self.transport + } + } +} + +private final class LockedInteger: @unchecked Sendable { + private let lock = NSLock() + private var integer = 0 + + var value: Int { + self.lock.withLock { self.integer } + } + + func increment() { + self.lock.withLock { self.integer += 1 } + } +} From 6c1610d8798c3ca79f1b715dca77df215bbef491 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 11:30:11 -0700 Subject: [PATCH 03/14] feat(browser): correlate native and CDP windows --- .../NativeBrowserWindowCorrelator.swift | 126 ++++++++ .../NativeBrowserWindowCorrelatorTests.swift | 296 ++++++++++++++++++ 2 files changed, 422 insertions(+) create mode 100644 Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/NativeBrowserWindowCorrelator.swift create mode 100644 Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/NativeBrowserWindowCorrelatorTests.swift diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/NativeBrowserWindowCorrelator.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/NativeBrowserWindowCorrelator.swift new file mode 100644 index 000000000..48a3784be --- /dev/null +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/NativeBrowserWindowCorrelator.swift @@ -0,0 +1,126 @@ +import Foundation +import PeekabooAutomationKit + +/// One internal CDP browser-window observation and the targets Chrome reports inside it. +/// +/// Raw CDP identities remain below the browser provider boundary. Public tool results receive +/// opaque page capabilities instead of either of these identifiers. +struct CDPBrowserWindowCandidate: Sendable, Equatable { + let windowID: BrowserMCPDevToolsWindowID + let bounds: CGRect + let titles: Set + let targetIDs: Set +} + +/// Internal proof that one fresh native window receipt names one exact CDP browser window. +struct NativeBrowserWindowCorrelation: Sendable, Equatable { + let nativeWindowIdentity: WindowMutationIdentity + let browserWindowID: BrowserMCPDevToolsWindowID + let browserBounds: CGRect +} + +enum NativeBrowserWindowCorrelationError: LocalizedError, Sendable, Equatable { + case staleNativeWindow + case noGeometryMatch + case ambiguousGeometry + case wrongTargetMembership + + var errorDescription: String? { + switch self { + case .staleNativeWindow: + "The native browser window receipt is stale or lacks exact bounds. Observe the window again." + case .noGeometryMatch: + "No CDP browser window matches the fresh native window geometry." + case .ambiguousGeometry: + "More than one CDP browser window matches the fresh native window geometry." + case .wrongTargetMembership: + "The requested browser target does not belong uniquely to the matched CDP window." + } + } +} + +enum NativeBrowserWindowCorrelator { + /// Chrome and WindowServer can differ slightly at frame edges. This fixed tolerance is intentionally + /// not caller-configurable: widening it would weaken exact-window authorization. + static let geometryTolerance: CGFloat = 8 + + static func correlate( + expectedNativeWindow: WindowMutationIdentity, + currentNativeWindow: WindowMutationIdentity?, + nativeTitle: String?, + requestedTargetID: String, + candidates: [CDPBrowserWindowCandidate]) throws -> NativeBrowserWindowCorrelation + { + guard let currentNativeWindow, + self.isValid(currentNativeWindow), + self.isValid(expectedNativeWindow), + currentNativeWindow.hasSameStableReceipt(as: expectedNativeWindow), + let nativeBounds = currentNativeWindow.capturedBounds + else { + throw NativeBrowserWindowCorrelationError.staleNativeWindow + } + + let geometryCandidates = candidates.filter { candidate in + candidate.windowID.rawValue > 0 && + self.isValid(candidate.bounds) && + self.matchesGeometry(candidate.bounds, nativeBounds) + } + guard !geometryCandidates.isEmpty else { + throw NativeBrowserWindowCorrelationError.noGeometryMatch + } + + let selected: CDPBrowserWindowCandidate + if geometryCandidates.count == 1 { + selected = geometryCandidates[0] + } else { + guard let nativeTitle, + !nativeTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw NativeBrowserWindowCorrelationError.ambiguousGeometry + } + let titleMatches = geometryCandidates.filter { $0.titles.contains(nativeTitle) } + guard titleMatches.count == 1, let titleMatch = titleMatches.first else { + throw NativeBrowserWindowCorrelationError.ambiguousGeometry + } + selected = titleMatch + } + + guard !requestedTargetID.isEmpty else { + throw NativeBrowserWindowCorrelationError.wrongTargetMembership + } + let targetWindowIDs = Set(candidates.compactMap { candidate in + candidate.targetIDs.contains(requestedTargetID) ? candidate.windowID : nil + }) + guard targetWindowIDs == [selected.windowID] else { + throw NativeBrowserWindowCorrelationError.wrongTargetMembership + } + + return NativeBrowserWindowCorrelation( + nativeWindowIdentity: currentNativeWindow, + browserWindowID: selected.windowID, + browserBounds: selected.bounds) + } + + private static func matchesGeometry(_ browserBounds: CGRect, _ nativeBounds: CGRect) -> Bool { + abs(browserBounds.origin.x - nativeBounds.origin.x) <= self.geometryTolerance && + abs(browserBounds.origin.y - nativeBounds.origin.y) <= self.geometryTolerance && + abs(browserBounds.width - nativeBounds.width) <= self.geometryTolerance && + abs(browserBounds.height - nativeBounds.height) <= self.geometryTolerance + } + + private static func isValid(_ identity: WindowMutationIdentity) -> Bool { + identity.windowID > 0 && + identity.ownerProcessIdentifier > 0 && + identity.ownerProcessStartIdentity > 0 && + identity.capturedBounds.map(self.isValid) == true + } + + private static func isValid(_ bounds: CGRect) -> Bool { + bounds.origin.x.isFinite && + bounds.origin.y.isFinite && + bounds.width.isFinite && + bounds.height.isFinite && + bounds.width > 0 && + bounds.height > 0 + } +} diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/NativeBrowserWindowCorrelatorTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/NativeBrowserWindowCorrelatorTests.swift new file mode 100644 index 000000000..84959d21f --- /dev/null +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/NativeBrowserWindowCorrelatorTests.swift @@ -0,0 +1,296 @@ +import Foundation +import PeekabooAutomationKit +import Testing +@testable import PeekabooAgentRuntime + +struct NativeBrowserWindowCorrelatorTests { + @Test + func `unique geometry and exact target membership correlate`() throws { + let identity = Self.identity(bounds: CGRect(x: 120, y: 80, width: 1280, height: 720)) + let result = try NativeBrowserWindowCorrelator.correlate( + expectedNativeWindow: identity, + currentNativeWindow: identity, + nativeTitle: nil, + requestedTargetID: "target-a", + candidates: [Self.candidate( + windowID: 91, + bounds: CGRect(x: 124, y: 75, width: 1272, height: 728), + targetIDs: ["target-a", "target-b"])]) + + #expect(result.nativeWindowIdentity == identity) + #expect(result.browserWindowID.rawValue == 91) + #expect(result.browserBounds == CGRect(x: 124, y: 75, width: 1272, height: 728)) + #expect(NativeBrowserWindowCorrelator.geometryTolerance == 8) + } + + @Test + func `fixed tolerance accepts its boundary and rejects any component beyond it`() throws { + let bounds = CGRect(x: 100, y: 200, width: 900, height: 700) + let identity = Self.identity(bounds: bounds) + let atBoundary = Self.candidate( + windowID: 91, + bounds: CGRect(x: 108, y: 192, width: 908, height: 692), + targetIDs: ["target-a"]) + + let result = try NativeBrowserWindowCorrelator.correlate( + expectedNativeWindow: identity, + currentNativeWindow: identity, + nativeTitle: nil, + requestedTargetID: "target-a", + candidates: [atBoundary]) + #expect(result.browserWindowID.rawValue == 91) + + let outsideBoundary = Self.candidate( + windowID: 92, + bounds: CGRect(x: 108.01, y: 200, width: 900, height: 700), + targetIDs: ["target-a"]) + #expect(throws: NativeBrowserWindowCorrelationError.noGeometryMatch) { + try NativeBrowserWindowCorrelator.correlate( + expectedNativeWindow: identity, + currentNativeWindow: identity, + nativeTitle: nil, + requestedTargetID: "target-a", + candidates: [outsideBoundary]) + } + } + + @Test + func `negative multi-display coordinates are compared without normalization`() throws { + let bounds = CGRect(x: -1920, y: -240, width: 1440, height: 900) + let identity = Self.identity(bounds: bounds) + let result = try NativeBrowserWindowCorrelator.correlate( + expectedNativeWindow: identity, + currentNativeWindow: identity, + nativeTitle: nil, + requestedTargetID: "target-negative", + candidates: [ + Self.candidate( + windowID: 10, + bounds: CGRect(x: 1920, y: 240, width: 1440, height: 900), + targetIDs: []), + Self.candidate( + windowID: 11, + bounds: CGRect(x: -1916, y: -246, width: 1436, height: 906), + targetIDs: ["target-negative"]), + ]) + + #expect(result.browserWindowID.rawValue == 11) + } + + @Test + func `exact title breaks only a geometry tie`() throws { + let bounds = CGRect(x: 20, y: 30, width: 1100, height: 800) + let identity = Self.identity(bounds: bounds) + let result = try NativeBrowserWindowCorrelator.correlate( + expectedNativeWindow: identity, + currentNativeWindow: identity, + nativeTitle: "Peekaboo - Background", + requestedTargetID: "target-b", + candidates: [ + Self.candidate( + windowID: 21, + bounds: bounds, + titles: ["Other", "Background Tab"], + targetIDs: ["target-a"]), + Self.candidate( + windowID: 22, + bounds: bounds, + titles: ["Peekaboo - Background", "Inactive Tab"], + targetIDs: ["target-b"]), + ]) + + #expect(result.browserWindowID.rawValue == 22) + } + + @Test + func `title comparison is exact and duplicate title matches remain ambiguous`() { + let bounds = CGRect(x: 20, y: 30, width: 1100, height: 800) + let identity = Self.identity(bounds: bounds) + let candidates = [ + Self.candidate( + windowID: 21, + bounds: bounds, + titles: ["Peekaboo"], + targetIDs: ["target-a"]), + Self.candidate( + windowID: 22, + bounds: bounds, + titles: ["peekaboo"], + targetIDs: ["target-b"]), + ] + + #expect(throws: NativeBrowserWindowCorrelationError.ambiguousGeometry) { + try NativeBrowserWindowCorrelator.correlate( + expectedNativeWindow: identity, + currentNativeWindow: identity, + nativeTitle: " PEEKABOO ", + requestedTargetID: "target-a", + candidates: candidates) + } + + let duplicateTitles = candidates.map { + Self.candidate( + windowID: $0.windowID.rawValue, + bounds: $0.bounds, + titles: ["Peekaboo"], + targetIDs: $0.targetIDs) + } + #expect(throws: NativeBrowserWindowCorrelationError.ambiguousGeometry) { + try NativeBrowserWindowCorrelator.correlate( + expectedNativeWindow: identity, + currentNativeWindow: identity, + nativeTitle: "Peekaboo", + requestedTargetID: "target-a", + candidates: duplicateTitles) + } + } + + @Test + func `title alone never authorizes a geometry mismatch`() { + let identity = Self.identity(bounds: CGRect(x: 20, y: 30, width: 1100, height: 800)) + let titleOnly = Self.candidate( + windowID: 21, + bounds: CGRect(x: 500, y: 500, width: 700, height: 600), + titles: ["Exact Title"], + targetIDs: ["target-a"]) + + #expect(throws: NativeBrowserWindowCorrelationError.noGeometryMatch) { + try NativeBrowserWindowCorrelator.correlate( + expectedNativeWindow: identity, + currentNativeWindow: identity, + nativeTitle: "Exact Title", + requestedTargetID: "target-a", + candidates: [titleOnly]) + } + } + + @Test + func `target membership is validation after geometry selection`() { + let bounds = CGRect(x: 20, y: 30, width: 1100, height: 800) + let identity = Self.identity(bounds: bounds) + let candidates = [ + Self.candidate(windowID: 21, bounds: bounds, targetIDs: ["other-target"]), + Self.candidate( + windowID: 22, + bounds: CGRect(x: 500, y: 500, width: 700, height: 600), + titles: ["Exact Title"], + targetIDs: ["requested-target"]), + ] + + #expect(throws: NativeBrowserWindowCorrelationError.wrongTargetMembership) { + try NativeBrowserWindowCorrelator.correlate( + expectedNativeWindow: identity, + currentNativeWindow: identity, + nativeTitle: "Exact Title", + requestedTargetID: "requested-target", + candidates: candidates) + } + } + + @Test + func `target must belong uniquely to the selected CDP window`() { + let bounds = CGRect(x: 20, y: 30, width: 1100, height: 800) + let identity = Self.identity(bounds: bounds) + let candidates = [ + Self.candidate(windowID: 21, bounds: bounds, targetIDs: ["target-a"]), + Self.candidate( + windowID: 22, + bounds: CGRect(x: 500, y: 500, width: 700, height: 600), + targetIDs: ["target-a"]), + ] + + #expect(throws: NativeBrowserWindowCorrelationError.wrongTargetMembership) { + try NativeBrowserWindowCorrelator.correlate( + expectedNativeWindow: identity, + currentNativeWindow: identity, + nativeTitle: nil, + requestedTargetID: "target-a", + candidates: candidates) + } + #expect(throws: NativeBrowserWindowCorrelationError.wrongTargetMembership) { + try NativeBrowserWindowCorrelator.correlate( + expectedNativeWindow: identity, + currentNativeWindow: identity, + nativeTitle: nil, + requestedTargetID: "", + candidates: [candidates[0]]) + } + } + + @Test + func `changed native receipt fails stale before looking at CDP`() { + let bounds = CGRect(x: 20, y: 30, width: 1100, height: 800) + let expected = Self.identity(bounds: bounds) + let moved = Self.identity(bounds: CGRect(x: 21, y: 30, width: 1100, height: 800)) + let replacement = WindowMutationIdentity( + windowID: expected.windowID, + ownerProcessIdentifier: expected.ownerProcessIdentifier, + ownerProcessStartIdentity: expected.ownerProcessStartIdentity + 1, + capturedBounds: bounds) + let candidate = Self.candidate(windowID: 21, bounds: bounds, targetIDs: ["target-a"]) + + for current in [nil, moved, replacement] as [WindowMutationIdentity?] { + #expect(throws: NativeBrowserWindowCorrelationError.staleNativeWindow) { + try NativeBrowserWindowCorrelator.correlate( + expectedNativeWindow: expected, + currentNativeWindow: current, + nativeTitle: nil, + requestedTargetID: "target-a", + candidates: [candidate]) + } + } + } + + @Test + func `missing native bounds and malformed CDP bounds fail closed`() { + let missingBounds = Self.identity(bounds: nil) + #expect(throws: NativeBrowserWindowCorrelationError.staleNativeWindow) { + try NativeBrowserWindowCorrelator.correlate( + expectedNativeWindow: missingBounds, + currentNativeWindow: missingBounds, + nativeTitle: "Exact Title", + requestedTargetID: "target-a", + candidates: [Self.candidate( + windowID: 21, + bounds: CGRect(x: 20, y: 30, width: 1100, height: 800), + titles: ["Exact Title"], + targetIDs: ["target-a"])]) + } + + let bounds = CGRect(x: 20, y: 30, width: 1100, height: 800) + let identity = Self.identity(bounds: bounds) + #expect(throws: NativeBrowserWindowCorrelationError.noGeometryMatch) { + try NativeBrowserWindowCorrelator.correlate( + expectedNativeWindow: identity, + currentNativeWindow: identity, + nativeTitle: "Exact Title", + requestedTargetID: "target-a", + candidates: [Self.candidate( + windowID: 21, + bounds: CGRect(x: 20, y: 30, width: CGFloat.nan, height: 800), + titles: ["Exact Title"], + targetIDs: ["target-a"])]) + } + } + + private static func identity(bounds: CGRect?) -> WindowMutationIdentity { + WindowMutationIdentity( + windowID: 7, + ownerProcessIdentifier: 42, + ownerProcessStartIdentity: 1042, + capturedBounds: bounds) + } + + private static func candidate( + windowID: Int, + bounds: CGRect, + titles: Set = [], + targetIDs: Set) -> CDPBrowserWindowCandidate + { + CDPBrowserWindowCandidate( + windowID: BrowserMCPDevToolsWindowID(rawValue: windowID), + bounds: bounds, + titles: titles, + targetIDs: targetIDs) + } +} From d52e5bc836ab2c3b8f4d64f6b8b6c9b56d32503a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 13:25:05 -0700 Subject: [PATCH 04/14] feat(browser): bind private tabs to native windows --- .../BrowserMCPChannelEndpointResolver.swift | 113 +++- .../BrowserMCPDevToolsEndpointResolver.swift | 14 +- .../BrowserMCPPageRoutingContract.swift | 10 +- .../Browser/BrowserMCPPrivateInterop.swift | 46 ++ .../Browser/BrowserMCPService.swift | 1 + .../Browser/BrowserMCPSessionManager.swift | 135 +++- ...rowserNativeWindowBindingCoordinator.swift | 373 +++++++++++ .../BrowserToolCapabilitySession.swift | 87 ++- ...owserMCPChannelEndpointResolverTests.swift | 31 + .../BrowserMCPConfigTests.swift | 4 +- .../BrowserMCPPrivateInteropTests.swift | 73 +++ .../BrowserMCPSessionManagerTests.swift | 132 +++- ...rNativeWindowBindingCoordinatorTests.swift | 577 ++++++++++++++++++ .../BrowserToolCapabilitySessionTests.swift | 127 ++++ .../BrowserToolTests.swift | 43 +- docs/browser-mcp.md | 5 + scripts/test-chrome-devtools-mcp-contract.mjs | 29 +- 17 files changed, 1736 insertions(+), 64 deletions(-) create mode 100644 Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPPrivateInterop.swift create mode 100644 Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserNativeWindowBindingCoordinator.swift create mode 100644 Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPPrivateInteropTests.swift create mode 100644 Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserNativeWindowBindingCoordinatorTests.swift diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPChannelEndpointResolver.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPChannelEndpointResolver.swift index 7a3c1577b..7abc806a5 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPChannelEndpointResolver.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPChannelEndpointResolver.swift @@ -22,6 +22,15 @@ private struct BrowserMCPChannelEndpointAuthority: Sendable, Equatable { let listener: DarwinProcessLoopbackListenerIdentity } +private struct BrowserMCPChannelEndpointResolutionPlan: Sendable { + let target: BrowserMCPChannelProcessTarget + let attempt: BrowserMCPConnectionAttempt + let activePortURL: URL + let readActivePort: @Sendable (URL) throws -> Data + let inspectListener: DarwinProcessLoopbackListenerInspector.Inspect + let reserveAuthority: (@MainActor @Sendable (BrowserMCPChannelEndpointReservation) throws -> Void)? +} + struct BrowserMCPChannelEndpointResolver: Sendable { typealias Resolve = @Sendable (BrowserMCPChannelProcessTarget) async throws -> BrowserMCPDevToolsEndpoint typealias ResolveInitial = @Sendable ( @@ -74,7 +83,7 @@ struct BrowserMCPChannelEndpointResolver: Sendable { static let live = BrowserMCPChannelEndpointResolver( resolveInitialWithReservation: { target, attempt, reserveAuthority in - try await Self.resolveEndpoint( + try await Self.resolveEndpointWithControl( target: target, attempt: attempt, activePortURL: Self.activePortURL( @@ -84,7 +93,13 @@ struct BrowserMCPChannelEndpointResolver: Sendable { try StableRegularFileReader.live.read(url, 1024) }, inspectListener: DarwinProcessLoopbackListenerInspector.live.inspect, - probeWebSocket: BrowserMCPDevToolsWebSocketProber.live.probe, + connectControl: { url, browserID, deadline, onDispatch in + try await BrowserMCPDevToolsControlSession.connect( + url, + expectedBrowserID: browserID, + deadline: deadline, + onDispatch: onDispatch) + }, reserveAuthority: reserveAuthority) }, revalidate: { target, expected in @@ -104,55 +119,114 @@ struct BrowserMCPChannelEndpointResolver: Sendable { target: BrowserMCPChannelProcessTarget, attempt: BrowserMCPConnectionAttempt = .standalone(), activePortURL: URL, - readActivePort: @Sendable (URL) throws -> Data, - inspectListener: DarwinProcessLoopbackListenerInspector.Inspect, - probeWebSocket: BrowserMCPDevToolsWebSocketProber.Probe, + readActivePort: @escaping @Sendable (URL) throws -> Data, + inspectListener: @escaping DarwinProcessLoopbackListenerInspector.Inspect, + probeWebSocket: @escaping BrowserMCPDevToolsWebSocketProber.Probe, reserveAuthority: (@MainActor @Sendable (BrowserMCPChannelEndpointReservation) throws -> Void)? = nil) async throws -> BrowserMCPDevToolsEndpoint + { + try await self.resolveEndpoint( + plan: .init( + target: target, + attempt: attempt, + activePortURL: activePortURL, + readActivePort: readActivePort, + inspectListener: inspectListener, + reserveAuthority: reserveAuthority), + establishControl: { url, browserID, deadline, onDispatch in + let version = try await probeWebSocket(url, browserID, deadline, onDispatch) + return (version, nil) + }) + } + + static func resolveEndpointWithControl( + target: BrowserMCPChannelProcessTarget, + attempt: BrowserMCPConnectionAttempt = .standalone(), + activePortURL: URL, + readActivePort: @escaping @Sendable (URL) throws -> Data, + inspectListener: @escaping DarwinProcessLoopbackListenerInspector.Inspect, + connectControl: @escaping @Sendable ( + URL, + String, + ContinuousClock.Instant, + @escaping @Sendable () -> Void) async throws -> BrowserMCPDevToolsControlConnection, + reserveAuthority: (@MainActor @Sendable (BrowserMCPChannelEndpointReservation) throws -> Void)? = nil) + async throws -> BrowserMCPDevToolsEndpoint + { + try await self.resolveEndpoint( + plan: .init( + target: target, + attempt: attempt, + activePortURL: activePortURL, + readActivePort: readActivePort, + inspectListener: inspectListener, + reserveAuthority: reserveAuthority), + establishControl: { url, browserID, deadline, onDispatch in + let connection = try await connectControl(url, browserID, deadline, onDispatch) + return (connection.version, connection.session) + }) + } + + private static func resolveEndpoint( + plan: BrowserMCPChannelEndpointResolutionPlan, + establishControl: @escaping @Sendable ( + URL, + String, + ContinuousClock.Instant, + @escaping @Sendable () -> Void) async throws + -> (BrowserMCPDevToolsVersion, BrowserMCPDevToolsControlSession?)) + async throws -> BrowserMCPDevToolsEndpoint { let before = try self.resolveAuthority( - target: target, - activePortURL: activePortURL, - readActivePort: readActivePort, - inspectListener: inspectListener) + target: plan.target, + activePortURL: plan.activePortURL, + readActivePort: plan.readActivePort, + inspectListener: plan.inspectListener) guard let webSocketURL = URL(string: before.webSocketDebuggerURL), webSocketURL.absoluteString == before.webSocketDebuggerURL else { throw BrowserMCPConnectionError.channelEndpointUnavailable( - target.channel, + plan.target.channel, "Chrome's DevToolsActivePort published a malformed WebSocket identity") } - try await reserveAuthority?(BrowserMCPChannelEndpointReservation( + try await plan.reserveAuthority?(BrowserMCPChannelEndpointReservation( browserURL: before.browserURL, webSocketDebuggerURL: before.webSocketDebuggerURL, browserID: before.browserID)) let version: BrowserMCPDevToolsVersion + let retainedControlSession: BrowserMCPDevToolsControlSession? do { - version = try await probeWebSocket( + (version, retainedControlSession) = try await establishControl( webSocketURL, before.browserID, - attempt.deadline, - attempt.state.markPermissionDispatchStarted) + plan.attempt.deadline, + plan.attempt.state.markPermissionDispatchStarted) } catch BrowserMCPDevToolsWebSocketProbeFailure.cancelled { throw BrowserMCPConnectionError.permissionBearingConnectionCancelled } catch let BrowserMCPDevToolsWebSocketProbeFailure.failed(error) { throw BrowserMCPConnectionError.permissionBearingConnectionFailed(error.localizedDescription) + } catch BrowserMCPDevToolsControlError.cancelled { + throw BrowserMCPConnectionError.permissionBearingConnectionCancelled + } catch let error as BrowserMCPDevToolsControlError { + throw BrowserMCPConnectionError.permissionBearingConnectionFailed(error.localizedDescription) } let after: BrowserMCPChannelEndpointAuthority do { after = try self.resolveAuthority( - target: target, - activePortURL: activePortURL, - readActivePort: readActivePort, - inspectListener: inspectListener) + target: plan.target, + activePortURL: plan.activePortURL, + readActivePort: plan.readActivePort, + inspectListener: plan.inspectListener) } catch { + await retainedControlSession?.close() throw BrowserMCPConnectionError.permissionBearingConnectionFailed( "Chrome's DevTools authority changed after Browser.getVersion: \(error.localizedDescription)") } guard after == before else { + await retainedControlSession?.close() throw BrowserMCPConnectionError.permissionBearingConnectionFailed( "Chrome's DevTools authority changed during Browser.getVersion") } @@ -162,7 +236,8 @@ struct BrowserMCPChannelEndpointResolver: Sendable { browserID: before.browserID, browserVersion: version.browserVersion, protocolVersion: version.protocolVersion, - listenerIdentity: before.listener) + listenerIdentity: before.listener, + retainedControlSession: retainedControlSession) } static func revalidateEndpoint( diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPDevToolsEndpointResolver.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPDevToolsEndpointResolver.swift index 7d83ed33f..6077ca5b1 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPDevToolsEndpointResolver.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPDevToolsEndpointResolver.swift @@ -9,6 +9,7 @@ struct BrowserMCPDevToolsEndpoint: Sendable, Equatable { let browserVersion: String let protocolVersion: String let listenerIdentity: DarwinProcessLoopbackListenerIdentity? + let retainedControlSession: BrowserMCPDevToolsControlSession? init( browserURL: String, @@ -16,7 +17,8 @@ struct BrowserMCPDevToolsEndpoint: Sendable, Equatable { browserID: String, browserVersion: String, protocolVersion: String, - listenerIdentity: DarwinProcessLoopbackListenerIdentity? = nil) + listenerIdentity: DarwinProcessLoopbackListenerIdentity? = nil, + retainedControlSession: BrowserMCPDevToolsControlSession? = nil) { self.browserURL = browserURL self.webSocketDebuggerURL = webSocketDebuggerURL @@ -24,6 +26,16 @@ struct BrowserMCPDevToolsEndpoint: Sendable, Equatable { self.browserVersion = browserVersion self.protocolVersion = protocolVersion self.listenerIdentity = listenerIdentity + self.retainedControlSession = retainedControlSession + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.browserURL == rhs.browserURL && + lhs.webSocketDebuggerURL == rhs.webSocketDebuggerURL && + lhs.browserID == rhs.browserID && + lhs.browserVersion == rhs.browserVersion && + lhs.protocolVersion == rhs.protocolVersion && + lhs.listenerIdentity == rhs.listenerIdentity } } diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPPageRoutingContract.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPPageRoutingContract.swift index b940fbded..b5e546e6e 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPPageRoutingContract.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPPageRoutingContract.swift @@ -100,7 +100,6 @@ enum BrowserMCPPageRoutingContract { "fill_form", "get_console_message", "get_network_request", - "get_tab_id", "handle_dialog", "hover", "lighthouse_audit", @@ -125,6 +124,14 @@ enum BrowserMCPPageRoutingContract { ] // chrome-devtools-mcp-contract:page-scoped-end + // Provider tools used only by Peekaboo's host-owned binding implementation. They are deliberately absent from + // raw public routing because their responses contain private CDP authority. + // chrome-devtools-mcp-contract:internal-only-begin + static let internalOnlyToolNames: Set = [ + "get_tab_id", + ] + // chrome-devtools-mcp-contract:internal-only-end + // These upstream tools are not marked `pageScoped`, but their v1.6.0 schemas still require `pageId`. // chrome-devtools-mcp-contract:explicit-page-target-begin static let explicitPageTargetToolNames: Set = [ @@ -166,6 +173,7 @@ enum BrowserMCPPageRoutingContract { static let allToolNames = pageTargetedToolNames .union(globalToolNames) .union(blockedSelectedPageToolNames) + .union(internalOnlyToolNames) static let readOnlyToolNames = BrowserToolActionSemantics.readOnlyToolNames static let mutatingToolNames = BrowserToolActionSemantics.mutatingToolNames static let argumentDependentToolNames = BrowserToolActionSemantics.argumentDependentToolNames diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPPrivateInterop.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPPrivateInterop.swift new file mode 100644 index 000000000..747e38ee1 --- /dev/null +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPPrivateInterop.swift @@ -0,0 +1,46 @@ +import MCP +import TachikomaMCP + +enum BrowserMCPPrivateInteropError: Error, Equatable { + case authorityUnavailable + case providerError + case missingTargetID + case invalidTargetID + case deadlineExceeded + case publicDispatchRefused +} + +/// Host-only access to the pinned provider's tab target identity. +/// +/// The returned string is private CDP authority. It may be stored in a caller-scoped capability binding but must +/// never be projected into CLI, MCP, Bridge, logs, or provider diagnostics. +enum BrowserMCPPrivateInterop { + static let targetIDToolName = "get_tab_id" + + static func targetIDCall(providerPageID: Int) -> BrowserMCPMappedCall { + BrowserMCPMappedCall( + toolName: self.targetIDToolName, + arguments: ["pageId": providerPageID]) + } + + static func targetID(from response: ToolResponse) throws -> String { + guard !response.isError else { throw BrowserMCPPrivateInteropError.providerError } + guard let value = response.structuredContent?.objectValue?["tabId"]?.stringValue else { + throw BrowserMCPPrivateInteropError.missingTargetID + } + guard self.isValidPrivateTargetID(value) else { + throw BrowserMCPPrivateInteropError.invalidTargetID + } + return value + } + + private static func isValidPrivateTargetID(_ value: String) -> Bool { + guard !value.isEmpty, value.utf8.count <= 128 else { return false } + return value.utf8.allSatisfy { byte in + byte >= 0x30 && byte <= 0x39 || + byte >= 0x41 && byte <= 0x5A || + byte >= 0x61 && byte <= 0x7A || + byte == 0x2D || byte == 0x2E || byte == 0x5F + } + } +} diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPService.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPService.swift index 8edf0cc27..7575c77d5 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPService.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPService.swift @@ -939,6 +939,7 @@ public final class BrowserMCPService: BrowserMCPClientProviding, BrowserMCPActio "chrome-devtools-mcp@1.6.0", "--experimentalPageIdRouting", "--experimentalStructuredContent", + "--experimentalInteropTools", ] static func chromeDevToolsConfig(browserURL: String, headless _: Bool) -> MCPServerConfig { diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPSessionManager.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPSessionManager.swift index 2af1bfddc..04a89ca49 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPSessionManager.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPSessionManager.swift @@ -81,6 +81,7 @@ private struct BrowserMCPResolvedTarget { let channelEndpoint: BrowserMCPDevToolsEndpoint? let codeSignatureIdentity: ChromeProcessCodeSignatureValidator.Identity? let targetKind: BrowserMCPConnectionTargetKind + let retainedControlSession: BrowserMCPDevToolsControlSession? } private struct BrowserMCPStatusInspection { @@ -117,12 +118,13 @@ final class BrowserMCPSessionManager: @unchecked Sendable { private let uploadStager: BrowserMCPUploadStager private let environmentOptions: BrowserMCPEnvironmentOptions private let executionGate = MCPToolSnapshotExecutionGate() - private var connectionReceipt: BrowserMCPConnectionReceipt? - private var providerSessionEpoch: BrowserMCPProviderSessionEpoch? + var connectionReceipt: BrowserMCPConnectionReceipt? + var providerSessionEpoch: BrowserMCPProviderSessionEpoch? private var connectionSupportsReceiptBoundExecution = false private var connectionChannelEndpoint: BrowserMCPDevToolsEndpoint? private var connectionCodeSignatureIdentity: ChromeProcessCodeSignatureValidator.Identity? private var connectionTargetKind: BrowserMCPConnectionTargetKind? + var connectionControlSession: BrowserMCPDevToolsControlSession? private var uploadWorkspace: BrowserMCPUploadWorkspace? private var activeUploadID: UUID? private var sessionEnded = false @@ -331,24 +333,32 @@ final class BrowserMCPSessionManager: @unchecked Sendable { } return DesktopActionResult(payload: status, outcome: .confirmedNoChange()) } + guard self.connectionControlSession == nil else { + await self.clearConnection() + throw BrowserMCPConnectionError.connectionLost( + "a retained native control session outlived its connection receipt") + } let target = try await self.resolveTarget( channel: channel, browserURL: browserURL, attempt: attempt, reserveTarget: reserveTarget) - try reserveTarget?(target.receipt) - if self.manager.hasServer(name: self.serverName) { - await self.manager.removeServer(name: self.serverName) - } var connectionAttemptDispatched = false do { + // Take ownership immediately: every fallible step after native endpoint resolution must close the + // permission-bearing control socket through `clearConnection()`. + self.connectionControlSession = target.retainedControlSession + try reserveTarget?(target.receipt) + if self.manager.hasServer(name: self.serverName) { + await self.manager.removeServer(name: self.serverName) + } let uploadWorkspace = try await self.uploadStager.createWorkspace() var config = target.config config.env["TMPDIR"] = uploadWorkspace.rootPath self.uploadWorkspace = uploadWorkspace - // Native channel setup has one owner-controlled identity probe, then this separately - // owned MCP child opens the session's execution WebSocket. Later validation never probes. + // Native channel setup retains the owner-controlled read-only browser session, then this separately + // owned MCP child opens the execution WebSocket. Later validation never reopens either connection. attempt.state.markConnectionDispatchStarted() connectionAttemptDispatched = true try await self.manager.addServer(name: self.serverName, config: config) @@ -560,11 +570,17 @@ final class BrowserMCPSessionManager: @unchecked Sendable { channel: BrowserMCPChannel?, expectedConnectionReceipt: BrowserMCPConnectionReceipt?, expectedProviderSessionEpoch: BrowserMCPProviderSessionEpoch?, - connectionPolicy: BrowserMCPExecutionConnectionPolicy) async throws -> BrowserMCPExecutionResult + connectionPolicy: BrowserMCPExecutionConnectionPolicy, + allowPrivateInterop: Bool = false) async throws -> BrowserMCPExecutionResult { guard !calls.isEmpty else { throw BrowserMCPConnectionError.connectionLost("the browser action sequence was empty") } + guard allowPrivateInterop || calls.allSatisfy({ + !BrowserMCPPageRoutingContract.internalOnlyToolNames.contains($0.toolName) + }) else { + throw BrowserMCPPrivateInteropError.publicDispatchRefused + } let preparation = try await self.prepareExecutionReceipt( channel: channel, expectedConnectionReceipt: expectedConnectionReceipt, @@ -1042,7 +1058,8 @@ final class BrowserMCPSessionManager: @unchecked Sendable { supportsReceiptBoundExecution: false, channelEndpoint: nil, codeSignatureIdentity: nil, - targetKind: .isolated) + targetKind: .isolated, + retainedControlSession: nil) } let candidates = self.detectedBrowsers(resolvedChannel) guard !candidates.isEmpty else { @@ -1107,6 +1124,7 @@ final class BrowserMCPSessionManager: @unchecked Sendable { processStartIdentity, channelIdentity) == codeSignatureIdentity else { + await endpoint.retainedControlSession?.close() throw BrowserMCPConnectionError.permissionBearingConnectionFailed( "the live Chrome bundle or signing identity changed during Browser.getVersion") } @@ -1127,7 +1145,8 @@ final class BrowserMCPSessionManager: @unchecked Sendable { supportsReceiptBoundExecution: true, channelEndpoint: endpoint, codeSignatureIdentity: codeSignatureIdentity, - targetKind: .nativeChannel) + targetKind: .nativeChannel, + retainedControlSession: endpoint.retainedControlSession) } private func resolveExactEndpointTarget( @@ -1158,7 +1177,8 @@ final class BrowserMCPSessionManager: @unchecked Sendable { supportsReceiptBoundExecution: true, channelEndpoint: nil, codeSignatureIdentity: nil, - targetKind: .external) + targetKind: .external, + retainedControlSession: nil) } private func validate( @@ -1305,6 +1325,9 @@ final class BrowserMCPSessionManager: @unchecked Sendable { self.connectionChannelEndpoint = nil self.connectionCodeSignatureIdentity = nil self.connectionTargetKind = nil + let controlSession = self.connectionControlSession + self.connectionControlSession = nil + await controlSession?.close() self.activeUploadID = nil let uploadWorkspace = self.uploadWorkspace self.uploadWorkspace = nil @@ -1336,4 +1359,92 @@ final class BrowserMCPSessionManager: @unchecked Sendable { throw error } } + + func withPrivateTargetBindingAuthority( + providerPageID: Int, + expectedSessionBinding: BrowserMCPExecutionSessionBinding, + deadline: ContinuousClock.Instant, + operation: @MainActor @Sendable ( + BrowserMCPDevToolsControlSession, + String) async throws -> Result) async throws -> Result + { + try await self.withExecutionGate { + let control = try await self.nativeBindingControl( + expectedSessionBinding: expectedSessionBinding) + try Self.requireNativeBindingDeadline(deadline) + let result: BrowserMCPExecutionResult + do { + result = try await BrowserMCPConnectionDeadline.run(until: deadline) { + try await self.executeSequenceUnlocked( + [BrowserMCPPrivateInterop.targetIDCall(providerPageID: providerPageID)], + channel: expectedSessionBinding.connectionReceipt.channel, + expectedConnectionReceipt: expectedSessionBinding.connectionReceipt, + expectedProviderSessionEpoch: expectedSessionBinding.providerSessionEpoch, + connectionPolicy: .requireExistingLiveReceipt, + allowPrivateInterop: true) + } + } catch BrowserMCPConnectionDeadlineError.timedOut { + throw BrowserMCPPrivateInteropError.deadlineExceeded + } + try Self.requireNativeBindingDeadline(deadline) + guard !result.response.isError, + result.actionFailure == nil, + result.completedCallCount == 1, + result.dispatchedCallCount == 1 + else { + throw BrowserMCPPrivateInteropError.providerError + } + let privateTargetID = try BrowserMCPPrivateInterop.targetID(from: result.response) + return try await operation(control, privateTargetID) + } + } + + func withNativeBindingExecutionGate( + expectedSessionBinding: BrowserMCPExecutionSessionBinding, + operation: @MainActor @Sendable (BrowserMCPDevToolsControlSession) async throws -> Result) + async throws -> Result + { + try await self.withExecutionGate { + let control = try await self.nativeBindingControl( + expectedSessionBinding: expectedSessionBinding) + return try await operation(control) + } + } + + func nativeBindingAuthorityIsLive( + expectedSessionBinding: BrowserMCPExecutionSessionBinding) async -> Bool + { + do { + return try await self.withExecutionGate { + _ = try await self.nativeBindingControl( + expectedSessionBinding: expectedSessionBinding) + return true + } + } catch { + return false + } + } + + private func nativeBindingControl( + expectedSessionBinding: BrowserMCPExecutionSessionBinding) async throws + -> BrowserMCPDevToolsControlSession + { + guard self.connectionReceipt == expectedSessionBinding.connectionReceipt, + self.providerSessionEpoch == expectedSessionBinding.providerSessionEpoch, + let control = self.connectionControlSession, + await control.state() == .open + else { + throw BrowserMCPPrivateInteropError.authorityUnavailable + } + return control + } + + private static func requireNativeBindingDeadline( + _ deadline: ContinuousClock.Instant) throws + { + try Task.checkCancellation() + guard ContinuousClock.now < deadline else { + throw BrowserMCPPrivateInteropError.deadlineExceeded + } + } } diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserNativeWindowBindingCoordinator.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserNativeWindowBindingCoordinator.swift new file mode 100644 index 000000000..00a0feb1b --- /dev/null +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserNativeWindowBindingCoordinator.swift @@ -0,0 +1,373 @@ +import CoreGraphics +import Foundation +import TachikomaMCP + +struct BrowserNativeWindowBindingProof: Sendable, Equatable { + enum Quality: String, Sendable, Equatable { + case exact + } + + let pageReference: String + let nativeWindowReceipt: BrowserNativeWindowReceipt + let quality: Quality +} + +enum BrowserNativeWindowBindingCoordinatorError: Error, Equatable { + case invalidPageCapability + case invalidNativeWindow + case privateTargetUnavailable + case controlUnavailable + case correlationRefused + case deadlineExceeded +} + +enum BrowserNativeWindowBindingCoordinator { + struct Dependencies: Sendable { + let receiptProviders: BrowserNativeWindowReceiptResolver.Providers + + static let live = Self(receiptProviders: .live) + } + + struct Context: Sendable { + let sessionBinding: BrowserMCPExecutionSessionBinding + let capabilities: BrowserToolCapabilitySession + let manager: BrowserMCPSessionManager + let deadline: ContinuousClock.Instant + } + + private struct BindAuthorityRequest: Sendable { + let pageReference: String + let nativeTarget: BrowserNativeWindowTarget + let privateTargetID: String + let context: Context + let receiptProviders: BrowserNativeWindowReceiptResolver.Providers + } + + @MainActor + static func bind( + pageReference: String, + nativeTarget: BrowserNativeWindowTarget, + context: Context, + dependencies: Dependencies) async throws -> BrowserNativeWindowBindingProof + { + try await context.capabilities.withExclusiveOperation { + let resolved: BrowserToolCapabilitySession.ResolvedArguments + do { + resolved = try await context.capabilities.resolve( + action: .snapshot, + arguments: ToolArguments(raw: ["page_id": pageReference]), + sessionBinding: context.sessionBinding) + } catch { + throw BrowserNativeWindowBindingCoordinatorError.invalidPageCapability + } + guard let providerPageID = resolved.providerPageID else { + throw BrowserNativeWindowBindingCoordinatorError.invalidPageCapability + } + + do { + return try await context.manager.withPrivateTargetBindingAuthority( + providerPageID: providerPageID, + expectedSessionBinding: context.sessionBinding, + deadline: context.deadline) + { control, privateTargetID in + do { + return try await self.bindUnderAuthority( + .init( + pageReference: pageReference, + nativeTarget: nativeTarget, + privateTargetID: privateTargetID, + context: context, + receiptProviders: dependencies.receiptProviders), + control: control) + } catch { + throw await self.validationError( + error, + pageReference: pageReference, + capabilities: context.capabilities, + control: control, + deadline: context.deadline) + } + } + } catch is CancellationError { + await self.invalidateAfterPrivateLookupFailure( + pageReference: pageReference, + context: context) + throw CancellationError() + } catch BrowserMCPPrivateInteropError.authorityUnavailable { + await context.capabilities.invalidateNativeWindowBindings() + throw BrowserNativeWindowBindingCoordinatorError.controlUnavailable + } catch BrowserMCPPrivateInteropError.deadlineExceeded { + await self.invalidateAfterPrivateLookupFailure( + pageReference: pageReference, + context: context) + throw BrowserNativeWindowBindingCoordinatorError.deadlineExceeded + } catch is BrowserMCPPrivateInteropError { + await self.invalidateAfterPrivateLookupFailure( + pageReference: pageReference, + context: context) + throw BrowserNativeWindowBindingCoordinatorError.privateTargetUnavailable + } catch let error as BrowserNativeWindowBindingCoordinatorError { + throw error + } catch { + await self.invalidateAfterPrivateLookupFailure( + pageReference: pageReference, + context: context) + throw BrowserNativeWindowBindingCoordinatorError.privateTargetUnavailable + } + } + } + + @MainActor + static func withRevalidatedMutation( + pageReference: String, + context: Context, + receiptProviders: BrowserNativeWindowReceiptResolver.Providers, + mutation: @MainActor @Sendable () async throws -> Result) async throws -> Result + { + try await context.capabilities.withExclusiveOperation { + do { + return try await context.manager.withNativeBindingExecutionGate( + expectedSessionBinding: context.sessionBinding) + { control in + do { + try await self.revalidateUnderAuthority( + pageReference: pageReference, + context: context, + control: control, + receiptProviders: receiptProviders) + } catch { + throw await self.validationError( + error, + pageReference: pageReference, + capabilities: context.capabilities, + control: control, + deadline: context.deadline) + } + try self.requireAuthorizationDeadline(context) + return try await mutation() + } + } catch is CancellationError { + throw CancellationError() + } catch BrowserMCPPrivateInteropError.authorityUnavailable { + await context.capabilities.invalidateNativeWindowBindings() + throw BrowserNativeWindowBindingCoordinatorError.controlUnavailable + } + } + } + + @MainActor + private static func bindUnderAuthority( + _ request: BindAuthorityRequest, + control: BrowserMCPDevToolsControlSession) async throws + -> BrowserNativeWindowBindingProof + { + let receipt: BrowserNativeWindowReceipt + do { + receipt = try BrowserNativeWindowReceiptResolver.capture( + target: request.nativeTarget, + providers: request.receiptProviders).get() + } catch { + throw BrowserNativeWindowBindingCoordinatorError.invalidNativeWindow + } + + let candidates = try await self.candidates( + control: control, + requestedTargetID: request.privateTargetID, + deadline: request.context.deadline) + let currentReceipt = try BrowserNativeWindowReceiptResolver.revalidate( + receipt, + providers: request.receiptProviders).get() + let correlation = try NativeBrowserWindowCorrelator.correlate( + expectedNativeWindow: receipt.windowIdentity, + currentNativeWindow: currentReceipt.windowIdentity, + nativeTitle: nil, + requestedTargetID: request.privateTargetID, + candidates: candidates) + let finalWindowID = try await control.getWindowForTarget( + targetID: request.privateTargetID, + deadline: request.context.deadline) + _ = try BrowserNativeWindowReceiptResolver.revalidate( + currentReceipt, + providers: request.receiptProviders).get() + guard finalWindowID == correlation.browserWindowID, + await control.state() == .open + else { + throw BrowserNativeWindowBindingCoordinatorError.correlationRefused + } + try self.requireAuthorizationDeadline(request.context) + try await request.context.capabilities.bindNativeWindow( + pageReference: request.pageReference, + sessionBinding: request.context.sessionBinding, + privateTargetID: request.privateTargetID, + privateBrowserWindowID: correlation.browserWindowID, + nativeWindowReceipt: currentReceipt) + return BrowserNativeWindowBindingProof( + pageReference: request.pageReference, + nativeWindowReceipt: currentReceipt, + quality: .exact) + } + + @MainActor + private static func revalidateUnderAuthority( + pageReference: String, + context: Context, + control: BrowserMCPDevToolsControlSession, + receiptProviders: BrowserNativeWindowReceiptResolver.Providers) async throws + { + let binding = try await context.capabilities.nativeWindowBinding( + pageReference: pageReference, + sessionBinding: context.sessionBinding) + let currentReceipt = try BrowserNativeWindowReceiptResolver.revalidate( + binding.nativeWindowReceipt, + providers: receiptProviders).get() + let candidates = try await self.candidates( + control: control, + requestedTargetID: binding.privateTargetID, + deadline: context.deadline) + let correlation = try NativeBrowserWindowCorrelator.correlate( + expectedNativeWindow: binding.nativeWindowReceipt.windowIdentity, + currentNativeWindow: currentReceipt.windowIdentity, + nativeTitle: nil, + requestedTargetID: binding.privateTargetID, + candidates: candidates) + let finalWindowID = try await control.getWindowForTarget( + targetID: binding.privateTargetID, + deadline: context.deadline) + _ = try BrowserNativeWindowReceiptResolver.revalidate( + currentReceipt, + providers: receiptProviders).get() + guard correlation.browserWindowID == binding.privateBrowserWindowID, + finalWindowID == binding.privateBrowserWindowID, + await control.state() == .open + else { + throw BrowserNativeWindowBindingCoordinatorError.correlationRefused + } + } + + @MainActor + private static func validationError( + _ error: any Error, + pageReference: String, + capabilities: BrowserToolCapabilitySession, + control: BrowserMCPDevToolsControlSession, + deadline: ContinuousClock.Instant) async -> any Error + { + if error is CancellationError { + if await control.state() == .open { + await capabilities.invalidateNativeWindowBinding(pageReference: pageReference) + } else { + await capabilities.invalidateNativeWindowBindings() + } + return CancellationError() + } + if let controlError = error as? BrowserMCPDevToolsControlError, + controlError == .cancelled + { + await capabilities.invalidateNativeWindowBindings() + return CancellationError() + } + if let controlError = error as? BrowserMCPDevToolsControlError, + case .timedOut = controlError + { + if await control.state() == .open { + await capabilities.invalidateNativeWindowBinding(pageReference: pageReference) + } else { + await capabilities.invalidateNativeWindowBindings() + } + return BrowserNativeWindowBindingCoordinatorError.deadlineExceeded + } + if error is BrowserMCPDevToolsControlError, + ContinuousClock.now >= deadline + { + if await control.state() == .open { + await capabilities.invalidateNativeWindowBinding(pageReference: pageReference) + } else { + await capabilities.invalidateNativeWindowBindings() + } + return BrowserNativeWindowBindingCoordinatorError.deadlineExceeded + } + if let coordinatorError = error as? BrowserNativeWindowBindingCoordinatorError { + if coordinatorError == .controlUnavailable { + await capabilities.invalidateNativeWindowBindings() + } else { + await capabilities.invalidateNativeWindowBinding(pageReference: pageReference) + } + return coordinatorError + } + if error is BrowserMCPDevToolsControlError { + guard await control.state() == .open else { + await capabilities.invalidateNativeWindowBindings() + return BrowserNativeWindowBindingCoordinatorError.controlUnavailable + } + } + await capabilities.invalidateNativeWindowBinding(pageReference: pageReference) + return BrowserNativeWindowBindingCoordinatorError.correlationRefused + } + + private static func invalidateAfterPrivateLookupFailure( + pageReference: String, + context: Context) async + { + if await context.manager.nativeBindingAuthorityIsLive( + expectedSessionBinding: context.sessionBinding) + { + await context.capabilities.invalidateNativeWindowBinding(pageReference: pageReference) + } else { + await context.capabilities.invalidateNativeWindowBindings() + } + } + + private static func requireAuthorizationDeadline(_ context: Context) throws { + try Task.checkCancellation() + guard ContinuousClock.now < context.deadline else { + throw BrowserNativeWindowBindingCoordinatorError.deadlineExceeded + } + } + + private static func candidates( + control: BrowserMCPDevToolsControlSession, + requestedTargetID: String, + deadline: ContinuousClock.Instant) async throws -> [CDPBrowserWindowCandidate] + { + let targets = try await control.getTargets(deadline: deadline).filter { $0.type == "page" } + var targetIDsByWindow: [BrowserMCPDevToolsWindowID: Set] = [:] + var titlesByWindow: [BrowserMCPDevToolsWindowID: Set] = [:] + for target in targets { + let windowID = try await control.getWindowForTarget( + targetID: target.targetID, + deadline: deadline) + targetIDsByWindow[windowID, default: []].insert(target.targetID) + if !target.title.isEmpty { + titlesByWindow[windowID, default: []].insert(target.title) + } + } + // chrome-devtools-mcp's private `get_tab_id` returns Puppeteer's tab target, while the default + // Target.getTargets result contains page targets. Resolve the tab target explicitly so correlation + // compares both identities in the same browser-window namespace without exposing either one. + let requestedWindowID = try await control.getWindowForTarget( + targetID: requestedTargetID, + deadline: deadline) + targetIDsByWindow[requestedWindowID, default: []].insert(requestedTargetID) + + var candidates: [CDPBrowserWindowCandidate] = [] + for windowID in targetIDsByWindow.keys.sorted(by: { $0.rawValue < $1.rawValue }) { + let bounds = try await control.getWindowBounds(windowID: windowID, deadline: deadline) + guard let left = bounds.left, + let top = bounds.top, + let width = bounds.width, + let height = bounds.height, + width > 0, + height > 0, + bounds.state != .minimized + else { + continue + } + candidates.append(CDPBrowserWindowCandidate( + windowID: windowID, + bounds: CGRect(x: left, y: top, width: width, height: height), + titles: titlesByWindow[windowID] ?? [], + targetIDs: targetIDsByWindow[windowID] ?? [])) + } + return candidates + } +} diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserToolCapabilitySession.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserToolCapabilitySession.swift index b5239b5f4..7e66756d1 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserToolCapabilitySession.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserToolCapabilitySession.swift @@ -2,6 +2,22 @@ import Foundation import MCP import TachikomaMCP +struct BrowserToolNativeWindowBinding: Sendable, Equatable { + let privateTargetID: String + let privateBrowserWindowID: BrowserMCPDevToolsWindowID + let nativeWindowReceipt: BrowserNativeWindowReceipt +} + +enum BrowserToolNativeWindowBindingError: Error, Equatable { + case sessionEnded + case invalidPageReference + case stalePageReference + case connectionMismatch + case invalidPrivateTarget + case invalidNativeWindow + case processMismatch +} + /// One caller-owned namespace for browser page and element capabilities. /// /// Chrome DevTools MCP exposes process-local integers and snapshot-local UIDs. They are provider @@ -41,6 +57,7 @@ actor BrowserToolCapabilitySession { var title: String? var navigationGeneration: UInt64 var snapshotReferences: Set + var nativeWindowBinding: BrowserToolNativeWindowBinding? } struct ResolvedArguments { @@ -130,6 +147,73 @@ actor BrowserToolCapabilitySession { providerUIDs: providerUIDs) } + func bindNativeWindow( + pageReference: String, + sessionBinding: BrowserMCPExecutionSessionBinding, + privateTargetID: String, + privateBrowserWindowID: BrowserMCPDevToolsWindowID, + nativeWindowReceipt: BrowserNativeWindowReceipt) throws + { + guard !self.ended else { throw BrowserToolNativeWindowBindingError.sessionEnded } + guard var page = self.pagesByReference[pageReference] else { + throw BrowserToolCapabilityReference.isValid(pageReference, prefix: "bp1") + ? BrowserToolNativeWindowBindingError.stalePageReference + : BrowserToolNativeWindowBindingError.invalidPageReference + } + guard page.connection == ConnectionBinding(sessionBinding: sessionBinding) else { + throw BrowserToolNativeWindowBindingError.connectionMismatch + } + guard !privateTargetID.isEmpty, privateBrowserWindowID.rawValue >= 0 else { + throw BrowserToolNativeWindowBindingError.invalidPrivateTarget + } + guard nativeWindowReceipt.windowIdentity.windowID > 0, + nativeWindowReceipt.windowIdentity.capturedBounds == nativeWindowReceipt.bounds + else { + throw BrowserToolNativeWindowBindingError.invalidNativeWindow + } + let receipt = sessionBinding.connectionReceipt + guard receipt.processIdentifier == nativeWindowReceipt.target.processIdentifier, + receipt.processStartIdentity == nativeWindowReceipt.target.processStartIdentity + else { + throw BrowserToolNativeWindowBindingError.processMismatch + } + page.nativeWindowBinding = BrowserToolNativeWindowBinding( + privateTargetID: privateTargetID, + privateBrowserWindowID: privateBrowserWindowID, + nativeWindowReceipt: nativeWindowReceipt) + self.pagesByReference[pageReference] = page + } + + func nativeWindowBinding( + pageReference: String, + sessionBinding: BrowserMCPExecutionSessionBinding) throws -> BrowserToolNativeWindowBinding + { + guard !self.ended else { throw BrowserToolNativeWindowBindingError.sessionEnded } + guard let page = self.pagesByReference[pageReference] else { + throw BrowserToolCapabilityReference.isValid(pageReference, prefix: "bp1") + ? BrowserToolNativeWindowBindingError.stalePageReference + : BrowserToolNativeWindowBindingError.invalidPageReference + } + guard page.connection == ConnectionBinding(sessionBinding: sessionBinding) else { + throw BrowserToolNativeWindowBindingError.connectionMismatch + } + guard let binding = page.nativeWindowBinding else { + throw BrowserToolNativeWindowBindingError.stalePageReference + } + return binding + } + + func invalidateNativeWindowBindings() { + let references = Array(self.pagesByReference.keys) + for reference in references { + self.pagesByReference[reference]?.nativeWindowBinding = nil + } + } + + func invalidateNativeWindowBinding(pageReference: String) { + self.pagesByReference[pageReference]?.nativeWindowBinding = nil + } + func project( _ response: ToolResponse, calls: [BrowserMCPMappedCall], @@ -521,7 +605,8 @@ actor BrowserToolCapabilitySession { url: url, title: title, navigationGeneration: 0, - snapshotReferences: []) + snapshotReferences: [], + nativeWindowBinding: nil) self.pageReferenceByProviderID[providerPageID] = reference return reference } diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPChannelEndpointResolverTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPChannelEndpointResolverTests.swift index 4cbb79b15..4a0b7302f 100644 --- a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPChannelEndpointResolverTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPChannelEndpointResolverTests.swift @@ -57,6 +57,37 @@ struct BrowserMCPChannelEndpointResolverTests { #expect(order.wasReserved) } + @Test + func `Native channel resolution retains the permission bearing control socket`() async throws { + let inspections = ListenerInspections([ + Self.listener(socket: 100), + Self.listener(socket: 100), + ]) + let transport = FakeControlTransport.respondingNormally + let opener = FakeControlTransportOpener(transport: transport) + + let endpoint = try await BrowserMCPChannelEndpointResolver.resolveEndpointWithControl( + target: Self.target(), + activePortURL: URL(fileURLWithPath: "/fixture/DevToolsActivePort"), + readActivePort: { _ in Self.activePortData() }, + inspectListener: { _, _, _ in try inspections.next() }, + connectControl: { url, browserID, deadline, onDispatch in + try await BrowserMCPDevToolsControlSession.connect( + url, + expectedBrowserID: browserID, + deadline: deadline, + onDispatch: onDispatch, + transportFactory: opener.factory) + }) + + let control = try #require(endpoint.retainedControlSession) + #expect(await control.state() == .open) + #expect(opener.openCount == 1) + #expect(transport.sentCommands().count == 1) + #expect(inspections.remaining == 0) + await control.close() + } + @Test func `same port listener reopen is refused during later revalidation`() async throws { let initialInspections = ListenerInspections([ diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPConfigTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPConfigTests.swift index 18dfe2128..dc767af42 100644 --- a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPConfigTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPConfigTests.swift @@ -55,13 +55,15 @@ struct BrowserMCPConfigTests { } private func expectStructuredCapabilityArguments(_ arguments: [String]) { - #expect(Array(arguments.prefix(4)) == [ + #expect(Array(arguments.prefix(5)) == [ "-y", "chrome-devtools-mcp@1.6.0", "--experimentalPageIdRouting", "--experimentalStructuredContent", + "--experimentalInteropTools", ]) #expect(arguments.count { $0 == "--experimentalPageIdRouting" } == 1) #expect(arguments.count { $0 == "--experimentalStructuredContent" } == 1) + #expect(arguments.count { $0 == "--experimentalInteropTools" } == 1) } } diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPPrivateInteropTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPPrivateInteropTests.swift new file mode 100644 index 000000000..15bf0e105 --- /dev/null +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPPrivateInteropTests.swift @@ -0,0 +1,73 @@ +import MCP +import TachikomaMCP +import Testing +@testable import PeekabooAgentRuntime + +struct BrowserMCPPrivateInteropTests { + @Test + func `Audited browser routing contract partitions pinned tool catalog`() { + #expect(BrowserMCPPageRoutingContract.dependencyVersion == "1.6.0") + #expect(BrowserMCPPageRoutingContract.pageScopedToolNames.count == 31) + #expect(BrowserMCPPageRoutingContract.explicitPageTargetToolNames.count == 3) + #expect(BrowserMCPPageRoutingContract.globalToolNames.count == 16) + #expect(BrowserMCPPageRoutingContract.blockedSelectedPageToolNames == ["trigger_extension_action"]) + #expect(BrowserMCPPageRoutingContract.internalOnlyToolNames == ["get_tab_id"]) + #expect(BrowserMCPPageRoutingContract.allToolNames.count == 52) + #expect(BrowserMCPPageRoutingContract.pageTargetedToolNames.isDisjoint( + with: BrowserMCPPageRoutingContract.globalToolNames)) + #expect(BrowserMCPPageRoutingContract.pageTargetedToolNames.isDisjoint( + with: BrowserMCPPageRoutingContract.blockedSelectedPageToolNames)) + #expect(BrowserMCPPageRoutingContract.globalToolNames.isDisjoint( + with: BrowserMCPPageRoutingContract.blockedSelectedPageToolNames)) + #expect(BrowserMCPPageRoutingContract.pageTargetedToolNames.isDisjoint( + with: BrowserMCPPageRoutingContract.internalOnlyToolNames)) + #expect(BrowserMCPPageRoutingContract.globalToolNames.isDisjoint( + with: BrowserMCPPageRoutingContract.internalOnlyToolNames)) + #expect(BrowserMCPPageRoutingContract.routing(for: "get_tab_id") == nil) + #expect(BrowserMCPPageRoutingContract.routing(for: "trigger_extension_action") == .blockedSelectedPage) + #expect(BrowserMCPPageRoutingContract.readOnlyToolNames.count == 27) + #expect(BrowserMCPPageRoutingContract.mutatingToolNames.count == 23) + #expect(BrowserMCPPageRoutingContract.argumentDependentToolNames == [ + "performance_start_trace", + "select_page", + ]) + #expect(BrowserMCPPageRoutingContract.allSemanticToolNames == BrowserMCPPageRoutingContract.allToolNames) + #expect(BrowserMCPPageRoutingContract.readOnlyToolNames.isDisjoint( + with: BrowserMCPPageRoutingContract.mutatingToolNames)) + #expect(BrowserMCPPageRoutingContract.readOnlyToolNames.isDisjoint( + with: BrowserMCPPageRoutingContract.argumentDependentToolNames)) + #expect(BrowserMCPPageRoutingContract.mutatingToolNames.isDisjoint( + with: BrowserMCPPageRoutingContract.argumentDependentToolNames)) + } + + @Test + func `Private tab target call is exact and cannot enter public routing`() { + let call = BrowserMCPPrivateInterop.targetIDCall(providerPageID: 17) + + #expect(call.toolName == "get_tab_id") + #expect(call.arguments["pageId"] as? Int == 17) + #expect(BrowserMCPPageRoutingContract.internalOnlyToolNames.contains(call.toolName)) + #expect(BrowserMCPPageRoutingContract.routing(for: call.toolName) == nil) + #expect(BrowserMCPPageRoutingContract.capabilityContract(for: call.toolName) == nil) + } + + @Test + func `Private tab target parser requires bounded structured identity`() throws { + let response = ToolResponse( + content: [.text(text: "Tab identity is private", annotations: nil, _meta: nil)], + structuredContent: .object(["tabId": .string("A1b2-target_3")])) + + #expect(try BrowserMCPPrivateInterop.targetID(from: response) == "A1b2-target_3") + #expect(throws: BrowserMCPPrivateInteropError.missingTargetID) { + _ = try BrowserMCPPrivateInterop.targetID(from: .text("A1b2-target_3")) + } + #expect(throws: BrowserMCPPrivateInteropError.invalidTargetID) { + _ = try BrowserMCPPrivateInterop.targetID(from: ToolResponse( + content: [], + structuredContent: .object(["tabId": .string("../../private")]))) + } + #expect(throws: BrowserMCPPrivateInteropError.providerError) { + _ = try BrowserMCPPrivateInterop.targetID(from: ToolResponse.error("provider failed")) + } + } +} diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPSessionManagerTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPSessionManagerTests.swift index cf5910f16..dfaad7f96 100644 --- a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPSessionManagerTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPSessionManagerTests.swift @@ -1364,6 +1364,119 @@ struct BrowserMCPSessionManagerTests { #expect(status.connectionReceipt == nil) } + @Test + func `failed provider probe closes retained native control`() async throws { + let transport = FakeControlTransport.respondingNormally + let opener = FakeControlTransportOpener(transport: transport) + let connection = try await BrowserMCPDevToolsControlSession.connect( + BrowserMCPDevToolsControlSessionTests.webSocketURL, + expectedBrowserID: "browser-a", + deadline: ContinuousClock.now.advanced(by: .seconds(2)), + transportFactory: opener.factory) + let endpointResolver = BrowserMCPChannelEndpointResolver( + resolveInitial: { _, _ in + BrowserMCPDevToolsEndpoint( + browserURL: "http://127.0.0.1:9222/", + webSocketDebuggerURL: BrowserMCPDevToolsControlSessionTests.webSocketURL.absoluteString, + browserID: "browser-a", + browserVersion: connection.version.browserVersion, + protocolVersion: connection.version.protocolVersion, + retainedControlSession: connection.session) + }, + revalidate: { _, _ in }) + let manager = MockBrowserMCPManager() + manager.executeError = MockBrowserError.probe + let session = Self.session( + manager: manager, + browsers: [Self.browser(pid: 62, generation: 3062)], + channelEndpointResolver: endpointResolver) + + await #expect(throws: DesktopActionFailure.self) { + _ = try await session.connectWithOutcome(channel: .stable) + } + + #expect(await connection.session.state() == .closed) + #expect(transport.cancelCount == 1) + #expect(opener.openCount == 1) + } + + @Test + func `orphaned retained control is closed before replacement resolution`() async throws { + let transport = FakeControlTransport.respondingNormally + let opener = FakeControlTransportOpener(transport: transport) + let connection = try await BrowserMCPDevToolsControlSession.connect( + BrowserMCPDevToolsControlSessionTests.webSocketURL, + expectedBrowserID: "browser-a", + deadline: ContinuousClock.now.advanced(by: .seconds(2)), + transportFactory: opener.factory) + let resolutionCount = GenerationBox(0) + let endpointResolver = BrowserMCPChannelEndpointResolver( + resolveInitial: { _, _ in + resolutionCount.set((resolutionCount.get() ?? 0) + 1) + throw MockBrowserError.probe + }, + revalidate: { _, _ in }) + let session = Self.session( + manager: MockBrowserMCPManager(), + browsers: [Self.browser(pid: 64, generation: 3064)], + channelEndpointResolver: endpointResolver) + session.connectionControlSession = connection.session + + await #expect(throws: DesktopActionFailure.self) { + _ = try await session.connectWithOutcome(channel: .stable) + } + + #expect(resolutionCount.get() == 0) + #expect(await connection.session.state() == .closed) + #expect(transport.cancelCount == 1) + } + + @Test + func `post probe signer drift closes retained native control`() async throws { + let transport = FakeControlTransport.respondingNormally + let opener = FakeControlTransportOpener(transport: transport) + let connection = try await BrowserMCPDevToolsControlSession.connect( + BrowserMCPDevToolsControlSessionTests.webSocketURL, + expectedBrowserID: "browser-a", + deadline: ContinuousClock.now.advanced(by: .seconds(2)), + transportFactory: opener.factory) + let endpointResolver = BrowserMCPChannelEndpointResolver( + resolveInitial: { _, _ in + BrowserMCPDevToolsEndpoint( + browserURL: "http://127.0.0.1:9222/", + webSocketDebuggerURL: BrowserMCPDevToolsControlSessionTests.webSocketURL.absoluteString, + browserID: "browser-a", + browserVersion: connection.version.browserVersion, + protocolVersion: connection.version.protocolVersion, + retainedControlSession: connection.session) + }, + revalidate: { _, _ in }) + let signatureChecks = GenerationBox(0) + let browser = Self.browser(pid: 63, generation: 3063) + let session = BrowserMCPSessionManager( + serverName: "test-browser", + manager: MockBrowserMCPManager(), + detectedBrowsers: { _ in [browser] }, + processStartIdentity: { _ in 3063 }, + processBundleIdentifier: { _ in "com.google.Chrome" }, + processCodeSignatureValidator: { _, _, channel in + let check = signatureChecks.get() ?? 0 + signatureChecks.set(check + 1) + return check == 0 ? .browserTestIdentity(channel: channel) : nil + }, + endpointResolver: Self.endpointResolver(), + channelEndpointResolver: endpointResolver, + environment: [:]) + + await #expect(throws: DesktopActionFailure.self) { + _ = try await session.connect(channel: .stable) + } + + #expect(await connection.session.state() == .closed) + #expect(transport.cancelCount == 1) + #expect(opener.openCount == 1) + } + @Test func `lost MCP child refuses without implicit reconnect`() async throws { let manager = MockBrowserMCPManager() @@ -1631,6 +1744,23 @@ extension BrowserMCPSessionManagerTests { #expect(receipt["browser_id"] == .string("browser-a")) } + @Test + func `generic execution cannot dispatch private tab target lookup`() async throws { + let provider = MockBrowserMCPManager() + let session = Self.exactSession(manager: provider) + _ = try await session.connect(channel: nil, browserURL: nil) + provider.executedTools.removeAll() + + await #expect(throws: BrowserMCPPrivateInteropError.publicDispatchRefused) { + _ = try await session.execute( + toolName: "get_tab_id", + arguments: ["pageId": 7], + channel: nil) + } + + #expect(provider.executedTools.isEmpty) + } + @Test func `action result service omits mutation outcome for failed read sequence`() async throws { let manager = MockBrowserMCPManager() @@ -2923,7 +3053,7 @@ extension BrowserMCPSessionManagerTests { } @MainActor -private final class MockBrowserMCPManager: BrowserMCPManaging { +final class MockBrowserMCPManager: BrowserMCPManaging { var connected = false var hasConfiguredServer = false var addedConfigs: [MCPServerConfig] = [] diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserNativeWindowBindingCoordinatorTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserNativeWindowBindingCoordinatorTests.swift new file mode 100644 index 000000000..e4afe9212 --- /dev/null +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserNativeWindowBindingCoordinatorTests.swift @@ -0,0 +1,577 @@ +import CoreGraphics +import Darwin +import Foundation +import MCP +import PeekabooAutomationKit +import TachikomaMCP +import Testing +@testable import PeekabooAgentRuntime + +@MainActor +struct BrowserNativeWindowBindingCoordinatorTests { + private static let nativeTarget = BrowserNativeWindowTarget( + processIdentifier: 4242, + processStartIdentity: 9001, + windowID: 313) + private static let nativeBounds = CGRect(x: -1200, y: 80, width: 1200, height: 800) + private static let detectedBrowser = DetectedBrowser( + name: "Google Chrome", + bundleIdentifier: "com.google.Chrome", + processIdentifier: nativeTarget.processIdentifier, + processStartIdentity: nativeTarget.processStartIdentity, + version: "151.0", + channel: .stable) + + @Test + func `Exact bind stores private ids only in caller capability and revalidates`() async throws { + let fixture = try await Self.fixture() + + let proof = try await BrowserNativeWindowBindingCoordinator.bind( + pageReference: fixture.pageReference, + nativeTarget: Self.nativeTarget, + context: fixture.context, + dependencies: Self.dependencies()) + + #expect(proof.pageReference == fixture.pageReference) + #expect(proof.nativeWindowReceipt.target == Self.nativeTarget) + #expect(proof.quality == .exact) + let binding = try await fixture.capabilities.nativeWindowBinding( + pageReference: fixture.pageReference, + sessionBinding: fixture.sessionBinding) + #expect(binding.privateTargetID == "target-a") + #expect(binding.privateBrowserWindowID == BrowserMCPDevToolsWindowID(rawValue: 41)) + try await BrowserNativeWindowBindingCoordinator.withRevalidatedMutation( + pageReference: fixture.pageReference, + context: fixture.context, + receiptProviders: Self.providers(), + mutation: {}) + #expect(fixture.opener.openCount == 1) + await fixture.control.close() + } + + @Test + func `Revalidated mutation holds provider and capability teardown gates through dispatch`() async throws { + let fixture = try await Self.fixture() + _ = try await BrowserNativeWindowBindingCoordinator.bind( + pageReference: fixture.pageReference, + nativeTarget: Self.nativeTarget, + context: fixture.context, + dependencies: Self.dependencies()) + let barrier = BindingMutationBarrier() + let mutationRan = LockedBoolean() + let mutation = Task { @MainActor in + try await BrowserNativeWindowBindingCoordinator.withRevalidatedMutation( + pageReference: fixture.pageReference, + context: fixture.context, + receiptProviders: Self.providers()) + { + await barrier.block() + mutationRan.value = true + } + } + await barrier.waitUntilBlocked() + let managerEnded = LockedBoolean() + let capabilityEnded = LockedBoolean() + let endManager = Task { @MainActor in + await fixture.manager.endSession() + managerEnded.value = true + } + let endCapabilities = Task { + await fixture.capabilities.end() + capabilityEnded.value = true + } + for _ in 0..<20 { + await Task.yield() + } + + #expect(!managerEnded.value) + #expect(!capabilityEnded.value) + await barrier.release() + try await mutation.value + await endManager.value + await endCapabilities.value + + #expect(mutationRan.value) + #expect(managerEnded.value) + #expect(capabilityEnded.value) + #expect(await fixture.control.state() == .closed) + } + + @Test + func `Moved tab refuses before mutation and clears the page binding`() async throws { + let moved = LockedBoolean() + let fixture = try await Self.fixture(windowID: { moved.value ? 42 : 41 }) + _ = try await BrowserNativeWindowBindingCoordinator.bind( + pageReference: fixture.pageReference, + nativeTarget: Self.nativeTarget, + context: fixture.context, + dependencies: Self.dependencies()) + moved.value = true + let mutationRan = LockedBoolean() + + await #expect(throws: BrowserNativeWindowBindingCoordinatorError.correlationRefused) { + try await BrowserNativeWindowBindingCoordinator.withRevalidatedMutation( + pageReference: fixture.pageReference, + context: fixture.context, + receiptProviders: Self.providers(), + mutation: { mutationRan.value = true }) + } + #expect(!mutationRan.value) + await #expect(throws: BrowserToolNativeWindowBindingError.stalePageReference) { + _ = try await fixture.capabilities.nativeWindowBinding( + pageReference: fixture.pageReference, + sessionBinding: fixture.sessionBinding) + } + #expect(fixture.opener.openCount == 1) + await fixture.control.close() + } + + @Test + func `Tab move after correlation refuses at the final authorization boundary`() async throws { + let windowLookups = LockedInteger() + let fixture = try await Self.fixture(windowID: { + windowLookups.increment() + return windowLookups.value == 6 ? 42 : 41 + }) + _ = try await BrowserNativeWindowBindingCoordinator.bind( + pageReference: fixture.pageReference, + nativeTarget: Self.nativeTarget, + context: fixture.context, + dependencies: Self.dependencies()) + let mutationRan = LockedBoolean() + + await #expect(throws: BrowserNativeWindowBindingCoordinatorError.correlationRefused) { + try await BrowserNativeWindowBindingCoordinator.withRevalidatedMutation( + pageReference: fixture.pageReference, + context: fixture.context, + receiptProviders: Self.providers(), + mutation: { mutationRan.value = true }) + } + + #expect(!mutationRan.value) + #expect(windowLookups.value == 6) + await fixture.control.close() + } + + @Test + func `Expired bind deadline refuses before private provider lookup`() async throws { + let fixture = try await Self.fixture() + let expiredContext = BrowserNativeWindowBindingCoordinator.Context( + sessionBinding: fixture.sessionBinding, + capabilities: fixture.capabilities, + manager: fixture.manager, + deadline: ContinuousClock.now.advanced(by: .milliseconds(-1))) + + await #expect(throws: BrowserNativeWindowBindingCoordinatorError.deadlineExceeded) { + _ = try await BrowserNativeWindowBindingCoordinator.bind( + pageReference: fixture.pageReference, + nativeTarget: Self.nativeTarget, + context: expiredContext, + dependencies: Self.dependencies()) + } + + #expect(fixture.provider.executedTools == ["list_pages"]) + await fixture.control.close() + } + + @Test + func `Invalid native receipt keeps its exact bind error classification`() async throws { + let fixture = try await Self.fixture() + let invalidProviders = BrowserNativeWindowReceiptResolver.Providers( + processStartIdentity: { _ in nil }, + windowIdentity: { _ in nil }, + windowMutationIdentity: { _ in nil }, + validateWindowMutationIdentity: { _ in false }) + + await #expect(throws: BrowserNativeWindowBindingCoordinatorError.invalidNativeWindow) { + _ = try await BrowserNativeWindowBindingCoordinator.bind( + pageReference: fixture.pageReference, + nativeTarget: Self.nativeTarget, + context: fixture.context, + dependencies: .init(receiptProviders: invalidProviders)) + } + + #expect(fixture.provider.executedTools.suffix(1) == ["get_tab_id"]) + await fixture.control.close() + } + + @Test + func `Stalled private provider lookup is cancelled at the bind deadline`() async throws { + let fixture = try await Self.fixture() + let firstProof = try await BrowserNativeWindowBindingCoordinator.bind( + pageReference: fixture.pageReference, + nativeTarget: Self.nativeTarget, + context: fixture.context, + dependencies: Self.dependencies()) + let listed = try await fixture.capabilities.project( + Self.twoPageResponse, + calls: [BrowserMCPMappedCall(toolName: "list_pages", arguments: [:])], + resolved: nil, + sessionBinding: fixture.sessionBinding) + let pageReferences = try #require(listed.structuredContent?.objectValue?["pages"]?.arrayValue) + .compactMap { $0.objectValue?["id"]?.stringValue } + let secondPageReference = try #require(pageReferences.first { $0 != fixture.pageReference }) + try await fixture.capabilities.bindNativeWindow( + pageReference: secondPageReference, + sessionBinding: fixture.sessionBinding, + privateTargetID: "target-b", + privateBrowserWindowID: .init(rawValue: 41), + nativeWindowReceipt: firstProof.nativeWindowReceipt) + fixture.provider.executeHandler = { toolName, _ in + guard toolName == "get_tab_id" else { return Self.pageResponse } + try await Task.sleep(for: .seconds(30)) + return ToolResponse.error("unreachable") + } + let deadlineContext = BrowserNativeWindowBindingCoordinator.Context( + sessionBinding: fixture.sessionBinding, + capabilities: fixture.capabilities, + manager: fixture.manager, + deadline: ContinuousClock.now.advanced(by: .milliseconds(40))) + let startedAt = ContinuousClock.now + + await #expect(throws: BrowserNativeWindowBindingCoordinatorError.deadlineExceeded) { + _ = try await BrowserNativeWindowBindingCoordinator.bind( + pageReference: fixture.pageReference, + nativeTarget: Self.nativeTarget, + context: deadlineContext, + dependencies: Self.dependencies()) + } + + #expect(startedAt.duration(to: .now) < .seconds(1)) + #expect(fixture.provider.executedTools.suffix(1) == ["get_tab_id"]) + #expect(await fixture.control.state() == .closed) + await #expect(throws: BrowserToolNativeWindowBindingError.stalePageReference) { + _ = try await fixture.capabilities.nativeWindowBinding( + pageReference: secondPageReference, + sessionBinding: fixture.sessionBinding) + } + } + + @Test + func `Native control timeout remains deadline exceeded and skips mutation`() async throws { + let stallTargets = LockedBoolean() + let fixture = try await Self.fixture(stallTargets: stallTargets) + _ = try await BrowserNativeWindowBindingCoordinator.bind( + pageReference: fixture.pageReference, + nativeTarget: Self.nativeTarget, + context: fixture.context, + dependencies: Self.dependencies()) + stallTargets.value = true + let deadlineContext = BrowserNativeWindowBindingCoordinator.Context( + sessionBinding: fixture.sessionBinding, + capabilities: fixture.capabilities, + manager: fixture.manager, + deadline: ContinuousClock.now.advanced(by: .milliseconds(40))) + let mutationRan = LockedBoolean() + + await #expect(throws: BrowserNativeWindowBindingCoordinatorError.deadlineExceeded) { + try await BrowserNativeWindowBindingCoordinator.withRevalidatedMutation( + pageReference: fixture.pageReference, + context: deadlineContext, + receiptProviders: Self.providers(), + mutation: { mutationRan.value = true }) + } + + #expect(!mutationRan.value) + #expect(await fixture.control.state() == .failed(.timedOut(method: "Target.getTargets"))) + await #expect(throws: BrowserToolNativeWindowBindingError.stalePageReference) { + _ = try await fixture.capabilities.nativeWindowBinding( + pageReference: fixture.pageReference, + sessionBinding: fixture.sessionBinding) + } + } + + @Test + func `Control death invalidates every private page binding without reopen`() async throws { + let fixture = try await Self.fixture() + _ = try await BrowserNativeWindowBindingCoordinator.bind( + pageReference: fixture.pageReference, + nativeTarget: Self.nativeTarget, + context: fixture.context, + dependencies: Self.dependencies()) + fixture.transport.inject(.failure(FakeControlTransportError.connectionLost)) + try await Self.waitForControlDeath(fixture.control) + let mutationRan = LockedBoolean() + + await #expect(throws: BrowserNativeWindowBindingCoordinatorError.controlUnavailable) { + try await BrowserNativeWindowBindingCoordinator.withRevalidatedMutation( + pageReference: fixture.pageReference, + context: fixture.context, + receiptProviders: Self.providers(), + mutation: { mutationRan.value = true }) + } + #expect(!mutationRan.value) + await #expect(throws: BrowserToolNativeWindowBindingError.stalePageReference) { + _ = try await fixture.capabilities.nativeWindowBinding( + pageReference: fixture.pageReference, + sessionBinding: fixture.sessionBinding) + } + #expect(fixture.opener.openCount == 1) + } + + private struct Fixture { + let capabilities: BrowserToolCapabilitySession + let pageReference: String + let sessionBinding: BrowserMCPExecutionSessionBinding + let manager: BrowserMCPSessionManager + let provider: MockBrowserMCPManager + let control: BrowserMCPDevToolsControlSession + let transport: FakeControlTransport + let opener: FakeControlTransportOpener + + var context: BrowserNativeWindowBindingCoordinator.Context { + .init( + sessionBinding: self.sessionBinding, + capabilities: self.capabilities, + manager: self.manager, + deadline: ContinuousClock.now.advanced(by: .seconds(2))) + } + } + + private static func fixture( + windowID: @escaping @Sendable () -> Int = { 41 }, + stallTargets: LockedBoolean? = nil) async throws -> Fixture + { + let nativeBounds = self.nativeBounds + let transport = FakeControlTransport { command in + let request = try BrowserMCPDevToolsControlSessionTests.decodeCommand(command) + switch request.method { + case "Browser.getVersion": + return [.success(BrowserMCPDevToolsControlSessionTests.response( + id: request.id, + result: ["product": "Chrome/151.0", "protocolVersion": "1.3"]))] + case "Target.getTargets": + if stallTargets?.value == true { + return [] + } + return [.success(BrowserMCPDevToolsControlSessionTests.response( + id: request.id, + result: ["targetInfos": [[ + "targetId": "page-a", + "type": "page", + "title": "Example", + "url": "https://example.test/", + ]]]))] + case "Browser.getWindowForTarget": + return [.success(BrowserMCPDevToolsControlSessionTests.response( + id: request.id, + result: ["windowId": windowID()]))] + case "Browser.getWindowBounds": + return [.success(BrowserMCPDevToolsControlSessionTests.response( + id: request.id, + result: ["bounds": [ + "left": Int(nativeBounds.origin.x), + "top": Int(nativeBounds.origin.y), + "width": Int(nativeBounds.width), + "height": Int(nativeBounds.height), + "windowState": "normal", + ]]))] + default: + Issue.record("Unexpected CDP method \(request.method)") + return [] + } + } + let opener = FakeControlTransportOpener(transport: transport) + let connection = try await BrowserMCPDevToolsControlSession.connect( + BrowserMCPDevToolsControlSessionTests.webSocketURL, + expectedBrowserID: "browser-a", + deadline: Self.deadline, + transportFactory: opener.factory) + let provider = MockBrowserMCPManager() + provider.executeHandler = { toolName, arguments in + switch toolName { + case "list_pages": + return Self.pageResponse + case "get_tab_id": + #expect(arguments["pageId"] as? Int == 7) + return ToolResponse( + content: [.text(text: "private target", annotations: nil, _meta: nil)], + structuredContent: .object(["tabId": .string("target-a")])) + default: + return ToolResponse.error("unexpected tool") + } + } + let endpointResolver = BrowserMCPChannelEndpointResolver( + resolveInitial: { _, _ in + BrowserMCPDevToolsEndpoint( + browserURL: "http://127.0.0.1:9222/", + webSocketDebuggerURL: BrowserMCPDevToolsControlSessionTests.webSocketURL.absoluteString, + browserID: "browser-a", + browserVersion: connection.version.browserVersion, + protocolVersion: connection.version.protocolVersion, + retainedControlSession: connection.session) + }, + revalidate: { _, _ in }) + let detectedBrowser = Self.detectedBrowser + let processStartIdentity = Self.nativeTarget.processStartIdentity + let manager = BrowserMCPSessionManager( + serverName: "native-window-binding-test", + manager: provider, + detectedBrowsers: { _ in [detectedBrowser] }, + processStartIdentity: { _ in processStartIdentity }, + processBundleIdentifier: { _ in "com.google.Chrome" }, + processCodeSignatureValidator: { _, _, channel in .browserTestIdentity(channel: channel) }, + channelEndpointResolver: endpointResolver, + environment: [:]) + let connected = try await manager.connect(channel: .stable) + let sessionBinding = try BrowserMCPExecutionSessionBinding( + connectionReceipt: #require(connected.connectionReceipt), + providerSessionEpoch: #require(connected.providerSessionEpoch)) + #expect(sessionBinding.connectionReceipt == Self.connectionReceipt) + let capabilities = BrowserToolCapabilitySession() + let listed = try await capabilities.project( + Self.pageResponse, + calls: [BrowserMCPMappedCall(toolName: "list_pages", arguments: [:])], + resolved: nil, + sessionBinding: sessionBinding) + let pageReference = try #require( + listed.structuredContent?.objectValue?["pages"]?.arrayValue?.first?.objectValue?["id"]?.stringValue) + return Fixture( + capabilities: capabilities, + pageReference: pageReference, + sessionBinding: sessionBinding, + manager: manager, + provider: provider, + control: connection.session, + transport: transport, + opener: opener) + } + + private static func dependencies() -> BrowserNativeWindowBindingCoordinator.Dependencies { + BrowserNativeWindowBindingCoordinator.Dependencies(receiptProviders: self.providers()) + } + + private static func providers() -> BrowserNativeWindowReceiptResolver.Providers { + let nativeTarget = self.nativeTarget + let nativeBounds = self.nativeBounds + return BrowserNativeWindowReceiptResolver.Providers( + processStartIdentity: { _ in nativeTarget.processStartIdentity }, + windowIdentity: { windowID in + SystemWindowIdentity( + windowID: windowID, + ownerProcessIdentifier: nativeTarget.processIdentifier, + ownerProcessStartIdentity: nativeTarget.processStartIdentity, + title: "Example", + bounds: nativeBounds, + layer: 0, + alpha: 1, + isOnScreen: true, + sharingState: .readOnly) + }, + windowMutationIdentity: { _ in + WindowMutationIdentity( + windowID: Int(nativeTarget.windowID), + ownerProcessIdentifier: nativeTarget.processIdentifier, + ownerProcessStartIdentity: nativeTarget.processStartIdentity, + capturedBounds: nativeBounds, + isMinimized: false) + }, + validateWindowMutationIdentity: { _ in true }) + } + + private static func waitForControlDeath(_ control: BrowserMCPDevToolsControlSession) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(1)) + while case .open = await control.state() { + guard ContinuousClock.now < deadline else { throw CancellationError() } + await Task.yield() + } + } + + private static var deadline: ContinuousClock.Instant { + ContinuousClock.now.advanced(by: .seconds(2)) + } + + private static let connectionReceipt = BrowserMCPConnectionReceipt( + channel: .stable, + processIdentifier: nativeTarget.processIdentifier, + processStartIdentity: nativeTarget.processStartIdentity, + bundleIdentifier: "com.google.Chrome", + browserURL: "http://127.0.0.1:9222/", + webSocketDebuggerURL: "ws://127.0.0.1:9222/devtools/browser/browser-a", + devToolsBrowserID: "browser-a", + browserVersion: "Chrome/151.0", + protocolVersion: "1.3") + private static let pageResponse = ToolResponse( + content: [.text( + text: "## Pages\n7: Example (https://example.test/) [selected]", + annotations: nil, + _meta: nil)], + structuredContent: .object(["pages": .array([.object([ + "id": .int(7), + "url": .string("https://example.test/"), + "title": .string("Example"), + "selected": .bool(true), + ])])])) + private static let twoPageResponse = ToolResponse( + content: [.text( + text: "## Pages\n7: Example (https://example.test/) [selected]\n8: Other (https://other.test/)", + annotations: nil, + _meta: nil)], + structuredContent: .object(["pages": .array([ + .object([ + "id": .int(7), + "url": .string("https://example.test/"), + "title": .string("Example"), + "selected": .bool(true), + ]), + .object([ + "id": .int(8), + "url": .string("https://other.test/"), + "title": .string("Other"), + "selected": .bool(false), + ]), + ])])) +} + +private final class LockedBoolean: @unchecked Sendable { + private let lock = NSLock() + private var storage = false + + var value: Bool { + get { self.lock.withLock { self.storage } } + set { self.lock.withLock { self.storage = newValue } } + } +} + +private final class LockedInteger: @unchecked Sendable { + private let lock = NSLock() + private var integer = 0 + + var value: Int { + self.lock.withLock { self.integer } + } + + func increment() { + self.lock.withLock { self.integer += 1 } + } +} + +private actor BindingMutationBarrier { + private var blocked = false + private var released = false + private var blockedWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func block() async { + self.blocked = true + self.blockedWaiters.forEach { $0.resume() } + self.blockedWaiters.removeAll() + guard !self.released else { return } + await withCheckedContinuation { continuation in + self.releaseWaiters.append(continuation) + } + } + + func waitUntilBlocked() async { + guard !self.blocked else { return } + await withCheckedContinuation { continuation in + self.blockedWaiters.append(continuation) + } + } + + func release() { + self.released = true + self.releaseWaiters.forEach { $0.resume() } + self.releaseWaiters.removeAll() + } +} diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserToolCapabilitySessionTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserToolCapabilitySessionTests.swift index fb71a41df..a3b1ca18c 100644 --- a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserToolCapabilitySessionTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserToolCapabilitySessionTests.swift @@ -1,4 +1,6 @@ +import Foundation import MCP +import PeekabooAutomationKit import TachikomaMCP import Testing @testable import PeekabooAgentRuntime @@ -308,6 +310,111 @@ struct BrowserToolCapabilitySessionTests { } } + @Test + func `native window binding stays private and caller scoped until control death`() async throws { + let session = BrowserToolCapabilitySession() + let receipt = Self.nativeReceipt() + let sessionBinding = Self.binding(receipt) + let listed = try await session.project( + Self.pageResponse(id: 23, url: "https://example.test/"), + calls: Self.calls("list_pages"), + resolved: nil, + sessionBinding: sessionBinding) + let pageReference = try Self.pageReference(from: listed) + let windowIdentity = WindowMutationIdentity( + windowID: 900, + ownerProcessIdentifier: 123, + ownerProcessStartIdentity: 456, + capturedBounds: CGRect(x: -1200, y: 80, width: 1000, height: 700)) + let nativeWindowReceipt = try BrowserNativeWindowReceipt( + target: BrowserNativeWindowTarget( + processIdentifier: 123, + processStartIdentity: 456, + windowID: 900), + windowIdentity: windowIdentity, + bounds: #require(windowIdentity.capturedBounds)) + + try await session.bindNativeWindow( + pageReference: pageReference, + sessionBinding: sessionBinding, + privateTargetID: "private-target-a", + privateBrowserWindowID: BrowserMCPDevToolsWindowID(rawValue: 77), + nativeWindowReceipt: nativeWindowReceipt) + + let binding = try await session.nativeWindowBinding( + pageReference: pageReference, + sessionBinding: sessionBinding) + #expect(binding == BrowserToolNativeWindowBinding( + privateTargetID: "private-target-a", + privateBrowserWindowID: BrowserMCPDevToolsWindowID(rawValue: 77), + nativeWindowReceipt: nativeWindowReceipt)) + #expect(!Self.text(from: listed).contains("private-target-a")) + #expect(listed.structuredContent?.objectValue?["pages"]?.arrayValue?.first?.objectValue?["id"] == + .string(pageReference)) + + await session.invalidateNativeWindowBindings() + await #expect(throws: BrowserToolNativeWindowBindingError.stalePageReference) { + _ = try await session.nativeWindowBinding( + pageReference: pageReference, + sessionBinding: sessionBinding) + } + } + + @Test + func `native window binding rejects process or caller session substitution`() async throws { + let session = BrowserToolCapabilitySession() + let receipt = Self.nativeReceipt() + let sessionBinding = Self.binding(receipt) + let listed = try await session.project( + Self.pageResponse(id: 24, url: "https://example.test/"), + calls: Self.calls("list_pages"), + resolved: nil, + sessionBinding: sessionBinding) + let pageReference = try Self.pageReference(from: listed) + let wrongProcess = WindowMutationIdentity( + windowID: 901, + ownerProcessIdentifier: 124, + ownerProcessStartIdentity: 456, + capturedBounds: CGRect(x: 0, y: 0, width: 800, height: 600)) + let wrongProcessReceipt = try BrowserNativeWindowReceipt( + target: BrowserNativeWindowTarget( + processIdentifier: 124, + processStartIdentity: 456, + windowID: 901), + windowIdentity: wrongProcess, + bounds: #require(wrongProcess.capturedBounds)) + + await #expect(throws: BrowserToolNativeWindowBindingError.processMismatch) { + try await session.bindNativeWindow( + pageReference: pageReference, + sessionBinding: sessionBinding, + privateTargetID: "private-target-b", + privateBrowserWindowID: BrowserMCPDevToolsWindowID(rawValue: 78), + nativeWindowReceipt: wrongProcessReceipt) + } + let otherSession = BrowserMCPExecutionSessionBinding( + connectionReceipt: receipt, + providerSessionEpoch: BrowserMCPProviderSessionEpoch()) + await #expect(throws: BrowserToolNativeWindowBindingError.connectionMismatch) { + try await session.bindNativeWindow( + pageReference: pageReference, + sessionBinding: otherSession, + privateTargetID: "private-target-b", + privateBrowserWindowID: BrowserMCPDevToolsWindowID(rawValue: 78), + nativeWindowReceipt: BrowserNativeWindowReceipt( + target: BrowserNativeWindowTarget( + processIdentifier: 123, + processStartIdentity: 456, + windowID: 901), + windowIdentity: WindowMutationIdentity( + windowID: 901, + ownerProcessIdentifier: 123, + ownerProcessStartIdentity: 456, + capturedBounds: CGRect(x: 0, y: 0, width: 800, height: 600)), + bounds: CGRect(x: 0, y: 0, width: 800, height: 600))) + } + } + private static func receipt() -> BrowserMCPConnectionReceipt { BrowserMCPConnectionReceipt( browserURL: "http://127.0.0.1:9222/", @@ -317,6 +424,19 @@ struct BrowserToolCapabilitySessionTests { protocolVersion: "1.3") } + private static func nativeReceipt() -> BrowserMCPConnectionReceipt { + BrowserMCPConnectionReceipt( + channel: .stable, + processIdentifier: 123, + processStartIdentity: 456, + bundleIdentifier: "com.google.Chrome", + browserURL: "http://127.0.0.1:9222/", + webSocketDebuggerURL: "ws://127.0.0.1:9222/devtools/browser/browser-a", + devToolsBrowserID: "browser-a", + browserVersion: "Chrome/151.0", + protocolVersion: "1.3") + } + private static func binding(_ receipt: BrowserMCPConnectionReceipt) -> BrowserMCPExecutionSessionBinding { .init( connectionReceipt: receipt, @@ -363,6 +483,13 @@ struct BrowserToolCapabilitySessionTests { return try #require(page["id"]?.stringValue) } + private static func text(from response: ToolResponse) -> String { + response.content.compactMap { content in + guard case let .text(text, _, _) = content else { return nil } + return text + }.joined(separator: "\n") + } + private static func elementReference(from response: ToolResponse) throws -> String { let root = try #require(response.structuredContent?.objectValue) let snapshot = try #require(root["snapshot"]?.objectValue) diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserToolTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserToolTests.swift index d649e23d7..e0ffae51e 100644 --- a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserToolTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserToolTests.swift @@ -697,33 +697,22 @@ struct BrowserToolTests { } @Test - func `Audited browser routing contract partitions pinned tool catalog`() { - #expect(BrowserMCPPageRoutingContract.dependencyVersion == "1.6.0") - #expect(BrowserMCPPageRoutingContract.pageScopedToolNames.count == 32) - #expect(BrowserMCPPageRoutingContract.explicitPageTargetToolNames.count == 3) - #expect(BrowserMCPPageRoutingContract.globalToolNames.count == 16) - #expect(BrowserMCPPageRoutingContract.blockedSelectedPageToolNames == ["trigger_extension_action"]) - #expect(BrowserMCPPageRoutingContract.allToolNames.count == 52) - #expect(BrowserMCPPageRoutingContract.pageTargetedToolNames.isDisjoint( - with: BrowserMCPPageRoutingContract.globalToolNames)) - #expect(BrowserMCPPageRoutingContract.pageTargetedToolNames.isDisjoint( - with: BrowserMCPPageRoutingContract.blockedSelectedPageToolNames)) - #expect(BrowserMCPPageRoutingContract.globalToolNames.isDisjoint( - with: BrowserMCPPageRoutingContract.blockedSelectedPageToolNames)) - #expect(BrowserMCPPageRoutingContract.routing(for: "trigger_extension_action") == .blockedSelectedPage) - #expect(BrowserMCPPageRoutingContract.readOnlyToolNames.count == 27) - #expect(BrowserMCPPageRoutingContract.mutatingToolNames.count == 23) - #expect(BrowserMCPPageRoutingContract.argumentDependentToolNames == [ - "performance_start_trace", - "select_page", - ]) - #expect(BrowserMCPPageRoutingContract.allSemanticToolNames == BrowserMCPPageRoutingContract.allToolNames) - #expect(BrowserMCPPageRoutingContract.readOnlyToolNames.isDisjoint( - with: BrowserMCPPageRoutingContract.mutatingToolNames)) - #expect(BrowserMCPPageRoutingContract.readOnlyToolNames.isDisjoint( - with: BrowserMCPPageRoutingContract.argumentDependentToolNames)) - #expect(BrowserMCPPageRoutingContract.mutatingToolNames.isDisjoint( - with: BrowserMCPPageRoutingContract.argumentDependentToolNames)) + func `Browser raw call cannot expose private tab target identity`() async throws { + let client = MockBrowserMCPClient(status: BrowserMCPStatus( + isConnected: true, + toolCount: 31, + detectedBrowsers: [])) + let tool = BrowserTool(client: client, executionPolicy: .unrestricted) + + let response = try await tool.execute(arguments: ToolArguments(raw: [ + "action": "call", + "mcp_tool": "get_tab_id", + "page_id": 12, + ])) + + #expect(response.isError == true) + #expect(Self.text(from: response).contains("Unsupported raw Chrome DevTools MCP tool")) + #expect(client.executedTools.isEmpty) } @Test diff --git a/docs/browser-mcp.md b/docs/browser-mcp.md index 78ef8e29b..b5f616948 100644 --- a/docs/browser-mcp.md +++ b/docs/browser-mcp.md @@ -54,6 +54,7 @@ npx -y chrome-devtools-mcp@1.6.0 \ --wsEndpoint=ws://127.0.0.1:/devtools/browser/ \ --experimentalPageIdRouting \ --experimentalStructuredContent \ + --experimentalInteropTools \ --no-usage-statistics \ --no-performance-crux ``` @@ -62,6 +63,10 @@ Peekaboo pins the verified Chrome DevTools MCP version because direct page-ID ro used to mint opaque page/element capabilities are experimental upstream contracts. Upgrade the pin only after its page-scoped schemas, structured response surfaces, and routing behavior have been revalidated. +The interop flag is enabled only so Peekaboo can privately map an opaque page capability to its CDP target during +exact native-window binding. The provider's `get_tab_id` tool is excluded from raw public routing, and raw CDP target +or browser-window IDs are never returned to callers. + For deterministic local tests or custom Chrome endpoints: - `PEEKABOO_BROWSER_MCP_ISOLATED=1` lets Chrome DevTools MCP launch a temporary Chrome profile. diff --git a/scripts/test-chrome-devtools-mcp-contract.mjs b/scripts/test-chrome-devtools-mcp-contract.mjs index fd6076ed3..ceb504a83 100644 --- a/scripts/test-chrome-devtools-mcp-contract.mjs +++ b/scripts/test-chrome-devtools-mcp-contract.mjs @@ -31,6 +31,7 @@ function namesInContractSection(name, source = routingContract) { const swiftVersion = routingContract.match(/dependencyVersion\s*=\s*"([^"]+)"/)?.[1]; const expectedPageScopedNames = namesInContractSection("page-scoped"); +const expectedInternalOnlyNames = namesInContractSection("internal-only"); const expectedExplicitPageTargetNames = namesInContractSection("explicit-page-target"); const expectedGlobalNames = namesInContractSection("global"); const expectedBlockedSelectedPageNames = namesInContractSection("blocked-selected-page"); @@ -55,6 +56,7 @@ const { createTools } = await import( const serverArgs = { experimentalPageIdRouting: true, experimentalStructuredContent: true, + experimentalInteropTools: true, slim: false, viaCli: false, }; @@ -168,6 +170,10 @@ const issueFormatterSource = readFileSync( new URL("../node_modules/chrome-devtools-mcp/build/src/formatters/IssueFormatter.js", import.meta.url), "utf8", ); +const pageToolsSource = readFileSync( + new URL("../node_modules/chrome-devtools-mcp/build/src/tools/pages.js", import.meta.url), + "utf8", +); assert.match(mcpPageSource, /Object\.values\(params\)/, "third-party parameter traversal changed"); assert.match(mcpPageSource, /Object\.keys\(value\)\.length === 1/, "third-party singleton UID rule changed"); assert.match( @@ -244,6 +250,21 @@ assert.match( /bodyParts\.push\('### Affected resources'\)/, "provider issue affected-resource section changed", ); +assert.match( + pageToolsSource, + /name: 'get_tab_id'[\s\S]*conditions: \['experimentalInteropTools'\]/, + "private tab-target lookup is no longer guarded by the interop flag", +); +assert.match( + pageToolsSource, + /const tabId = page\.pptrPage\._tabId;[\s\S]*response\.setTabId\(tabId\)/, + "private tab-target lookup no longer returns Puppeteer's target identity", +); +assert.match( + mcpResponseSource, + /structuredContent\.tabId = this\.#tabId/, + "private tab-target identity is no longer available in structured content", +); assert.match( issueFormatterSource, /details\.push\(`uid=\$\{item\.uid\}`\)/, @@ -261,13 +282,19 @@ const explicitPageTargetNames = pageTargetedNames.filter((name) => !pageScopedNa const globalNames = tools.map((tool) => tool.name).filter((name) => !pageTargetedNames.includes(name)).sort(); const auditedNames = [ ...expectedPageScopedNames, + ...expectedInternalOnlyNames, ...expectedExplicitPageTargetNames, ...expectedGlobalNames, ...expectedBlockedSelectedPageNames, ].sort(); assert.equal(pageScopedTools.length, 32, "the pinned dependency page-scoped contract changed"); -assert.deepEqual(pageScopedNames, expectedPageScopedNames, "Swift page-scoped raw-tool catalog drifted"); +assert.deepEqual( + pageScopedNames, + [...expectedPageScopedNames, ...expectedInternalOnlyNames].sort(), + "Swift public/internal page-scoped catalogs drifted from the provider", +); +assert.deepEqual(expectedInternalOnlyNames, ["get_tab_id"], "re-audit private provider tools before exposure"); assert.deepEqual( explicitPageTargetNames, expectedExplicitPageTargetNames, From 6b5fb651edcc94c38235ee1606018040105e94f9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 13:45:35 -0700 Subject: [PATCH 05/14] fix(browser): bind mutations to revalidated windows --- ...rowserNativeWindowBindingCoordinator.swift | 29 +++++-- ...rNativeWindowBindingCoordinatorTests.swift | 83 ++++++++++++++++--- 2 files changed, 91 insertions(+), 21 deletions(-) diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserNativeWindowBindingCoordinator.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserNativeWindowBindingCoordinator.swift index 00a0feb1b..fc41fe67a 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserNativeWindowBindingCoordinator.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserNativeWindowBindingCoordinator.swift @@ -122,15 +122,16 @@ enum BrowserNativeWindowBindingCoordinator { pageReference: String, context: Context, receiptProviders: BrowserNativeWindowReceiptResolver.Providers, - mutation: @MainActor @Sendable () async throws -> Result) async throws -> Result + mutation: @MainActor @Sendable (BrowserNativeWindowReceipt) async throws -> Result) async throws -> Result { try await context.capabilities.withExclusiveOperation { do { return try await context.manager.withNativeBindingExecutionGate( expectedSessionBinding: context.sessionBinding) { control in + let revalidatedReceipt: BrowserNativeWindowReceipt do { - try await self.revalidateUnderAuthority( + revalidatedReceipt = try await self.revalidateUnderAuthority( pageReference: pageReference, context: context, control: control, @@ -144,7 +145,7 @@ enum BrowserNativeWindowBindingCoordinator { deadline: context.deadline) } try self.requireAuthorizationDeadline(context) - return try await mutation() + return try await mutation(revalidatedReceipt) } } catch is CancellationError { throw CancellationError() @@ -213,6 +214,7 @@ enum BrowserNativeWindowBindingCoordinator { context: Context, control: BrowserMCPDevToolsControlSession, receiptProviders: BrowserNativeWindowReceiptResolver.Providers) async throws + -> BrowserNativeWindowReceipt { let binding = try await context.capabilities.nativeWindowBinding( pageReference: pageReference, @@ -233,7 +235,7 @@ enum BrowserNativeWindowBindingCoordinator { let finalWindowID = try await control.getWindowForTarget( targetID: binding.privateTargetID, deadline: context.deadline) - _ = try BrowserNativeWindowReceiptResolver.revalidate( + let finalReceipt = try BrowserNativeWindowReceiptResolver.revalidate( currentReceipt, providers: receiptProviders).get() guard correlation.browserWindowID == binding.privateBrowserWindowID, @@ -242,6 +244,7 @@ enum BrowserNativeWindowBindingCoordinator { else { throw BrowserNativeWindowBindingCoordinatorError.correlationRefused } + return finalReceipt } @MainActor @@ -333,9 +336,14 @@ enum BrowserNativeWindowBindingCoordinator { var targetIDsByWindow: [BrowserMCPDevToolsWindowID: Set] = [:] var titlesByWindow: [BrowserMCPDevToolsWindowID: Set] = [:] for target in targets { - let windowID = try await control.getWindowForTarget( - targetID: target.targetID, - deadline: deadline) + let windowID: BrowserMCPDevToolsWindowID + do { + windowID = try await control.getWindowForTarget( + targetID: target.targetID, + deadline: deadline) + } catch BrowserMCPDevToolsControlError.staleTarget where target.targetID != requestedTargetID { + continue + } targetIDsByWindow[windowID, default: []].insert(target.targetID) if !target.title.isEmpty { titlesByWindow[windowID, default: []].insert(target.title) @@ -351,7 +359,12 @@ enum BrowserNativeWindowBindingCoordinator { var candidates: [CDPBrowserWindowCandidate] = [] for windowID in targetIDsByWindow.keys.sorted(by: { $0.rawValue < $1.rawValue }) { - let bounds = try await control.getWindowBounds(windowID: windowID, deadline: deadline) + let bounds: BrowserMCPDevToolsWindowBounds + do { + bounds = try await control.getWindowBounds(windowID: windowID, deadline: deadline) + } catch BrowserMCPDevToolsControlError.staleWindow where windowID != requestedWindowID { + continue + } guard let left = bounds.left, let top = bounds.top, let width = bounds.width, diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserNativeWindowBindingCoordinatorTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserNativeWindowBindingCoordinatorTests.swift index e4afe9212..466aabe04 100644 --- a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserNativeWindowBindingCoordinatorTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserNativeWindowBindingCoordinatorTests.swift @@ -44,11 +44,34 @@ struct BrowserNativeWindowBindingCoordinatorTests { pageReference: fixture.pageReference, context: fixture.context, receiptProviders: Self.providers(), - mutation: {}) + mutation: { receipt in + #expect(receipt == proof.nativeWindowReceipt) + }) #expect(fixture.opener.openCount == 1) await fixture.control.close() } + @Test + func `Unrelated stale page and window candidates do not revoke exact binding`() async throws { + let fixture = try await Self.fixture(includeStaleUnrelatedCandidates: true) + let proof = try await BrowserNativeWindowBindingCoordinator.bind( + pageReference: fixture.pageReference, + nativeTarget: Self.nativeTarget, + context: fixture.context, + dependencies: Self.dependencies()) + + try await BrowserNativeWindowBindingCoordinator.withRevalidatedMutation( + pageReference: fixture.pageReference, + context: fixture.context, + receiptProviders: Self.providers(), + mutation: { receipt in + #expect(receipt == proof.nativeWindowReceipt) + }) + + #expect(await fixture.control.state() == .open) + await fixture.control.close() + } + @Test func `Revalidated mutation holds provider and capability teardown gates through dispatch`() async throws { let fixture = try await Self.fixture() @@ -64,7 +87,7 @@ struct BrowserNativeWindowBindingCoordinatorTests { pageReference: fixture.pageReference, context: fixture.context, receiptProviders: Self.providers()) - { + { _ in await barrier.block() mutationRan.value = true } @@ -114,7 +137,7 @@ struct BrowserNativeWindowBindingCoordinatorTests { pageReference: fixture.pageReference, context: fixture.context, receiptProviders: Self.providers(), - mutation: { mutationRan.value = true }) + mutation: { _ in mutationRan.value = true }) } #expect(!mutationRan.value) await #expect(throws: BrowserToolNativeWindowBindingError.stalePageReference) { @@ -145,7 +168,7 @@ struct BrowserNativeWindowBindingCoordinatorTests { pageReference: fixture.pageReference, context: fixture.context, receiptProviders: Self.providers(), - mutation: { mutationRan.value = true }) + mutation: { _ in mutationRan.value = true }) } #expect(!mutationRan.value) @@ -269,7 +292,7 @@ struct BrowserNativeWindowBindingCoordinatorTests { pageReference: fixture.pageReference, context: deadlineContext, receiptProviders: Self.providers(), - mutation: { mutationRan.value = true }) + mutation: { _ in mutationRan.value = true }) } #expect(!mutationRan.value) @@ -298,7 +321,7 @@ struct BrowserNativeWindowBindingCoordinatorTests { pageReference: fixture.pageReference, context: fixture.context, receiptProviders: Self.providers(), - mutation: { mutationRan.value = true }) + mutation: { _ in mutationRan.value = true }) } #expect(!mutationRan.value) await #expect(throws: BrowserToolNativeWindowBindingError.stalePageReference) { @@ -330,7 +353,8 @@ struct BrowserNativeWindowBindingCoordinatorTests { private static func fixture( windowID: @escaping @Sendable () -> Int = { 41 }, - stallTargets: LockedBoolean? = nil) async throws -> Fixture + stallTargets: LockedBoolean? = nil, + includeStaleUnrelatedCandidates: Bool = false) async throws -> Fixture { let nativeBounds = self.nativeBounds let transport = FakeControlTransport { command in @@ -344,19 +368,52 @@ struct BrowserNativeWindowBindingCoordinatorTests { if stallTargets?.value == true { return [] } + var targetInfos: [[String: Any]] = [[ + "targetId": "page-a", + "type": "page", + "title": "Example", + "url": "https://example.test/", + ]] + if includeStaleUnrelatedCandidates { + targetInfos.append([ + "targetId": "page-dead-target", + "type": "page", + "title": "Closed target", + "url": "https://closed-target.test/", + ]) + targetInfos.append([ + "targetId": "page-dead-window", + "type": "page", + "title": "Closed window", + "url": "https://closed-window.test/", + ]) + } return [.success(BrowserMCPDevToolsControlSessionTests.response( id: request.id, - result: ["targetInfos": [[ - "targetId": "page-a", - "type": "page", - "title": "Example", - "url": "https://example.test/", - ]]]))] + result: ["targetInfos": targetInfos]))] case "Browser.getWindowForTarget": + let targetID = request.params["targetId"] as? String + if targetID == "page-dead-target" { + return [.success(BrowserMCPDevToolsControlSessionTests.errorResponse( + id: request.id, + code: -32000, + message: "No target with given id"))] + } + if targetID == "page-dead-window" { + return [.success(BrowserMCPDevToolsControlSessionTests.response( + id: request.id, + result: ["windowId": 99]))] + } return [.success(BrowserMCPDevToolsControlSessionTests.response( id: request.id, result: ["windowId": windowID()]))] case "Browser.getWindowBounds": + if request.params["windowId"] as? Int == 99 { + return [.success(BrowserMCPDevToolsControlSessionTests.errorResponse( + id: request.id, + code: -32000, + message: "Browser window not found"))] + } return [.success(BrowserMCPDevToolsControlSessionTests.response( id: request.id, result: ["bounds": [ From 274b61bcaa7582ad6b78c25aeb4e0b144f297b7e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 17:09:32 -0700 Subject: [PATCH 06/14] feat(browser): bind opaque pages to native windows --- CHANGELOG.md | 1 + .../Agent/Tools/AgentSystemPrompt.swift | 3 + .../Browser/BrowserMCPExecutionEvidence.swift | 32 +++ .../Browser/BrowserMCPService.swift | 111 ++++++++- .../Browser/BrowserMCPSessionManager.swift | 115 +++++++--- ...rowserNativeWindowBindingCoordinator.swift | 214 +++++++++++------- ...rToolCapabilitySession+OperationGate.swift | 19 ++ .../BrowserToolCapabilitySession.swift | 73 +++--- .../NativeBrowserWindowCorrelator.swift | 17 +- .../BrowserTool+NativeWindowBinding.swift | 192 ++++++++++++++++ .../MCP/Tools/BrowserTool.swift | 148 ++++++++++-- ...rNativeWindowBindingCoordinatorTests.swift | 88 ++++++- ...rowserToolCapabilityIntegrationTests.swift | 173 +++++++++++++- .../BrowserToolCapabilitySessionTests.swift | 13 ++ .../NativeBrowserWindowCorrelatorTests.swift | 61 +---- .../MCP/MCPPolicyAwareCatalogTests.swift | 4 + docs/browser-mcp.md | 17 +- 17 files changed, 1045 insertions(+), 236 deletions(-) create mode 100644 Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserToolCapabilitySession+OperationGate.swift create mode 100644 Core/PeekabooCore/Sources/PeekabooAgentRuntime/MCP/Tools/BrowserTool+NativeWindowBinding.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 45fa6ed28..b7724a726 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Added - Add opt-in native installed-application inventory to CLI and MCP as a PID-free sidecar, with declared UI/background classification and no Spotlight, AppleScript, or private APIs. +- Add process-local MCP and Agent binding between opaque Chrome page capabilities and exact native PID-generation/window receipts, with final tab/window revalidation before every bound mutation and no raw CDP ID disclosure. - Let trusted MCP hosts explicitly authorize foreground UI for one server process while keeping background-only as the default. Thanks @Austin1serb for #612. - Report per-window `combined_eligible`, `pixels_only`, or `unknown` observation eligibility in CLI and MCP, including screenshot-only recovery. - Add an embedding-only Bridge protocol 1.32 API for signed, process-generation-bound observation. diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Agent/Tools/AgentSystemPrompt.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Agent/Tools/AgentSystemPrompt.swift index 8e29427a7..f220e911b 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Agent/Tools/AgentSystemPrompt.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Agent/Tools/AgentSystemPrompt.swift @@ -355,6 +355,9 @@ public struct AgentSystemPrompt { - Start each Chrome flow with `list_pages` or `new_page`, retain its opaque page reference, and include it as `page_id` in every later page-scoped browser action. Use element references only from that page's newest snapshot. Never copy page or element references across Agent sessions. + - When browser page work must stay inside one exact native Chrome window, call `bind_window` with that opaque + page reference plus the exact Chrome PID and WindowServer window ID. Rebind after any refusal; never retry a + bound mutation through an unbound page or a raw provider ID. - Foreground-capable sessions may use `bring_to_front: true` or `background: false` only when the task explicitly requires foreground Chrome; background-only sessions must never emit either form. - If `browser` fails or is unavailable, fall back to native Peekaboo screen/AX tools. diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPExecutionEvidence.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPExecutionEvidence.swift index af1856950..797b69fa0 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPExecutionEvidence.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPExecutionEvidence.swift @@ -40,6 +40,21 @@ public enum BrowserMCPExecutionEvidence { structuredContent: response.structuredContent) } + static func attachingNativeWindowReceipt( + to response: ToolResponse, + receipt: BrowserNativeWindowReceipt) -> ToolResponse + { + var fields = response.meta?.objectValue ?? [:] + var evidenceFields = fields[self.metadataKey]?.objectValue ?? [:] + evidenceFields["native_window_receipt"] = .object(self.nativeWindowReceiptFields(receipt)) + fields[self.metadataKey] = .object(evidenceFields) + return ToolResponse( + content: response.content, + isError: response.isError, + meta: .object(fields), + structuredContent: response.structuredContent) + } + static func split( _ meta: Value?) -> (evidence: Value?, providerMeta: Value?) { @@ -86,4 +101,21 @@ public enum BrowserMCPExecutionEvidence { } return fields } + + static func nativeWindowReceiptFields( + _ receipt: BrowserNativeWindowReceipt) -> [String: Value] + { + [ + "pid": .int(Int(receipt.target.processIdentifier)), + "process_start_identity_decimal": .string(String(receipt.target.processStartIdentity)), + "window_id": .int(Int(receipt.target.windowID)), + "bounds": .object([ + "x": .double(Double(receipt.bounds.origin.x)), + "y": .double(Double(receipt.bounds.origin.y)), + "width": .double(Double(receipt.bounds.width)), + "height": .double(Double(receipt.bounds.height)), + ]), + "quality": .string(BrowserNativeWindowBindingProof.Quality.exact.rawValue), + ] + } } diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPService.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPService.swift index 7575c77d5..5fb231867 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPService.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPService.swift @@ -272,6 +272,36 @@ public protocol BrowserMCPAtomicSessionActionProviding: BrowserMCPActionResultPr elementPreflight: BrowserMCPElementPreflight?) async throws -> DesktopActionResult } +struct BrowserMCPNativeWindowBoundActionResult: Sendable { + let actionResult: DesktopActionResult + let nativeWindowReceipt: BrowserNativeWindowReceipt +} + +struct BrowserMCPNativeWindowBoundExecutionRequest: Sendable { + let calls: [BrowserMCPMappedCall] + let channel: BrowserMCPChannel? + let sessionBinding: BrowserMCPExecutionSessionBinding + let elementPreflight: BrowserMCPElementPreflight? + let pageReference: String + let deadline: ContinuousClock.Instant +} + +protocol BrowserMCPNativeWindowBindingProviding: BrowserMCPAtomicSessionActionProviding { + var nativeWindowBindingCapabilitySession: BrowserToolCapabilitySession? { get } + + @MainActor + func bindNativeWindowHoldingCapabilityGate( + pageReference: String, + target: BrowserNativeWindowTarget, + expectedSessionBinding: BrowserMCPExecutionSessionBinding, + deadline: ContinuousClock.Instant) async throws -> BrowserNativeWindowBindingProof + + @MainActor + func executeNativeWindowBoundSequenceWithOutcomeHoldingCapabilityGate( + _ request: BrowserMCPNativeWindowBoundExecutionRequest) async throws + -> BrowserMCPNativeWindowBoundActionResult +} + protocol BrowserMCPAuthenticatedSessionEnding: BrowserMCPClientProviding { @MainActor func endAuthenticatedBrowserSession() async @@ -336,7 +366,8 @@ extension BrowserMCPClientProviding { public final class BrowserMCPService: BrowserMCPClientProviding, BrowserMCPActionResultProviding, BrowserMCPAtomicSessionActionProviding, - BrowserMCPConnectionResultProviding, BrowserMCPAuthenticatedSessionEnding, @unchecked Sendable + BrowserMCPConnectionResultProviding, BrowserMCPAuthenticatedSessionEnding, + BrowserMCPNativeWindowBindingProviding, @unchecked Sendable { public let supportsNativeBrowserConnectionBinding: Bool @@ -346,6 +377,7 @@ public final class BrowserMCPService: BrowserMCPClientProviding, BrowserMCPActio @MainActor private var authenticatedSessionPool: BrowserMCPAuthenticatedSessionPool? private let sessionCapabilities: BrowserToolCapabilitySession? private let sessionMutationGate: MCPToolSnapshotExecutionGate? + private let nativeWindowBindingDependencies: BrowserNativeWindowBindingCoordinator.Dependencies @MainActor private var ownedSession: ( pool: BrowserMCPAuthenticatedSessionPool, id: BrowserMCPAuthenticatedSessionPool.SessionID)? @@ -357,6 +389,7 @@ public final class BrowserMCPService: BrowserMCPClientProviding, BrowserMCPActio self.ownedSession = nil self.sessionCapabilities = nil self.sessionMutationGate = nil + self.nativeWindowBindingDependencies = .live self.authenticatedSessionPool = BrowserMCPAuthenticatedSessionPool { serverName in BrowserMCPSessionManager(serverName: serverName) } @@ -369,6 +402,7 @@ public final class BrowserMCPService: BrowserMCPClientProviding, BrowserMCPActio self.ownedSession = nil self.sessionCapabilities = nil self.sessionMutationGate = nil + self.nativeWindowBindingDependencies = .live self.authenticatedSessionPool = authenticatedSessionPool } @@ -380,6 +414,7 @@ public final class BrowserMCPService: BrowserMCPClientProviding, BrowserMCPActio self.ownedSession = nil self.sessionCapabilities = nil self.sessionMutationGate = nil + self.nativeWindowBindingDependencies = .live self.authenticatedSessionPool = BrowserMCPAuthenticatedSessionPool { serverName in BrowserMCPSessionManager(serverName: serverName, manager: manager) } @@ -392,7 +427,8 @@ public final class BrowserMCPService: BrowserMCPClientProviding, BrowserMCPActio pool: BrowserMCPAuthenticatedSessionPool, id: BrowserMCPAuthenticatedSessionPool.SessionID)? = nil, sessionCapabilities: BrowserToolCapabilitySession? = nil, - sessionMutationGate: MCPToolSnapshotExecutionGate? = nil) + sessionMutationGate: MCPToolSnapshotExecutionGate? = nil, + nativeWindowBindingDependencies: BrowserNativeWindowBindingCoordinator.Dependencies = .live) { self.supportsNativeBrowserConnectionBinding = sessionManager.supportsNativeBrowserConnectionBinding self.sessionManager = sessionManager @@ -400,6 +436,7 @@ public final class BrowserMCPService: BrowserMCPClientProviding, BrowserMCPActio self.ownedSession = ownedSession self.sessionCapabilities = sessionCapabilities self.sessionMutationGate = sessionMutationGate + self.nativeWindowBindingDependencies = nativeWindowBindingDependencies } /// Creates a version-neutral process-local browser service for one explicitly authenticated caller session. @@ -458,10 +495,71 @@ public final class BrowserMCPService: BrowserMCPClientProviding, BrowserMCPActio self.sessionCapabilities } + var nativeWindowBindingCapabilitySession: BrowserToolCapabilitySession? { + self.supportsNativeBrowserConnectionBinding ? self.sessionCapabilities : nil + } + var browserMutationExecutionGate: MCPToolSnapshotExecutionGate? { self.sessionMutationGate } + @MainActor + func bindNativeWindowHoldingCapabilityGate( + pageReference: String, + target: BrowserNativeWindowTarget, + expectedSessionBinding: BrowserMCPExecutionSessionBinding, + deadline: ContinuousClock.Instant) async throws -> BrowserNativeWindowBindingProof + { + guard let capabilities = self.nativeWindowBindingCapabilitySession else { + throw BrowserNativeWindowBindingCoordinatorError.controlUnavailable + } + return try await BrowserNativeWindowBindingCoordinator.bindHoldingCapabilityGate( + pageReference: pageReference, + nativeTarget: target, + context: .init( + sessionBinding: expectedSessionBinding, + capabilities: capabilities, + manager: self.resolvedSessionManager(), + deadline: deadline), + dependencies: self.nativeWindowBindingDependencies) + } + + @MainActor + func executeNativeWindowBoundSequenceWithOutcomeHoldingCapabilityGate( + _ request: BrowserMCPNativeWindowBoundExecutionRequest) async throws + -> BrowserMCPNativeWindowBoundActionResult + { + guard let capabilities = self.nativeWindowBindingCapabilitySession else { + throw BrowserNativeWindowBindingCoordinatorError.controlUnavailable + } + do { + let bound = try await self.resolvedSessionManager().executeNativeWindowBoundSequence( + request, + capabilities: capabilities, + receiptProviders: self.nativeWindowBindingDependencies.receiptProviders) + let projected = try Self.projectExecutionResult(bound.result, calls: request.calls) + return BrowserMCPNativeWindowBoundActionResult( + actionResult: DesktopActionResult( + payload: BrowserMCPExecutionEvidence.attachingNativeWindowReceipt( + to: projected.payload, + receipt: bound.nativeWindowReceipt), + outcome: projected.outcome), + nativeWindowReceipt: bound.nativeWindowReceipt) + } catch BrowserMCPConnectionError.expectedConnectionReceiptMismatch, + BrowserMCPConnectionError.expectedProviderSessionEpochMismatch + { + throw DesktopActionFailure.preDispatchRefusal( + reason: .targetUnavailable, + message: "The exact browser provider session changed before bound tool dispatch.", + hint: "Refresh browser status and bind the page to its native window again.") + } catch BrowserMCPConnectionError.receiptBindingUnsupported { + throw DesktopActionFailure.preDispatchRefusal( + reason: .operationUnsupported, + message: "The browser provider cannot atomically execute a native-window-bound action.", + hint: "Update the runtime host before retrying native browser window binding.") + } + } + @MainActor public func status(channel: BrowserMCPChannel? = nil) async -> BrowserMCPStatus { let status = await self.resolvedSessionManager().status(channel: channel) @@ -726,6 +824,15 @@ public final class BrowserMCPService: BrowserMCPClientProviding, BrowserMCPActio } } } + return try Self.projectExecutionResult(result, calls: calls) + } + + private static func projectExecutionResult( + _ result: BrowserMCPExecutionResult, + calls: [BrowserMCPMappedCall]) throws -> DesktopActionResult + { + let semantics = calls.map(Self.actionSemantics) + let plannedMutationCount = semantics.count(where: { $0 == .mutating }) let projected = try result.projectingMutationProgress(for: calls) let executionOutcome: DesktopActionOutcome? = if plannedMutationCount > 0 { projected.actionFailure?.outcome ?? Self.successOutcome( diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPSessionManager.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPSessionManager.swift index 04a89ca49..1005e7520 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPSessionManager.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPSessionManager.swift @@ -18,6 +18,7 @@ protocol BrowserMCPManaging: AnyObject { private struct BrowserMCPPreparedExecution { let sessionBinding: BrowserMCPExecutionSessionBinding let connectionOutcome: DesktopActionOutcome? + let receiptBound: Bool } private enum BrowserMCPConnectionTargetKind: Sendable, Equatable { @@ -503,6 +504,48 @@ final class BrowserMCPSessionManager: @unchecked Sendable { } } + func executeNativeWindowBoundSequence( + _ request: BrowserMCPNativeWindowBoundExecutionRequest, + capabilities: BrowserToolCapabilitySession, + receiptProviders: BrowserNativeWindowReceiptResolver.Providers) async throws + -> BrowserNativeWindowBoundExecution + { + do { + return try await self.withExecutionGate { + let preparation = try await self.prepareExecutionReceipt( + channel: request.channel, + expectedConnectionReceipt: request.sessionBinding.connectionReceipt, + expectedProviderSessionEpoch: request.sessionBinding.providerSessionEpoch, + connectionPolicy: .requireExistingLiveReceipt) + try await self.requireElementPreflightUnlocked( + request.elementPreflight, + preparation: preparation) + let control = try await self.nativeBindingControl( + expectedSessionBinding: request.sessionBinding) + let context = BrowserNativeWindowBindingCoordinator.Context( + sessionBinding: request.sessionBinding, + capabilities: capabilities, + manager: self, + deadline: request.deadline) + let nativeWindowReceipt = try await BrowserNativeWindowBindingCoordinator + .revalidateHoldingAuthorities( + pageReference: request.pageReference, + context: context, + control: control, + receiptProviders: receiptProviders) + try Self.requireNativeBindingDeadline(request.deadline) + let result = try await self.executePreparedSequenceUnlocked( + request.calls, + preparation: preparation) + return BrowserNativeWindowBoundExecution( + result: result, + nativeWindowReceipt: nativeWindowReceipt) + } + } catch is CancellationError { + throw Self.preDispatchFailure(CancellationError()) + } + } + func executeSequence( _ calls: [BrowserMCPMappedCall], channel: BrowserMCPChannel?, @@ -530,34 +573,17 @@ final class BrowserMCPSessionManager: @unchecked Sendable { { do { return try await self.withExecutionGate { - if let elementPreflight { - let preflight = try await self.executeSequenceUnlocked( - [BrowserMCPMappedCall( - toolName: "take_snapshot", - arguments: ["pageId": elementPreflight.providerPageID])], - channel: channel, - expectedConnectionReceipt: expectedSessionBinding.connectionReceipt, - expectedProviderSessionEpoch: expectedSessionBinding.providerSessionEpoch, - connectionPolicy: .requireExistingLiveReceipt) - let currentUIDs = BrowserMCPProviderSnapshotParser.providerUIDs(in: preflight.response) - // chrome-devtools-mcp v1.6.0 preserves a UID only for the same per-page - // loaderId/backendNodeId pair. The pinned dependency contract checks that identity rule. - guard !preflight.response.isError, - preflight.actionFailure == nil, - currentUIDs.isSuperset(of: elementPreflight.providerUIDs) - else { - throw DesktopActionFailure.preDispatchRefusal( - reason: .targetUnavailable, - message: "Browser element references are stale in the current page document.", - hint: "Take a fresh browser snapshot and retry with its new opaque element references.") - } - } - return try await self.executeSequenceUnlocked( - calls, + let preparation = try await self.prepareExecutionReceipt( channel: channel, expectedConnectionReceipt: expectedSessionBinding.connectionReceipt, expectedProviderSessionEpoch: expectedSessionBinding.providerSessionEpoch, connectionPolicy: .requireExistingLiveReceipt) + try await self.requireElementPreflightUnlocked( + elementPreflight, + preparation: preparation) + return try await self.executePreparedSequenceUnlocked( + calls, + preparation: preparation) } } catch is CancellationError { throw Self.preDispatchFailure(CancellationError()) @@ -586,6 +612,15 @@ final class BrowserMCPSessionManager: @unchecked Sendable { expectedConnectionReceipt: expectedConnectionReceipt, expectedProviderSessionEpoch: expectedProviderSessionEpoch, connectionPolicy: connectionPolicy) + return try await self.executePreparedSequenceUnlocked( + calls, + preparation: preparation) + } + + private func executePreparedSequenceUnlocked( + _ calls: [BrowserMCPMappedCall], + preparation: BrowserMCPPreparedExecution) async throws -> BrowserMCPExecutionResult + { let sessionBinding = preparation.sessionBinding let receipt = sessionBinding.connectionReceipt @@ -610,7 +645,7 @@ final class BrowserMCPSessionManager: @unchecked Sendable { response = .error(actionFailure?.message ?? "Browser sequence stopped") break } - if expectedConnectionReceipt != nil { + if preparation.receiptBound { throw Self.preDispatchFailure(cause) } throw cause @@ -692,6 +727,30 @@ final class BrowserMCPSessionManager: @unchecked Sendable { providerReturnedError: providerReturnedError) } + private func requireElementPreflightUnlocked( + _ elementPreflight: BrowserMCPElementPreflight?, + preparation: BrowserMCPPreparedExecution) async throws + { + guard let elementPreflight else { return } + let preflight = try await self.executePreparedSequenceUnlocked( + [BrowserMCPMappedCall( + toolName: "take_snapshot", + arguments: ["pageId": elementPreflight.providerPageID])], + preparation: preparation) + let currentUIDs = BrowserMCPProviderSnapshotParser.providerUIDs(in: preflight.response) + // chrome-devtools-mcp v1.6.0 preserves a UID only for the same per-page + // loaderId/backendNodeId pair. The pinned dependency contract checks that identity rule. + guard !preflight.response.isError, + preflight.actionFailure == nil, + currentUIDs.isSuperset(of: elementPreflight.providerUIDs) + else { + throw DesktopActionFailure.preDispatchRefusal( + reason: .targetUnavailable, + message: "Browser element references are stale in the current page document.", + hint: "Take a fresh browser snapshot and retry with its new opaque element references.") + } + } + // Connection authority, target locking, and live-receipt validation stay in one pre-dispatch control flow. // swiftlint:disable:next cyclomatic_complexity private func prepareExecutionReceipt( @@ -739,7 +798,8 @@ final class BrowserMCPSessionManager: @unchecked Sendable { sessionBinding: .init( connectionReceipt: receipt, providerSessionEpoch: providerSessionEpoch), - connectionOutcome: connection.outcome) + connectionOutcome: connection.outcome, + receiptBound: false) } guard let receipt = self.connectionReceipt, let providerSessionEpoch = self.providerSessionEpoch @@ -817,7 +877,8 @@ final class BrowserMCPSessionManager: @unchecked Sendable { sessionBinding: .init( connectionReceipt: receipt, providerSessionEpoch: providerSessionEpoch), - connectionOutcome: nil) + connectionOutcome: nil, + receiptBound: expectedConnectionReceipt != nil) } private static func partialSequenceFailure( diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserNativeWindowBindingCoordinator.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserNativeWindowBindingCoordinator.swift index fc41fe67a..f56bfb53e 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserNativeWindowBindingCoordinator.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserNativeWindowBindingCoordinator.swift @@ -12,6 +12,11 @@ struct BrowserNativeWindowBindingProof: Sendable, Equatable { let quality: Quality } +struct BrowserNativeWindowBoundExecution: Sendable { + let result: BrowserMCPExecutionResult + let nativeWindowReceipt: BrowserNativeWindowReceipt +} + enum BrowserNativeWindowBindingCoordinatorError: Error, Equatable { case invalidPageCapability case invalidNativeWindow @@ -38,6 +43,7 @@ enum BrowserNativeWindowBindingCoordinator { private struct BindAuthorityRequest: Sendable { let pageReference: String let nativeTarget: BrowserNativeWindowTarget + let capturedReceipt: BrowserNativeWindowReceipt let privateTargetID: String let context: Context let receiptProviders: BrowserNativeWindowReceiptResolver.Providers @@ -51,69 +57,98 @@ enum BrowserNativeWindowBindingCoordinator { dependencies: Dependencies) async throws -> BrowserNativeWindowBindingProof { try await context.capabilities.withExclusiveOperation { - let resolved: BrowserToolCapabilitySession.ResolvedArguments - do { - resolved = try await context.capabilities.resolve( - action: .snapshot, - arguments: ToolArguments(raw: ["page_id": pageReference]), - sessionBinding: context.sessionBinding) - } catch { - throw BrowserNativeWindowBindingCoordinatorError.invalidPageCapability - } - guard let providerPageID = resolved.providerPageID else { - throw BrowserNativeWindowBindingCoordinatorError.invalidPageCapability - } + try await self.bindHoldingCapabilityGate( + pageReference: pageReference, + nativeTarget: nativeTarget, + context: context, + dependencies: dependencies) + } + } - do { - return try await context.manager.withPrivateTargetBindingAuthority( - providerPageID: providerPageID, - expectedSessionBinding: context.sessionBinding, - deadline: context.deadline) - { control, privateTargetID in - do { - return try await self.bindUnderAuthority( - .init( - pageReference: pageReference, - nativeTarget: nativeTarget, - privateTargetID: privateTargetID, - context: context, - receiptProviders: dependencies.receiptProviders), - control: control) - } catch { - throw await self.validationError( - error, + @MainActor + static func bindHoldingCapabilityGate( + pageReference: String, + nativeTarget: BrowserNativeWindowTarget, + context: Context, + dependencies: Dependencies) async throws -> BrowserNativeWindowBindingProof + { + let connectionReceipt = context.sessionBinding.connectionReceipt + guard connectionReceipt.processIdentifier == nativeTarget.processIdentifier, + connectionReceipt.processStartIdentity == nativeTarget.processStartIdentity + else { + throw BrowserNativeWindowBindingCoordinatorError.invalidNativeWindow + } + let resolved: BrowserToolCapabilitySession.ResolvedArguments + do { + resolved = try await context.capabilities.resolve( + action: .snapshot, + arguments: ToolArguments(raw: ["page_id": pageReference]), + sessionBinding: context.sessionBinding) + } catch { + throw BrowserNativeWindowBindingCoordinatorError.invalidPageCapability + } + guard let providerPageID = resolved.providerPageID else { + throw BrowserNativeWindowBindingCoordinatorError.invalidPageCapability + } + let capturedReceipt: BrowserNativeWindowReceipt + do { + capturedReceipt = try BrowserNativeWindowReceiptResolver.capture( + target: nativeTarget, + providers: dependencies.receiptProviders).get() + } catch { + throw BrowserNativeWindowBindingCoordinatorError.invalidNativeWindow + } + + do { + return try await context.manager.withPrivateTargetBindingAuthority( + providerPageID: providerPageID, + expectedSessionBinding: context.sessionBinding, + deadline: context.deadline) + { control, privateTargetID in + do { + return try await self.bindUnderAuthority( + .init( pageReference: pageReference, - capabilities: context.capabilities, - control: control, - deadline: context.deadline) - } + nativeTarget: nativeTarget, + capturedReceipt: capturedReceipt, + privateTargetID: privateTargetID, + context: context, + receiptProviders: dependencies.receiptProviders), + control: control) + } catch { + throw await self.validationError( + error, + pageReference: pageReference, + capabilities: context.capabilities, + control: control, + deadline: context.deadline) } - } catch is CancellationError { - await self.invalidateAfterPrivateLookupFailure( - pageReference: pageReference, - context: context) - throw CancellationError() - } catch BrowserMCPPrivateInteropError.authorityUnavailable { - await context.capabilities.invalidateNativeWindowBindings() - throw BrowserNativeWindowBindingCoordinatorError.controlUnavailable - } catch BrowserMCPPrivateInteropError.deadlineExceeded { - await self.invalidateAfterPrivateLookupFailure( - pageReference: pageReference, - context: context) - throw BrowserNativeWindowBindingCoordinatorError.deadlineExceeded - } catch is BrowserMCPPrivateInteropError { - await self.invalidateAfterPrivateLookupFailure( - pageReference: pageReference, - context: context) - throw BrowserNativeWindowBindingCoordinatorError.privateTargetUnavailable - } catch let error as BrowserNativeWindowBindingCoordinatorError { - throw error - } catch { - await self.invalidateAfterPrivateLookupFailure( - pageReference: pageReference, - context: context) - throw BrowserNativeWindowBindingCoordinatorError.privateTargetUnavailable } + } catch is CancellationError { + await self.invalidateAfterPrivateLookupFailure( + pageReference: pageReference, + context: context) + throw CancellationError() + } catch BrowserMCPPrivateInteropError.authorityUnavailable { + await context.capabilities.invalidateNativeWindowBindings() + throw BrowserNativeWindowBindingCoordinatorError.controlUnavailable + } catch BrowserMCPPrivateInteropError.deadlineExceeded { + await self.invalidateAfterPrivateLookupFailure( + pageReference: pageReference, + context: context) + throw BrowserNativeWindowBindingCoordinatorError.deadlineExceeded + } catch is BrowserMCPPrivateInteropError { + await self.invalidateAfterPrivateLookupFailure( + pageReference: pageReference, + context: context) + throw BrowserNativeWindowBindingCoordinatorError.privateTargetUnavailable + } catch let error as BrowserNativeWindowBindingCoordinatorError { + throw error + } catch { + await self.invalidateAfterPrivateLookupFailure( + pageReference: pageReference, + context: context) + throw BrowserNativeWindowBindingCoordinatorError.privateTargetUnavailable } } @@ -129,21 +164,11 @@ enum BrowserNativeWindowBindingCoordinator { return try await context.manager.withNativeBindingExecutionGate( expectedSessionBinding: context.sessionBinding) { control in - let revalidatedReceipt: BrowserNativeWindowReceipt - do { - revalidatedReceipt = try await self.revalidateUnderAuthority( - pageReference: pageReference, - context: context, - control: control, - receiptProviders: receiptProviders) - } catch { - throw await self.validationError( - error, - pageReference: pageReference, - capabilities: context.capabilities, - control: control, - deadline: context.deadline) - } + let revalidatedReceipt = try await self.revalidateHoldingAuthorities( + pageReference: pageReference, + context: context, + control: control, + receiptProviders: receiptProviders) try self.requireAuthorizationDeadline(context) return try await mutation(revalidatedReceipt) } @@ -162,26 +187,16 @@ enum BrowserNativeWindowBindingCoordinator { control: BrowserMCPDevToolsControlSession) async throws -> BrowserNativeWindowBindingProof { - let receipt: BrowserNativeWindowReceipt - do { - receipt = try BrowserNativeWindowReceiptResolver.capture( - target: request.nativeTarget, - providers: request.receiptProviders).get() - } catch { - throw BrowserNativeWindowBindingCoordinatorError.invalidNativeWindow - } - let candidates = try await self.candidates( control: control, requestedTargetID: request.privateTargetID, deadline: request.context.deadline) let currentReceipt = try BrowserNativeWindowReceiptResolver.revalidate( - receipt, + request.capturedReceipt, providers: request.receiptProviders).get() let correlation = try NativeBrowserWindowCorrelator.correlate( - expectedNativeWindow: receipt.windowIdentity, + expectedNativeWindow: request.capturedReceipt.windowIdentity, currentNativeWindow: currentReceipt.windowIdentity, - nativeTitle: nil, requestedTargetID: request.privateTargetID, candidates: candidates) let finalWindowID = try await control.getWindowForTarget( @@ -209,7 +224,7 @@ enum BrowserNativeWindowBindingCoordinator { } @MainActor - private static func revalidateUnderAuthority( + static func revalidateUnderAuthority( pageReference: String, context: Context, control: BrowserMCPDevToolsControlSession, @@ -229,7 +244,6 @@ enum BrowserNativeWindowBindingCoordinator { let correlation = try NativeBrowserWindowCorrelator.correlate( expectedNativeWindow: binding.nativeWindowReceipt.windowIdentity, currentNativeWindow: currentReceipt.windowIdentity, - nativeTitle: nil, requestedTargetID: binding.privateTargetID, candidates: candidates) let finalWindowID = try await control.getWindowForTarget( @@ -247,6 +261,30 @@ enum BrowserNativeWindowBindingCoordinator { return finalReceipt } + @MainActor + static func revalidateHoldingAuthorities( + pageReference: String, + context: Context, + control: BrowserMCPDevToolsControlSession, + receiptProviders: BrowserNativeWindowReceiptResolver.Providers) async throws + -> BrowserNativeWindowReceipt + { + do { + return try await self.revalidateUnderAuthority( + pageReference: pageReference, + context: context, + control: control, + receiptProviders: receiptProviders) + } catch { + throw await self.validationError( + error, + pageReference: pageReference, + capabilities: context.capabilities, + control: control, + deadline: context.deadline) + } + } + @MainActor private static func validationError( _ error: any Error, diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserToolCapabilitySession+OperationGate.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserToolCapabilitySession+OperationGate.swift new file mode 100644 index 000000000..89287b308 --- /dev/null +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserToolCapabilitySession+OperationGate.swift @@ -0,0 +1,19 @@ +extension BrowserToolCapabilitySession { + func withExclusiveOperation( + _ operation: @MainActor @Sendable () async throws -> Result) async throws -> Result + { + try await self.operationGate.acquire() + guard !self.ended else { + await self.operationGate.release() + throw BrowserToolCapabilityError.sessionEnded + } + do { + let result = try await operation() + await self.operationGate.release() + return result + } catch { + await self.operationGate.release() + throw error + } + } +} diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserToolCapabilitySession.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserToolCapabilitySession.swift index 7e66756d1..f09425a89 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserToolCapabilitySession.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserToolCapabilitySession.swift @@ -8,6 +8,12 @@ struct BrowserToolNativeWindowBinding: Sendable, Equatable { let nativeWindowReceipt: BrowserNativeWindowReceipt } +private enum BrowserToolNativeWindowBindingState: Sendable, Equatable { + case unbound + case bound(BrowserToolNativeWindowBinding) + case invalidated +} + enum BrowserToolNativeWindowBindingError: Error, Equatable { case sessionEnded case invalidPageReference @@ -57,7 +63,7 @@ actor BrowserToolCapabilitySession { var title: String? var navigationGeneration: UInt64 var snapshotReferences: Set - var nativeWindowBinding: BrowserToolNativeWindowBinding? + var nativeWindowBindingState: BrowserToolNativeWindowBindingState } struct ResolvedArguments { @@ -71,27 +77,9 @@ actor BrowserToolCapabilitySession { private var pageReferenceByProviderID: [Int: String] = [:] private var snapshotsByReference: [String: SnapshotRecord] = [:] private var elementsByReference: [String: ElementRecord] = [:] - private let operationGate = MCPToolSnapshotExecutionGate() + let operationGate = MCPToolSnapshotExecutionGate() private var endTask: Task? - private var ended = false - - func withExclusiveOperation( - _ operation: @MainActor @Sendable () async throws -> Result) async throws -> Result - { - try await self.operationGate.acquire() - guard !self.ended else { - await self.operationGate.release() - throw BrowserToolCapabilityError.sessionEnded - } - do { - let result = try await operation() - await self.operationGate.release() - return result - } catch { - await self.operationGate.release() - throw error - } - } + var ended = false func resolve( action: BrowserAction, @@ -177,13 +165,36 @@ actor BrowserToolCapabilitySession { else { throw BrowserToolNativeWindowBindingError.processMismatch } - page.nativeWindowBinding = BrowserToolNativeWindowBinding( + page.nativeWindowBindingState = .bound(BrowserToolNativeWindowBinding( privateTargetID: privateTargetID, privateBrowserWindowID: privateBrowserWindowID, - nativeWindowReceipt: nativeWindowReceipt) + nativeWindowReceipt: nativeWindowReceipt)) self.pagesByReference[pageReference] = page } + func hasNativeWindowBinding( + pageReference: String, + sessionBinding: BrowserMCPExecutionSessionBinding) throws -> Bool + { + guard !self.ended else { throw BrowserToolNativeWindowBindingError.sessionEnded } + guard let page = self.pagesByReference[pageReference] else { + throw BrowserToolCapabilityReference.isValid(pageReference, prefix: "bp1") + ? BrowserToolNativeWindowBindingError.stalePageReference + : BrowserToolNativeWindowBindingError.invalidPageReference + } + guard page.connection == ConnectionBinding(sessionBinding: sessionBinding) else { + throw BrowserToolNativeWindowBindingError.connectionMismatch + } + return switch page.nativeWindowBindingState { + case .unbound: + false + case .bound: + true + case .invalidated: + throw BrowserToolNativeWindowBindingError.stalePageReference + } + } + func nativeWindowBinding( pageReference: String, sessionBinding: BrowserMCPExecutionSessionBinding) throws -> BrowserToolNativeWindowBinding @@ -197,7 +208,7 @@ actor BrowserToolCapabilitySession { guard page.connection == ConnectionBinding(sessionBinding: sessionBinding) else { throw BrowserToolNativeWindowBindingError.connectionMismatch } - guard let binding = page.nativeWindowBinding else { + guard case let .bound(binding) = page.nativeWindowBindingState else { throw BrowserToolNativeWindowBindingError.stalePageReference } return binding @@ -206,12 +217,20 @@ actor BrowserToolCapabilitySession { func invalidateNativeWindowBindings() { let references = Array(self.pagesByReference.keys) for reference in references { - self.pagesByReference[reference]?.nativeWindowBinding = nil + guard var page = self.pagesByReference[reference], + case .bound = page.nativeWindowBindingState + else { continue } + page.nativeWindowBindingState = .invalidated + self.pagesByReference[reference] = page } } func invalidateNativeWindowBinding(pageReference: String) { - self.pagesByReference[pageReference]?.nativeWindowBinding = nil + guard var page = self.pagesByReference[pageReference], + case .bound = page.nativeWindowBindingState + else { return } + page.nativeWindowBindingState = .invalidated + self.pagesByReference[pageReference] = page } func project( @@ -606,7 +625,7 @@ actor BrowserToolCapabilitySession { title: title, navigationGeneration: 0, snapshotReferences: [], - nativeWindowBinding: nil) + nativeWindowBindingState: .unbound) self.pageReferenceByProviderID[providerPageID] = reference return reference } diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/NativeBrowserWindowCorrelator.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/NativeBrowserWindowCorrelator.swift index 48a3784be..d2755ad19 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/NativeBrowserWindowCorrelator.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/NativeBrowserWindowCorrelator.swift @@ -47,7 +47,6 @@ enum NativeBrowserWindowCorrelator { static func correlate( expectedNativeWindow: WindowMutationIdentity, currentNativeWindow: WindowMutationIdentity?, - nativeTitle: String?, requestedTargetID: String, candidates: [CDPBrowserWindowCandidate]) throws -> NativeBrowserWindowCorrelation { @@ -69,20 +68,8 @@ enum NativeBrowserWindowCorrelator { throw NativeBrowserWindowCorrelationError.noGeometryMatch } - let selected: CDPBrowserWindowCandidate - if geometryCandidates.count == 1 { - selected = geometryCandidates[0] - } else { - guard let nativeTitle, - !nativeTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - throw NativeBrowserWindowCorrelationError.ambiguousGeometry - } - let titleMatches = geometryCandidates.filter { $0.titles.contains(nativeTitle) } - guard titleMatches.count == 1, let titleMatch = titleMatches.first else { - throw NativeBrowserWindowCorrelationError.ambiguousGeometry - } - selected = titleMatch + guard geometryCandidates.count == 1, let selected = geometryCandidates.first else { + throw NativeBrowserWindowCorrelationError.ambiguousGeometry } guard !requestedTargetID.isEmpty else { diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/MCP/Tools/BrowserTool+NativeWindowBinding.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/MCP/Tools/BrowserTool+NativeWindowBinding.swift new file mode 100644 index 000000000..2825191e1 --- /dev/null +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/MCP/Tools/BrowserTool+NativeWindowBinding.swift @@ -0,0 +1,192 @@ +import CoreGraphics +import MCP +import PeekabooAutomationKit +import PeekabooFoundation +import TachikomaMCP + +extension BrowserTool { + @MainActor + func executeNativeWindowBindingResponse(arguments: ToolArguments) async throws -> ToolResponse { + do { + return try await self.executeNativeWindowBinding(arguments: arguments) + } catch is CancellationError { + return try MCPToolResponseMetadataProjector.errorResponse( + for: .preDispatchRefusal( + reason: .requestCancelled, + message: "Native browser window binding was cancelled before any browser mutation.", + hint: "Retry only if the same page-to-window binding is still wanted."), + invalidatedSnapshotID: nil) + } catch let failure as DesktopActionFailure { + return try MCPToolResponseMetadataProjector.errorResponse( + for: failure, + invalidatedSnapshotID: nil) + } catch let error as BrowserToolNativeWindowBindingError { + return try MCPToolResponseMetadataProjector.errorResponse( + for: Self.nativeWindowBindingFailure(error), + invalidatedSnapshotID: nil) + } catch let error as BrowserNativeWindowBindingCoordinatorError { + return try MCPToolResponseMetadataProjector.errorResponse( + for: Self.nativeWindowBindingFailure(error), + invalidatedSnapshotID: nil) + } catch { + return try MCPToolResponseMetadataProjector.errorResponse( + for: .preDispatchRefusal( + reason: .targetUnavailable, + message: "Native browser window binding could not establish exact authority.", + hint: "Refresh browser pages and native windows before retrying.", + causeDescription: error.localizedDescription), + invalidatedSnapshotID: nil) + } + } + + @MainActor + private func executeNativeWindowBinding(arguments: ToolArguments) async throws -> ToolResponse { + guard let capabilitySession = self.capabilitySession, + let provider = self.nativeWindowBindingProvider + else { + throw DesktopActionFailure.preDispatchRefusal( + reason: .operationUnsupported, + message: "Native browser window binding requires one process-local scoped MCP or Agent session.", + hint: "Use the browser tool through a local MCP or Agent session; standalone CLI and remote Bridge " + + "binding remain unavailable until an authenticated 1.38 browser namespace exists.") + } + let allowedKeys: Set = ["action", "page_id", "pid", "window_id"] + let unexpectedKeys = Set(arguments.rawDictionary.keys).subtracting(allowedKeys).sorted() + guard unexpectedKeys.isEmpty else { + throw DesktopActionFailure.preDispatchRefusal( + reason: .invalidRequest, + message: "bind_window received unsupported argument(s): \(unexpectedKeys.joined(separator: ", ")).", + hint: "Pass exactly action, page_id, pid, and window_id.") + } + guard let pageReference = arguments.getString("page_id"), + BrowserToolCapabilityReference.isValid(pageReference, prefix: "bp1") + else { + throw DesktopActionFailure.preDispatchRefusal( + reason: .invalidRequest, + message: "bind_window requires an opaque page_id from this session.", + hint: "Run list_pages in the same session and use its bp1 reference.") + } + guard let rawPID = try arguments.validatedInt("pid"), + let processIdentifier = Int32(exactly: rawPID), + processIdentifier > 0 + else { + throw DesktopActionFailure.preDispatchRefusal( + reason: .invalidRequest, + message: "bind_window pid must be a positive Int32.", + hint: "Use the exact PID reported by browser status and native window inventory.") + } + guard let rawWindowID = try arguments.validatedInt("window_id"), + let windowID = UInt32(exactly: rawWindowID), + windowID > 0 + else { + throw DesktopActionFailure.preDispatchRefusal( + reason: .invalidRequest, + message: "bind_window window_id must be a positive UInt32.", + hint: "Use the exact WindowServer ID from native window inventory.") + } + + return try await capabilitySession.withExclusiveOperation { + let status = await self.client.status(channel: nil) + await capabilitySession.observeStatus(status) + guard self.client.supportsNativeBrowserConnectionBinding, + status.isConnected, + let receipt = status.connectionReceipt, + let generation = receipt.processStartIdentity, + receipt.processIdentifier == processIdentifier, + let channel = receipt.channel, + let channelIdentity = ChromeChannelIdentity(rawValue: channel.rawValue), + receipt.bundleIdentifier == channelIdentity.bundleIdentifier, + let browserURL = receipt.browserURL, + let webSocketDebuggerURL = receipt.webSocketDebuggerURL, + let browserID = receipt.devToolsBrowserID, + BrowserLoopbackEndpoint(browserURL: browserURL)?.matchesWebSocketDebuggerURL( + webSocketDebuggerURL, + browserID: browserID) == true + else { + throw DesktopActionFailure.preDispatchRefusal( + reason: .targetUnavailable, + message: "bind_window requires the current process-bound official Chrome connection receipt.", + hint: "Connect one local Chrome channel explicitly, refresh status, and retry with its exact PID.") + } + let proof = try await provider.bindNativeWindowHoldingCapabilityGate( + pageReference: pageReference, + target: BrowserNativeWindowTarget( + processIdentifier: processIdentifier, + processStartIdentity: generation, + windowID: CGWindowID(windowID)), + expectedSessionBinding: .init( + connectionReceipt: receipt, + providerSessionEpoch: Self.requireProviderSessionEpoch(status)), + deadline: ContinuousClock.now.advanced(by: .seconds(10))) + return try Self.nativeWindowBindingResponse(proof) + } + } + + private static func requireProviderSessionEpoch( + _ status: BrowserMCPStatus) throws -> BrowserMCPProviderSessionEpoch + { + guard let epoch = status.providerSessionEpoch else { + throw DesktopActionFailure.preDispatchRefusal( + reason: .targetUnavailable, + message: "The browser provider child has no exact session epoch.", + hint: "Reconnect the process-local browser session and retry.") + } + return epoch + } + + private static func nativeWindowBindingResponse( + _ proof: BrowserNativeWindowBindingProof) throws -> ToolResponse + { + let receipt = proof.nativeWindowReceipt + let exactWindow = try UIAutomationTarget.ExactWindow( + identity: receipt.windowIdentity, + bounds: receipt.bounds) + let identity = DesktopTargetIdentity(exactWindow: exactWindow) + let bindingFields: [String: Value] = [ + "state": .string("bound"), + "page_id": .string(proof.pageReference), + "pid": .int(Int(receipt.target.processIdentifier)), + "process_start_identity_decimal": .string(String(receipt.target.processStartIdentity)), + "window_id": .int(Int(receipt.target.windowID)), + "bounds": .object([ + "x": .double(Double(receipt.bounds.origin.x)), + "y": .double(Double(receipt.bounds.origin.y)), + "width": .double(Double(receipt.bounds.width)), + "height": .double(Double(receipt.bounds.height)), + ]), + "quality": .string(proof.quality.rawValue), + ] + let meta = try MCPDesktopTargetMetadataProjector.fields( + identity, + merging: ["browser_window_binding": .object(bindingFields)]) + return ToolResponse.text( + "Bound page \(proof.pageReference) to Chrome pid \(receipt.target.processIdentifier) " + + "window \(receipt.target.windowID) (exact).", + meta: .object(meta)) + } + + static func nativeWindowBindingFailure(_ error: any Error) -> DesktopActionFailure { + let reason: DesktopActionOutcome.RefusalReason + let message: String + let hint: String + switch error { + case BrowserNativeWindowBindingCoordinatorError.controlUnavailable: + reason = .transportSessionUnavailable + message = "The retained native browser control session is unavailable." + hint = "Reconnect the process-local browser session and bind the page again." + case BrowserNativeWindowBindingCoordinatorError.deadlineExceeded: + reason = .targetUnavailable + message = "Native browser window validation exceeded its deadline before mutation dispatch." + hint = "Refresh browser and native window state before retrying." + default: + reason = .targetUnavailable + message = "The opaque browser page no longer has valid exact native-window authority." + hint = "Refresh list_pages and native windows, then bind the page again." + } + return .preDispatchRefusal( + reason: reason, + message: message, + hint: hint, + causeDescription: error.localizedDescription) + } +} diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/MCP/Tools/BrowserTool.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/MCP/Tools/BrowserTool.swift index 5c027de0d..9f224504b 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/MCP/Tools/BrowserTool.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/MCP/Tools/BrowserTool.swift @@ -8,12 +8,17 @@ public enum BrowserToolInstructionAudience: Sendable { case commandLine } +public enum BrowserProcessLocalAction { + public static let bindWindow = "bind_window" +} + public struct BrowserTool: MCPTool { - private let client: any BrowserMCPClientProviding + let client: any BrowserMCPClientProviding private let connectionPolicy: BrowserMCPExecutionConnectionPolicy private let executionPolicy: MCPToolExecutionPolicy private let instructionAudience: BrowserToolInstructionAudience - private let capabilitySession: BrowserToolCapabilitySession? + let capabilitySession: BrowserToolCapabilitySession? + let nativeWindowBindingProvider: (any BrowserMCPNativeWindowBindingProviding)? public let name = "browser" public var description: String { @@ -42,13 +47,18 @@ public struct BrowserTool: MCPTool { public var inputSchema: Value { let foregroundCapable = self.executionPolicy != .backgroundOnly - let actions = BrowserAction.allCases.filter { foregroundCapable || $0 != .connect } + var actions = BrowserAction.allCases + .filter { foregroundCapable || $0 != .connect } + .map(\.rawValue) + if self.nativeWindowBindingProvider != nil { + actions.append(BrowserProcessLocalAction.bindWindow) + } var properties: [String: Value] = [ "action": SchemaBuilder.string( description: foregroundCapable ? "Browser action. Use status before explicit connect; connect may present Chrome approval UI." : "Background-safe action against an existing exact connection; connect is unavailable.", - enum: actions.map(\.rawValue)), + enum: actions), "channel": SchemaBuilder.string( description: """ Chrome channel selected by explicit connect. Defaults to the running Chrome channel, then stable. @@ -121,6 +131,14 @@ public struct BrowserTool: MCPTool { "mcp_args_json": SchemaBuilder.string(description: "Advanced: JSON object args for raw MCP call. " + "Page-targeted tools require top-level page_id; nested pageId cannot select the page."), ] + if self.nativeWindowBindingProvider != nil { + properties["pid"] = SchemaBuilder.integer( + description: "Exact Chrome PID for bind_window; must match the connection receipt.", + minimum: 1) + properties["window_id"] = SchemaBuilder.integer( + description: "Exact WindowServer ID for bind_window.", + minimum: 1) + } if foregroundCapable { properties["browser_url"] = SchemaBuilder.string(description: """ Exact loopback DevTools HTTP endpoint for connect, for example http://127.0.0.1:9222. @@ -138,10 +156,22 @@ public struct BrowserTool: MCPTool { client: (any BrowserMCPClientProviding)? = nil, instructionAudience: BrowserToolInstructionAudience = .mcp) { - self.client = client ?? context.browser + let resolvedClient = client ?? context.browser + let capabilitySession = instructionAudience == .mcp ? context.browserCapabilities : nil + let nativeProvider = resolvedClient as? any BrowserMCPNativeWindowBindingProviding + self.client = resolvedClient self.executionPolicy = context.executionPolicy self.instructionAudience = instructionAudience - self.capabilitySession = instructionAudience == .mcp ? context.browserCapabilities : nil + self.capabilitySession = capabilitySession + if context.executionHost == .local, + let capabilitySession, + let nativeProvider, + nativeProvider.nativeWindowBindingCapabilitySession === capabilitySession + { + self.nativeWindowBindingProvider = nativeProvider + } else { + self.nativeWindowBindingProvider = nil + } self.connectionPolicy = context.executionPolicy == .backgroundOnly ? .requireExistingLiveReceipt : .allowAutoConnect @@ -156,6 +186,7 @@ public struct BrowserTool: MCPTool { self.executionPolicy = executionPolicy self.instructionAudience = instructionAudience self.capabilitySession = nil + self.nativeWindowBindingProvider = nil self.connectionPolicy = executionPolicy == .backgroundOnly ? .requireExistingLiveReceipt : .allowAutoConnect @@ -166,9 +197,13 @@ public struct BrowserTool: MCPTool { if let rejection = self.executionPolicy.rejection(toolName: self.name, arguments: arguments) { return rejection } - guard let actionName = arguments.getString("action"), - let action = BrowserAction(rawValue: actionName) - else { + guard let actionName = arguments.getString("action") else { + return ToolResponse.error("Missing or invalid required parameter: action") + } + if actionName == BrowserProcessLocalAction.bindWindow { + return try await self.executeNativeWindowBindingResponse(arguments: arguments) + } + guard let action = BrowserAction(rawValue: actionName) else { return ToolResponse.error("Missing or invalid required parameter: action") } @@ -204,6 +239,14 @@ public struct BrowserTool: MCPTool { browserURL: browserURL) } catch let error as BrowserToolCapabilityError { return ToolResponse.error(error.localizedDescription) + } catch let error as BrowserToolNativeWindowBindingError { + return try MCPToolResponseMetadataProjector.errorResponse( + for: Self.nativeWindowBindingFailure(error), + invalidatedSnapshotID: nil) + } catch let error as BrowserNativeWindowBindingCoordinatorError { + return try MCPToolResponseMetadataProjector.errorResponse( + for: Self.nativeWindowBindingFailure(error), + invalidatedSnapshotID: nil) } catch let error as BrowserToolError { return ToolResponse.error(error.localizedDescription) } catch let error as MCPToolArgumentValueError { @@ -359,11 +402,24 @@ public struct BrowserTool: MCPTool { } else { nil } + let nativeBoundPageReference: String? = if Self.sequenceSemantics(calls) == .mutating, + let pageReference = resolved?.pageReference, + let capabilitySession, + let sessionBinding, + try await capabilitySession.hasNativeWindowBinding( + pageReference: pageReference, + sessionBinding: sessionBinding) + { + pageReference + } else { + nil + } let response = try await self.executeSequence( calls, channel: channel, expectedSessionBinding: sessionBinding, - elementPreflight: elementPreflight) + elementPreflight: elementPreflight, + nativeBoundPageReference: nativeBoundPageReference) guard let capabilitySession = self.capabilitySession else { return response } do { return try await capabilitySession.project( @@ -421,7 +477,8 @@ public struct BrowserTool: MCPTool { _ calls: [BrowserMCPMappedCall], channel: BrowserMCPChannel?, expectedSessionBinding: BrowserMCPExecutionSessionBinding? = nil, - elementPreflight: BrowserMCPElementPreflight? = nil) async throws -> ToolResponse + elementPreflight: BrowserMCPElementPreflight? = nil, + nativeBoundPageReference: String? = nil) async throws -> ToolResponse { let semantics = Self.sequenceSemantics(calls) guard let resultClient = self.client as? any BrowserMCPActionResultProviding else { @@ -447,7 +504,26 @@ public struct BrowserTool: MCPTool { try Self.checkCancellationBeforeProviderEntry() let result: DesktopActionResult do { - if let expectedSessionBinding { + if let nativeBoundPageReference { + guard let expectedSessionBinding, + let nativeWindowBindingProvider = self.nativeWindowBindingProvider + else { + throw DesktopActionFailure.preDispatchRefusal( + reason: .operationUnsupported, + message: "The bound browser page has no process-local native execution authority.", + hint: "Use the same scoped MCP or Agent session that created the binding.") + } + result = try await nativeWindowBindingProvider + .executeNativeWindowBoundSequenceWithOutcomeHoldingCapabilityGate( + .init( + calls: calls, + channel: channel, + sessionBinding: expectedSessionBinding, + elementPreflight: elementPreflight, + pageReference: nativeBoundPageReference, + deadline: ContinuousClock.now.advanced(by: .seconds(10)))) + .actionResult + } else if let expectedSessionBinding { guard let atomicClient = self.client as? any BrowserMCPAtomicSessionActionProviding else { throw DesktopActionFailure.preDispatchRefusal( reason: .operationUnsupported, @@ -709,11 +785,16 @@ public struct BrowserTool: MCPTool { extension BrowserTool: MCPToolArgumentSemanticValidating { func validateArgumentSemantics(_ arguments: ToolArguments) throws { - guard let actionName = arguments.getString("action"), - let action = BrowserAction(rawValue: actionName) - else { + guard let actionName = arguments.getString("action") else { throw BrowserToolError.missingParameter("action") } + if actionName == BrowserProcessLocalAction.bindWindow { + try self.validateNativeWindowBindingArguments(arguments) + return + } + guard let action = BrowserAction(rawValue: actionName) else { + throw BrowserToolError.invalidAction(actionName) + } let providerArguments = try self.capabilityValidationArguments(arguments) switch action { @@ -726,6 +807,34 @@ extension BrowserTool: MCPToolArgumentSemanticValidating { } } + private func validateNativeWindowBindingArguments(_ arguments: ToolArguments) throws { + guard self.nativeWindowBindingProvider != nil else { + throw BrowserToolError.invalidAction(BrowserProcessLocalAction.bindWindow) + } + let allowedKeys: Set = ["action", "page_id", "pid", "window_id"] + if let unexpected = Set(arguments.rawDictionary.keys).subtracting(allowedKeys).min() { + throw BrowserToolError.unsupportedBindingParameter(unexpected) + } + guard let pageReference = arguments.getString("page_id") else { + throw BrowserToolError.missingParameter("page_id") + } + guard BrowserToolCapabilityReference.isValid(pageReference, prefix: "bp1") else { + throw BrowserToolCapabilityError.invalidPageReference + } + guard let rawPID = try arguments.validatedInt("pid") else { + throw BrowserToolError.missingParameter("pid") + } + guard Int32(exactly: rawPID).map({ $0 > 0 }) == true else { + throw BrowserToolError.invalidProcessIdentifier + } + guard let rawWindowID = try arguments.validatedInt("window_id") else { + throw BrowserToolError.missingParameter("window_id") + } + guard UInt32(exactly: rawWindowID).map({ $0 > 0 }) == true else { + throw BrowserToolError.invalidWindowIdentifier + } + } + private func capabilityValidationArguments(_ arguments: ToolArguments) throws -> ToolArguments { guard self.capabilitySession != nil else { return arguments } var raw = arguments.rawDictionary @@ -754,6 +863,9 @@ private enum BrowserToolError: LocalizedError { case unsupportedRawTool(String) case selectedPageRoutingUnsupported(String) case globalPageReferenceUnsupported(String) + case unsupportedBindingParameter(String) + case invalidProcessIdentifier + case invalidWindowIdentifier var errorDescription: String? { switch self { @@ -773,6 +885,12 @@ private enum BrowserToolError: LocalizedError { "until upstream adds explicit pageId routing" case let .globalPageReferenceUnsupported(toolName): "Raw global Chrome DevTools MCP tool '\(toolName)' does not accept page_id" + case let .unsupportedBindingParameter(name): + "bind_window does not accept \(name); pass exactly page_id, pid, and window_id" + case .invalidProcessIdentifier: + "pid must be a positive Int32" + case .invalidWindowIdentifier: + "window_id must be a positive UInt32" } } } diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserNativeWindowBindingCoordinatorTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserNativeWindowBindingCoordinatorTests.swift index 466aabe04..d7b15e7c5 100644 --- a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserNativeWindowBindingCoordinatorTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserNativeWindowBindingCoordinatorTests.swift @@ -51,6 +51,92 @@ struct BrowserNativeWindowBindingCoordinatorTests { await fixture.control.close() } + @Test + func `manager owned bound mutation uses one capability and execution gate`() async throws { + let fixture = try await Self.fixture() + let proof = try await BrowserNativeWindowBindingCoordinator.bind( + pageReference: fixture.pageReference, + nativeTarget: Self.nativeTarget, + context: fixture.context, + dependencies: Self.dependencies()) + fixture.provider.executeHandler = { toolName, _ in + switch toolName { + case "take_snapshot": + ToolResponse( + content: [.text( + text: "uid=1_0 button \"Continue\"", + annotations: nil, + _meta: nil)], + structuredContent: .object([ + "snapshot": .object([ + "id": .string("1_0"), + "role": .string("button"), + "name": .string("Continue"), + ]), + ])) + case "click": + ToolResponse.text("clicked") + default: + ToolResponse.error("unexpected bound tool") + } + } + + let execution = try await fixture.capabilities.withExclusiveOperation { + try await fixture.manager.executeNativeWindowBoundSequence( + .init( + calls: [BrowserMCPMappedCall( + toolName: "click", + arguments: ["pageId": 7, "uid": "1_0"])], + channel: .stable, + sessionBinding: fixture.sessionBinding, + elementPreflight: .init(providerPageID: 7, providerUIDs: ["1_0"]), + pageReference: fixture.pageReference, + deadline: Self.deadline), + capabilities: fixture.capabilities, + receiptProviders: Self.providers()) + } + + #expect(!execution.result.response.isError) + #expect(execution.nativeWindowReceipt == proof.nativeWindowReceipt) + #expect(fixture.provider.executedTools.suffix(2) == ["take_snapshot", "click"]) + await fixture.control.close() + } + + @Test + func `bound provider cancellation remains indeterminate after dispatch`() async throws { + let fixture = try await Self.fixture() + let proof = try await BrowserNativeWindowBindingCoordinator.bind( + pageReference: fixture.pageReference, + nativeTarget: Self.nativeTarget, + context: fixture.context, + dependencies: Self.dependencies()) + fixture.provider.executeHandler = { toolName, _ in + #expect(toolName == "navigate_page") + throw CancellationError() + } + + let execution = try await fixture.capabilities.withExclusiveOperation { + try await fixture.manager.executeNativeWindowBoundSequence( + .init( + calls: [BrowserMCPMappedCall( + toolName: "navigate_page", + arguments: ["pageId": 7, "type": "url", "url": "https://next.test/"])], + channel: .stable, + sessionBinding: fixture.sessionBinding, + elementPreflight: nil, + pageReference: fixture.pageReference, + deadline: Self.deadline), + capabilities: fixture.capabilities, + receiptProviders: Self.providers()) + } + + #expect(execution.result.completedCallCount == 0) + #expect(execution.result.dispatchedCallCount == 1) + #expect(execution.result.actionFailure?.outcome.state == .indeterminate) + #expect(execution.result.actionFailure?.outcome.retrySafety == .unsafe) + #expect(execution.nativeWindowReceipt == proof.nativeWindowReceipt) + } + @Test func `Unrelated stale page and window candidates do not revoke exact binding`() async throws { let fixture = try await Self.fixture(includeStaleUnrelatedCandidates: true) @@ -214,7 +300,7 @@ struct BrowserNativeWindowBindingCoordinatorTests { dependencies: .init(receiptProviders: invalidProviders)) } - #expect(fixture.provider.executedTools.suffix(1) == ["get_tab_id"]) + #expect(fixture.provider.executedTools == ["list_pages"]) await fixture.control.close() } diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserToolCapabilityIntegrationTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserToolCapabilityIntegrationTests.swift index 206b31f4c..b18229d7d 100644 --- a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserToolCapabilityIntegrationTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserToolCapabilityIntegrationTests.swift @@ -59,6 +59,113 @@ struct BrowserToolCapabilityIntegrationTests { #expect(client.sequences.count == 2) } + @Test + func `process local bind window is opaque and invalidation never falls back`() async throws { + let client = CapabilityBrowserMCPClient() + let context = Self.context(client: client) + client.nativeWindowBindingCapabilitySession = context.browserCapabilities + let tool = BrowserTool(context: context) + let schema = try #require(tool.inputSchema.objectValue) + let properties = try #require(schema["properties"]?.objectValue) + let actions = try #require(properties["action"]?.objectValue?["enum"]?.arrayValue) + #expect(actions.contains(.string(BrowserProcessLocalAction.bindWindow))) + #expect(properties["pid"] != nil) + #expect(properties["window_id"] != nil) + + let listed = try await context.execute( + tool: tool, + arguments: ToolArguments(raw: ["action": "list_pages"])) + let pageReference = try Self.pageReference(from: listed) + let mismatchedPID = try await context.execute( + tool: tool, + arguments: ToolArguments(raw: [ + "action": BrowserProcessLocalAction.bindWindow, + "page_id": pageReference, + "pid": 43, + "window_id": 313, + ])) + #expect(mismatchedPID.isError) + #expect(client.nativeBindCount == 0) + + let bound = try await context.execute( + tool: tool, + arguments: ToolArguments(raw: [ + "action": BrowserProcessLocalAction.bindWindow, + "page_id": pageReference, + "pid": 42, + "window_id": 313, + ])) + + #expect(!bound.isError) + #expect(client.nativeBindCount == 1) + let boundText = Self.allText(from: bound) + #expect(boundText.contains(pageReference)) + #expect(!boundText.contains("private-target-a")) + #expect(!boundText.contains("provider_session_epoch")) + let binding = bound.meta?.objectValue?["browser_window_binding"]?.objectValue + #expect(binding?["process_start_identity_decimal"] == .string("1001")) + + let normalDispatchCount = client.sequences.count + let navigated = try await context.execute( + tool: tool, + arguments: ToolArguments(raw: [ + "action": "navigate", + "page_id": pageReference, + "url": "https://next.test/", + ])) + #expect(!navigated.isError) + #expect(client.nativeBoundSequences.count == 1) + #expect(client.sequences.count == normalDispatchCount) + let nativeEvidence = navigated.meta?.objectValue?[BrowserMCPExecutionEvidence.metadataKey]? + .objectValue?["native_window_receipt"]?.objectValue + #expect(nativeEvidence?["window_id"] == .int(313)) + + await context.browserCapabilities.invalidateNativeWindowBinding(pageReference: pageReference) + let refused = try await context.execute( + tool: tool, + arguments: ToolArguments(raw: [ + "action": "navigate", + "page_id": pageReference, + "url": "https://refused.test/", + ])) + #expect(refused.isError) + #expect(client.nativeBoundSequences.count == 1) + #expect(client.sequences.count == normalDispatchCount) + } + + @Test + func `provider child replacement rejects old opaque refs before tool dispatch`() async throws { + let client = CapabilityBrowserMCPClient() + let context = Self.context(client: client) + let tool = BrowserTool(context: context) + + let listed = try await context.execute( + tool: tool, + arguments: ToolArguments(raw: ["action": "list_pages"])) + let pageReference = try Self.pageReference(from: listed) + let snapshotted = try await context.execute( + tool: tool, + arguments: ToolArguments(raw: [ + "action": "snapshot", + "page_id": pageReference, + ])) + let elementReference = try Self.elementReference(from: snapshotted) + let dispatchCount = client.sequences.count + + client.restartProviderChild() + let rejected = try await context.execute( + tool: tool, + arguments: ToolArguments(raw: [ + "action": "click", + "page_id": pageReference, + "uid": elementReference, + ])) + + #expect(rejected.isError) + #expect(Self.text(from: rejected).contains("another or expired provider session")) + #expect(client.sequences.count == dispatchCount) + } + @Test func `text only daemon snapshot refuses instead of minting ambiguous element refs`() async throws { let client = CapabilityBrowserMCPClient(structuredResponses: false) @@ -958,14 +1065,18 @@ extension BrowserToolCapabilityIntegrationTests { @MainActor private final class CapabilityBrowserMCPClient: BrowserMCPClientProviding, BrowserMCPActionResultProviding, - BrowserMCPAtomicSessionActionProviding, + BrowserMCPAtomicSessionActionProviding, BrowserMCPNativeWindowBindingProviding, @unchecked Sendable { + let supportsNativeBrowserConnectionBinding = true let structuredResponses: Bool let providesEpoch: Bool - let providerSessionEpoch = BrowserMCPProviderSessionEpoch() + private(set) var providerSessionEpoch = BrowserMCPProviderSessionEpoch() private(set) var sequences: [[BrowserMCPMappedCall]] = [] private(set) var elementPreflights: [BrowserMCPElementPreflight?] = [] + private(set) var nativeBindCount = 0 + private(set) var nativeBoundSequences: [[BrowserMCPMappedCall]] = [] + nonisolated(unsafe) var nativeWindowBindingCapabilitySession: BrowserToolCapabilitySession? var executeHandler: (@MainActor (String) async -> ToolResponse)? init(structuredResponses: Bool = true, providesEpoch: Bool = true) { @@ -973,12 +1084,20 @@ private final class CapabilityBrowserMCPClient: BrowserMCPClientProviding, Brows self.providesEpoch = providesEpoch } + func restartProviderChild() { + self.providerSessionEpoch = BrowserMCPProviderSessionEpoch() + } + func status(channel _: BrowserMCPChannel?) async -> BrowserMCPStatus { BrowserMCPStatus( isConnected: true, toolCount: 52, detectedBrowsers: [], connectionReceipt: BrowserMCPConnectionReceipt( + channel: .stable, + processIdentifier: 42, + processStartIdentity: 1001, + bundleIdentifier: "com.google.Chrome", browserURL: "http://127.0.0.1:9222/", webSocketDebuggerURL: "ws://127.0.0.1:9222/devtools/browser/browser-a", devToolsBrowserID: "browser-a", @@ -1032,6 +1151,56 @@ private final class CapabilityBrowserMCPClient: BrowserMCPClientProviding, Brows return try await self.executeSequenceWithOutcome(calls, channel: channel) } + func bindNativeWindowHoldingCapabilityGate( + pageReference: String, + target: BrowserNativeWindowTarget, + expectedSessionBinding: BrowserMCPExecutionSessionBinding, + deadline _: ContinuousClock.Instant) async throws -> BrowserNativeWindowBindingProof + { + let capabilities = try #require(self.nativeWindowBindingCapabilitySession) + let bounds = CGRect(x: -1200, y: 80, width: 1200, height: 800) + let identity = WindowMutationIdentity( + windowID: Int(target.windowID), + ownerProcessIdentifier: target.processIdentifier, + ownerProcessStartIdentity: target.processStartIdentity, + capturedBounds: bounds) + let receipt = BrowserNativeWindowReceipt( + target: target, + windowIdentity: identity, + bounds: bounds) + self.nativeBindCount += 1 + try await capabilities.bindNativeWindow( + pageReference: pageReference, + sessionBinding: expectedSessionBinding, + privateTargetID: "private-target-a", + privateBrowserWindowID: .init(rawValue: 41), + nativeWindowReceipt: receipt) + return BrowserNativeWindowBindingProof( + pageReference: pageReference, + nativeWindowReceipt: receipt, + quality: .exact) + } + + func executeNativeWindowBoundSequenceWithOutcomeHoldingCapabilityGate( + _ request: BrowserMCPNativeWindowBoundExecutionRequest) async throws + -> BrowserMCPNativeWindowBoundActionResult + { + self.nativeBoundSequences.append(request.calls) + let response = self.response(for: request.calls.last?.toolName) + let outcome = self.outcome(for: request.calls, response: response) + let capabilities = try #require(self.nativeWindowBindingCapabilitySession) + let receipt = try await capabilities.nativeWindowBinding( + pageReference: request.pageReference, + sessionBinding: request.sessionBinding).nativeWindowReceipt + return BrowserMCPNativeWindowBoundActionResult( + actionResult: DesktopActionResult( + payload: BrowserMCPExecutionEvidence.attachingNativeWindowReceipt( + to: response, + receipt: receipt), + outcome: outcome), + nativeWindowReceipt: receipt) + } + private func response(for toolName: String?) -> ToolResponse { switch toolName { case "list_pages": self.pageResponse() diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserToolCapabilitySessionTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserToolCapabilitySessionTests.swift index a3b1ca18c..8e228c060 100644 --- a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserToolCapabilitySessionTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserToolCapabilitySessionTests.swift @@ -334,6 +334,10 @@ struct BrowserToolCapabilitySessionTests { windowIdentity: windowIdentity, bounds: #require(windowIdentity.capturedBounds)) + #expect(try await !session.hasNativeWindowBinding( + pageReference: pageReference, + sessionBinding: sessionBinding)) + try await session.bindNativeWindow( pageReference: pageReference, sessionBinding: sessionBinding, @@ -341,6 +345,10 @@ struct BrowserToolCapabilitySessionTests { privateBrowserWindowID: BrowserMCPDevToolsWindowID(rawValue: 77), nativeWindowReceipt: nativeWindowReceipt) + #expect(try await session.hasNativeWindowBinding( + pageReference: pageReference, + sessionBinding: sessionBinding)) + let binding = try await session.nativeWindowBinding( pageReference: pageReference, sessionBinding: sessionBinding) @@ -353,6 +361,11 @@ struct BrowserToolCapabilitySessionTests { .string(pageReference)) await session.invalidateNativeWindowBindings() + await #expect(throws: BrowserToolNativeWindowBindingError.stalePageReference) { + _ = try await session.hasNativeWindowBinding( + pageReference: pageReference, + sessionBinding: sessionBinding) + } await #expect(throws: BrowserToolNativeWindowBindingError.stalePageReference) { _ = try await session.nativeWindowBinding( pageReference: pageReference, diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/NativeBrowserWindowCorrelatorTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/NativeBrowserWindowCorrelatorTests.swift index 84959d21f..3abe3c452 100644 --- a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/NativeBrowserWindowCorrelatorTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/NativeBrowserWindowCorrelatorTests.swift @@ -10,7 +10,6 @@ struct NativeBrowserWindowCorrelatorTests { let result = try NativeBrowserWindowCorrelator.correlate( expectedNativeWindow: identity, currentNativeWindow: identity, - nativeTitle: nil, requestedTargetID: "target-a", candidates: [Self.candidate( windowID: 91, @@ -35,7 +34,6 @@ struct NativeBrowserWindowCorrelatorTests { let result = try NativeBrowserWindowCorrelator.correlate( expectedNativeWindow: identity, currentNativeWindow: identity, - nativeTitle: nil, requestedTargetID: "target-a", candidates: [atBoundary]) #expect(result.browserWindowID.rawValue == 91) @@ -48,7 +46,6 @@ struct NativeBrowserWindowCorrelatorTests { try NativeBrowserWindowCorrelator.correlate( expectedNativeWindow: identity, currentNativeWindow: identity, - nativeTitle: nil, requestedTargetID: "target-a", candidates: [outsideBoundary]) } @@ -61,7 +58,6 @@ struct NativeBrowserWindowCorrelatorTests { let result = try NativeBrowserWindowCorrelator.correlate( expectedNativeWindow: identity, currentNativeWindow: identity, - nativeTitle: nil, requestedTargetID: "target-negative", candidates: [ Self.candidate( @@ -78,44 +74,19 @@ struct NativeBrowserWindowCorrelatorTests { } @Test - func `exact title breaks only a geometry tie`() throws { - let bounds = CGRect(x: 20, y: 30, width: 1100, height: 800) - let identity = Self.identity(bounds: bounds) - let result = try NativeBrowserWindowCorrelator.correlate( - expectedNativeWindow: identity, - currentNativeWindow: identity, - nativeTitle: "Peekaboo - Background", - requestedTargetID: "target-b", - candidates: [ - Self.candidate( - windowID: 21, - bounds: bounds, - titles: ["Other", "Background Tab"], - targetIDs: ["target-a"]), - Self.candidate( - windowID: 22, - bounds: bounds, - titles: ["Peekaboo - Background", "Inactive Tab"], - targetIDs: ["target-b"]), - ]) - - #expect(result.browserWindowID.rawValue == 22) - } - - @Test - func `title comparison is exact and duplicate title matches remain ambiguous`() { + func `title metadata cannot break a geometry tie`() { let bounds = CGRect(x: 20, y: 30, width: 1100, height: 800) let identity = Self.identity(bounds: bounds) let candidates = [ Self.candidate( windowID: 21, bounds: bounds, - titles: ["Peekaboo"], + titles: ["Other", "Background Tab"], targetIDs: ["target-a"]), Self.candidate( windowID: 22, bounds: bounds, - titles: ["peekaboo"], + titles: ["Peekaboo - Background", "Inactive Tab"], targetIDs: ["target-b"]), ] @@ -123,26 +94,9 @@ struct NativeBrowserWindowCorrelatorTests { try NativeBrowserWindowCorrelator.correlate( expectedNativeWindow: identity, currentNativeWindow: identity, - nativeTitle: " PEEKABOO ", - requestedTargetID: "target-a", + requestedTargetID: "target-b", candidates: candidates) } - - let duplicateTitles = candidates.map { - Self.candidate( - windowID: $0.windowID.rawValue, - bounds: $0.bounds, - titles: ["Peekaboo"], - targetIDs: $0.targetIDs) - } - #expect(throws: NativeBrowserWindowCorrelationError.ambiguousGeometry) { - try NativeBrowserWindowCorrelator.correlate( - expectedNativeWindow: identity, - currentNativeWindow: identity, - nativeTitle: "Peekaboo", - requestedTargetID: "target-a", - candidates: duplicateTitles) - } } @Test @@ -158,7 +112,6 @@ struct NativeBrowserWindowCorrelatorTests { try NativeBrowserWindowCorrelator.correlate( expectedNativeWindow: identity, currentNativeWindow: identity, - nativeTitle: "Exact Title", requestedTargetID: "target-a", candidates: [titleOnly]) } @@ -181,7 +134,6 @@ struct NativeBrowserWindowCorrelatorTests { try NativeBrowserWindowCorrelator.correlate( expectedNativeWindow: identity, currentNativeWindow: identity, - nativeTitle: "Exact Title", requestedTargetID: "requested-target", candidates: candidates) } @@ -203,7 +155,6 @@ struct NativeBrowserWindowCorrelatorTests { try NativeBrowserWindowCorrelator.correlate( expectedNativeWindow: identity, currentNativeWindow: identity, - nativeTitle: nil, requestedTargetID: "target-a", candidates: candidates) } @@ -211,7 +162,6 @@ struct NativeBrowserWindowCorrelatorTests { try NativeBrowserWindowCorrelator.correlate( expectedNativeWindow: identity, currentNativeWindow: identity, - nativeTitle: nil, requestedTargetID: "", candidates: [candidates[0]]) } @@ -234,7 +184,6 @@ struct NativeBrowserWindowCorrelatorTests { try NativeBrowserWindowCorrelator.correlate( expectedNativeWindow: expected, currentNativeWindow: current, - nativeTitle: nil, requestedTargetID: "target-a", candidates: [candidate]) } @@ -248,7 +197,6 @@ struct NativeBrowserWindowCorrelatorTests { try NativeBrowserWindowCorrelator.correlate( expectedNativeWindow: missingBounds, currentNativeWindow: missingBounds, - nativeTitle: "Exact Title", requestedTargetID: "target-a", candidates: [Self.candidate( windowID: 21, @@ -263,7 +211,6 @@ struct NativeBrowserWindowCorrelatorTests { try NativeBrowserWindowCorrelator.correlate( expectedNativeWindow: identity, currentNativeWindow: identity, - nativeTitle: "Exact Title", requestedTargetID: "target-a", candidates: [Self.candidate( windowID: 21, diff --git a/Core/PeekabooCore/Tests/PeekabooTests/MCP/MCPPolicyAwareCatalogTests.swift b/Core/PeekabooCore/Tests/PeekabooTests/MCP/MCPPolicyAwareCatalogTests.swift index 703132a50..00db05ad2 100644 --- a/Core/PeekabooCore/Tests/PeekabooTests/MCP/MCPPolicyAwareCatalogTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooTests/MCP/MCPPolicyAwareCatalogTests.swift @@ -136,6 +136,9 @@ struct MCPPolicyAwareCatalogTests { #expect(!actions.contains(.string(BrowserAction.connect.rawValue))) #expect(actions.contains(.string(BrowserAction.status.rawValue))) #expect(actions.contains(.string(BrowserAction.listPages.rawValue))) + #expect(!actions.contains(.string(BrowserProcessLocalAction.bindWindow))) + #expect(properties["pid"] == nil) + #expect(properties["window_id"] == nil) #expect(properties["browser_url"] == nil) #expect(tool.description.contains("Connect is unavailable")) #expect(tool.description.contains("Restart this exact MCP server/session")) @@ -153,6 +156,7 @@ struct MCPPolicyAwareCatalogTests { return } #expect(foregroundActions.contains(.string(BrowserAction.connect.rawValue))) + #expect(!foregroundActions.contains(.string(BrowserProcessLocalAction.bindWindow))) #expect(foregroundProperties["browser_url"] != nil) #expect(foregroundTool.description.contains("accept Chrome's remote debugging prompt")) } diff --git a/docs/browser-mcp.md b/docs/browser-mcp.md index b5f616948..916367b1f 100644 --- a/docs/browser-mcp.md +++ b/docs/browser-mcp.md @@ -37,8 +37,9 @@ automatically. Once Chrome publishes exact loopback listener belongs to the detected Chrome PID and process generation, and opens the exact published WebSocket. That native connection remains pending while Chrome shows its approval prompt, has a bounded 60-second wait, sends CDP `Browser.getVersion`, and then -revalidates the process-owned listener. Peekaboo then closes the native probe and passes its exact WebSocket URL identity -as `--wsEndpoint` to Chrome DevTools MCP; the separately owned MCP child opens the second and final WebSocket used for +revalidates the process-owned listener. Peekaboo retains that first WebSocket as a read-only, host-owned control session +and passes its exact URL identity as `--wsEndpoint` to Chrome DevTools MCP; the separately owned MCP child opens the +second and final WebSocket used for execution. A new explicit foreground channel connect therefore creates exactly two legitimate WebSocket connections, and Chrome may show one approval dialog for each. Once the child is connected, status, repeated connect, and browser execution revalidate the active-port file, kernel listener, PID generation, and bundle without opening another native @@ -136,6 +137,13 @@ Browser MCP state is owned by `BrowserMCPService` through `BrowserMCPSessionMana navigation, disconnect, connection replacement, and MCP-session teardown invalidate their complete subordinate namespace. Before element dispatch, the same provider gate takes a fresh snapshot and proves every provider UID is still present in the current document. References copied into another caller session fail before Chrome dispatch. +- Process-local MCP and Agent sessions with one native-channel Chrome receipt can bind an opaque page to an exact native + window using `{ "action": "bind_window", "page_id": "bp1_...", "pid": 123, "window_id": 456 }`. All three + selectors are required; the process generation comes only from the exact connection receipt. Peekaboo privately + correlates the page target and Chrome window geometry, then revalidates the native receipt, tab membership, retained + control session, and provider child immediately before every bound mutation. A moved tab, resized/replaced window, + restarted process/provider, or dead control session invalidates the binding and never falls back to unbound dispatch. + Explicit URLs, isolated profiles, remote/custom providers, and standalone CLI sessions cannot bind. - Independently authenticated process-local browser sessions own separate Chrome DevTools MCP children and FIFO execution/mutation gates, so one blocked session does not stall another while calls within each session remain ordered. Peekaboo reserves the canonical process/DevTools target before permission-bearing provider setup; two @@ -166,6 +174,7 @@ Common actions: - `navigate` - `wait_for` - `snapshot` +- `bind_window` (process-local MCP/Agent sessions only) - `click` - `fill` - `type` @@ -204,6 +213,10 @@ compatibility boundary rather than a persistent caller capability namespace. `se `type` and `press_key` also require an opaque element reference from the newest snapshot as `uid`. Peekaboo holds one browser execution gate while it focuses that exact uid and sends the keyboard operation; concurrent page work cannot interleave between those leaves. +After `bind_window` succeeds, every mutation result carries a fresh sanitized native receipt with PID, decimal process +generation, WindowServer ID, bounds, and `quality: exact`. Provider page integers, CDP target IDs, CDP browser-window +IDs, and the private `get_tab_id` result never cross the public tool boundary. + ## Examples CLI: From df9d7809de1b090e59eb68327290ad08451a96d6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 17:09:52 -0700 Subject: [PATCH 07/14] fix(cli): refuse ephemeral browser window binding --- Apps/CLI/CHANGELOG.md | 1 + .../PeekabooCLI/Commands/MCP/BrowserCommand.swift | 11 +++++++++++ .../InvalidInputOrderingCLITests.swift | 7 +++++++ .../PreRuntimeInvalidInputOrderingTests.swift | 4 ++++ docs/commands/browser.md | 6 ++++++ 5 files changed, 29 insertions(+) diff --git a/Apps/CLI/CHANGELOG.md b/Apps/CLI/CHANGELOG.md index 3b142eabd..8aa5de13e 100644 --- a/Apps/CLI/CHANGELOG.md +++ b/Apps/CLI/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `click --modifiers ... --foreground` with exact snapshot preflight and truthful cursor/focus restoration reporting. ### Changed +- Refuse standalone `browser bind-window` before runtime discovery until Bridge can carry an authenticated persistent browser namespace; process-local MCP and Agent sessions own the exact binding instead. - Read `config credential set` secrets from no-echo prompts, stdin, or owner-only files; let `config provider add` also accept non-secret references; retain deprecated argv compatibility. - Skip provider discovery and Agent construction for caller-local commands that cannot invoke the Agent. - Avoid reopening and hashing Bridge screenshot artifacts twice before CLI or MCP consumption while retaining signed client verification and use-time publication checks. diff --git a/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand.swift b/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand.swift index fe3cadf5d..a3cb8aeff 100644 --- a/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand.swift +++ b/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand.swift @@ -31,6 +31,14 @@ private struct BrowserCommandInputError: LocalizedError, ResultEnvelopeError { envelopeHint: "Run `peekaboo browser connect --browser-url http://127.0.0.1:9222 --foreground`." ) } + + static func nativeWindowBindingRequiresNamespace() -> Self { + Self( + errorDescription: "browser bind-window is not available to standalone CLI invocations.", + envelopeHint: "Use one process-local MCP or Agent browser session. Durable CLI binding requires an " + + "authenticated Bridge 1.38 browser namespace receipt." + ) + } } @MainActor @@ -165,6 +173,9 @@ InjectedRuntimeBackedCommand { let normalizedAction = self.action .trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: "-", with: "_") + if normalizedAction == BrowserProcessLocalAction.bindWindow { + throw BrowserCommandInputError.nativeWindowBindingRequiresNamespace() + } guard BrowserAction(rawValue: normalizedAction) != nil else { throw ValidationError("Unsupported browser action '\(self.action)'") } diff --git a/Apps/CLI/Tests/CLIRuntimeTests/InvalidInputOrderingCLITests.swift b/Apps/CLI/Tests/CLIRuntimeTests/InvalidInputOrderingCLITests.swift index 2ac8e8bd2..477f17bf1 100644 --- a/Apps/CLI/Tests/CLIRuntimeTests/InvalidInputOrderingCLITests.swift +++ b/Apps/CLI/Tests/CLIRuntimeTests/InvalidInputOrderingCLITests.swift @@ -121,6 +121,13 @@ struct InvalidInputOrderingCLITests { message: "Unsupported browser action 'frobnicate'", hint: nil ), + JSONCase( + arguments: ["browser", "bind-window", "--json"], + code: "VALIDATION_ERROR", + message: "browser bind-window is not available to standalone CLI invocations.", + hint: "Use one process-local MCP or Agent browser session. Durable CLI binding requires an " + + "authenticated Bridge 1.38 browser namespace receipt." + ), JSONCase( arguments: [ "browser", "connect", "--browser-url", "ftp://127.0.0.1:1", "--json", diff --git a/Apps/CLI/Tests/CoreCLITests/PreRuntimeInvalidInputOrderingTests.swift b/Apps/CLI/Tests/CoreCLITests/PreRuntimeInvalidInputOrderingTests.swift index 55a7d4524..4ef27518a 100644 --- a/Apps/CLI/Tests/CoreCLITests/PreRuntimeInvalidInputOrderingTests.swift +++ b/Apps/CLI/Tests/CoreCLITests/PreRuntimeInvalidInputOrderingTests.swift @@ -15,6 +15,10 @@ struct PreRuntimeInvalidInputOrderingTests { ["peekaboo", "browser", "frobnicate", "--json"], "Unsupported browser action 'frobnicate'" ), + ( + ["peekaboo", "browser", "bind-window", "--json"], + "browser bind-window is not available to standalone CLI invocations." + ), ( [ "peekaboo", "browser", "connect", "--browser-url", "ftp://127.0.0.1:1", "--json", diff --git a/docs/commands/browser.md b/docs/commands/browser.md index 788fad513..1592ba545 100644 --- a/docs/commands/browser.md +++ b/docs/commands/browser.md @@ -47,6 +47,12 @@ these raw CLI compatibility values. Those references also bind the exact provide and expire after a newer snapshot, navigation, disconnect, connection replacement, or session end. Bridge-backed opaque-reference sessions currently fail closed pending an authenticated browser-session wire namespace. +`browser bind-window` is intentionally unavailable to standalone CLI invocations. A one-shot CLI process cannot retain +the caller-owned opaque page capability or native binding, and the daemon's legacy shared browser state is not a safe +substitute. Use one process-local MCP or Agent browser session. Durable CLI binding will require an authenticated Bridge +1.38 browser namespace receipt; until that wire contract exists, the CLI refuses before runtime discovery or provider +dispatch. + `browser upload-file` requires `--page-id`, a fresh file-input `--uid`, and an absolute `--path` to a current-user regular file no larger than 100 MiB. Peekaboo never grants Chrome DevTools MCP unrestricted filesystem access. The daemon copies the already-open source into its private browser-session temporary root, preserves only the source basename, and From adb86ba64580b2c98dfdf4b1264665271eb12715 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 17:38:16 -0700 Subject: [PATCH 08/14] feat(browser): add scoped namespace runtime --- ...rMCPScopedNamespaceResponseSanitizer.swift | 577 +++++++++++++++++ .../BrowserMCPScopedNamespaceRuntime.swift | 335 ++++++++++ ...rowserMCPScopedNamespaceRuntimeTests.swift | 588 ++++++++++++++++++ 3 files changed, 1500 insertions(+) create mode 100644 Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceResponseSanitizer.swift create mode 100644 Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceRuntime.swift create mode 100644 Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPScopedNamespaceRuntimeTests.swift diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceResponseSanitizer.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceResponseSanitizer.swift new file mode 100644 index 000000000..9261aa93e --- /dev/null +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceResponseSanitizer.swift @@ -0,0 +1,577 @@ +import CoreGraphics +import Foundation +import MCP +import PeekabooAutomationKit +import PeekabooFoundation +import TachikomaMCP + +/// Final defense-in-depth boundary before a scoped browser result reaches Bridge serialization. +/// +/// BrowserTool owns semantic page/element projection. This scrubber removes host-only connection fields everywhere in +/// the returned tree and fails closed if a provider capability field survived that projection. +enum BrowserMCPScopedNamespaceResponseSanitizer { + private enum FieldDisposition { + case keep + case removeHostOnly + case removeUnsafeCapability + } + + private static let hostOnlyFields: Set = [ + "browserid", + "browserurl", + "devtoolsbrowserid", + "providersessionepoch", + "websocketdebuggerurl", + ] + + private static let unsafeCapabilityFields: Set = [ + "cdpbrowserwindowid", + "cdptargetid", + "privatebrowserwindowid", + "privatetargetid", + "providerpageid", + "provideruid", + "targetid", + ] + + static func result( + _ response: ToolResponse, + arguments: ToolArguments, + policy: BrowserMCPScopedNamespaceExecutionPolicy) -> BrowserMCPScopedNamespaceExecutionResult + { + var foundUnsafeCapability = false + let content = response.content.map { + self.scrubContent($0, foundUnsafeCapability: &foundUnsafeCapability) + } + let meta = response.meta.flatMap { + self.scrubValue($0, foundUnsafeCapability: &foundUnsafeCapability) + } + let structuredContent = response.structuredContent.flatMap { + self.scrubValue($0, foundUnsafeCapability: &foundUnsafeCapability) + } + let scrubbed = ToolResponse( + content: content, + isError: response.isError, + meta: meta, + structuredContent: structuredContent) + let nativeWindowReceipt = self.nativeWindowReceipt( + from: scrubbed.meta, + requestedPageReference: arguments.getString("page_id")) + let targetIdentity = nativeWindowReceipt.flatMap(self.targetIdentity) ?? + self.processTargetIdentity(from: scrubbed.meta) + + let sanitized: ToolResponse = if foundUnsafeCapability { + self.withheldResponse( + arguments: arguments, + policy: policy, + originalMeta: scrubbed.meta) + } else { + scrubbed + } + let outcome = MCPToolResponseMetadataProjector + .actionOutcomeResolution(from: sanitized.meta) + .projection?.outcome + return BrowserMCPScopedNamespaceExecutionResult( + response: sanitized, + targetIdentity: targetIdentity, + outcome: outcome, + nativeWindowReceipt: nativeWindowReceipt) + } + + private static func scrubContent( + _ content: Tool.Content, + foundUnsafeCapability: inout Bool) -> Tool.Content + { + switch content { + case let .text(text, annotations, metadata): + return .text( + text: self.scrubText(text, foundUnsafeCapability: &foundUnsafeCapability), + annotations: annotations, + _meta: self.scrubMetadata(metadata, foundUnsafeCapability: &foundUnsafeCapability)) + case let .image(data, mimeType, annotations, metadata): + return .image( + data: data, + mimeType: mimeType, + annotations: annotations, + _meta: self.scrubMetadata(metadata, foundUnsafeCapability: &foundUnsafeCapability)) + case let .audio(data, mimeType, annotations, metadata): + return .audio( + data: data, + mimeType: mimeType, + annotations: annotations, + _meta: self.scrubMetadata(metadata, foundUnsafeCapability: &foundUnsafeCapability)) + case let .resource(resource, annotations, metadata): + let sanitizedMetadata = self.scrubMetadata( + metadata, + foundUnsafeCapability: &foundUnsafeCapability) + let sanitizedResourceMetadata = self.scrubMetadata( + resource._meta, + foundUnsafeCapability: &foundUnsafeCapability) + let uri = self.scrubText(resource.uri, foundUnsafeCapability: &foundUnsafeCapability) + if let text = resource.text { + return .resource( + resource: .text( + self.scrubText(text, foundUnsafeCapability: &foundUnsafeCapability), + uri: uri, + mimeType: resource.mimeType, + _meta: sanitizedResourceMetadata), + annotations: annotations, + _meta: sanitizedMetadata) + } + if let blob = resource.blob, let data = Data(base64Encoded: blob) { + return .resource( + resource: .binary( + data, + uri: uri, + mimeType: resource.mimeType, + _meta: sanitizedResourceMetadata), + annotations: annotations, + _meta: sanitizedMetadata) + } + return .resource( + resource: .binary( + Data(), + uri: uri, + mimeType: resource.mimeType, + _meta: sanitizedResourceMetadata), + annotations: annotations, + _meta: sanitizedMetadata) + case let .resourceLink(uri, name, title, description, mimeType, annotations): + return .resourceLink( + uri: self.scrubText(uri, foundUnsafeCapability: &foundUnsafeCapability), + name: self.scrubText(name, foundUnsafeCapability: &foundUnsafeCapability), + title: title.map { self.scrubText($0, foundUnsafeCapability: &foundUnsafeCapability) }, + description: description.map { + self.scrubText($0, foundUnsafeCapability: &foundUnsafeCapability) + }, + mimeType: mimeType, + annotations: annotations) + } + } + + private static func scrubMetadata( + _ metadata: Metadata?, + foundUnsafeCapability: inout Bool) -> Metadata? + { + guard let metadata, + case let .object(fields)? = self.scrubValue( + .object(metadata.fields), + foundUnsafeCapability: &foundUnsafeCapability) + else { return nil } + return fields.isEmpty ? nil : Metadata(additionalFields: fields) + } + + private static func scrubValue( + _ value: Value, + path: [String] = [], + foundUnsafeCapability: inout Bool) -> Value? + { + switch value { + case let .object(fields): + var result: [String: Value] = [:] + result.reserveCapacity(fields.count) + for (key, child) in fields { + let normalizedKey = self.normalizedField(key) + switch self.disposition(for: key, value: child, parentPath: path) { + case .keep: + if let scrubbed = self.scrubValue( + child, + path: path + [normalizedKey], + foundUnsafeCapability: &foundUnsafeCapability) + { + result[key] = scrubbed + } + case .removeHostOnly: + continue + case .removeUnsafeCapability: + foundUnsafeCapability = true + } + } + return .object(result) + case let .array(values): + if path.last == "browserpagerefs" { + let references = values.compactMap { value -> Value? in + guard case let .string(reference) = value, + BrowserToolCapabilityReference.isValid(reference, prefix: "bp1") + else { + foundUnsafeCapability = true + return nil + } + return value + } + return .array(references) + } + return .array(values.compactMap { + self.scrubValue($0, path: path, foundUnsafeCapability: &foundUnsafeCapability) + }) + case let .string(string): + return .string(self.scrubText(string, foundUnsafeCapability: &foundUnsafeCapability)) + case .int, .double, .bool, .null, .data: + return value + } + } + + private static func disposition( + for key: String, + value: Value, + parentPath: [String]) -> FieldDisposition + { + let normalized = self.normalizedField(key) + if self.hostOnlyFields.contains(normalized) { + return .removeHostOnly + } + if self.unsafeCapabilityFields.contains(normalized) { + return .removeUnsafeCapability + } + if normalized == "pageid" || normalized == "pageref" { + guard case let .string(reference) = value, + BrowserToolCapabilityReference.isValid(reference, prefix: "bp1") + else { return .removeUnsafeCapability } + } + if normalized == "elementref" { + guard case let .string(reference) = value, + BrowserToolCapabilityReference.isValid(reference, prefix: "be1") + else { return .removeUnsafeCapability } + } + if normalized == "snapshotref" || normalized == "browsersnapshotref" { + guard case let .string(reference) = value, + BrowserToolCapabilityReference.isValid(reference, prefix: "bs1") + else { return .removeUnsafeCapability } + } + if normalized == "id", + parentPath.last == "pages" || parentPath.last == "extensionpages" + { + guard case let .string(reference) = value, + BrowserToolCapabilityReference.isValid(reference, prefix: "bp1") + else { return .removeUnsafeCapability } + } + if normalized == "id", parentPath.contains("snapshot") { + guard case let .string(reference) = value, + BrowserToolCapabilityReference.isValid(reference, prefix: "be1") + else { return .removeUnsafeCapability } + } + if normalized == "uid" || normalized == "touid" { + guard case let .string(reference) = value else { return .keep } + if BrowserToolCapabilityReference.isValid(reference, prefix: "be1") { + return .keep + } + if self.isProviderUID(reference) { + return .removeUnsafeCapability + } + } + return .keep + } + + private static func scrubText( + _ text: String, + foundUnsafeCapability: inout Bool) -> String + { + if let scrubbedJSON = self.scrubJSONText( + text, + foundUnsafeCapability: &foundUnsafeCapability) + { + return scrubbedJSON + } + let lines = text.split(separator: "\n", omittingEmptySubsequences: false) + var sanitized: [Substring] = [] + sanitized.reserveCapacity(lines.count) + let mayContainPageRows = text.contains("## Pages") || text.contains("## Extension Pages") + for line in lines { + let normalized = line + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if self.containsAssignment( + in: normalized, + names: [ + "browser_id", + "browserid", + "browser_url", + "browserurl", + "devtools_browser_id", + "devtoolsbrowserid", + "provider_session_epoch", + "providersessionepoch", + "websocket_debugger_url", + "websocketdebuggerurl", + ]) + { + continue + } + if self.containsAssignment( + in: normalized, + names: [ + "cdp_target_id", + "cdptargetid", + "private_target_id", + "privatetargetid", + "provider_page_id", + "providerpageid", + "provider_uid", + "provideruid", + "target_id", + "targetid", + ]) + { + foundUnsafeCapability = true + } + if self.containsRawProviderUID(in: normalized) || + mayContainPageRows && self.hasRawPageRowPrefix(normalized) + { + foundUnsafeCapability = true + } + sanitized.append(line) + } + return sanitized.map(String.init).joined(separator: "\n") + } + + private static func containsRawProviderUID(in line: String) -> Bool { + var searchStart = line.startIndex + while searchStart < line.endIndex, + let marker = line.range(of: "uid=", range: searchStart.. String? + { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.first == "{" || trimmed.first == "[", + let data = trimmed.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data), + let scrubbed = self.scrubValue( + Value.from(object), + foundUnsafeCapability: &foundUnsafeCapability), + let jsonObject = try? scrubbed.toAnyAgentToolValue().toJSON(), + JSONSerialization.isValidJSONObject(jsonObject), + let encoded = try? JSONSerialization.data( + withJSONObject: jsonObject, + options: [.sortedKeys]) + else { return nil } + return String(data: encoded, encoding: .utf8) + } + + private static func containsAssignment(in text: String, names: [String]) -> Bool { + names.contains { name in + text.contains("\(name)=") || + text.contains("\(name):") || + text.contains("\(name) =") || + text.contains("\(name) :") || + text.contains("\"\(name)\"") + } + } + + private static func hasRawPageRowPrefix(_ line: String) -> Bool { + let content = line.drop(while: \.isWhitespace) + let digits = content.prefix { character in + guard let ascii = character.asciiValue else { return false } + return (48...57).contains(ascii) + } + return !digits.isEmpty && content.dropFirst(digits.count).hasPrefix(":") + } + + private static func isProviderUID(_ value: String) -> Bool { + if value.hasPrefix("stashed-") { + return self.isASCIIDigits(value.dropFirst("stashed-".count)) + } + let components = value.split(separator: "_", omittingEmptySubsequences: false) + return components.count == 2 && components.allSatisfy(self.isASCIIDigits) + } + + private static func isASCIIDigits(_ value: Substring) -> Bool { + !value.isEmpty && value.utf8.allSatisfy { $0 >= 0x30 && $0 <= 0x39 } + } + + private static func normalizedField(_ field: String) -> String { + field.lowercased().unicodeScalars.reduce(into: "") { result, scalar in + let value = scalar.value + guard (48...57).contains(value) || (97...122).contains(value) else { return } + result.unicodeScalars.append(scalar) + } + } + + private static func withheldResponse( + arguments: ToolArguments, + policy: BrowserMCPScopedNamespaceExecutionPolicy, + originalMeta: Value?) -> ToolResponse + { + let message = "Browser provider completed the request, but process-private identifiers were withheld." + guard MCPToolSnapshotMutationPolicy.effect(toolName: "browser", arguments: arguments) != .none else { + return ToolResponse.error(message) + } + + let originalOutcome = MCPToolResponseMetadataProjector + .actionOutcomeResolution(from: originalMeta) + .projection?.outcome + let foreground = policy == .explicitlyForegroundAllowed && + MCPToolExecutionPolicy.browserRequiresForegroundAuthority(arguments) + let failure = DesktopActionFailure.indeterminate( + route: originalOutcome?.route ?? .local, + delivery: originalOutcome?.delivery ?? .init( + mechanism: .browserProtocol, + mode: foreground ? .foreground : .background), + evidence: .completionUnknown, + unitCount: originalOutcome?.dispatchState.unitCount, + message: message, + hint: "Observe the browser before retrying; do not reuse prior page or element references.") + return (try? MCPToolResponseMetadataProjector.errorResponse( + for: failure, + invalidatedSnapshotID: nil)) ?? ToolResponse.error(message) + } + + private static func nativeWindowReceipt( + from meta: Value?, + requestedPageReference: String?) -> BrowserMCPScopedNamespaceNativeWindowReceipt? + { + guard case let .object(fields)? = meta else { return nil } + if let binding = fields["browser_window_binding"]?.objectValue, + let pageReference = binding["page_id"]?.stringValue, + let receipt = self.nativeWindowReceipt(from: binding, pageReference: pageReference) + { + return receipt + } + guard let pageReference = requestedPageReference, + let execution = fields[BrowserMCPExecutionEvidence.metadataKey]?.objectValue, + let native = execution["native_window_receipt"]?.objectValue + else { return nil } + return self.nativeWindowReceipt(from: native, pageReference: pageReference) + } + + private static func nativeWindowReceipt( + from fields: [String: Value], + pageReference: String) -> BrowserMCPScopedNamespaceNativeWindowReceipt? + { + guard BrowserToolCapabilityReference.isValid(pageReference, prefix: "bp1"), + let process = self.processReceipt(from: fields), + let rawWindowID = fields["window_id"]?.intValue, + let windowID = UInt32(exactly: rawWindowID), + windowID > 0, + let boundsFields = fields["bounds"]?.objectValue, + let bounds = self.bounds(from: boundsFields), + fields["quality"]?.stringValue == BrowserMCPScopedNamespaceNativeWindowReceipt.Quality.exact.rawValue + else { return nil } + return BrowserMCPScopedNamespaceNativeWindowReceipt( + pageReference: pageReference, + processIdentifier: process.processIdentifier, + processStartIdentity: process.processStartIdentity, + windowID: windowID, + bounds: bounds, + quality: .exact) + } + + private static func targetIdentity( + from receipt: BrowserMCPScopedNamespaceNativeWindowReceipt) -> DesktopTargetIdentity? + { + let identity = WindowMutationIdentity( + windowID: Int(receipt.windowID), + ownerProcessIdentifier: receipt.processIdentifier, + ownerProcessStartIdentity: receipt.processStartIdentity, + capturedBounds: receipt.bounds) + guard let exactWindow = try? UIAutomationTarget.ExactWindow( + identity: identity, + bounds: receipt.bounds) + else { return nil } + return DesktopTargetIdentity(exactWindow: exactWindow) + } + + private static func processTargetIdentity(from meta: Value?) -> DesktopTargetIdentity? { + guard case let .object(fields)? = meta else { return nil } + if let execution = fields[BrowserMCPExecutionEvidence.metadataKey]?.objectValue, + let receipt = execution["connection_receipt"]?.objectValue, + let identity = self.processIdentity(from: receipt) + { + return identity + } + if let receipt = fields["target_receipt"]?.objectValue { + return self.processIdentity(from: receipt) + } + return nil + } + + private static func processIdentity(from fields: [String: Value]) -> DesktopTargetIdentity? { + guard let process = self.processReceipt(from: fields) else { return nil } + return try? DesktopTargetIdentity(processIdentity: process) + } + + private static func processReceipt(from fields: [String: Value]) -> ApplicationProcessIdentity? { + guard let rawPID = fields["pid"]?.intValue, + let pid = Int32(exactly: rawPID), + pid > 0, + let generation = self.processGeneration(from: fields), + generation > 0 + else { return nil } + return ApplicationProcessIdentity( + processIdentifier: pid, + processStartIdentity: generation) + } + + private static func processGeneration(from fields: [String: Value]) -> UInt64? { + if let decimal = fields["process_start_identity_decimal"]?.stringValue { + return UInt64(decimal) + } + guard let value = fields["process_start_identity"]?.intValue, value > 0 else { return nil } + return UInt64(value) + } + + private static func bounds(from fields: [String: Value]) -> CGRect? { + guard let x = self.number(fields["x"]), + let y = self.number(fields["y"]), + let width = self.number(fields["width"]), + let height = self.number(fields["height"]), + x.isFinite, + y.isFinite, + width.isFinite, + height.isFinite, + width > 0, + height > 0 + else { return nil } + return CGRect(x: x, y: y, width: width, height: height) + } + + private static func number(_ value: Value?) -> Double? { + switch value { + case let .double(number): + number + case let .int(number): + Double(number) + default: + nil + } + } +} diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceRuntime.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceRuntime.swift new file mode 100644 index 000000000..36fc43fb3 --- /dev/null +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceRuntime.swift @@ -0,0 +1,335 @@ +import CoreGraphics +import Foundation +import MCP +import PeekabooAutomationKit +import PeekabooFoundation +import TachikomaMCP + +/// Host-local identity for one authenticated browser capability namespace. +/// +/// Authentication and receipt validation are Bridge responsibilities. The runtime accepts this identity only after +/// the authority layer has admitted the current request, and never parses bearer tokens or reusable wire receipts. +public struct BrowserMCPScopedNamespaceID: Hashable, Sendable { + public let rawValue: UUID + + public init(rawValue: UUID) { + self.rawValue = rawValue + } +} + +/// Per-call authority for a scoped browser action. +/// +/// Foreground authority is deliberately invocation-scoped. It is never retained on the namespace, so a foreground +/// connect cannot silently authorize later page-fronting actions. +public enum BrowserMCPScopedNamespaceExecutionPolicy: Equatable, Sendable { + case backgroundOnly + case explicitlyForegroundAllowed +} + +/// Exact native-window evidence projected from trusted host metadata. +public struct BrowserMCPScopedNamespaceNativeWindowReceipt: Equatable, Sendable { + public enum Quality: String, Equatable, Sendable { + case exact + } + + public let pageReference: String + public let processIdentifier: Int32 + public let processStartIdentity: UInt64 + public let windowID: UInt32 + public let bounds: CGRect + public let quality: Quality +} + +/// Sanitized host result for one high-level BrowserTool invocation. +/// +/// `targetIdentity` and `outcome` are extracted from Peekaboo-owned evidence before Bridge serialization. The Bridge +/// handler can therefore attest mutation semantics without reinterpreting untrusted provider metadata. +public struct BrowserMCPScopedNamespaceExecutionResult: Sendable { + public let response: ToolResponse + public let targetIdentity: DesktopTargetIdentity? + public let outcome: DesktopActionOutcome? + public let nativeWindowReceipt: BrowserMCPScopedNamespaceNativeWindowReceipt? +} + +public enum BrowserMCPScopedNamespaceRuntimeError: LocalizedError, Equatable, Sendable { + case localExecutionRequired + case localBrowserServiceRequired + case scopedSessionUnavailable + case namespaceAlreadyExists + case namespaceUnknown + case namespaceClosing + case namespaceEnded + + public var errorDescription: String? { + switch self { + case .localExecutionRequired: + "Browser capability namespaces require the local execution host." + case .localBrowserServiceRequired: + "Browser capability namespaces require Peekaboo's concrete local browser service." + case .scopedSessionUnavailable: + "The local browser service could not create an independent authenticated session." + case .namespaceAlreadyExists: + "The browser capability namespace already exists." + case .namespaceUnknown: + "The browser capability namespace is unknown to this runtime instance." + case .namespaceClosing: + "The browser capability namespace is closing and rejects new work." + case .namespaceEnded: + "The browser capability namespace has ended and cannot be reused." + } + } +} + +@MainActor +protocol BrowserMCPScopedNamespaceSession: AnyObject { + func execute( + arguments: ToolArguments, + policy: BrowserMCPScopedNamespaceExecutionPolicy) async throws -> ToolResponse + func close() async +} + +/// Owns process-local browser children for authenticated Bridge namespaces. +/// +/// No action ever executes on the root BrowserMCPService. Opening a namespace obtains a distinct service from +/// BrowserMCPAuthenticatedSessionPool, including its provider child, capability map, mutation gate, and operation +/// gate. Closing publishes the terminal phase before awaiting those gates, so later requests fail without entering +/// BrowserTool while already-admitted work is drained. +@MainActor +public final class BrowserMCPScopedNamespaceRuntime { + typealias SessionFactory = @MainActor (BrowserMCPScopedNamespaceID) throws + -> any BrowserMCPScopedNamespaceSession + + private enum Slot { + case active(any BrowserMCPScopedNamespaceSession) + case closing(Task) + case ended + } + + private enum Phase: Equatable { + case active + case retiring + case ended + } + + private let makeSession: SessionFactory + private var slots: [BrowserMCPScopedNamespaceID: Slot] = [:] + private var phase = Phase.active + private var retirementTask: Task? + + /// Creates a runtime adapter around one local services context. + /// + /// The supplied context contributes desktop mutation coordination and the pool-owning root browser service. The + /// root is only a factory/lock owner; it is never exposed as a namespace or used for action dispatch. + public convenience init(context: MCPToolContext) throws { + guard context.executionHost == .local else { + throw BrowserMCPScopedNamespaceRuntimeError.localExecutionRequired + } + guard let root = context.browser as? BrowserMCPService else { + throw BrowserMCPScopedNamespaceRuntimeError.localBrowserServiceRequired + } + self.init { _ in + let sessionID = BrowserMCPAuthenticatedSessionPool.SessionID() + guard let service = root.authenticatedSession(sessionID) else { + throw BrowserMCPScopedNamespaceRuntimeError.scopedSessionUnavailable + } + return BrowserMCPScopedNamespaceLiveSession( + service: service, + baseContext: context) + } + } + + init(makeSession: @escaping SessionFactory) { + self.makeSession = makeSession + } + + /// Allocates the exact provider child for a previously authenticated namespace identity. + public func open(_ namespaceID: BrowserMCPScopedNamespaceID) throws { + switch self.phase { + case .active: + break + case .retiring: + throw BrowserMCPScopedNamespaceRuntimeError.namespaceClosing + case .ended: + throw BrowserMCPScopedNamespaceRuntimeError.namespaceEnded + } + if let slot = self.slots[namespaceID] { + switch slot { + case .active: + throw BrowserMCPScopedNamespaceRuntimeError.namespaceAlreadyExists + case .closing: + throw BrowserMCPScopedNamespaceRuntimeError.namespaceClosing + case .ended: + throw BrowserMCPScopedNamespaceRuntimeError.namespaceEnded + } + } + self.slots[namespaceID] = try .active(self.makeSession(namespaceID)) + } + + /// Executes one high-level BrowserTool request in the caller's exact capability namespace. + /// + /// `bind_window` intentionally travels through this same entry point. There is no raw/provider call surface and no + /// fallback to legacy browserExecute, a root browser client, or another namespace. + public func execute( + in namespaceID: BrowserMCPScopedNamespaceID, + arguments: ToolArguments, + policy: BrowserMCPScopedNamespaceExecutionPolicy = .backgroundOnly) async throws + -> BrowserMCPScopedNamespaceExecutionResult + { + switch self.phase { + case .active: + break + case .retiring: + throw BrowserMCPScopedNamespaceRuntimeError.namespaceClosing + case .ended: + throw BrowserMCPScopedNamespaceRuntimeError.namespaceEnded + } + let session: any BrowserMCPScopedNamespaceSession + switch self.slots[namespaceID] { + case let .active(activeSession): + session = activeSession + case .closing: + throw BrowserMCPScopedNamespaceRuntimeError.namespaceClosing + case .ended: + throw BrowserMCPScopedNamespaceRuntimeError.namespaceEnded + case nil: + throw BrowserMCPScopedNamespaceRuntimeError.namespaceUnknown + } + + let response = try await session.execute(arguments: arguments, policy: policy) + return BrowserMCPScopedNamespaceResponseSanitizer.result( + response, + arguments: arguments, + policy: policy) + } + + /// Publishes terminal state, drains the namespace's gates, and ends its exact provider child. + /// + /// Duplicate close callers join the same task. Ended identities remain tombstoned for this runtime generation and + /// cannot accidentally acquire a fresh capability map. + public func close(_ namespaceID: BrowserMCPScopedNamespaceID) async throws { + guard self.slots[namespaceID] != nil else { + throw BrowserMCPScopedNamespaceRuntimeError.namespaceUnknown + } + guard let task = self.beginClose(namespaceID) else { return } + await task.value + if case .closing? = self.slots[namespaceID] { + self.slots[namespaceID] = .ended + } + } + + /// Ends every namespace during host-generation retirement without serializing independent drains. + public func closeAll() async { + if let retirementTask { + await retirementTask.value + return + } + guard self.phase == .active else { return } + self.phase = .retiring + let namespaceIDs = Array(self.slots.keys) + let tasks = namespaceIDs.compactMap(self.beginClose) + let retirementTask = Task { @MainActor in + for task in tasks { + await task.value + } + for namespaceID in namespaceIDs where self.slots[namespaceID].map(Self.isClosing) == true { + self.slots[namespaceID] = .ended + } + self.phase = .ended + } + self.retirementTask = retirementTask + await retirementTask.value + } + + private func beginClose(_ namespaceID: BrowserMCPScopedNamespaceID) -> Task? { + switch self.slots[namespaceID] { + case let .active(session): + let task = Task { @MainActor in + await session.close() + } + self.slots[namespaceID] = .closing(task) + return task + case let .closing(existing): + return existing + case .ended, nil: + return nil + } + } + + private static func isClosing(_ slot: Slot) -> Bool { + if case .closing = slot { + return true + } + return false + } +} + +@MainActor +private final class BrowserMCPScopedNamespaceLiveSession: BrowserMCPScopedNamespaceSession { + private let backgroundContext: MCPToolContext + private let foregroundContext: MCPToolContext + + init(service: BrowserMCPService, baseContext: MCPToolContext) { + let owner = MCPToolSnapshotOwner() + self.backgroundContext = baseContext.browserNamespaceContext( + service: service, + owner: owner, + executionPolicy: .backgroundOnly) + self.foregroundContext = baseContext.browserNamespaceContext( + service: service, + owner: owner, + executionPolicy: .foregroundAllowed) + } + + func execute( + arguments: ToolArguments, + policy: BrowserMCPScopedNamespaceExecutionPolicy) async throws -> ToolResponse + { + let context = switch policy { + case .backgroundOnly: + self.backgroundContext + case .explicitlyForegroundAllowed: + self.foregroundContext + } + return try await context.execute( + tool: BrowserTool(context: context), + arguments: arguments) + } + + func close() async { + // Both policy views share this exact service, capability session, lifecycle gate, and snapshot owner. Releasing + // one view drains all BrowserTool work before ending the owned pool child. + await self.backgroundContext.releaseSnapshotOwner() + } +} + +extension MCPToolContext { + fileprivate func browserNamespaceContext( + service: BrowserMCPService, + owner: MCPToolSnapshotOwner, + executionPolicy: MCPToolExecutionPolicy) -> Self + { + Self( + automation: self.automation, + menu: self.menu, + windows: self.windows, + applications: self.applications, + dialogs: self.dialogs, + dock: self.dock, + screenCapture: self.screenCapture, + desktopObservation: self.desktopObservation, + snapshots: self.snapshots, + screens: self.screens, + agent: self.agent, + permissions: self.permissions, + clipboard: self.clipboard, + browser: service, + permissionsStatusProvider: self.permissionsStatusProvider, + snapshotMutationCoordinator: self.snapshotMutationCoordinator, + snapshotExecutionGate: self.snapshotExecutionGate, + browserMutationExecutionGate: service.browserMutationExecutionGate, + snapshotOwner: owner, + executionPolicy: executionPolicy, + executionHost: .local, + capturePreflightRefusal: self.capturePreflightRefusal) + } +} diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPScopedNamespaceRuntimeTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPScopedNamespaceRuntimeTests.swift new file mode 100644 index 000000000..555cdf15f --- /dev/null +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPScopedNamespaceRuntimeTests.swift @@ -0,0 +1,588 @@ +import Foundation +import MCP +import TachikomaMCP +import Testing +@testable import PeekabooAgentRuntime + +@MainActor +struct BrowserMCPScopedNamespaceRuntimeTests { + @Test + func `cross namespace page references refuse before fake provider dispatch`() async throws { + let fixture = NamespaceRuntimeFixture() + let firstID = Self.namespaceID(1) + let secondID = Self.namespaceID(2) + try fixture.runtime.open(firstID) + try fixture.runtime.open(secondID) + + let firstPage = try await Self.pageReference(from: fixture.runtime.execute( + in: firstID, + arguments: ToolArguments(raw: ["action": "list_pages"]))) + let secondPage = try await Self.pageReference(from: fixture.runtime.execute( + in: secondID, + arguments: ToolArguments(raw: ["action": "list_pages"]))) + #expect(firstPage != secondPage) + let second = try #require(fixture.sessions[secondID]) + let dispatchCount = second.providerDispatchCount + + let rejected = try await fixture.runtime.execute( + in: secondID, + arguments: ToolArguments(raw: [ + "action": "snapshot", + "page_id": firstPage, + ])) + + #expect(rejected.response.isError) + #expect(Self.text(from: rejected.response).contains("another or expired provider session")) + #expect(second.providerDispatchCount == dispatchCount) + #expect(secondPage.hasPrefix("bp1_")) + } + + @Test + func `close publishes closing then joins one drain and tombstones the identity`() async throws { + let fixture = NamespaceRuntimeFixture() + let namespaceID = Self.namespaceID(3) + try fixture.runtime.open(namespaceID) + let session = try #require(fixture.sessions[namespaceID]) + let executionBarrier = NamespaceRuntimeBarrier() + session.executionBarrier = executionBarrier + + let execution = Task { @MainActor in + try await fixture.runtime.execute( + in: namespaceID, + arguments: ToolArguments(raw: ["action": "status"])) + } + await executionBarrier.waitUntilBlocked() + let firstCloseFinished = NamespaceRuntimeFlag() + let secondCloseFinished = NamespaceRuntimeFlag() + let firstClose = Task { @MainActor in + try await fixture.runtime.close(namespaceID) + await firstCloseFinished.mark() + } + await session.closeStarted.waitUntilSignalled() + let secondClose = Task { @MainActor in + try await fixture.runtime.close(namespaceID) + await secondCloseFinished.mark() + } + await Task.yield() + + do { + _ = try await fixture.runtime.execute( + in: namespaceID, + arguments: ToolArguments(raw: ["action": "status"])) + Issue.record("Expected closing namespace to reject new work") + } catch let error as BrowserMCPScopedNamespaceRuntimeError { + #expect(error == .namespaceClosing) + } + #expect(await !firstCloseFinished.value) + #expect(await !secondCloseFinished.value) + #expect(session.closeCount == 1) + + await executionBarrier.release() + _ = try await execution.value + try await firstClose.value + try await secondClose.value + #expect(session.closeCount == 1) + #expect(await firstCloseFinished.value) + #expect(await secondCloseFinished.value) + + do { + _ = try await fixture.runtime.execute( + in: namespaceID, + arguments: ToolArguments(raw: ["action": "status"])) + Issue.record("Expected ended namespace to reject new work") + } catch let error as BrowserMCPScopedNamespaceRuntimeError { + #expect(error == .namespaceEnded) + } + #expect(throws: BrowserMCPScopedNamespaceRuntimeError.namespaceEnded) { + try fixture.runtime.open(namespaceID) + } + try await fixture.runtime.close(namespaceID) + #expect(session.closeCount == 1) + } + + @Test + func `independent namespaces overlap while each retains its own session`() async throws { + let fixture = NamespaceRuntimeFixture() + let firstID = Self.namespaceID(4) + let secondID = Self.namespaceID(5) + try fixture.runtime.open(firstID) + try fixture.runtime.open(secondID) + let first = try #require(fixture.sessions[firstID]) + let second = try #require(fixture.sessions[secondID]) + let firstBarrier = NamespaceRuntimeBarrier() + let secondBarrier = NamespaceRuntimeBarrier() + first.executionBarrier = firstBarrier + second.executionBarrier = secondBarrier + + let firstExecution = Task { @MainActor in + try await fixture.runtime.execute( + in: firstID, + arguments: ToolArguments(raw: ["action": "status"])) + } + await firstBarrier.waitUntilBlocked() + let secondExecution = Task { @MainActor in + try await fixture.runtime.execute( + in: secondID, + arguments: ToolArguments(raw: ["action": "status"])) + } + await secondBarrier.waitUntilBlocked() + + #expect(first.activeExecutionCount == 1) + #expect(second.activeExecutionCount == 1) + await firstBarrier.release() + await secondBarrier.release() + _ = try await firstExecution.value + _ = try await secondExecution.value + } + + @Test + func `new runtime generation and namespace reject references from an ended session`() async throws { + let firstFixture = NamespaceRuntimeFixture() + let endedID = Self.namespaceID(6) + try firstFixture.runtime.open(endedID) + let endedPage = try await Self.pageReference(from: firstFixture.runtime.execute( + in: endedID, + arguments: ToolArguments(raw: ["action": "list_pages"]))) + try await firstFixture.runtime.close(endedID) + + let restartedFixture = NamespaceRuntimeFixture() + let restartedID = Self.namespaceID(7) + try restartedFixture.runtime.open(restartedID) + let restarted = try #require(restartedFixture.sessions[restartedID]) + let dispatchCount = restarted.providerDispatchCount + let rejected = try await restartedFixture.runtime.execute( + in: restartedID, + arguments: ToolArguments(raw: [ + "action": "snapshot", + "page_id": endedPage, + ])) + + #expect(rejected.response.isError) + #expect(restarted.providerDispatchCount == dispatchCount) + let restartedPage = try await Self.pageReference(from: restartedFixture.runtime.execute( + in: restartedID, + arguments: ToolArguments(raw: ["action": "list_pages"]))) + #expect(restartedPage != endedPage) + } + + @Test + func `host generation retirement closes independent children concurrently`() async throws { + let fixture = NamespaceRuntimeFixture() + let firstID = Self.namespaceID(10) + let secondID = Self.namespaceID(11) + try fixture.runtime.open(firstID) + try fixture.runtime.open(secondID) + let first = try #require(fixture.sessions[firstID]) + let second = try #require(fixture.sessions[secondID]) + let firstBarrier = NamespaceRuntimeBarrier() + let secondBarrier = NamespaceRuntimeBarrier() + first.executionBarrier = firstBarrier + second.executionBarrier = secondBarrier + let firstExecution = Task { @MainActor in + try await fixture.runtime.execute( + in: firstID, + arguments: ToolArguments(raw: ["action": "status"])) + } + let secondExecution = Task { @MainActor in + try await fixture.runtime.execute( + in: secondID, + arguments: ToolArguments(raw: ["action": "status"])) + } + await firstBarrier.waitUntilBlocked() + await secondBarrier.waitUntilBlocked() + + let retirement = Task { @MainActor in await fixture.runtime.closeAll() } + await first.closeStarted.waitUntilSignalled() + await second.closeStarted.waitUntilSignalled() + #expect(first.closeCount == 1) + #expect(second.closeCount == 1) + #expect(throws: BrowserMCPScopedNamespaceRuntimeError.namespaceClosing) { + try fixture.runtime.open(Self.namespaceID(12)) + } + + await firstBarrier.release() + await secondBarrier.release() + _ = try await firstExecution.value + _ = try await secondExecution.value + await retirement.value + #expect(throws: BrowserMCPScopedNamespaceRuntimeError.namespaceEnded) { + try fixture.runtime.open(Self.namespaceID(13)) + } + for namespaceID in [firstID, secondID] { + do { + _ = try await fixture.runtime.execute( + in: namespaceID, + arguments: ToolArguments(raw: ["action": "status"])) + Issue.record("Expected retired namespace to remain ended") + } catch let error as BrowserMCPScopedNamespaceRuntimeError { + #expect(error == .namespaceEnded) + } + } + } + + @Test + func `bind and foreground connect use one high level non sticky execution path`() async throws { + let fixture = NamespaceRuntimeFixture() + let namespaceID = Self.namespaceID(8) + try fixture.runtime.open(namespaceID) + let session = try #require(fixture.sessions[namespaceID]) + _ = try await fixture.runtime.execute( + in: namespaceID, + arguments: ToolArguments(raw: ["action": "connect"]), + policy: .explicitlyForegroundAllowed) + let page = try await Self.pageReference(from: fixture.runtime.execute( + in: namespaceID, + arguments: ToolArguments(raw: ["action": "list_pages"]))) + + let bound = try await fixture.runtime.execute( + in: namespaceID, + arguments: ToolArguments(raw: [ + "action": BrowserProcessLocalAction.bindWindow, + "page_id": page, + "pid": 42, + "window_id": 313, + ])) + let navigated = try await fixture.runtime.execute( + in: namespaceID, + arguments: ToolArguments(raw: [ + "action": "navigate", + "page_id": page, + "url": "https://example.test/next", + ])) + _ = try await fixture.runtime.execute( + in: namespaceID, + arguments: ToolArguments(raw: ["action": "status"])) + + let bind = try #require(session.executions.first { $0.arguments.getString("action") == "bind_window" }) + #expect(bind.arguments.getString("page_id") == page) + #expect(bind.arguments.getInt("pid") == 42) + #expect(bind.arguments.getInt("window_id") == 313) + #expect(bind.arguments.getValue(for: "mcp_tool") == nil) + #expect(bind.policy == .backgroundOnly) + #expect(session.executions.first?.policy == .explicitlyForegroundAllowed) + #expect(session.executions.dropFirst().allSatisfy { $0.policy == .backgroundOnly }) + for receipt in [bound.nativeWindowReceipt, navigated.nativeWindowReceipt] { + #expect(receipt?.pageReference == page) + #expect(receipt?.processIdentifier == 42) + #expect(receipt?.processStartIdentity == 1001) + #expect(receipt?.windowID == 313) + #expect(receipt?.quality == .exact) + } + } + + @Test + func `recursive scrubber removes host IDs and fails closed on raw provider capabilities`() async throws { + let fixture = NamespaceRuntimeFixture() + let namespaceID = Self.namespaceID(9) + try fixture.runtime.open(namespaceID) + let session = try #require(fixture.sessions[namespaceID]) + session.response = ToolResponse( + content: [.text( + text: "Chrome status\n- endpoint=http://127.0.0.1:9222 browser_id=private-browser", + annotations: nil, + _meta: Metadata(additionalFields: [ + "nested": .object(["browser_url": .string("http://127.0.0.1:9222")]), + ]))], + meta: .object([ + BrowserMCPExecutionEvidence.metadataKey: .object([ + "provider_session_epoch": .string("private-epoch"), + "connection_receipt": .object([ + "pid": .int(42), + "process_start_identity_decimal": .string("1001"), + "browser_url": .string("http://127.0.0.1:9222"), + "websocket_debugger_url": .string("ws://127.0.0.1:9222/devtools/browser/private"), + "browser_id": .string("private-browser"), + ]), + ]), + "wrapper": .array([.object([ + "devToolsBrowserID": .string("private-browser"), + ])]), + ]), + structuredContent: .object([ + "deep": .array([.object([ + "browserURL": .string("http://127.0.0.1:9222"), + ])]), + ])) + + let scrubbed = try await fixture.runtime.execute( + in: namespaceID, + arguments: ToolArguments(raw: ["action": "status"])) + + #expect(!scrubbed.response.isError) + #expect(scrubbed.targetIdentity?.processIdentity.processIdentifier == 42) + #expect(scrubbed.targetIdentity?.processIdentity.processStartIdentity == 1001) + let scrubbedDump = Self.dump(scrubbed.response) + #expect(!scrubbedDump.contains("private-browser")) + #expect(!scrubbedDump.contains("private-epoch")) + #expect(!scrubbedDump.contains("127.0.0.1:9222")) + #expect(!scrubbedDump.contains("provider_session_epoch")) + + session.response = ToolResponse( + content: [.text( + text: "browser_id=private-plain\n- uid=1_0 button \"Continue\"\n" + + #"{"targetId":"private-json-target"}"#, + annotations: nil, + _meta: nil)], + meta: .object([ + "pages": .array([.object([ + "id": .int(7), + "deeper": .object(["uid": .string("1_0")]), + ])]), + ]), + structuredContent: .object([ + "targetId": .string("private-target"), + ])) + + let withheld = try await fixture.runtime.execute( + in: namespaceID, + arguments: ToolArguments(raw: ["action": "list_pages"])) + #expect(withheld.response.isError) + let withheldDump = Self.dump(withheld.response) + #expect(withheldDump.contains("process-private identifiers were withheld")) + for privateValue in [ + "1_0", + "private-json-target", + "private-plain", + "private-target", + "targetId", + "uid", + ] { + #expect(!withheldDump.contains(privateValue)) + } + } + + private static func namespaceID(_ suffix: UInt8) -> BrowserMCPScopedNamespaceID { + BrowserMCPScopedNamespaceID(rawValue: UUID(uuid: ( + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, suffix))) + } + + private static func pageReference( + from result: BrowserMCPScopedNamespaceExecutionResult) throws -> String + { + let root = try #require(result.response.structuredContent?.objectValue) + let pages = try #require(root["pages"]?.arrayValue) + return try #require(pages.first?.objectValue?["id"]?.stringValue) + } + + private static func text(from response: ToolResponse) -> String { + guard case let .text(text, _, _)? = response.content.first else { return "" } + return text + } + + private static func dump(_ response: ToolResponse) -> String { + let content = response.content.flatMap { item -> [String] in + switch item { + case let .text(text, _, metadata): + return [text] + self.dump(metadata.map { .object($0.fields) }) + case let .image(_, _, _, metadata), + let .audio(_, _, _, metadata), + let .resource(_, _, metadata): + return self.dump(metadata.map { .object($0.fields) }) + case let .resourceLink(uri, name, title, description, mimeType, _): + return [uri, name, title, description, mimeType].compactMap(\.self) + } + } + return (content + self.dump(response.meta) + self.dump(response.structuredContent)) + .joined(separator: "\n") + } + + private static func dump(_ value: Value?) -> [String] { + guard let value else { return [] } + switch value { + case let .object(fields): + return fields.flatMap { key, value in [key] + self.dump(value) } + case let .array(values): + return values.flatMap(self.dump) + case let .string(string): + return [string] + case let .int(value): + return [String(value)] + case let .double(value): + return [String(value)] + case let .bool(value): + return [String(value)] + case .null: + return ["null"] + case let .data(mimeType, data): + return [mimeType, data.base64EncodedString()].compactMap(\.self) + } + } +} + +@MainActor +private final class NamespaceRuntimeFixture { + private(set) var sessions: [BrowserMCPScopedNamespaceID: NamespaceRuntimeSession] = [:] + lazy var runtime = BrowserMCPScopedNamespaceRuntime { [unowned self] namespaceID in + let session = NamespaceRuntimeSession(namespaceID: namespaceID) + self.sessions[namespaceID] = session + return session + } +} + +@MainActor +private final class NamespaceRuntimeSession: BrowserMCPScopedNamespaceSession { + struct Execution { + let arguments: ToolArguments + let policy: BrowserMCPScopedNamespaceExecutionPolicy + } + + let closeStarted = NamespaceRuntimeSignal() + private(set) var executions: [Execution] = [] + private(set) var providerDispatchCount = 0 + private(set) var activeExecutionCount = 0 + private(set) var closeCount = 0 + var executionBarrier: NamespaceRuntimeBarrier? + var response: ToolResponse? + + private let pageReference: String + private var drainWaiters: [CheckedContinuation] = [] + private var hasNativeBinding = false + + init(namespaceID: BrowserMCPScopedNamespaceID) { + let token = namespaceID.rawValue.uuidString + .replacingOccurrences(of: "-", with: "") + .lowercased() + self.pageReference = "bp1_\(token)" + } + + func execute( + arguments: ToolArguments, + policy: BrowserMCPScopedNamespaceExecutionPolicy) async throws -> ToolResponse + { + self.executions.append(.init(arguments: arguments, policy: policy)) + self.activeExecutionCount += 1 + defer { + self.activeExecutionCount -= 1 + if self.activeExecutionCount == 0 { + self.drainWaiters.forEach { $0.resume() } + self.drainWaiters.removeAll() + } + } + if let executionBarrier { + await executionBarrier.block() + } + if let response { + self.providerDispatchCount += 1 + return response + } + + let action = arguments.getString("action") + if let requestedPage = arguments.getString("page_id"), requestedPage != self.pageReference { + return ToolResponse.error( + "The browser page reference belongs to another or expired provider session. Refresh list_pages.") + } + self.providerDispatchCount += 1 + if action == "list_pages" { + return ToolResponse( + content: [.text( + text: "\(self.pageReference): Example", + annotations: nil, + _meta: nil)], + structuredContent: .object([ + "pages": .array([.object([ + "id": .string(self.pageReference), + "title": .string("Example"), + ])]), + ])) + } + if action == BrowserProcessLocalAction.bindWindow { + self.hasNativeBinding = true + var fields = self.nativeWindowReceiptFields + fields["page_id"] = .string(self.pageReference) + return ToolResponse.text( + "bound", + meta: .object(["browser_window_binding": .object(fields)])) + } + if self.hasNativeBinding, action == "navigate" { + return ToolResponse.text( + "navigated", + meta: .object([ + BrowserMCPExecutionEvidence.metadataKey: .object([ + "native_window_receipt": .object(self.nativeWindowReceiptFields), + ]), + ])) + } + return ToolResponse.text("ok") + } + + private var nativeWindowReceiptFields: [String: Value] { + [ + "pid": .int(42), + "process_start_identity_decimal": .string("1001"), + "window_id": .int(313), + "bounds": .object([ + "x": .double(10), + "y": .double(20), + "width": .double(900), + "height": .double(700), + ]), + "quality": .string("exact"), + ] + } + + func close() async { + self.closeCount += 1 + await self.closeStarted.signal() + guard self.activeExecutionCount > 0 else { return } + await withCheckedContinuation { continuation in + self.drainWaiters.append(continuation) + } + } +} + +private actor NamespaceRuntimeBarrier { + private var blocked = false + private var released = false + private var blockedWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func block() async { + self.blocked = true + self.blockedWaiters.forEach { $0.resume() } + self.blockedWaiters.removeAll() + guard !self.released else { return } + await withCheckedContinuation { continuation in + self.releaseWaiters.append(continuation) + } + } + + func waitUntilBlocked() async { + guard !self.blocked else { return } + await withCheckedContinuation { continuation in + self.blockedWaiters.append(continuation) + } + } + + func release() { + self.released = true + self.releaseWaiters.forEach { $0.resume() } + self.releaseWaiters.removeAll() + } +} + +private actor NamespaceRuntimeSignal { + private var signalled = false + private var waiters: [CheckedContinuation] = [] + + func signal() { + self.signalled = true + self.waiters.forEach { $0.resume() } + self.waiters.removeAll() + } + + func waitUntilSignalled() async { + guard !self.signalled else { return } + await withCheckedContinuation { continuation in + self.waiters.append(continuation) + } + } +} + +private actor NamespaceRuntimeFlag { + private(set) var value = false + + func mark() { + self.value = true + } +} From 489ebb9d58157317d4bc4d3891751c4e81836a63 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 17:45:52 -0700 Subject: [PATCH 09/14] feat(bridge): add browser capability namespace wire --- ...idgeBrowserCapabilityNamespaceModels.swift | 713 ++++++++++++++++++ ...rowserCapabilityNamespaceNegotiation.swift | 32 + ...CapabilityNamespaceReceiptValidation.swift | 33 + ...geClient+BrowserCapabilityNamespaces.swift | 143 ++++ .../PeekabooBridgeClient+Transport.swift | 18 + .../PeekabooBridge/PeekabooBridgeClient.swift | 58 +- .../PeekabooBridgeConstants.swift | 6 +- .../PeekabooBridge/PeekabooBridgeModels.swift | 12 + .../PeekabooBridgeOperation+Policy.swift | 12 + .../PeekabooBridgeOperationDescriptor.swift | 15 + ...PeekabooBridgeOperationReceiptModels.swift | 5 + ...eekabooBridgeOperationResponseFamily.swift | 3 + ...ridgeOperationResponseTargetEvidence.swift | 19 + ...ekabooBridgeOperationResultSemantics.swift | 38 +- .../PeekabooBridgeOperationSessionClaim.swift | 8 + ...eekabooBridgeRequest+DesktopMutation.swift | 33 + .../PeekabooBridgeRequestResponse.swift | 9 + .../PeekabooBridgeServer+Handlers.swift | 6 + .../PeekabooBridgeServer+Handshake.swift | 33 + .../PeekabooBridge/PeekabooBridgeServer.swift | 32 + .../PeekabooBridgeServiceProviding.swift | 20 + .../BrowserCapabilityNamespaceWireTests.swift | 381 ++++++++++ ...serCapabilityNamespaceHandshakeTests.swift | 180 +++++ 23 files changed, 1797 insertions(+), 12 deletions(-) create mode 100644 Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceModels.swift create mode 100644 Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceNegotiation.swift create mode 100644 Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceReceiptValidation.swift create mode 100644 Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeClient+BrowserCapabilityNamespaces.swift create mode 100644 Core/PeekabooCore/Tests/PeekabooBridgeTests/BrowserCapabilityNamespaceWireTests.swift create mode 100644 Core/PeekabooCore/Tests/PeekabooTests/BrowserCapabilityNamespaceHandshakeTests.swift diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceModels.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceModels.swift new file mode 100644 index 000000000..00f673581 --- /dev/null +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceModels.swift @@ -0,0 +1,713 @@ +import CoreGraphics +import Foundation +import PeekabooAutomationKit +import PeekabooFoundation + +private enum PeekabooBridgeBrowserCapabilityReference { + static func isCanonicalPage(_ value: String) -> Bool { + let prefix = "bp1_" + guard value.hasPrefix(prefix) else { return false } + let token = value.dropFirst(prefix.count) + return token.count == 32 && token.allSatisfy { character in + guard let ascii = character.asciiValue else { return false } + return (48...57).contains(ascii) || (97...102).contains(ascii) + } + } +} + +/// The authenticated local process identity that owns one browser capability namespace. +public struct PeekabooBridgeBrowserCapabilityPrincipal: Codable, Equatable, Sendable { + public let effectiveUserIdentifier: UInt32 + public let teamIdentifier: String + public let bundleIdentifier: String + public let codeSignatureHash: String + + public init( + effectiveUserIdentifier: UInt32, + teamIdentifier: String, + bundleIdentifier: String, + codeSignatureHash: String) + { + self.effectiveUserIdentifier = effectiveUserIdentifier + self.teamIdentifier = teamIdentifier + self.bundleIdentifier = bundleIdentifier + self.codeSignatureHash = codeSignatureHash + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case effectiveUserIdentifier + case teamIdentifier + case bundleIdentifier + case codeSignatureHash + } + + public init(from decoder: any Decoder) throws { + try PeekabooBridgeClosedPayload.requireExactKeys( + CodingKeys.self, + from: decoder, + description: "Browser capability principal") + let container = try decoder.container(keyedBy: CodingKeys.self) + self.effectiveUserIdentifier = try container.decode(UInt32.self, forKey: .effectiveUserIdentifier) + self.teamIdentifier = try container.decode(String.self, forKey: .teamIdentifier) + self.bundleIdentifier = try container.decode(String.self, forKey: .bundleIdentifier) + self.codeSignatureHash = try container.decode(String.self, forKey: .codeSignatureHash) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.effectiveUserIdentifier, forKey: .effectiveUserIdentifier) + try container.encode(self.teamIdentifier, forKey: .teamIdentifier) + try container.encode(self.bundleIdentifier, forKey: .bundleIdentifier) + try container.encode(self.codeSignatureHash, forKey: .codeSignatureHash) + } +} + +/// Listener-signed authority for one reusable, caller-owned browser capability namespace. +public struct PeekabooBridgeBrowserCapabilityNamespaceReceiptPayload: Codable, Equatable, Sendable { + public let schemaVersion: Int + public let namespaceID: UUID + public let listenerInstanceID: UUID + public let listenerPublicKeySHA256: String + public let registryGenerationID: UUID + public let principal: PeekabooBridgeBrowserCapabilityPrincipal + public let issuedAtUnixMilliseconds: Int64 + public let expiresAtUnixMilliseconds: Int64 + + public init( + schemaVersion: Int = 1, + namespaceID: UUID, + listenerInstanceID: UUID, + listenerPublicKeySHA256: String, + registryGenerationID: UUID, + principal: PeekabooBridgeBrowserCapabilityPrincipal, + issuedAtUnixMilliseconds: Int64, + expiresAtUnixMilliseconds: Int64) + { + self.schemaVersion = schemaVersion + self.namespaceID = namespaceID + self.listenerInstanceID = listenerInstanceID + self.listenerPublicKeySHA256 = listenerPublicKeySHA256 + self.registryGenerationID = registryGenerationID + self.principal = principal + self.issuedAtUnixMilliseconds = issuedAtUnixMilliseconds + self.expiresAtUnixMilliseconds = expiresAtUnixMilliseconds + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case schemaVersion + case namespaceID + case listenerInstanceID + case listenerPublicKeySHA256 + case registryGenerationID + case principal + case issuedAtUnixMilliseconds + case expiresAtUnixMilliseconds + } + + public init(from decoder: any Decoder) throws { + try PeekabooBridgeClosedPayload.requireExactKeys( + CodingKeys.self, + from: decoder, + description: "Browser capability namespace receipt payload") + let container = try decoder.container(keyedBy: CodingKeys.self) + self.schemaVersion = try container.decode(Int.self, forKey: .schemaVersion) + self.namespaceID = try container.decode(UUID.self, forKey: .namespaceID) + self.listenerInstanceID = try container.decode(UUID.self, forKey: .listenerInstanceID) + self.listenerPublicKeySHA256 = try container.decode(String.self, forKey: .listenerPublicKeySHA256) + self.registryGenerationID = try container.decode(UUID.self, forKey: .registryGenerationID) + self.principal = try container.decode(PeekabooBridgeBrowserCapabilityPrincipal.self, forKey: .principal) + self.issuedAtUnixMilliseconds = try container.decode(Int64.self, forKey: .issuedAtUnixMilliseconds) + self.expiresAtUnixMilliseconds = try container.decode(Int64.self, forKey: .expiresAtUnixMilliseconds) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.schemaVersion, forKey: .schemaVersion) + try container.encode(self.namespaceID, forKey: .namespaceID) + try container.encode(self.listenerInstanceID, forKey: .listenerInstanceID) + try container.encode(self.listenerPublicKeySHA256, forKey: .listenerPublicKeySHA256) + try container.encode(self.registryGenerationID, forKey: .registryGenerationID) + try container.encode(self.principal, forKey: .principal) + try container.encode(self.issuedAtUnixMilliseconds, forKey: .issuedAtUnixMilliseconds) + try container.encode(self.expiresAtUnixMilliseconds, forKey: .expiresAtUnixMilliseconds) + } +} + +public struct PeekabooBridgeBrowserCapabilityNamespaceReceipt: Codable, Equatable, Sendable { + public let payload: PeekabooBridgeBrowserCapabilityNamespaceReceiptPayload + public let signature: Data + + public init( + payload: PeekabooBridgeBrowserCapabilityNamespaceReceiptPayload, + signature: Data) + { + self.payload = payload + self.signature = signature + } + + /// Exact bytes covered by ``signature`` after canonical Bridge encoding. + public var unsignedPayload: PeekabooBridgeBrowserCapabilityNamespaceReceiptPayload { + self.payload + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case payload + case signature + } + + public init(from decoder: any Decoder) throws { + try PeekabooBridgeClosedPayload.requireExactKeys( + CodingKeys.self, + from: decoder, + description: "Browser capability namespace receipt") + let container = try decoder.container(keyedBy: CodingKeys.self) + self.payload = try container.decode( + PeekabooBridgeBrowserCapabilityNamespaceReceiptPayload.self, + forKey: .payload) + self.signature = try container.decode(Data.self, forKey: .signature) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.payload, forKey: .payload) + try container.encode(self.signature, forKey: .signature) + } +} + +/// Empty today so later namespace-creation options can be additive without changing the request case. +public struct PeekabooBridgeBrowserCapabilityNamespaceCreateRequest: Codable, Equatable, Sendable { + public init() {} + + public init(from decoder: any Decoder) throws { + try PeekabooBridgeClosedPayload.requireExactKeys( + [], + from: decoder, + description: "Browser capability namespace create request") + } + + public func encode(to encoder: any Encoder) throws { + _ = encoder.container(keyedBy: EmptyCodingKey.self) + } + + private enum EmptyCodingKey: String, CodingKey {} +} + +/// Per-call authority. A namespace never permanently acquires foreground permission. +public enum PeekabooBridgeBrowserCapabilityExecutionMode: String, Codable, Equatable, Sendable { + case backgroundOnly = "background_only" + case foregroundAllowed = "foreground_allowed" +} + +/// Closed high-level browser surface carried by protocol 1.38 namespaces. +/// +/// Provider tool names are deliberately absent. Adding a high-level action requires a protocol review instead of +/// silently extending the legacy raw ``PeekabooBridgeBrowserExecuteRequest`` escape hatch. +public enum PeekabooBridgeBrowserHighLevelAction: String, Codable, CaseIterable, Equatable, Sendable { + case status + case connect + case disconnect + case listPages = "list_pages" + case selectPage = "select_page" + case closePage = "close_page" + case newPage = "new_page" + case navigate + case waitFor = "wait_for" + case snapshot + case click + case fill + case fillForm = "fill_form" + case drag + case hover + case type + case pressKey = "press_key" + case uploadFile = "upload_file" + case handleDialog = "handle_dialog" + case console + case network + case screenshot + case performanceTrace = "performance_trace" +} + +public struct PeekabooBridgeBrowserHighLevelActionRequest: Codable, Equatable, Sendable { + public let action: PeekabooBridgeBrowserHighLevelAction + public let arguments: [String: PeekabooBridgeJSONValue] + + public init( + action: PeekabooBridgeBrowserHighLevelAction, + arguments: [String: PeekabooBridgeJSONValue] = [:]) + { + self.action = action + self.arguments = arguments + } + + /// Canonical high-level BrowserTool arguments. The typed action always wins over dictionary input. + public var toolArguments: [String: PeekabooBridgeJSONValue] { + var arguments = self.arguments + arguments["action"] = .string(self.action.rawValue) + return arguments + } + + public var isReadOnly: Bool { + switch self.action { + case .status, .disconnect, .listPages, .waitFor, .snapshot, .console, .network, .screenshot: + true + case .selectPage: + self.booleanArgument("bring_to_front") != true + case .performanceTrace: + (self.stringArgument("trace_action") ?? "start") != "start" || + self.booleanArgument("reload") == false + case .connect, .closePage, .newPage, .navigate, .click, .fill, .fillForm, .drag, .hover, .type, + .pressKey, .uploadFile, .handleDialog: + false + } + } + + var requestsForegroundDelivery: Bool { + switch self.action { + case .connect: + true + case .selectPage: + self.booleanArgument("bring_to_front") == true + case .newPage: + // The high-level BrowserTool adapter normalizes omission to background=true before provider dispatch. + // This wire carries that adapter contract, not the provider's raw new_page default. + self.booleanArgument("background") == false + default: + false + } + } + + private func booleanArgument(_ name: String) -> Bool? { + guard case let .bool(value)? = self.arguments[name] else { return nil } + return value + } + + private func stringArgument(_ name: String) -> String? { + guard case let .string(value)? = self.arguments[name] else { return nil } + return value + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case action + case arguments + } + + public init(from decoder: any Decoder) throws { + try PeekabooBridgeClosedPayload.requireExactKeys( + CodingKeys.self, + from: decoder, + description: "Browser high-level action request") + let container = try decoder.container(keyedBy: CodingKeys.self) + self.action = try container.decode(PeekabooBridgeBrowserHighLevelAction.self, forKey: .action) + self.arguments = try container.decode([String: PeekabooBridgeJSONValue].self, forKey: .arguments) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.action, forKey: .action) + try container.encode(self.arguments, forKey: .arguments) + } +} + +public struct PeekabooBridgeBrowserBindWindowRequest: Codable, Equatable, Sendable { + public let pageID: String + public let processIdentifier: Int32 + public let windowID: UInt32 + + public init(pageID: String, processIdentifier: Int32, windowID: UInt32) { + self.pageID = pageID + self.processIdentifier = processIdentifier + self.windowID = windowID + } + + public var toolArguments: [String: PeekabooBridgeJSONValue] { + [ + "action": .string("bind_window"), + "page_id": .string(self.pageID), + "pid": .int(Int(self.processIdentifier)), + "window_id": .int(Int(self.windowID)), + ] + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case pageID = "page_id" + case processIdentifier = "pid" + case windowID = "window_id" + } + + public init(from decoder: any Decoder) throws { + try PeekabooBridgeClosedPayload.requireExactKeys( + CodingKeys.self, + from: decoder, + description: "Browser native-window binding request") + let container = try decoder.container(keyedBy: CodingKeys.self) + self.pageID = try container.decode(String.self, forKey: .pageID) + self.processIdentifier = try container.decode(Int32.self, forKey: .processIdentifier) + self.windowID = try container.decode(UInt32.self, forKey: .windowID) + guard PeekabooBridgeBrowserCapabilityReference.isCanonicalPage(self.pageID), + self.processIdentifier > 0, + self.windowID > 0 + else { + throw DecodingError.dataCorrupted(.init( + codingPath: decoder.codingPath, + debugDescription: "Browser native-window binding selectors are not canonical")) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.pageID, forKey: .pageID) + try container.encode(self.processIdentifier, forKey: .processIdentifier) + try container.encode(self.windowID, forKey: .windowID) + } +} + +public enum PeekabooBridgeBrowserCapabilityNamespaceAction: Codable, Equatable, Sendable { + case bindWindow(PeekabooBridgeBrowserBindWindowRequest) + case executeAction(PeekabooBridgeBrowserHighLevelActionRequest) + + public var toolArguments: [String: PeekabooBridgeJSONValue] { + switch self { + case let .bindWindow(request): + request.toolArguments + case let .executeAction(request): + request.toolArguments + } + } + + public var isReadOnly: Bool { + switch self { + case .bindWindow: + true + case let .executeAction(request): + request.isReadOnly + } + } + + var requestsForegroundDelivery: Bool { + switch self { + case .bindWindow: + false + case let .executeAction(request): + request.requestsForegroundDelivery + } + } + + private enum CodingKeys: String, CodingKey { + case bindWindow + case executeAction + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if container.contains(.bindWindow), !container.contains(.executeAction) { + try PeekabooBridgeClosedPayload.requireExactKeys( + [CodingKeys.bindWindow.stringValue], + from: decoder, + description: "Browser capability namespace action") + self = try .bindWindow(container.decode(PeekabooBridgeBrowserBindWindowRequest.self, forKey: .bindWindow)) + } else if container.contains(.executeAction), !container.contains(.bindWindow) { + try PeekabooBridgeClosedPayload.requireExactKeys( + [CodingKeys.executeAction.stringValue], + from: decoder, + description: "Browser capability namespace action") + self = try .executeAction(container.decode( + PeekabooBridgeBrowserHighLevelActionRequest.self, + forKey: .executeAction)) + } else { + throw DecodingError.dataCorrupted(.init( + codingPath: decoder.codingPath, + debugDescription: "Browser capability namespace action must carry exactly one closed case")) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .bindWindow(request): + try container.encode(request, forKey: .bindWindow) + case let .executeAction(request): + try container.encode(request, forKey: .executeAction) + } + } +} + +public struct PeekabooBridgeBrowserCapabilityNamespaceRequest: Codable, Equatable, Sendable { + public let namespaceReceipt: PeekabooBridgeBrowserCapabilityNamespaceReceipt + public let executionMode: PeekabooBridgeBrowserCapabilityExecutionMode + public let action: PeekabooBridgeBrowserCapabilityNamespaceAction + + public init( + namespaceReceipt: PeekabooBridgeBrowserCapabilityNamespaceReceipt, + executionMode: PeekabooBridgeBrowserCapabilityExecutionMode = .backgroundOnly, + action: PeekabooBridgeBrowserCapabilityNamespaceAction) + { + self.namespaceReceipt = namespaceReceipt + self.executionMode = executionMode + self.action = action + } + + public var toolArguments: [String: PeekabooBridgeJSONValue] { + self.action.toolArguments + } + + public var isReadOnly: Bool { + self.action.isReadOnly + } + + var requestsForegroundDelivery: Bool { + self.action.requestsForegroundDelivery + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case namespaceReceipt + case executionMode + case action + } + + public init(from decoder: any Decoder) throws { + try PeekabooBridgeClosedPayload.requireExactKeys( + CodingKeys.self, + from: decoder, + description: "Browser capability namespace request") + let container = try decoder.container(keyedBy: CodingKeys.self) + self.namespaceReceipt = try container.decode( + PeekabooBridgeBrowserCapabilityNamespaceReceipt.self, + forKey: .namespaceReceipt) + self.executionMode = try container.decode( + PeekabooBridgeBrowserCapabilityExecutionMode.self, + forKey: .executionMode) + self.action = try container.decode(PeekabooBridgeBrowserCapabilityNamespaceAction.self, forKey: .action) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.namespaceReceipt, forKey: .namespaceReceipt) + try container.encode(self.executionMode, forKey: .executionMode) + try container.encode(self.action, forKey: .action) + } +} + +public struct PeekabooBridgeBrowserCapabilityNamespaceCloseRequest: Codable, Equatable, Sendable { + public let namespaceReceipt: PeekabooBridgeBrowserCapabilityNamespaceReceipt + + public init(namespaceReceipt: PeekabooBridgeBrowserCapabilityNamespaceReceipt) { + self.namespaceReceipt = namespaceReceipt + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case namespaceReceipt + } + + public init(from decoder: any Decoder) throws { + try PeekabooBridgeClosedPayload.requireExactKeys( + CodingKeys.self, + from: decoder, + description: "Browser capability namespace close request") + let container = try decoder.container(keyedBy: CodingKeys.self) + self.namespaceReceipt = try container.decode( + PeekabooBridgeBrowserCapabilityNamespaceReceipt.self, + forKey: .namespaceReceipt) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.namespaceReceipt, forKey: .namespaceReceipt) + } +} + +/// Sanitized exact native-window evidence returned by binding and every subsequently bound mutation. +public struct PeekabooBridgeBrowserNativeWindowReceipt: Codable, Equatable, Sendable { + public enum Quality: String, Codable, Equatable, Sendable { + case exact + } + + public let pageReference: String + public let processIdentifier: Int32 + public let processStartIdentityDecimal: String + public let windowID: UInt32 + public let bounds: CGRect + public let quality: Quality + + public init( + pageReference: String, + processIdentifier: Int32, + processStartIdentityDecimal: String, + windowID: UInt32, + bounds: CGRect, + quality: Quality = .exact) + { + self.pageReference = pageReference + self.processIdentifier = processIdentifier + self.processStartIdentityDecimal = processStartIdentityDecimal + self.windowID = windowID + self.bounds = bounds + self.quality = quality + } + + var targetEvidence: DesktopTargetIdentity.Evidence? { + guard PeekabooBridgeBrowserCapabilityReference.isCanonicalPage(self.pageReference), + self.processIdentifier > 0, + let processStartIdentity = UInt64(self.processStartIdentityDecimal), + processStartIdentity > 0, + String(processStartIdentity) == self.processStartIdentityDecimal, + self.windowID > 0, + self.bounds.origin.x.isFinite, + self.bounds.origin.y.isFinite, + self.bounds.width.isFinite, + self.bounds.height.isFinite, + self.bounds.width > 0, + self.bounds.height > 0 + else { return nil } + let identity = WindowMutationIdentity( + windowID: Int(self.windowID), + ownerProcessIdentifier: self.processIdentifier, + ownerProcessStartIdentity: processStartIdentity, + capturedBounds: self.bounds) + guard let exactWindow = try? UIAutomationTarget.ExactWindow(identity: identity, bounds: self.bounds) else { + return nil + } + return .init(target: DesktopTargetIdentity(exactWindow: exactWindow)) + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case pageReference + case processIdentifier + case processStartIdentityDecimal + case windowID + case bounds + case quality + } + + public init(from decoder: any Decoder) throws { + try PeekabooBridgeClosedPayload.requireExactKeys( + CodingKeys.self, + from: decoder, + description: "Browser native-window receipt") + let container = try decoder.container(keyedBy: CodingKeys.self) + self.pageReference = try container.decode(String.self, forKey: .pageReference) + self.processIdentifier = try container.decode(Int32.self, forKey: .processIdentifier) + self.processStartIdentityDecimal = try container.decode(String.self, forKey: .processStartIdentityDecimal) + self.windowID = try container.decode(UInt32.self, forKey: .windowID) + self.bounds = try container.decode(CGRect.self, forKey: .bounds) + self.quality = try container.decode(Quality.self, forKey: .quality) + guard self.targetEvidence != nil else { + throw DecodingError.dataCorrupted(.init( + codingPath: decoder.codingPath, + debugDescription: "Browser native-window receipt is not canonical exact target evidence")) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.pageReference, forKey: .pageReference) + try container.encode(self.processIdentifier, forKey: .processIdentifier) + try container.encode(self.processStartIdentityDecimal, forKey: .processStartIdentityDecimal) + try container.encode(self.windowID, forKey: .windowID) + try container.encode(self.bounds, forKey: .bounds) + try container.encode(self.quality, forKey: .quality) + } +} + +/// Sanitized BrowserTool response. Provider endpoints, target IDs, page integers, and raw connection receipts are +/// intentionally not representable in this protocol-1.38 response. +public struct PeekabooBridgeBrowserCapabilityNamespaceActionResponse: Codable, Equatable, Sendable { + public let content: [PeekabooBridgeJSONValue] + public let isError: Bool + public let meta: PeekabooBridgeJSONValue? + public let structuredContent: PeekabooBridgeJSONValue? + public let nativeWindowReceipt: PeekabooBridgeBrowserNativeWindowReceipt? + + public init( + content: [PeekabooBridgeJSONValue], + isError: Bool, + meta: PeekabooBridgeJSONValue? = nil, + structuredContent: PeekabooBridgeJSONValue? = nil, + nativeWindowReceipt: PeekabooBridgeBrowserNativeWindowReceipt? = nil) + { + self.content = content + self.isError = isError + self.meta = meta + self.structuredContent = structuredContent + self.nativeWindowReceipt = nativeWindowReceipt + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case content + case isError + case meta + case structuredContent + case nativeWindowReceipt + } + + public init(from decoder: any Decoder) throws { + let typedContainer = try decoder.container(keyedBy: CodingKeys.self) + var expectedKeys: Set = [ + CodingKeys.content.stringValue, + CodingKeys.isError.stringValue, + ] + for key in [CodingKeys.meta, .structuredContent, .nativeWindowReceipt] + where typedContainer.contains(key) + { + expectedKeys.insert(key.stringValue) + } + try PeekabooBridgeClosedPayload.requireExactKeys( + expectedKeys, + from: decoder, + description: "Browser capability namespace action response") + let container = typedContainer + self.content = try container.decode([PeekabooBridgeJSONValue].self, forKey: .content) + self.isError = try container.decode(Bool.self, forKey: .isError) + self.meta = try container.decodeIfPresent(PeekabooBridgeJSONValue.self, forKey: .meta) + self.structuredContent = try container.decodeIfPresent( + PeekabooBridgeJSONValue.self, + forKey: .structuredContent) + self.nativeWindowReceipt = try container.decodeIfPresent( + PeekabooBridgeBrowserNativeWindowReceipt.self, + forKey: .nativeWindowReceipt) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.content, forKey: .content) + try container.encode(self.isError, forKey: .isError) + try container.encodeIfPresent(self.meta, forKey: .meta) + try container.encodeIfPresent(self.structuredContent, forKey: .structuredContent) + try container.encodeIfPresent(self.nativeWindowReceipt, forKey: .nativeWindowReceipt) + } +} + +public struct PeekabooBridgeBrowserCapabilityNamespaceCloseResponse: Codable, Equatable, Sendable { + public enum Status: String, Codable, Equatable, Sendable { + case closed + } + + public let namespaceID: UUID + public let status: Status + + public init(namespaceID: UUID, status: Status = .closed) { + self.namespaceID = namespaceID + self.status = status + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case namespaceID + case status + } + + public init(from decoder: any Decoder) throws { + try PeekabooBridgeClosedPayload.requireExactKeys( + CodingKeys.self, + from: decoder, + description: "Browser capability namespace close response") + let container = try decoder.container(keyedBy: CodingKeys.self) + self.namespaceID = try container.decode(UUID.self, forKey: .namespaceID) + self.status = try container.decode(Status.self, forKey: .status) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.namespaceID, forKey: .namespaceID) + try container.encode(self.status, forKey: .status) + } +} diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceNegotiation.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceNegotiation.swift new file mode 100644 index 000000000..25a7dbb61 --- /dev/null +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceNegotiation.swift @@ -0,0 +1,32 @@ +import Foundation + +enum PeekabooBridgeBrowserCapabilityNamespaceNegotiation { + struct HostSupport { + let hostKind: PeekabooBridgeHostKind + let maximumProtocolVersion: PeekabooBridgeProtocolVersion + let allowedOperations: Set + let supportsBrowserCapabilityNamespaces: Bool + let supportsNativeBrowserWindowBinding: Bool + } + + struct SessionSupport { + let host: HostSupport + let usesAttestedOperationReceipts: Bool + let clientCapabilities: Set + } + + static func hostCanDeclareCapabilities(_ support: HostSupport) -> Bool { + support.hostKind == .onDemand && + support.maximumProtocolVersion >= PeekabooBridgeConstants.browserCapabilityNamespaceVersion && + support.supportsBrowserCapabilityNamespaces && + support.supportsNativeBrowserWindowBinding && + PeekabooBridgeOperation.browserCapabilityNamespaceOperations.isSubset(of: support.allowedOperations) + } + + static func sessionCanNegotiateCapabilities(_ support: SessionSupport) -> Bool { + support.usesAttestedOperationReceipts && + self.hostCanDeclareCapabilities(support.host) && + support.clientCapabilities.contains(PeekabooBridgeClientCapability.browserCapabilityNamespaces) && + support.clientCapabilities.contains(PeekabooBridgeClientCapability.nativeBrowserWindowBinding) + } +} diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceReceiptValidation.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceReceiptValidation.swift new file mode 100644 index 000000000..518882e57 --- /dev/null +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceReceiptValidation.swift @@ -0,0 +1,33 @@ +import Foundation + +enum PeekabooBridgeBrowserCapabilityNamespaceReceiptValidation { + static func validateNativeTarget( + _ payload: PeekabooBridgeOperationReceiptPayload, + request: PeekabooBridgeRequest, + response: PeekabooBridgeResponse, + plan: PeekabooBridgeOperationResultSemantics.PeekabooBridgeRequestPlan) throws + { + guard request.operation == .browserCapabilityNamespace, + plan.result.completion.mutatesDesktop, + !PeekabooBridgeOperationResultSemantics.isNoDispatchFailure(response) + else { return } + let responseReceipt = response.browserCapabilityNamespaceResponse?.nativeWindowReceipt + guard case let .window(signedWindow) = payload.target else { + guard responseReceipt == nil else { + throw PeekabooBridgeOperationReceiptError.receiptMismatch( + "unbound browser namespace native-window target") + } + return + } + guard let responseReceipt, + let responseWindow = responseReceipt.targetEvidence?.windowIdentity, + signedWindow.hasSameStableReceipt(as: responseWindow), + case let .browserCapabilityNamespace(namespaceRequest) = request.unwrappedOperationRequest, + case let .string(requestedPage)? = namespaceRequest.toolArguments["page_id"], + requestedPage == responseReceipt.pageReference + else { + throw PeekabooBridgeOperationReceiptError.receiptMismatch( + "bound browser namespace native-window target") + } + } +} diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeClient+BrowserCapabilityNamespaces.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeClient+BrowserCapabilityNamespaces.swift new file mode 100644 index 000000000..b493f28da --- /dev/null +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeClient+BrowserCapabilityNamespaces.swift @@ -0,0 +1,143 @@ +import Foundation +import PeekabooAutomationKit +import PeekabooFoundation + +extension PeekabooBridgeClient { + public func createBrowserCapabilityNamespace() async throws + -> PeekabooBridgeBrowserCapabilityNamespaceReceipt + { + try self.requireBrowserCapabilityNamespace(nativeWindowBinding: false) + let response = try await self.send(.browserCreateCapabilityNamespace(.init())) + switch response { + case let .browserCapabilityNamespaceCreated(receipt): + return receipt + case let .error(envelope): + throw envelope + default: + throw PeekabooBridgeErrorEnvelope( + code: .invalidRequest, + message: "Unexpected browser capability namespace creation response") + } + } + + public func executeBrowserCapabilityNamespace( + _ request: PeekabooBridgeBrowserCapabilityNamespaceRequest) async throws + -> PeekabooBridgeBrowserCapabilityNamespaceActionResponse + { + try await self.executeBrowserCapabilityNamespaceResult(request).payload + } + + public func executeBrowserCapabilityNamespaceResult( + _ request: PeekabooBridgeBrowserCapabilityNamespaceRequest) async throws + -> DesktopActionResult + { + let needsNativeWindowBinding = if case .bindWindow = request.action { + true + } else { + false + } + try self.requireBrowserCapabilityNamespace(nativeWindowBinding: needsNativeWindowBinding) + if request.executionMode == .backgroundOnly, request.requestsForegroundDelivery { + throw DesktopActionFailure.preDispatchRefusal( + route: .bridge, + reason: .foregroundRequired, + message: "This browser namespace action requires explicit foreground authority.", + hint: "Retry only with foreground_allowed when interrupting the user is intentional.") + } + let bridgeRequest = PeekabooBridgeRequest.browserCapabilityNamespace(request) + if request.isReadOnly { + let response = try await self.send(bridgeRequest) + switch response { + case let .browserCapabilityNamespaceAction(payload): + try Self.validateBrowserCapabilityNamespaceResponse(payload, request: request) + return .init(payload: payload, outcome: nil) + case let .error(envelope): + throw envelope + default: + throw PeekabooBridgeErrorEnvelope( + code: .invalidRequest, + message: "Unexpected browser capability namespace action response") + } + } + let result = try await self.actionResult( + for: bridgeRequest, + expectedResponse: "browser capability namespace action", + operationReceiptRequirement: .required) + { response in + guard case let .browserCapabilityNamespaceAction(payload) = response else { return nil } + return payload + } + try Self.validateBrowserCapabilityNamespaceResponse(result.payload, request: request) + return result.desktopActionResult + } + + public func closeBrowserCapabilityNamespace( + _ receipt: PeekabooBridgeBrowserCapabilityNamespaceReceipt) async throws + -> PeekabooBridgeBrowserCapabilityNamespaceCloseResponse + { + try self.requireBrowserCapabilityNamespace(nativeWindowBinding: false) + let response = try await self.send(.browserCloseCapabilityNamespace(.init(namespaceReceipt: receipt))) + switch response { + case let .browserCapabilityNamespaceClosed(closed) + where closed.namespaceID == receipt.payload.namespaceID && closed.status == .closed: + return closed + case .browserCapabilityNamespaceClosed: + throw PeekabooBridgeErrorEnvelope( + code: .invalidRequest, + message: "Browser capability namespace close response contradicted its receipt") + case let .error(envelope): + throw envelope + default: + throw PeekabooBridgeErrorEnvelope( + code: .invalidRequest, + message: "Unexpected browser capability namespace close response") + } + } + + private func requireBrowserCapabilityNamespace(nativeWindowBinding: Bool) throws { + guard self.browserCapabilityNamespacesEnabled else { + throw DesktopActionFailure.preDispatchRefusal( + route: .bridge, + reason: .runtimeIncompatible, + message: "This Bridge host did not negotiate caller-owned browser capability namespaces.", + hint: "Use a current local on-demand Peekaboo host and complete a fresh handshake.") + } + guard !nativeWindowBinding || self.nativeBrowserWindowBindingEnabled else { + throw DesktopActionFailure.preDispatchRefusal( + route: .bridge, + reason: .runtimeIncompatible, + message: "This browser capability namespace cannot bind an exact native window.", + hint: "Update the local on-demand Peekaboo host and create a new namespace.") + } + } + + private static func validateBrowserCapabilityNamespaceResponse( + _ response: PeekabooBridgeBrowserCapabilityNamespaceActionResponse, + request: PeekabooBridgeBrowserCapabilityNamespaceRequest) throws + { + if let receipt = response.nativeWindowReceipt { + guard receipt.targetEvidence != nil, + case let .string(requestedPage)? = request.toolArguments["page_id"], + receipt.pageReference == requestedPage + else { + throw PeekabooBridgeErrorEnvelope( + code: .invalidRequest, + message: "Browser namespace response carried contradictory native-window target evidence") + } + } + guard case let .bindWindow(binding) = request.action else { + // Verified operation receipts independently require this typed evidence whenever the signed mutation + // target is an exact window, so a bound mutation cannot be accepted merely because this client is + // stateless. + return + } + guard let receipt = response.nativeWindowReceipt, + receipt.processIdentifier == binding.processIdentifier, + receipt.windowID == binding.windowID + else { + throw PeekabooBridgeErrorEnvelope( + code: .invalidRequest, + message: "Browser native-window binding response omitted or contradicted its exact target receipt") + } + } +} diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeClient+Transport.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeClient+Transport.swift index 81c41ccdc..5a5742cf2 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeClient+Transport.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeClient+Transport.swift @@ -189,6 +189,24 @@ extension PeekabooBridgeClient { } private func requireNegotiatedInputCapabilities(for request: PeekabooBridgeRequest) throws { + if request.requiresBrowserCapabilityNamespaces, + !self.browserCapabilityNamespacesEnabled + { + throw DesktopActionFailure.preDispatchRefusal( + route: .bridge, + reason: .runtimeIncompatible, + message: "This Bridge host did not negotiate a caller-owned browser capability namespace.", + hint: "Use a current local on-demand Peekaboo host and complete a fresh handshake.") + } + if request.requiresNativeBrowserWindowBinding, + !self.nativeBrowserWindowBindingEnabled + { + throw DesktopActionFailure.preDispatchRefusal( + route: .bridge, + reason: .runtimeIncompatible, + message: "This Bridge namespace cannot bind browser pages to exact native windows.", + hint: "Update the local on-demand Peekaboo host and create a new namespace.") + } if request.createsOrPublishesSnapshotState || request.requiresProducerBoundSnapshotReferences, !self.producerBoundSnapshotReferencesEnabled { diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeClient.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeClient.swift index 7716fee8f..db8112b54 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeClient.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeClient.swift @@ -62,6 +62,8 @@ public actor PeekabooBridgeClient { var processGenerationBoundElementMutationsEnabled = false var foregroundModifierClickSnapshotLeaseEnabled = false var nativeBrowserConnectionBindingEnabled = false + var browserCapabilityNamespacesEnabled = false + var nativeBrowserWindowBindingEnabled = false var producerBoundSnapshotReferencesEnabled = false var targetedClickAccessibilityValueDeliveryEnabled = false var requestPinnedExactWindowScrollReceiptEnabled = false @@ -465,6 +467,8 @@ public actor PeekabooBridgeClient { self.processGenerationBoundElementMutationsEnabled = false self.foregroundModifierClickSnapshotLeaseEnabled = false self.nativeBrowserConnectionBindingEnabled = false + self.browserCapabilityNamespacesEnabled = false + self.nativeBrowserWindowBindingEnabled = false self.producerBoundSnapshotReferencesEnabled = false self.targetedClickAccessibilityValueDeliveryEnabled = false self.requestPinnedExactWindowScrollReceiptEnabled = false @@ -721,13 +725,7 @@ public actor PeekabooBridgeClient { requestedHostKind: inputs.requestedHost, operationClientInstanceID: self.operationClientInstanceID, replacingOperationSessionID: replacingOperationSessionID, - clientCapabilities: protocolVersion >= PeekabooBridgeConstants.producerBoundSnapshotReferencesVersion - ? [ - PeekabooBridgeClientCapability.producerBoundSnapshotReferences, - PeekabooBridgeClientCapability.targetedClickAccessibilityValueDelivery, - PeekabooBridgeClientCapability.installedApplicationCatalog, - ] - : nil) + clientCapabilities: Self.handshakeClientCapabilities(protocolVersion: protocolVersion)) let reply = try await self.sendCarryingActionOutcome(.handshake(payload), timeoutSec: timeoutSec) try self.validateTrustedConnectedHost(reply.connectedHost) let response = reply.response @@ -923,6 +921,10 @@ public actor PeekabooBridgeClient { Self.supportsForegroundModifierClickSnapshotLease(handshake), nativeBrowserConnectionBindingEnabled: Self.supportsNativeBrowserConnectionBinding(handshake), + browserCapabilityNamespacesEnabled: + Self.supportsBrowserCapabilityNamespaces(handshake), + nativeBrowserWindowBindingEnabled: + Self.supportsNativeBrowserWindowBinding(handshake), producerBoundSnapshotReferencesEnabled: Self.supportsProducerBoundSnapshotReferences(handshake), targetedClickAccessibilityValueDeliveryEnabled: @@ -1079,6 +1081,44 @@ public actor PeekabooBridgeClient { operations.isSubset(of: Set(handshake.supportedOperations)) } + static func supportsBrowserCapabilityNamespaces( + _ handshake: PeekabooBridgeHandshakeResponse) -> Bool + { + let operations = PeekabooBridgeOperation.browserCapabilityNamespaceOperations + return handshake.negotiatedVersion >= PeekabooBridgeConstants.browserCapabilityNamespaceVersion && + handshake.hostKind == .onDemand && + handshake.hostCapabilities?.contains(PeekabooBridgeHostCapability.attestedOperationReceipts) == true && + handshake.hostCapabilities?.contains(PeekabooBridgeHostCapability.browserCapabilityNamespaces) == true && + handshake.hostCapabilities?.contains(PeekabooBridgeHostCapability.nativeBrowserWindowBinding) == true && + operations.isSubset(of: Set(handshake.supportedOperations)) && + operations.isSubset(of: Set(handshake.enabledOperations ?? handshake.supportedOperations)) + } + + static func supportsNativeBrowserWindowBinding( + _ handshake: PeekabooBridgeHandshakeResponse) -> Bool + { + self.supportsBrowserCapabilityNamespaces(handshake) && + handshake.hostCapabilities?.contains(PeekabooBridgeHostCapability.nativeBrowserWindowBinding) == true + } + + private static func handshakeClientCapabilities( + protocolVersion: PeekabooBridgeProtocolVersion) -> [String]? + { + guard protocolVersion >= PeekabooBridgeConstants.producerBoundSnapshotReferencesVersion else { return nil } + var capabilities = [ + PeekabooBridgeClientCapability.producerBoundSnapshotReferences, + PeekabooBridgeClientCapability.targetedClickAccessibilityValueDelivery, + ] + if protocolVersion >= PeekabooBridgeConstants.installedApplicationCatalogVersion { + capabilities.append(PeekabooBridgeClientCapability.installedApplicationCatalog) + } + if protocolVersion >= PeekabooBridgeConstants.browserCapabilityNamespaceVersion { + capabilities.append(PeekabooBridgeClientCapability.browserCapabilityNamespaces) + capabilities.append(PeekabooBridgeClientCapability.nativeBrowserWindowBinding) + } + return capabilities + } + private static func supportsRequestPinnedExactWindowScrollReceipt( _ handshake: PeekabooBridgeHandshakeResponse) -> Bool { @@ -1141,6 +1181,8 @@ public actor PeekabooBridgeClient { self.foregroundModifierClickSnapshotLeaseEnabled = candidate.foregroundModifierClickSnapshotLeaseEnabled self.nativeBrowserConnectionBindingEnabled = candidate.nativeBrowserConnectionBindingEnabled + self.browserCapabilityNamespacesEnabled = candidate.browserCapabilityNamespacesEnabled + self.nativeBrowserWindowBindingEnabled = candidate.nativeBrowserWindowBindingEnabled self.producerBoundSnapshotReferencesEnabled = candidate.producerBoundSnapshotReferencesEnabled self.targetedClickAccessibilityValueDeliveryEnabled = candidate.targetedClickAccessibilityValueDeliveryEnabled @@ -1443,6 +1485,8 @@ private struct PeekabooBridgeClientHandshakeCandidate: Sendable { let processGenerationBoundElementMutationsEnabled: Bool let foregroundModifierClickSnapshotLeaseEnabled: Bool let nativeBrowserConnectionBindingEnabled: Bool + let browserCapabilityNamespacesEnabled: Bool + let nativeBrowserWindowBindingEnabled: Bool let producerBoundSnapshotReferencesEnabled: Bool let targetedClickAccessibilityValueDeliveryEnabled: Bool let requestPinnedExactWindowScrollReceiptEnabled: Bool diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeConstants.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeConstants.swift index 8606986d3..6bd5f5bfc 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeConstants.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeConstants.swift @@ -65,7 +65,11 @@ public enum PeekabooBridgeConstants { } /// Current protocol version supported by this build. - public static let protocolVersion = PeekabooBridgeProtocolVersion(major: 1, minor: 37) + public static let protocolVersion = PeekabooBridgeProtocolVersion(major: 1, minor: 38) + + /// First protocol with authenticated, caller-owned browser capability namespaces and exact native-window binding. + public static let browserCapabilityNamespaceVersion = + PeekabooBridgeProtocolVersion(major: 1, minor: 38) /// First protocol that can attest one exact Chrome bundle, process generation, listener, and DevTools identity. public static let nativeBrowserConnectionBindingVersion = diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeModels.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeModels.swift index c7be1ad80..d0acff691 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeModels.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeModels.swift @@ -48,6 +48,9 @@ public enum PeekabooBridgeOperation: String, Codable, Sendable, CaseIterable, Ha case browserConnect case browserDisconnect case browserExecute + case browserCreateCapabilityNamespace + case browserCapabilityNamespace + case browserCloseCapabilityNamespace // Capture case captureScreen case captureWindow @@ -276,6 +279,11 @@ public enum PeekabooBridgeOperation: String, Codable, Sendable, CaseIterable, Ha if version < PeekabooBridgeConstants.producerBoundSnapshotReferencesVersion { compatible.remove(.ownsSnapshot) } + if version < PeekabooBridgeConstants.browserCapabilityNamespaceVersion { + compatible.remove(.browserCreateCapabilityNamespace) + compatible.remove(.browserCapabilityNamespace) + compatible.remove(.browserCloseCapabilityNamespace) + } return compatible } // swiftlint:enable cyclomatic_complexity @@ -384,6 +392,8 @@ public enum PeekabooBridgeHostCapability { public static let producerBoundSnapshotReferences = "producerBoundSnapshotReferences" public static let targetedClickAccessibilityValueDelivery = "targetedClickAccessibilityValueDelivery" public static let processGenerationBoundElementMutations = "processGenerationBoundElementMutations" + public static let browserCapabilityNamespaces = "browserCapabilityNamespaces" + public static let nativeBrowserWindowBinding = "nativeBrowserWindowBinding" public static let exactDialogInputExecution = "exactDialogInputExecution" public static let exactForcedDialogDismissExecution = "exactForcedDialogDismissExecution" public static let dialogInputFocusPolicy = "dialogInputFocusPolicy" @@ -408,6 +418,8 @@ public enum PeekabooBridgeClientCapability { public static let producerBoundSnapshotReferences = "producerBoundSnapshotReferences" public static let targetedClickAccessibilityValueDelivery = "targetedClickAccessibilityValueDelivery" public static let installedApplicationCatalog = "installedApplicationCatalog" + public static let browserCapabilityNamespaces = "browserCapabilityNamespaces" + public static let nativeBrowserWindowBinding = "nativeBrowserWindowBinding" } public struct PeekabooBridgeHandshakeResponse: Codable, Sendable { diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperation+Policy.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperation+Policy.swift index 5bc38e26d..429903313 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperation+Policy.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperation+Policy.swift @@ -1,6 +1,13 @@ import Foundation extension PeekabooBridgeOperation { + /// Protocol-1.38 operations reserved for an authenticated on-demand host with a complete scoped browser runtime. + public static let browserCapabilityNamespaceOperations: Set = [ + .browserCreateCapabilityNamespace, + .browserCapabilityNamespace, + .browserCloseCapabilityNamespace, + ] + var mutatesDesktop: Bool { PeekabooBridgeOperationResultSemantics.contract(for: self).completion.mutatesDesktop } @@ -158,4 +165,9 @@ extension PeekabooBridgeOperation { .browserDisconnect, .browserExecute, ]) + + /// Explicit allowlist for the local on-demand daemon. Handshake negotiation still removes the namespace + /// operations unless every protocol, receipt, client-offer, host-kind, and service-support condition is true. + public static let onDemandDefaultAllowlist: Set = + PeekabooBridgeOperation.remoteDefaultAllowlist.union(Self.browserCapabilityNamespaceOperations) } diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationDescriptor.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationDescriptor.swift index 800b4db17..ce8779589 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationDescriptor.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationDescriptor.swift @@ -106,6 +106,21 @@ extension PeekabooBridgeOperationResultSemantics { targetPolicy: .external, responseFamilies: [.browserToolResponse], responseTargetEvidence: .browserConnection) + case .browserCreateCapabilityNamespace: + descriptor( + completion: .readOnly, + targetPolicy: .notApplicable, + responseFamilies: [.browserCapabilityNamespaceReceipt]) + case .browserCapabilityNamespace: + descriptor( + completion: .dispatchedUnverified(browserBackground), + targetPolicy: .external, + responseFamilies: [.browserCapabilityNamespaceAction]) + case .browserCloseCapabilityNamespace: + descriptor( + completion: .readOnly, + targetPolicy: .notApplicable, + responseFamilies: [.browserCapabilityNamespaceClose]) case .captureScreen, .captureFrontmost, .captureArea: descriptor( read: .globalExclusive, diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationReceiptModels.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationReceiptModels.swift index 9c2c76ec4..dfb114fa9 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationReceiptModels.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationReceiptModels.swift @@ -879,6 +879,11 @@ enum PeekabooBridgeOperationReceiptSemantics { return } } + try PeekabooBridgeBrowserCapabilityNamespaceReceiptValidation.validateNativeTarget( + payload, + request: request, + response: response, + plan: plan) if case .browser = payload.target, ![PeekabooBridgeOperation.browserConnect, .browserExecute].contains(request.operation) { diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationResponseFamily.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationResponseFamily.swift index 6e68aaa6a..87a608aad 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationResponseFamily.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationResponseFamily.swift @@ -11,6 +11,9 @@ extension PeekabooBridgeOperationResultSemantics.ResponseFamily { (.bool, .bool), (.browserStatus, .browserStatus), (.browserToolResponse, .browserToolResponse), + (.browserCapabilityNamespaceReceipt, .browserCapabilityNamespaceCreated), + (.browserCapabilityNamespaceAction, .browserCapabilityNamespaceAction), + (.browserCapabilityNamespaceClose, .browserCapabilityNamespaceClosed), (.capture, .capture), (.clickResult, .clickResult), (.daemonStatus, .daemonStatus), diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationResponseTargetEvidence.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationResponseTargetEvidence.swift index 31156a476..8d324bf13 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationResponseTargetEvidence.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationResponseTargetEvidence.swift @@ -34,6 +34,19 @@ extension PeekabooBridgeRequest { } extension PeekabooBridgeResponse { + var browserCapabilityNamespaceResponse: PeekabooBridgeBrowserCapabilityNamespaceActionResponse? { + switch self { + case let .attestedOperation(payload): + payload.response.browserCapabilityNamespaceResponse + case let .projectedAction(payload): + payload.response.browserCapabilityNamespaceResponse + case let .browserCapabilityNamespaceAction(response): + response + default: + nil + } + } + var browserExecutionResponse: PeekabooBridgeBrowserToolResponse? { switch self { case let .attestedOperation(payload): @@ -103,6 +116,9 @@ extension PeekabooBridgeResponse { case let .browserToolResponse(result) where plan.operation == .browserExecute && plan.target.responseEvidenceSource == .browserConnection: return [Self.browserEvidence(result.connectionReceipt)].compactMap(\.self) + case let .browserCapabilityNamespaceAction(result) + where plan.operation == .browserCapabilityNamespace: + return [result.nativeWindowReceipt?.targetEvidence].compactMap(\.self) case let .browserStatus(status) where plan.operation == .browserConnect && plan.target.responseEvidenceSource == .browserConnection: return [Self.browserEvidence(status.connectionReceipt)].compactMap(\.self) @@ -132,6 +148,9 @@ extension PeekabooBridgeResponse { .certificationProducerAttestation, .browserStatus, .browserToolResponse, + .browserCapabilityNamespaceCreated, + .browserCapabilityNamespaceAction, + .browserCapabilityNamespaceClosed, .capture, .elementDetection, .focusedElement, diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationResultSemantics.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationResultSemantics.swift index ee0b0c5de..548887e36 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationResultSemantics.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationResultSemantics.swift @@ -62,6 +62,9 @@ enum PeekabooBridgeOperationResultSemantics { case bool case browserStatus case browserToolResponse + case browserCapabilityNamespaceReceipt + case browserCapabilityNamespaceAction + case browserCapabilityNamespaceClose case capture case clickResult case daemonStatus @@ -841,9 +844,8 @@ extension PeekabooBridgeOperationResultSemantics { // Only invalid carriage remains wrapped after canonical unwrapping. It must not be // granted the inner operation's mutation semantics. return .init(completion: .readOnly, targetPolicy: .notApplicable) - case let .browserExecute(payload): - guard payload.isReadOnly else { return self.contract(for: request.operation) } - return .init(completion: .readOnly, targetPolicy: .notApplicable) + case .browserExecute, .browserCapabilityNamespace: + return self.browserContract(for: request) case let .click(payload): let delivery: DesktopActionOutcome.Delivery let targetPolicy: TargetPolicy @@ -976,6 +978,25 @@ extension PeekabooBridgeOperationResultSemantics { } } + private static func browserContract(for request: PeekabooBridgeRequest) -> Contract { + switch request { + case let .browserExecute(payload): + guard payload.isReadOnly else { return self.contract(for: request.operation) } + return .init(completion: .readOnly, targetPolicy: .notApplicable) + case let .browserCapabilityNamespace(payload): + guard !payload.isReadOnly else { + return .init(completion: .readOnly, targetPolicy: .notApplicable) + } + let mode: DesktopActionOutcome.Delivery.Mode = + payload.requestsForegroundDelivery ? .foreground : .background + return .init( + completion: .dispatchedUnverified(.init(mechanism: .browserProtocol, mode: mode)), + targetPolicy: .external) + default: + preconditionFailure("Browser contract requested for non-browser operation") + } + } + private static func directCaptureContract( visualizerMode: CaptureVisualizerMode, readOnlyTargetPolicy: TargetPolicy, @@ -1286,6 +1307,9 @@ extension PeekabooBridgeOperationResultSemantics { .browserConnect, .browserDisconnect, .browserExecute, + .browserCreateCapabilityNamespace, + .browserCapabilityNamespace, + .browserCloseCapabilityNamespace, .captureScreen, .captureWindow, .captureFrontmost, @@ -1419,7 +1443,8 @@ extension PeekabooBridgeOperationResultSemantics { switch request.operation { case .agentExecutionTrace: return [.dispatchedUnverified] - case .requestPostEventPermission, .browserExecute, .swipe, .drag, .moveMouse, + case .requestPostEventPermission, .browserExecute, .browserCapabilityNamespace, + .swipe, .drag, .moveMouse, .clickMenuItem, .clickMenuItemByName, .clickMenuExtra, .clickMenuBarItemNamed, .clickMenuBarItemIndex, .launchDockItem, .rightClickDockItem, @@ -1473,6 +1498,7 @@ extension PeekabooBridgeOperationResultSemantics { .createExactWindowHeldPointerOwner, .daemonStatus, .daemonStop, .browserStatus, .browserDisconnect, + .browserCreateCapabilityNamespace, .browserCloseCapabilityNamespace, .getFocusedElement, .waitForElement, .listWindows, .getFocusedWindow, .listApplications, .findApplication, .getFrontmostApplication, .isApplicationRunning, .listMenus, .listFrontmostMenus, .listMenuExtras, .menuExtraOpenMenuFrame, @@ -1802,6 +1828,10 @@ extension PeekabooBridgeOperationResultSemantics { return [rule(browserForeground, .exact(1))] case .browserExecute: return [rule(browserBackground, .variable)] + case .browserCapabilityNamespace: + guard case .dispatchedUnverified = contract.completion else { return [] } + let delivery = contract.completion.fixedDelivery ?? browserBackground + return [rule(delivery, .variable)] default: guard let delivery = contract.completion.fixedDelivery else { return [] } return [rule(delivery, .exact(1))] diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationSessionClaim.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationSessionClaim.swift index cd9551340..7f3e9d7ed 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationSessionClaim.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationSessionClaim.swift @@ -5,6 +5,8 @@ struct PeekabooBridgeNegotiatedSessionCapabilities: Hashable, Sendable { let statelessClickVariants: Bool let exactWindowHeldPointerLifecycle: Bool let nativeBrowserConnectionBinding: Bool + let browserCapabilityNamespaces: Bool + let nativeBrowserWindowBinding: Bool let producerBoundSnapshotReferences: Bool let targetedClickAccessibilityValueDelivery: Bool let requestPinnedExactWindowScrollReceipt: Bool @@ -17,6 +19,8 @@ struct PeekabooBridgeNegotiatedSessionCapabilities: Hashable, Sendable { statelessClickVariants: true, exactWindowHeldPointerLifecycle: true, nativeBrowserConnectionBinding: true, + browserCapabilityNamespaces: true, + nativeBrowserWindowBinding: true, producerBoundSnapshotReferences: true, targetedClickAccessibilityValueDelivery: true, requestPinnedExactWindowScrollReceipt: true, @@ -29,6 +33,8 @@ struct PeekabooBridgeNegotiatedSessionCapabilities: Hashable, Sendable { statelessClickVariants: Bool, exactWindowHeldPointerLifecycle: Bool, nativeBrowserConnectionBinding: Bool = false, + browserCapabilityNamespaces: Bool = false, + nativeBrowserWindowBinding: Bool = false, producerBoundSnapshotReferences: Bool = false, targetedClickAccessibilityValueDelivery: Bool = false, requestPinnedExactWindowScrollReceipt: Bool = false, @@ -40,6 +46,8 @@ struct PeekabooBridgeNegotiatedSessionCapabilities: Hashable, Sendable { self.statelessClickVariants = statelessClickVariants self.exactWindowHeldPointerLifecycle = exactWindowHeldPointerLifecycle self.nativeBrowserConnectionBinding = nativeBrowserConnectionBinding + self.browserCapabilityNamespaces = browserCapabilityNamespaces + self.nativeBrowserWindowBinding = nativeBrowserWindowBinding self.producerBoundSnapshotReferences = producerBoundSnapshotReferences self.targetedClickAccessibilityValueDelivery = targetedClickAccessibilityValueDelivery self.requestPinnedExactWindowScrollReceipt = requestPinnedExactWindowScrollReceipt diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeRequest+DesktopMutation.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeRequest+DesktopMutation.swift index 52445a9ae..234707092 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeRequest+DesktopMutation.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeRequest+DesktopMutation.swift @@ -36,6 +36,9 @@ extension PeekabooBridgeRequest { } var minimumNegotiatedProtocolVersion: PeekabooBridgeProtocolVersion? { + if self.requiresBrowserCapabilityNamespaces { + return PeekabooBridgeConstants.browserCapabilityNamespaceVersion + } if self.requiresNativeBrowserConnectionBinding { return PeekabooBridgeConstants.nativeBrowserConnectionBindingVersion } @@ -122,6 +125,36 @@ extension PeekabooBridgeRequest { } } + var requiresBrowserCapabilityNamespaces: Bool { + switch self.unwrappedOperationRequest.operation { + case .browserCreateCapabilityNamespace, + .browserCapabilityNamespace, + .browserCloseCapabilityNamespace: + true + default: + false + } + } + + var requiresNativeBrowserWindowBinding: Bool { + guard case let .browserCapabilityNamespace(payload) = self.unwrappedOperationRequest, + case .bindWindow = payload.action + else { return false } + return true + } + + func validateBrowserCapabilityExecutionMode() throws { + guard case let .browserCapabilityNamespace(payload) = self.unwrappedOperationRequest, + payload.requestsForegroundDelivery, + payload.executionMode != .foregroundAllowed + else { return } + throw DesktopActionFailure.preDispatchRefusal( + route: .bridge, + reason: .foregroundRequired, + message: "This browser namespace action requires explicit foreground authority.", + hint: "Retry only with foreground_allowed when interrupting the user is intentional.") + } + var requiresRequestPinnedExactWindowScrollReceipt: Bool { self.unwrappedOperationRequest.operation == .targetedScroll } diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeRequestResponse.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeRequestResponse.swift index 5e3fec518..44745f63f 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeRequestResponse.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeRequestResponse.swift @@ -18,6 +18,9 @@ public enum PeekabooBridgeRequest: Codable, Sendable { case browserConnect(PeekabooBridgeBrowserChannelRequest) case browserDisconnect case browserExecute(PeekabooBridgeBrowserExecuteRequest) + case browserCreateCapabilityNamespace(PeekabooBridgeBrowserCapabilityNamespaceCreateRequest) + case browserCapabilityNamespace(PeekabooBridgeBrowserCapabilityNamespaceRequest) + case browserCloseCapabilityNamespace(PeekabooBridgeBrowserCapabilityNamespaceCloseRequest) case captureScreen(PeekabooBridgeCaptureScreenRequest) case captureWindow(PeekabooBridgeCaptureWindowRequest) case captureFrontmost(PeekabooBridgeCaptureFrontmostRequest) @@ -143,6 +146,9 @@ extension PeekabooBridgeRequest { case .browserConnect: .browserConnect case .browserDisconnect: .browserDisconnect case .browserExecute: .browserExecute + case .browserCreateCapabilityNamespace: .browserCreateCapabilityNamespace + case .browserCapabilityNamespace: .browserCapabilityNamespace + case .browserCloseCapabilityNamespace: .browserCloseCapabilityNamespace case .captureScreen: .captureScreen case .captureWindow: .captureWindow case .captureFrontmost: .captureFrontmost @@ -264,6 +270,9 @@ public enum PeekabooBridgeResponse: Codable, Sendable { case certificationProducerAttestation(PeekabooBridgeCertificationProducerAttestationResponse) case browserStatus(PeekabooBridgeBrowserStatus) case browserToolResponse(PeekabooBridgeBrowserToolResponse) + case browserCapabilityNamespaceCreated(PeekabooBridgeBrowserCapabilityNamespaceReceipt) + case browserCapabilityNamespaceAction(PeekabooBridgeBrowserCapabilityNamespaceActionResponse) + case browserCapabilityNamespaceClosed(PeekabooBridgeBrowserCapabilityNamespaceCloseResponse) case capture(CaptureResult) case elementDetection(ElementDetectionResult) case focusedElement(UIFocusInfo?) diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+Handlers.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+Handlers.swift index 27122db47..7f46a72d6 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+Handlers.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+Handlers.swift @@ -50,6 +50,12 @@ extension PeekabooBridgeServer { return try await .init(response: self.handleBrowserRequest(request)) } return try await self.handleBrowserExecute(payload) + case .browserCreateCapabilityNamespace, + .browserCapabilityNamespace, + .browserCloseCapabilityNamespace: + throw PeekabooBridgeErrorEnvelope( + code: .operationNotSupported, + message: "This Bridge host has no browser capability namespace runtime") case .captureScreen, .captureWindow, .captureFrontmost, .captureArea: return try await .init(response: self.handleCaptureRequest(request)) case .desktopObservation: diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+Handshake.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+Handshake.swift index 9c60e05e0..2f2e2079a 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+Handshake.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+Handshake.swift @@ -74,6 +74,24 @@ extension PeekabooBridgeServer { var advertisedOps = compatibleOperations.advertised.sorted { $0.rawValue < $1.rawValue } var enabledOps = compatibleOperations.enabled let clientCapabilities = Set(payload.clientCapabilities ?? []) + let browserNamespaceOperations = PeekabooBridgeOperation.browserCapabilityNamespaceOperations + let browserNamespaceService = self.services as? any PeekabooBridgeBrowserCapabilityNamespaceProviding + let supportsBrowserCapabilityNamespaces = + PeekabooBridgeBrowserCapabilityNamespaceNegotiation.sessionCanNegotiateCapabilities(.init( + host: .init( + hostKind: self.hostKind, + maximumProtocolVersion: negotiated, + allowedOperations: Set(advertisedOps).intersection(enabledOps), + supportsBrowserCapabilityNamespaces: + browserNamespaceService?.supportsBrowserCapabilityNamespaces == true, + supportsNativeBrowserWindowBinding: + browserNamespaceService?.supportsNativeBrowserWindowBinding == true), + usesAttestedOperationReceipts: supportsAttestedOperationReceipts, + clientCapabilities: clientCapabilities)) + if !supportsBrowserCapabilityNamespaces { + advertisedOps.removeAll { browserNamespaceOperations.contains($0) } + enabledOps.subtract(browserNamespaceOperations) + } if !clientCapabilities.contains(PeekabooBridgeClientCapability.producerBoundSnapshotReferences) { advertisedOps.removeAll { $0 == .ownsSnapshot } enabledOps.remove(.ownsSnapshot) @@ -145,6 +163,10 @@ extension PeekabooBridgeServer { """) var advertisedCapabilities = self.hostCapabilities + if !supportsBrowserCapabilityNamespaces { + advertisedCapabilities.remove(PeekabooBridgeHostCapability.browserCapabilityNamespaces) + advertisedCapabilities.remove(PeekabooBridgeHostCapability.nativeBrowserWindowBinding) + } let browserOperations: Set = [ .browserStatus, .browserConnect, @@ -293,6 +315,10 @@ extension PeekabooBridgeServer { PeekabooBridgeHostCapability.exactWindowHeldPointerLifecycle), nativeBrowserConnectionBinding: advertisedCapabilities.contains( PeekabooBridgeHostCapability.nativeBrowserConnectionBinding), + browserCapabilityNamespaces: advertisedCapabilities.contains( + PeekabooBridgeHostCapability.browserCapabilityNamespaces), + nativeBrowserWindowBinding: advertisedCapabilities.contains( + PeekabooBridgeHostCapability.nativeBrowserWindowBinding), producerBoundSnapshotReferences: advertisedCapabilities.contains( PeekabooBridgeHostCapability.producerBoundSnapshotReferences), targetedClickAccessibilityValueDelivery: advertisedCapabilities.contains( @@ -433,6 +459,13 @@ extension PeekabooBridgeServer { var operations = self.allowedOperations // Retain the wire enum for old-client decoding, but current hosts never advertise or execute the probe. operations.remove(._appleScriptProbe) + let browserNamespaceService = self.services as? any PeekabooBridgeBrowserCapabilityNamespaceProviding + if self.hostKind != .onDemand || + browserNamespaceService?.supportsBrowserCapabilityNamespaces != true || + browserNamespaceService?.supportsNativeBrowserWindowBinding != true + { + operations.subtract(PeekabooBridgeOperation.browserCapabilityNamespaceOperations) + } if self.daemonControl == nil { operations.remove(.daemonStatus) operations.remove(.daemonStop) diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer.swift index dfac35139..f65ee95c3 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer.swift @@ -219,6 +219,17 @@ public final class PeekabooBridgeServer { { resolvedHostCapabilities.insert(PeekabooBridgeHostCapability.nativeBrowserConnectionBinding) } + let browserNamespaceService = services as? any PeekabooBridgeBrowserCapabilityNamespaceProviding + resolvedHostCapabilities = protocolBrowserNamespaceCapabilities( + resolvedHostCapabilities, + support: .init( + hostKind: hostKind, + maximumProtocolVersion: supportedVersions.upperBound, + allowedOperations: self.allowedOperations, + supportsBrowserCapabilityNamespaces: + browserNamespaceService?.supportsBrowserCapabilityNamespaces == true, + supportsNativeBrowserWindowBinding: + browserNamespaceService?.supportsNativeBrowserWindowBinding == true)) if supportedVersions.upperBound >= PeekabooBridgeConstants.producerBoundSnapshotReferencesVersion, services.snapshots.supportsProducerBoundSnapshotReferences, self.allowedOperations.contains(.ownsSnapshot) @@ -1092,6 +1103,7 @@ public final class PeekabooBridgeServer { message: "Certification operations require a signed Bridge operation receipt") } } + try request.validateBrowserCapabilityExecutionMode() if request.requiresRequestPinnedExactWindowScrollReceipt { let session = PeekabooBridgeRequestContext.negotiatedSessionCapabilities let negotiatedVersion = session?.protocolVersion ?? self.receiptlessProtocolVersion(for: peer) @@ -1117,6 +1129,11 @@ public final class PeekabooBridgeServer { guard (negotiatedVersion ?? .init(major: 0, minor: 0)) >= minimumVersion, !request.requiresNativeBrowserConnectionBinding || session?.nativeBrowserConnectionBinding == true, + !request.requiresBrowserCapabilityNamespaces || + (PeekabooBridgeRequestContext.usesAttestedOperationResultSemantics && + session?.browserCapabilityNamespaces == true), + !request.requiresNativeBrowserWindowBinding || + session?.nativeBrowserWindowBinding == true, !request.requiresProducerBoundSnapshotReferences || session?.producerBoundSnapshotReferences == true, !request.requiresTargetedClickAccessibilityValueDelivery || @@ -1324,3 +1341,18 @@ private func protocolHostCapabilities( } return capabilities } + +private func protocolBrowserNamespaceCapabilities( + _ declaredCapabilities: Set, + support: PeekabooBridgeBrowserCapabilityNamespaceNegotiation.HostSupport) -> Set +{ + var capabilities = declaredCapabilities + if PeekabooBridgeBrowserCapabilityNamespaceNegotiation.hostCanDeclareCapabilities(support) { + capabilities.insert(PeekabooBridgeHostCapability.browserCapabilityNamespaces) + capabilities.insert(PeekabooBridgeHostCapability.nativeBrowserWindowBinding) + } else { + capabilities.remove(PeekabooBridgeHostCapability.browserCapabilityNamespaces) + capabilities.remove(PeekabooBridgeHostCapability.nativeBrowserWindowBinding) + } + return capabilities +} diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServiceProviding.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServiceProviding.swift index b5ff11cd4..4dc2631ba 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServiceProviding.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServiceProviding.swift @@ -45,6 +45,26 @@ public protocol PeekabooBridgeBrowserConnectionResultProviding: PeekabooBridgeSe browserURL: String?) async throws -> DesktopActionResult } +/// Capability declaration for the protocol-1.38 caller-owned browser namespace runtime. +/// +/// Conformance alone grants nothing: current hosts must explicitly override both defaults before the on-demand +/// handshake can advertise the closed namespace operations. +@MainActor +public protocol PeekabooBridgeBrowserCapabilityNamespaceProviding: PeekabooBridgeServiceProviding { + var supportsBrowserCapabilityNamespaces: Bool { get } + var supportsNativeBrowserWindowBinding: Bool { get } +} + +extension PeekabooBridgeBrowserCapabilityNamespaceProviding { + public var supportsBrowserCapabilityNamespaces: Bool { + false + } + + public var supportsNativeBrowserWindowBinding: Bool { + false + } +} + extension PeekabooBridgeBrowserConnectionResultProviding { public var supportsNativeBrowserConnectionBinding: Bool { false diff --git a/Core/PeekabooCore/Tests/PeekabooBridgeTests/BrowserCapabilityNamespaceWireTests.swift b/Core/PeekabooCore/Tests/PeekabooBridgeTests/BrowserCapabilityNamespaceWireTests.swift new file mode 100644 index 000000000..0b46b7d66 --- /dev/null +++ b/Core/PeekabooCore/Tests/PeekabooBridgeTests/BrowserCapabilityNamespaceWireTests.swift @@ -0,0 +1,381 @@ +import CoreGraphics +import Foundation +import PeekabooFoundation +import Testing +@testable import PeekabooBridge + +struct BrowserCapabilityNamespaceWireTests { + private static let namespaceOperations = PeekabooBridgeOperation.browserCapabilityNamespaceOperations + private static let pageReference = "bp1_0123456789abcdef0123456789abcdef" + + @Test + func `protocol 1 38 owns the closed namespace vocabulary`() { + let legacy = PeekabooBridgeProtocolVersion(major: 1, minor: 37) + + #expect(PeekabooBridgeConstants.protocolVersion >= .init(major: 1, minor: 38)) + #expect(PeekabooBridgeConstants.browserCapabilityNamespaceVersion == .init(major: 1, minor: 38)) + #expect(PeekabooBridgeHostCapability.browserCapabilityNamespaces == "browserCapabilityNamespaces") + #expect(PeekabooBridgeHostCapability.nativeBrowserWindowBinding == "nativeBrowserWindowBinding") + #expect(PeekabooBridgeClientCapability.browserCapabilityNamespaces == "browserCapabilityNamespaces") + #expect(PeekabooBridgeClientCapability.nativeBrowserWindowBinding == "nativeBrowserWindowBinding") + #expect(PeekabooBridgeOperation.compatible(Self.namespaceOperations, with: legacy).isEmpty) + #expect(PeekabooBridgeOperation.compatible( + Self.namespaceOperations, + with: PeekabooBridgeConstants.protocolVersion) == Self.namespaceOperations) + #expect(Self.namespaceOperations.isDisjoint(with: PeekabooBridgeOperation.remoteDefaultAllowlist)) + #expect(Self.namespaceOperations.isDisjoint(with: PeekabooBridgeOperation.embeddedDefaultAllowlist)) + #expect(Self.namespaceOperations.isSubset(of: PeekabooBridgeOperation.onDemandDefaultAllowlist)) + } + + @Test + func `signed namespace receipt round trips without private browser identifiers`() throws { + let receipt = Self.receipt() + let data = try JSONEncoder.peekabooBridgeEncoder().encode(receipt) + let decoded = try JSONDecoder.peekabooBridgeDecoder().decode( + PeekabooBridgeBrowserCapabilityNamespaceReceipt.self, + from: data) + let encoded = try #require(String(data: data, encoding: .utf8)) + + #expect(decoded == receipt) + #expect(decoded.unsignedPayload == receipt.payload) + #expect(!encoded.contains("webSocketDebuggerURL")) + #expect(!encoded.contains("devToolsBrowserID")) + #expect(!encoded.contains("targetID")) + #expect(!encoded.contains("providerSessionEpoch")) + } + + @Test + func `signed namespace receipt rejects unknown authority keys at every level`() throws { + let data = try JSONEncoder.peekabooBridgeEncoder().encode(Self.receipt()) + let root = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + var receiptWithUnknown = root + receiptWithUnknown["unknown"] = true + + var payloadWithUnknown = root + var payload = try #require(payloadWithUnknown["payload"] as? [String: Any]) + payload["unknown"] = true + payloadWithUnknown["payload"] = payload + + var principalWithUnknown = root + var principalPayload = try #require(principalWithUnknown["payload"] as? [String: Any]) + var principal = try #require(principalPayload["principal"] as? [String: Any]) + principal["unknown"] = true + principalPayload["principal"] = principal + principalWithUnknown["payload"] = principalPayload + + for object in [receiptWithUnknown, payloadWithUnknown, principalWithUnknown] { + let altered = try JSONSerialization.data(withJSONObject: object) + #expect(throws: DecodingError.self) { + _ = try JSONDecoder.peekabooBridgeDecoder().decode( + PeekabooBridgeBrowserCapabilityNamespaceReceipt.self, + from: altered) + } + } + } + + @Test + func `typed bind action encodes only public selectors and namespace authority`() throws { + let payload = PeekabooBridgeBrowserCapabilityNamespaceRequest( + namespaceReceipt: Self.receipt(), + action: .bindWindow(.init( + pageID: Self.pageReference, + processIdentifier: 4242, + windowID: 77))) + let request = PeekabooBridgeRequest.browserCapabilityNamespace(payload) + let data = try JSONEncoder.peekabooBridgeEncoder().encode(request) + let decoded = try JSONDecoder.peekabooBridgeDecoder().decode(PeekabooBridgeRequest.self, from: data) + + guard case let .browserCapabilityNamespace(decodedPayload) = decoded, + case let .bindWindow(binding) = decodedPayload.action + else { + Issue.record("Expected typed namespace bind action") + return + } + #expect(decoded.operation == .browserCapabilityNamespace) + #expect(decodedPayload.executionMode == .backgroundOnly) + #expect(binding == .init(pageID: Self.pageReference, processIdentifier: 4242, windowID: 77)) + #expect(decodedPayload.toolArguments == [ + "action": .string("bind_window"), + "page_id": .string(Self.pageReference), + "pid": .int(4242), + "window_id": .int(77), + ]) + } + + @Test + func `high level action carriage has no raw provider escape hatch`() throws { + #expect(!PeekabooBridgeBrowserHighLevelAction.allCases.map(\.rawValue).contains("call")) + let request = PeekabooBridgeBrowserHighLevelActionRequest( + action: .click, + arguments: [ + "action": .string("call"), + "page_id": .string(Self.pageReference), + "uid": .string("be1_element"), + ]) + #expect(request.toolArguments["action"] == .string("click")) + + let invalid = Data(#"{"action":"call","arguments":{}}"#.utf8) + #expect(throws: DecodingError.self) { + _ = try JSONDecoder.peekabooBridgeDecoder().decode( + PeekabooBridgeBrowserHighLevelActionRequest.self, + from: invalid) + } + } + + @Test + func `namespace response vocabulary carries only sanitized tool fields`() throws { + let response = PeekabooBridgeResponse.browserCapabilityNamespaceAction(.init( + content: [.string("clicked")], + isError: false, + meta: .object(["browser_page_refs": .array([.string(Self.pageReference)])]), + structuredContent: .object(["state": .string("complete")]))) + let data = try JSONEncoder.peekabooBridgeEncoder().encode(response) + let decoded = try JSONDecoder.peekabooBridgeDecoder().decode(PeekabooBridgeResponse.self, from: data) + + guard case let .browserCapabilityNamespaceAction(payload) = decoded else { + Issue.record("Expected namespace action response") + return + } + #expect(payload.content == [.string("clicked")]) + #expect(payload.structuredContent == .object(["state": .string("complete")])) + } + + @Test + func `native-window response receipt converts to exact target evidence`() throws { + let nativeReceipt = PeekabooBridgeBrowserNativeWindowReceipt( + pageReference: Self.pageReference, + processIdentifier: 4242, + processStartIdentityDecimal: "9001", + windowID: 77, + bounds: CGRect(x: 10, y: 20, width: 800, height: 600)) + let response = PeekabooBridgeBrowserCapabilityNamespaceActionResponse( + content: [.string("bound")], + isError: false, + nativeWindowReceipt: nativeReceipt) + let data = try JSONEncoder.peekabooBridgeEncoder().encode(response) + let decoded = try JSONDecoder.peekabooBridgeDecoder().decode( + PeekabooBridgeBrowserCapabilityNamespaceActionResponse.self, + from: data) + let evidence = try #require(decoded.nativeWindowReceipt?.targetEvidence) + let request = PeekabooBridgeRequest.browserCapabilityNamespace(.init( + namespaceReceipt: Self.receipt(), + action: .executeAction(.init( + action: .click, + arguments: ["page_id": .string(Self.pageReference)])))) + let plan = PeekabooBridgeOperationResultSemantics.requestPlan(for: request, vocabulary: .current) + let projectedEvidence = PeekabooBridgeResponse.browserCapabilityNamespaceAction(decoded) + .operationTargetEvidence(for: plan) + + #expect(evidence.processIdentifier == 4242) + #expect(evidence.processIdentity?.processStartIdentity == 9001) + #expect(evidence.windowID == 77) + #expect(evidence.windowBounds == CGRect(x: 10, y: 20, width: 800, height: 600)) + #expect(projectedEvidence == [evidence]) + } + + @Test + func `namespace operation envelopes reject unknown signed fields`() throws { + let request = PeekabooBridgeBrowserCapabilityNamespaceRequest( + namespaceReceipt: Self.receipt(), + action: .executeAction(.init(action: .listPages))) + let response = PeekabooBridgeBrowserCapabilityNamespaceActionResponse( + content: [], + isError: false) + var requestObject = try #require(JSONSerialization.jsonObject( + with: JSONEncoder.peekabooBridgeEncoder().encode(request)) as? [String: Any]) + requestObject["unknown"] = true + let alteredRequest = try JSONSerialization.data(withJSONObject: requestObject) + #expect(throws: DecodingError.self) { + _ = try JSONDecoder.peekabooBridgeDecoder().decode( + PeekabooBridgeBrowserCapabilityNamespaceRequest.self, + from: alteredRequest) + } + + var responseObject = try #require(JSONSerialization.jsonObject( + with: JSONEncoder.peekabooBridgeEncoder().encode(response)) as? [String: Any]) + responseObject["unknown"] = true + let alteredResponse = try JSONSerialization.data(withJSONObject: responseObject) + #expect(throws: DecodingError.self) { + _ = try JSONDecoder.peekabooBridgeDecoder().decode( + PeekabooBridgeBrowserCapabilityNamespaceActionResponse.self, + from: alteredResponse) + } + } + + @Test + func `namespace request descriptors preserve read and mutation boundaries`() { + let receipt = Self.receipt() + let bind = PeekabooBridgeRequest.browserCapabilityNamespace(.init( + namespaceReceipt: receipt, + action: .bindWindow(.init( + pageID: Self.pageReference, + processIdentifier: 4242, + windowID: 77)))) + let list = PeekabooBridgeRequest.browserCapabilityNamespace(.init( + namespaceReceipt: receipt, + action: .executeAction(.init(action: .listPages)))) + let click = PeekabooBridgeRequest.browserCapabilityNamespace(.init( + namespaceReceipt: receipt, + action: .executeAction(.init(action: .click)))) + let connect = PeekabooBridgeRequest.browserCapabilityNamespace(.init( + namespaceReceipt: receipt, + executionMode: .foregroundAllowed, + action: .executeAction(.init(action: .connect)))) + let unauthorizedConnect = PeekabooBridgeRequest.browserCapabilityNamespace(.init( + namespaceReceipt: receipt, + action: .executeAction(.init(action: .connect)))) + let defaultNewPage = PeekabooBridgeRequest.browserCapabilityNamespace(.init( + namespaceReceipt: receipt, + action: .executeAction(.init(action: .newPage)))) + + #expect(!PeekabooBridgeOperationResultSemantics.contract(for: bind).completion.mutatesDesktop) + #expect(!PeekabooBridgeOperationResultSemantics.contract(for: list).completion.mutatesDesktop) + #expect(PeekabooBridgeOperationResultSemantics.contract(for: click).completion.fixedDelivery == .init( + mechanism: .browserProtocol, + mode: .background)) + #expect(PeekabooBridgeOperationResultSemantics.contract(for: connect).completion.fixedDelivery == .init( + mechanism: .browserProtocol, + mode: .foreground)) + #expect(PeekabooBridgeOperationResultSemantics.contract( + for: unauthorizedConnect).completion.fixedDelivery == .init( + mechanism: .browserProtocol, + mode: .foreground)) + #expect(throws: DesktopActionFailure.self) { + try unauthorizedConnect.validateBrowserCapabilityExecutionMode() + } + #expect(PeekabooBridgeOperationResultSemantics.contract( + for: defaultNewPage).completion.fixedDelivery == .init( + mechanism: .browserProtocol, + mode: .background)) + #expect(throws: Never.self) { + try defaultNewPage.validateBrowserCapabilityExecutionMode() + } + #expect(!PeekabooBridgeOperationResultSemantics.contract( + for: .browserCreateCapabilityNamespace(.init())).completion.mutatesDesktop) + #expect(!PeekabooBridgeOperationResultSemantics.contract( + for: .browserCloseCapabilityNamespace(.init(namespaceReceipt: receipt))).completion.mutatesDesktop) + } + + @Test(arguments: Self.negotiationRefusals) + func `namespace negotiation fails closed for every incomplete matrix row`( + row: NegotiationRow) + { + #expect(!PeekabooBridgeBrowserCapabilityNamespaceNegotiation.sessionCanNegotiateCapabilities(.init( + host: .init( + hostKind: row.hostKind, + maximumProtocolVersion: row.version, + allowedOperations: row.operations, + supportsBrowserCapabilityNamespaces: row.supportsNamespaces, + supportsNativeBrowserWindowBinding: row.supportsBinding), + usesAttestedOperationReceipts: row.usesReceipts, + clientCapabilities: row.clientCapabilities))) + } + + @Test + func `complete on demand attested matrix negotiates both session claims`() { + let capabilities: Set = [ + PeekabooBridgeClientCapability.browserCapabilityNamespaces, + PeekabooBridgeClientCapability.nativeBrowserWindowBinding, + ] + #expect(PeekabooBridgeBrowserCapabilityNamespaceNegotiation.sessionCanNegotiateCapabilities(.init( + host: .init( + hostKind: .onDemand, + maximumProtocolVersion: PeekabooBridgeConstants.protocolVersion, + allowedOperations: Self.namespaceOperations, + supportsBrowserCapabilityNamespaces: true, + supportsNativeBrowserWindowBinding: true), + usesAttestedOperationReceipts: true, + clientCapabilities: capabilities))) + #expect(PeekabooBridgeNegotiatedSessionCapabilities.current.browserCapabilityNamespaces) + #expect(PeekabooBridgeNegotiatedSessionCapabilities.current.nativeBrowserWindowBinding) + } + + @Test + func `client rejects namespace handshake missing native binding capability`() { + let handshake = PeekabooBridgeHandshakeResponse( + negotiatedVersion: PeekabooBridgeConstants.protocolVersion, + hostKind: .onDemand, + build: nil, + supportedOperations: Array(Self.namespaceOperations), + enabledOperations: Array(Self.namespaceOperations), + hostCapabilities: [ + PeekabooBridgeHostCapability.attestedOperationReceipts, + PeekabooBridgeHostCapability.browserCapabilityNamespaces, + ]) + + #expect(!PeekabooBridgeClient.supportsBrowserCapabilityNamespaces(handshake)) + #expect(!PeekabooBridgeClient.supportsNativeBrowserWindowBinding(handshake)) + } + + private static let completeClientCapabilities: Set = [ + PeekabooBridgeClientCapability.browserCapabilityNamespaces, + PeekabooBridgeClientCapability.nativeBrowserWindowBinding, + ] + + static let negotiationRefusals: [NegotiationRow] = [ + .init(version: .init(major: 1, minor: 37)), + .init(hostKind: .gui), + .init(hostKind: .helper), + .init(hostKind: .inProcess), + .init(usesReceipts: false), + .init(clientCapabilities: []), + .init(clientCapabilities: [PeekabooBridgeClientCapability.browserCapabilityNamespaces]), + .init(clientCapabilities: [PeekabooBridgeClientCapability.nativeBrowserWindowBinding]), + .init(operations: [.browserCreateCapabilityNamespace, .browserCapabilityNamespace]), + .init(supportsNamespaces: false), + .init(supportsBinding: false), + ] + + struct NegotiationRow: Sendable, CustomTestStringConvertible { + let version: PeekabooBridgeProtocolVersion + let hostKind: PeekabooBridgeHostKind + let usesReceipts: Bool + let clientCapabilities: Set + let operations: Set + let supportsNamespaces: Bool + let supportsBinding: Bool + + init( + version: PeekabooBridgeProtocolVersion = PeekabooBridgeConstants.protocolVersion, + hostKind: PeekabooBridgeHostKind = .onDemand, + usesReceipts: Bool = true, + clientCapabilities: Set = BrowserCapabilityNamespaceWireTests.completeClientCapabilities, + operations: Set = BrowserCapabilityNamespaceWireTests.namespaceOperations, + supportsNamespaces: Bool = true, + supportsBinding: Bool = true) + { + self.version = version + self.hostKind = hostKind + self.usesReceipts = usesReceipts + self.clientCapabilities = clientCapabilities + self.operations = operations + self.supportsNamespaces = supportsNamespaces + self.supportsBinding = supportsBinding + } + + var testDescription: String { + "v=\(self.version.major).\(self.version.minor) host=\(self.hostKind.rawValue) " + + "receipts=\(self.usesReceipts) client=\(self.clientCapabilities.sorted()) " + + "operations=\(self.operations.map(\.rawValue).sorted()) " + + "service=\(self.supportsNamespaces)/\(self.supportsBinding)" + } + } + + private static func receipt() -> PeekabooBridgeBrowserCapabilityNamespaceReceipt { + .init( + payload: .init( + namespaceID: UUID(uuidString: "10000000-0000-0000-0000-000000000001")!, + listenerInstanceID: UUID(uuidString: "20000000-0000-0000-0000-000000000002")!, + listenerPublicKeySHA256: String(repeating: "a", count: 64), + registryGenerationID: UUID(uuidString: "30000000-0000-0000-0000-000000000003")!, + principal: .init( + effectiveUserIdentifier: 501, + teamIdentifier: "TEAMID1234", + bundleIdentifier: "boo.peekaboo.peekaboo", + codeSignatureHash: String(repeating: "b", count: 40)), + issuedAtUnixMilliseconds: 1_800_000_000_000, + expiresAtUnixMilliseconds: 1_800_000_300_000), + signature: Data(repeating: 0x5A, count: 64)) + } +} diff --git a/Core/PeekabooCore/Tests/PeekabooTests/BrowserCapabilityNamespaceHandshakeTests.swift b/Core/PeekabooCore/Tests/PeekabooTests/BrowserCapabilityNamespaceHandshakeTests.swift new file mode 100644 index 000000000..d44437a1a --- /dev/null +++ b/Core/PeekabooCore/Tests/PeekabooTests/BrowserCapabilityNamespaceHandshakeTests.swift @@ -0,0 +1,180 @@ +import Darwin +import Foundation +import PeekabooAutomationKit +import PeekabooBridgeTestSupport +import PeekabooFoundation +import Testing +@testable import PeekabooBridge + +struct BrowserCapabilityNamespaceHandshakeTests { + private static let pageReference = "bp1_0123456789abcdef0123456789abcdef" + + @Test + @MainActor + func `complete current on-demand handshake advertises the closed namespace surface`() async throws { + let socketPath = "/tmp/peekaboo-browser-namespace-handshake-\(UUID().uuidString).sock" + let server = PeekabooBridgeServer( + services: StubServices(), + hostKind: .onDemand, + allowlistedTeams: [], + allowlistedBundles: [], + allowedOperations: PeekabooBridgeOperation.onDemandDefaultAllowlist) + let host = PeekabooBridgeHost( + socketPath: socketPath, + server: server, + allowedTeamIDs: [], + requestTimeoutSec: 2) + try await host.startChecked() + defer { Task { await host.stop() } } + + let client = TrustedBridgeClientFixture.make(socketPath: socketPath, requestTimeoutSec: 2) + let handshake = try await client.handshake(client: .init( + bundleIdentifier: "dev.peekaboo.browser-namespace", + teamIdentifier: nil, + processIdentifier: getpid())) + + #expect(handshake.negotiatedVersion >= PeekabooBridgeConstants.browserCapabilityNamespaceVersion) + #expect(PeekabooBridgeOperation.browserCapabilityNamespaceOperations.isSubset(of: + Set(handshake.supportedOperations))) + #expect(PeekabooBridgeOperation.browserCapabilityNamespaceOperations.isSubset(of: + Set(handshake.enabledOperations ?? []))) + #expect(handshake.hostCapabilities?.contains( + PeekabooBridgeHostCapability.browserCapabilityNamespaces) == true) + #expect(handshake.hostCapabilities?.contains( + PeekabooBridgeHostCapability.nativeBrowserWindowBinding) == true) + #expect(handshake.operationSessionAttestation != nil) + } + + @Test + @MainActor + func `GUI host strips namespace operations and capabilities despite complete service`() async throws { + let socketPath = "/tmp/peekaboo-browser-namespace-gui-\(UUID().uuidString).sock" + let server = PeekabooBridgeServer( + services: StubServices(), + hostKind: .gui, + allowlistedTeams: [], + allowlistedBundles: [], + allowedOperations: PeekabooBridgeOperation.onDemandDefaultAllowlist) + let host = PeekabooBridgeHost( + socketPath: socketPath, + server: server, + allowedTeamIDs: [], + requestTimeoutSec: 2) + try await host.startChecked() + defer { Task { await host.stop() } } + + let client = TrustedBridgeClientFixture.make(socketPath: socketPath, requestTimeoutSec: 2) + let handshake = try await client.handshake(client: .init( + bundleIdentifier: "dev.peekaboo.browser-namespace-gui", + teamIdentifier: nil, + processIdentifier: getpid())) + + #expect(PeekabooBridgeOperation.browserCapabilityNamespaceOperations.isDisjoint(with: + Set(handshake.supportedOperations))) + #expect(handshake.hostCapabilities?.contains( + PeekabooBridgeHostCapability.browserCapabilityNamespaces) != true) + #expect(handshake.hostCapabilities?.contains( + PeekabooBridgeHostCapability.nativeBrowserWindowBinding) != true) + } + + @Test + func `signed bound mutation target requires matching typed native receipt`() throws { + let namespaceRequest = PeekabooBridgeRequest.browserCapabilityNamespace(.init( + namespaceReceipt: Self.namespaceReceipt(), + action: .executeAction(.init( + action: .click, + arguments: ["page_id": .string(Self.pageReference)])))) + let plan = PeekabooBridgeOperationResultSemantics.requestPlan(for: namespaceRequest, vocabulary: .current) + let nativeReceipt = PeekabooBridgeBrowserNativeWindowReceipt( + pageReference: Self.pageReference, + processIdentifier: 4242, + processStartIdentityDecimal: "9001", + windowID: 77, + bounds: CGRect(x: 10, y: 20, width: 800, height: 600)) + let window = try #require(nativeReceipt.targetEvidence?.windowIdentity) + let signedWindow = Self.operationPayload(target: .window(window)) + let missingReceipt = PeekabooBridgeResponse.browserCapabilityNamespaceAction(.init( + content: [], + isError: false)) + let matchingReceipt = PeekabooBridgeResponse.browserCapabilityNamespaceAction(.init( + content: [], + isError: false, + nativeWindowReceipt: nativeReceipt)) + + #expect(throws: PeekabooBridgeOperationReceiptError.self) { + try PeekabooBridgeBrowserCapabilityNamespaceReceiptValidation.validateNativeTarget( + signedWindow, + request: namespaceRequest, + response: missingReceipt, + plan: plan) + } + #expect(throws: Never.self) { + try PeekabooBridgeBrowserCapabilityNamespaceReceiptValidation.validateNativeTarget( + signedWindow, + request: namespaceRequest, + response: matchingReceipt, + plan: plan) + } + #expect(throws: PeekabooBridgeOperationReceiptError.self) { + try PeekabooBridgeBrowserCapabilityNamespaceReceiptValidation.validateNativeTarget( + Self.operationPayload(target: .process(window.processIdentity)), + request: namespaceRequest, + response: matchingReceipt, + plan: plan) + } + } + + private static func operationPayload( + target: PeekabooBridgeOperationTargetReceipt) -> PeekabooBridgeOperationReceiptPayload + { + let sessionID = UUID(uuidString: "40000000-0000-0000-0000-000000000004")! + let sequence = PeekabooBridgeOperationSessionSequence(0) + return PeekabooBridgeOperationReceiptPayload( + requestID: PeekabooBridgeOperationReceiptCoding.deterministicRequestID( + sessionID: sessionID, + sequence: sequence), + sessionID: sessionID, + sessionSequence: sequence, + sessionAttestationSHA256: String(repeating: "a", count: 64), + listenerInstanceID: UUID(uuidString: "50000000-0000-0000-0000-000000000005")!, + listenerPublicKeySHA256: String(repeating: "b", count: 64), + host: .init(processIdentifier: 1, processStartIdentity: 2, codeSignatureHash: "host"), + clientInstanceID: UUID(uuidString: "60000000-0000-0000-0000-000000000006")!, + client: .init(processIdentifier: 3, processStartIdentity: 4, codeSignatureHash: "client"), + operation: .browserCapabilityNamespace, + requestSHA256: String(repeating: "c", count: 64), + responseSHA256: String(repeating: "d", count: 64), + target: target, + outcome: nil, + remainingClaimCount: 1, + startedAtUnixMilliseconds: 1, + completedAtUnixMilliseconds: 2) + } + + private static func namespaceReceipt() -> PeekabooBridgeBrowserCapabilityNamespaceReceipt { + .init( + payload: .init( + namespaceID: UUID(uuidString: "10000000-0000-0000-0000-000000000001")!, + listenerInstanceID: UUID(uuidString: "20000000-0000-0000-0000-000000000002")!, + listenerPublicKeySHA256: String(repeating: "a", count: 64), + registryGenerationID: UUID(uuidString: "30000000-0000-0000-0000-000000000003")!, + principal: .init( + effectiveUserIdentifier: 501, + teamIdentifier: "TEAMID1234", + bundleIdentifier: "boo.peekaboo.peekaboo", + codeSignatureHash: String(repeating: "b", count: 40)), + issuedAtUnixMilliseconds: 1_800_000_000_000, + expiresAtUnixMilliseconds: 1_800_000_300_000), + signature: Data(repeating: 0x5A, count: 64)) + } +} + +extension StubServices: PeekabooBridgeBrowserCapabilityNamespaceProviding { + var supportsBrowserCapabilityNamespaces: Bool { + true + } + + var supportsNativeBrowserWindowBinding: Bool { + true + } +} From 23f6255fa8e2f4680af0616f7df99d4530845bf0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 17:38:37 -0700 Subject: [PATCH 10/14] feat(bridge): add browser namespace authority --- ...eBrowserCapabilityNamespaceAuthority.swift | 901 ++++++++++++++++++ .../PeekabooBridgeOperationReceipts.swift | 6 + ...serCapabilityNamespaceAuthorityTests.swift | 535 +++++++++++ 3 files changed, 1442 insertions(+) create mode 100644 Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceAuthority.swift create mode 100644 Core/PeekabooCore/Tests/PeekabooTests/PeekabooBridgeBrowserCapabilityNamespaceAuthorityTests.swift diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceAuthority.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceAuthority.swift new file mode 100644 index 000000000..e508789f7 --- /dev/null +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceAuthority.swift @@ -0,0 +1,901 @@ +import CryptoKit +import Darwin +import Foundation + +enum PeekabooBridgeBrowserCapabilityNamespaceError: Error, Equatable, LocalizedError, Sendable { + case invalidConfiguration + case invalidPrincipal + case unauthenticatedNamespaceAdmission + case unauthenticatedClaimAdmission + case invalidReceipt + case invalidSignature + case listenerMismatch + case registryGenerationMismatch + case principalMismatch + case receiptNotYetValid + case receiptExpired + case registryInvalidated + case registryDraining + case namespaceNotFound + case namespaceClosing + case namespaceClosed + case namespaceExpired + case namespaceCapacityExceeded + case claimCapacityExceeded + case replayedClaim + case claimMismatch + case drainAlreadyAwaited + + var errorDescription: String? { + switch self { + case .invalidConfiguration: + "Browser capability namespace limits are invalid" + case .invalidPrincipal: + "Browser capability namespace principal is incomplete or malformed" + case .unauthenticatedNamespaceAdmission: + "Browser capability namespaces require an authenticated local native-capable host" + case .unauthenticatedClaimAdmission: + "Browser capability namespace claims require a scoped local execution authorization" + case .invalidReceipt: + "Browser capability namespace receipt is incomplete or malformed" + case .invalidSignature: + "Browser capability namespace receipt signature is invalid" + case .listenerMismatch: + "Browser capability namespace receipt belongs to another Bridge listener" + case .registryGenerationMismatch: + "Browser capability namespace receipt belongs to another host registry generation" + case .principalMismatch: + "Browser capability namespace receipt belongs to another signed principal" + case .receiptNotYetValid: + "Browser capability namespace receipt was issued in the future" + case .receiptExpired: + "Browser capability namespace receipt expired" + case .registryInvalidated: + "Browser capability namespace registry was invalidated by a host restart" + case .registryDraining: + "Browser capability namespace registry is draining and no longer accepts work" + case .namespaceNotFound: + "Browser capability namespace is not live in this host" + case .namespaceClosing: + "Browser capability namespace is closing and no longer accepts work" + case .namespaceClosed: + "Browser capability namespace is closed" + case .namespaceExpired: + "Browser capability namespace expired" + case .namespaceCapacityExceeded: + "Browser capability namespace registry is at capacity" + case .claimCapacityExceeded: + "Browser capability namespace exhausted its bounded replay fence" + case .replayedClaim: + "Browser capability namespace claim was already used" + case .claimMismatch: + "Browser capability namespace claim does not match a live operation" + case .drainAlreadyAwaited: + "Browser capability namespace drain already has a waiting owner" + } + } +} + +/// Host-side proof that namespace creation already passed transport, principal, and provider admission. +/// +/// This type is deliberately internal and has no permissive default. The Bridge server constructs it only after +/// proving that execution is local, the socket peer is authenticated, and a concrete native-capable service exists. +struct PeekabooBridgeBrowserCapabilityNamespaceAdmission: Equatable, Sendable { + let allowsNativeBrowserWindowBinding: Bool + + init?( + isLocalExecutionHost: Bool, + isAuthenticatedPeer: Bool, + hasNativeCapableService: Bool) + { + guard isLocalExecutionHost, isAuthenticatedPeer, hasNativeCapableService else { return nil } + self.allowsNativeBrowserWindowBinding = true + } +} + +/// Per-operation policy proof. Foreground permission is intentionally absent from namespace state. +struct PeekabooBridgeBrowserCapabilityClaimAdmission: Equatable, Sendable { + let executionPolicy: PeekabooBridgeBrowserCapabilityExecutionMode + + init?( + executionPolicy: PeekabooBridgeBrowserCapabilityExecutionMode, + isLocalExecutionHost: Bool, + isAuthenticatedPeer: Bool, + hasScopedForegroundAuthorization: Bool = false) + { + guard isLocalExecutionHost, isAuthenticatedPeer else { return nil } + if executionPolicy == .foregroundAllowed, !hasScopedForegroundAuthorization { + return nil + } + self.executionPolicy = executionPolicy + } +} + +/// Unforgeable outside PeekabooBridge and safe for the scoped runtime to consume without parsing bearer data. +struct PeekabooBridgeBrowserCapabilityNamespaceAuthorization: Equatable, Sendable { + let namespaceID: UUID + let registryGenerationID: UUID + let claimID: UUID + let principal: PeekabooBridgeBrowserCapabilityPrincipal + let executionPolicy: PeekabooBridgeBrowserCapabilityExecutionMode + let allowsNativeBrowserWindowBinding: Bool +} + +struct PeekabooBridgeBrowserCapabilityNamespaceIdentity: Equatable, Sendable { + let namespaceID: UUID + let registryGenerationID: UUID + let principal: PeekabooBridgeBrowserCapabilityPrincipal + let allowsNativeBrowserWindowBinding: Bool + fileprivate let drainLeaseID: UInt64? +} + +struct PeekabooBridgeBrowserCapabilityNamespaceClaim: Equatable, Sendable { + let authorization: PeekabooBridgeBrowserCapabilityNamespaceAuthorization + fileprivate let receiptSHA256: String +} + +struct PeekabooBridgeBrowserCapabilityNamespaceSigningContext: Sendable { + typealias SignCanonicalPayload = @Sendable ( + PeekabooBridgeBrowserCapabilityNamespaceReceiptPayload) throws -> Data + + let listenerAttestation: PeekabooBridgeListenerAttestation + private let signCanonicalPayload: SignCanonicalPayload + + init( + listenerAttestation: PeekabooBridgeListenerAttestation, + signCanonicalPayload: @escaping SignCanonicalPayload) throws + { + try listenerAttestation.validateSignature() + self.listenerAttestation = listenerAttestation + self.signCanonicalPayload = signCanonicalPayload + } + + func sign( + _ payload: PeekabooBridgeBrowserCapabilityNamespaceReceiptPayload) throws + -> PeekabooBridgeBrowserCapabilityNamespaceReceipt + { + let receipt = try PeekabooBridgeBrowserCapabilityNamespaceReceipt( + payload: payload, + signature: self.signCanonicalPayload(payload)) + try self.validateSignature(receipt) + return receipt + } + + func validateSignature(_ receipt: PeekabooBridgeBrowserCapabilityNamespaceReceipt) throws { + try self.listenerAttestation.validateSignature() + guard receipt.payload.listenerInstanceID == self.listenerAttestation.listenerInstanceID, + receipt.payload.listenerPublicKeySHA256 == PeekabooBridgeOperationReceiptCoding.sha256( + self.listenerAttestation.publicKey) + else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.listenerMismatch + } + let publicKey: Curve25519.Signing.PublicKey + do { + publicKey = try Curve25519.Signing.PublicKey( + rawRepresentation: self.listenerAttestation.publicKey) + } catch { + throw PeekabooBridgeBrowserCapabilityNamespaceError.invalidReceipt + } + guard try publicKey.isValidSignature( + receipt.signature, + for: PeekabooBridgeOperationReceiptCoding.canonicalData(receipt.payload)) + else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.invalidSignature + } + } +} + +extension PeekabooBridgeOperationReceiptAuthority { + func browserCapabilityNamespaceSigningContext() throws + -> PeekabooBridgeBrowserCapabilityNamespaceSigningContext + { + try PeekabooBridgeBrowserCapabilityNamespaceSigningContext( + listenerAttestation: self.attestation, + signCanonicalPayload: { [self] payload in + try self.signBrowserCapabilityNamespacePayload(payload) + }) + } +} + +/// Host-lifetime authority for reusable browser capability namespaces. +/// +/// The signed receipt is reusable across same-principal CLI processes. Replay protection applies to the enclosing +/// attested Bridge request ID, allowing distinct requests to execute concurrently without turning the namespace into +/// a one-shot token. No BrowserMCPService type crosses this boundary. +actor PeekabooBridgeBrowserCapabilityNamespaceAuthority { + struct Configuration: Equatable, Sendable { + static let hardMaximumNamespaceCount = 1024 + static let hardMaximumLifetimeMilliseconds: Int64 = 60 * 60 * 1000 + static let hardMaximumClaimCountPerNamespace = 65536 + static let hardMaximumFutureSkewMilliseconds: Int64 = 60 * 1000 + + static let current = Self( + maximumNamespaceCount: 64, + maximumLifetimeMilliseconds: 15 * 60 * 1000, + maximumClaimCountPerNamespace: 16384, + maximumFutureSkewMilliseconds: 5 * 1000) + + let maximumNamespaceCount: Int + let maximumLifetimeMilliseconds: Int64 + let maximumClaimCountPerNamespace: Int + let maximumFutureSkewMilliseconds: Int64 + + init( + maximumNamespaceCount: Int, + maximumLifetimeMilliseconds: Int64, + maximumClaimCountPerNamespace: Int, + maximumFutureSkewMilliseconds: Int64) + { + self.maximumNamespaceCount = maximumNamespaceCount + self.maximumLifetimeMilliseconds = maximumLifetimeMilliseconds + self.maximumClaimCountPerNamespace = maximumClaimCountPerNamespace + self.maximumFutureSkewMilliseconds = maximumFutureSkewMilliseconds + } + + fileprivate var isValid: Bool { + (2...Self.hardMaximumNamespaceCount).contains(self.maximumNamespaceCount) && + (1...Self.hardMaximumLifetimeMilliseconds).contains(self.maximumLifetimeMilliseconds) && + (1...Self.hardMaximumClaimCountPerNamespace).contains(self.maximumClaimCountPerNamespace) && + (0...Self.hardMaximumFutureSkewMilliseconds).contains(self.maximumFutureSkewMilliseconds) + } + } + + enum LifecycleState: String, Equatable, Sendable { + case open + case closing + case closed + case expired + } + + typealias UnixMillisecondsClock = @Sendable () -> Int64 + typealias UUIDGenerator = @Sendable () -> UUID + + let registryGenerationID: UUID + + private let signingContext: PeekabooBridgeBrowserCapabilityNamespaceSigningContext + private let hostEffectiveUserIdentifier: uid_t + private let configuration: Configuration + private let clock: UnixMillisecondsClock + private let uuidGenerator: UUIDGenerator + private var entries: [UUID: Entry] = [:] + private var ordinal: UInt64 = 0 + private var invalidatedForRestart = false + private var drainingAll = false + private var drainLeaseOrdinal: UInt64 = 0 + private var allDrainWaiter: DrainWaiter? + + init( + signingContext: PeekabooBridgeBrowserCapabilityNamespaceSigningContext, + hostEffectiveUserIdentifier: uid_t = geteuid(), + configuration: Configuration = .current, + clock: @escaping UnixMillisecondsClock = { + PeekabooBridgeOperationReceiptCoding.unixMilliseconds() + }, + uuidGenerator: @escaping UUIDGenerator = { UUID() }) throws + { + guard configuration.isValid else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.invalidConfiguration + } + let registryGenerationID = uuidGenerator() + guard Self.isVersion4(registryGenerationID) else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.invalidConfiguration + } + self.signingContext = signingContext + self.hostEffectiveUserIdentifier = hostEffectiveUserIdentifier + self.configuration = configuration + self.clock = clock + self.uuidGenerator = uuidGenerator + self.registryGenerationID = registryGenerationID + } + + static func principal( + for peer: PeekabooBridgePeer, + hostEffectiveUserIdentifier: uid_t = geteuid()) throws + -> PeekabooBridgeBrowserCapabilityPrincipal + { + guard let liveIdentity = peer.liveIdentity, + liveIdentity.effectiveUserIdentifier == hostEffectiveUserIdentifier, + peer.userIdentifier == liveIdentity.effectiveUserIdentifier, + let teamIdentifier = peer.teamIdentifier, + let bundleIdentifier = peer.bundleIdentifier, + let codeSignatureHash = liveIdentity.codeSignatureHash, + peer.codeSignatureHash == codeSignatureHash + else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.invalidPrincipal + } + let principal = PeekabooBridgeBrowserCapabilityPrincipal( + effectiveUserIdentifier: liveIdentity.effectiveUserIdentifier, + teamIdentifier: teamIdentifier, + bundleIdentifier: bundleIdentifier, + codeSignatureHash: codeSignatureHash) + try Self.validatePrincipal(principal, expectedUserIdentifier: hostEffectiveUserIdentifier) + return principal + } + + func open( + principal: PeekabooBridgeBrowserCapabilityPrincipal, + admission: PeekabooBridgeBrowserCapabilityNamespaceAdmission, + lifetimeMilliseconds: Int64) throws -> PeekabooBridgeBrowserCapabilityNamespaceReceipt + { + guard admission.allowsNativeBrowserWindowBinding else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.unauthenticatedNamespaceAdmission + } + try self.requireAcceptingRegistry() + try Self.validatePrincipal(principal, expectedUserIdentifier: self.hostEffectiveUserIdentifier) + let now = self.clock() + self.expireEntries(at: now) + let receipt = try self.makeReceipt( + principal: principal, + lifetimeMilliseconds: lifetimeMilliseconds, + now: now) + try self.reserveCapacity(excluding: []) + self.ordinal &+= 1 + self.entries[receipt.payload.namespaceID] = Entry( + receipt: receipt, + state: .open, + allowsNativeBrowserWindowBinding: admission.allowsNativeBrowserWindowBinding, + ordinal: self.ordinal) + return receipt + } + + /// Atomically creates a successor before revoking the predecessor. In-flight predecessor claims may drain. + func rollover( + _ receipt: PeekabooBridgeBrowserCapabilityNamespaceReceipt, + principal: PeekabooBridgeBrowserCapabilityPrincipal, + admission: PeekabooBridgeBrowserCapabilityNamespaceAdmission, + lifetimeMilliseconds: Int64) throws -> PeekabooBridgeBrowserCapabilityNamespaceReceipt + { + guard admission.allowsNativeBrowserWindowBinding else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.unauthenticatedNamespaceAdmission + } + try self.requireAcceptingRegistry() + let now = self.clock() + let namespaceID = try self.validateRegisteredReceipt( + receipt, + principal: principal, + at: now, + allowsClosedNamespace: true) + guard let predecessor = self.entries[namespaceID] else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.namespaceNotFound + } + guard predecessor.outstandingDrainLeaseID == nil, predecessor.drainWaiter == nil else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.namespaceClosing + } + guard predecessor.state == .open || + ((predecessor.state == .closing || predecessor.state == .closed) && + predecessor.claimedIDs.count >= self.configuration.maximumClaimCountPerNamespace) + else { + throw self.lifecycleError(predecessor.state) + } + + let successor = try self.makeReceipt( + principal: principal, + lifetimeMilliseconds: lifetimeMilliseconds, + now: now) + try self.reserveCapacity(excluding: [namespaceID]) + self.ordinal &+= 1 + self.entries[successor.payload.namespaceID] = Entry( + receipt: successor, + state: .open, + allowsNativeBrowserWindowBinding: admission.allowsNativeBrowserWindowBinding, + ordinal: self.ordinal) + predecessor.state = predecessor.activeClaimIDs.isEmpty ? .closed : .closing + self.resumeNamespaceDrainWaiterIfDrained(predecessor) + return successor + } + + func claim( + _ receipt: PeekabooBridgeBrowserCapabilityNamespaceReceipt, + principal: PeekabooBridgeBrowserCapabilityPrincipal, + claimID: UUID, + admission: PeekabooBridgeBrowserCapabilityClaimAdmission) throws + -> PeekabooBridgeBrowserCapabilityNamespaceClaim + { + try self.requireAcceptingRegistry() + guard Self.isNonzero(claimID) else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.invalidReceipt + } + let now = self.clock() + let namespaceID = try self.validateRegisteredReceipt(receipt, principal: principal, at: now) + guard let entry = self.entries[namespaceID] else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.namespaceNotFound + } + if entry.claimedIDs.contains(claimID) { + throw PeekabooBridgeBrowserCapabilityNamespaceError.replayedClaim + } + guard entry.state == .open else { + throw self.lifecycleError(entry.state) + } + guard entry.claimedIDs.count < self.configuration.maximumClaimCountPerNamespace else { + entry.state = entry.activeClaimIDs.isEmpty ? .closed : .closing + throw PeekabooBridgeBrowserCapabilityNamespaceError.claimCapacityExceeded + } + guard entry.allowsNativeBrowserWindowBinding else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.unauthenticatedClaimAdmission + } + + entry.claimedIDs.insert(claimID) + entry.activeClaimIDs.insert(claimID) + if entry.claimedIDs.count == self.configuration.maximumClaimCountPerNamespace { + entry.state = .closing + } + return try PeekabooBridgeBrowserCapabilityNamespaceClaim( + authorization: .init( + namespaceID: namespaceID, + registryGenerationID: self.registryGenerationID, + claimID: claimID, + principal: principal, + executionPolicy: admission.executionPolicy, + allowsNativeBrowserWindowBinding: true), + receiptSHA256: PeekabooBridgeOperationReceiptCoding.sha256(receipt)) + } + + func complete(_ claim: PeekabooBridgeBrowserCapabilityNamespaceClaim) throws { + guard claim.authorization.registryGenerationID == self.registryGenerationID, + let entry = self.entries[claim.authorization.namespaceID], + entry.receipt.payload.principal == claim.authorization.principal, + entry.allowsNativeBrowserWindowBinding == claim.authorization.allowsNativeBrowserWindowBinding, + try PeekabooBridgeOperationReceiptCoding.sha256(entry.receipt) == claim.receiptSHA256, + entry.activeClaimIDs.remove(claim.authorization.claimID) != nil + else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.claimMismatch + } + self.finishTerminalStateIfDrained(entry) + self.resumeAllDrainWaiterIfDrained() + if self.invalidatedForRestart { + throw PeekabooBridgeBrowserCapabilityNamespaceError.registryInvalidated + } + } + + /// Revokes new claims before the caller closes the scoped runtime namespace. + func beginClose( + _ receipt: PeekabooBridgeBrowserCapabilityNamespaceReceipt, + principal: PeekabooBridgeBrowserCapabilityPrincipal) throws + -> PeekabooBridgeBrowserCapabilityNamespaceIdentity + { + try self.requireLiveRegistry() + let namespaceID = try self.validateRegisteredReceipt( + receipt, + principal: principal, + at: self.clock()) + guard let entry = self.entries[namespaceID] else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.namespaceNotFound + } + guard entry.state == .open || entry.state == .closing else { + throw self.lifecycleError(entry.state) + } + let drainLeaseID: UInt64? + if entry.activeClaimIDs.isEmpty { + entry.state = .closed + drainLeaseID = nil + } else { + entry.state = .closing + if let existing = entry.outstandingDrainLeaseID { + drainLeaseID = existing + } else { + let issued = self.nextDrainLeaseID() + entry.outstandingDrainLeaseID = issued + drainLeaseID = issued + } + } + return PeekabooBridgeBrowserCapabilityNamespaceIdentity( + namespaceID: namespaceID, + registryGenerationID: self.registryGenerationID, + principal: principal, + allowsNativeBrowserWindowBinding: entry.allowsNativeBrowserWindowBinding, + drainLeaseID: drainLeaseID) + } + + func awaitDrained(identity: PeekabooBridgeBrowserCapabilityNamespaceIdentity) async throws { + try self.requireLiveRegistry() + try Task.checkCancellation() + guard identity.registryGenerationID == self.registryGenerationID else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.registryGenerationMismatch + } + guard let drainLeaseID = identity.drainLeaseID else { return } + guard let entry = self.entries[identity.namespaceID] else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.namespaceNotFound + } + guard entry.receipt.payload.principal == identity.principal, + entry.allowsNativeBrowserWindowBinding == identity.allowsNativeBrowserWindowBinding + else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.principalMismatch + } + guard entry.outstandingDrainLeaseID == drainLeaseID else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.claimMismatch + } + guard entry.state != .open else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.namespaceClosing + } + if entry.activeClaimIDs.isEmpty { + entry.outstandingDrainLeaseID = nil + self.finishTerminalStateIfDrained(entry) + return + } + guard entry.drainWaiter == nil else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.drainAlreadyAwaited + } + let waiterID = self.nextDrainWaiterID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + } else { + entry.drainWaiter = DrainWaiter(id: waiterID, continuation: continuation) + } + } + } onCancel: { + Task { + await self.cancelNamespaceDrainWaiter( + namespaceID: identity.namespaceID, + waiterID: waiterID) + } + } + try Task.checkCancellation() + } + + func close( + _ receipt: PeekabooBridgeBrowserCapabilityNamespaceReceipt, + principal: PeekabooBridgeBrowserCapabilityPrincipal) async throws + { + let identity = try self.beginClose(receipt, principal: principal) + try await self.awaitDrained(identity: identity) + } + + /// Stops all namespaces, waits for in-flight authority claims, and leaves no accepting entry. + func drainAll() async throws { + try self.requireLiveRegistry() + try Task.checkCancellation() + self.drainingAll = true + let now = self.clock() + self.expireEntries(at: now) + for entry in self.entries.values where entry.state == .open { + entry.state = entry.activeClaimIDs.isEmpty ? .closed : .closing + self.resumeNamespaceDrainWaiterIfDrained(entry) + } + guard self.entries.values.contains(where: { !$0.activeClaimIDs.isEmpty }) else { return } + guard self.allDrainWaiter == nil else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.drainAlreadyAwaited + } + let waiterID = self.nextDrainWaiterID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + } else { + self.allDrainWaiter = DrainWaiter(id: waiterID, continuation: continuation) + } + } + } onCancel: { + Task { + await self.cancelAllDrainWaiter(waiterID: waiterID) + } + } + try Task.checkCancellation() + } + + /// Immediately invalidates this generation. A replacement authority must mint a new generation and namespace IDs. + @discardableResult + func invalidateForRestart() -> Int { + guard !self.invalidatedForRestart else { return 0 } + self.invalidatedForRestart = true + self.drainingAll = true + let invalidatedCount = self.entries.count + for entry in self.entries.values { + entry.state = .closed + entry.outstandingDrainLeaseID = nil + let waiter = entry.drainWaiter + entry.drainWaiter = nil + waiter?.continuation.resume( + throwing: PeekabooBridgeBrowserCapabilityNamespaceError.registryInvalidated) + } + let allWaiter = self.allDrainWaiter + self.allDrainWaiter = nil + allWaiter?.continuation.resume( + throwing: PeekabooBridgeBrowserCapabilityNamespaceError.registryInvalidated) + return invalidatedCount + } + + func lifecycleState(namespaceID: UUID) -> LifecycleState? { + self.expireEntries(at: self.clock()) + return self.entries[namespaceID]?.state + } + + func activeClaimCount(namespaceID: UUID) -> Int? { + self.entries[namespaceID]?.activeClaimIDs.count + } + + func verify( + _ receipt: PeekabooBridgeBrowserCapabilityNamespaceReceipt, + principal: PeekabooBridgeBrowserCapabilityPrincipal) throws + { + _ = try self.validateRegisteredReceipt(receipt, principal: principal, at: self.clock()) + } + + private func requireLiveRegistry() throws { + guard !self.invalidatedForRestart else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.registryInvalidated + } + } + + private func requireAcceptingRegistry() throws { + try self.requireLiveRegistry() + guard !self.drainingAll else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.registryDraining + } + } + + private func makeReceipt( + principal: PeekabooBridgeBrowserCapabilityPrincipal, + lifetimeMilliseconds: Int64, + now: Int64) throws -> PeekabooBridgeBrowserCapabilityNamespaceReceipt + { + guard now > 0, + (1...self.configuration.maximumLifetimeMilliseconds).contains(lifetimeMilliseconds) + else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.invalidReceipt + } + let (expiresAt, overflow) = now.addingReportingOverflow(lifetimeMilliseconds) + guard !overflow else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.invalidReceipt + } + let namespaceID = try self.nextUniqueNamespaceID() + let payload = PeekabooBridgeBrowserCapabilityNamespaceReceiptPayload( + schemaVersion: 1, + namespaceID: namespaceID, + listenerInstanceID: self.signingContext.listenerAttestation.listenerInstanceID, + listenerPublicKeySHA256: PeekabooBridgeOperationReceiptCoding.sha256( + self.signingContext.listenerAttestation.publicKey), + registryGenerationID: self.registryGenerationID, + principal: principal, + issuedAtUnixMilliseconds: now, + expiresAtUnixMilliseconds: expiresAt) + return try self.signingContext.sign(payload) + } + + private func validateRegisteredReceipt( + _ receipt: PeekabooBridgeBrowserCapabilityNamespaceReceipt, + principal: PeekabooBridgeBrowserCapabilityPrincipal, + at now: Int64, + allowsClosedNamespace: Bool = false) throws -> UUID + { + try self.requireLiveRegistry() + try Self.validatePrincipal(principal, expectedUserIdentifier: self.hostEffectiveUserIdentifier) + self.expireEntries(at: now) + try self.validateReceipt(receipt, principal: principal, at: now) + guard let entry = self.entries[receipt.payload.namespaceID] else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.namespaceNotFound + } + guard entry.receipt == receipt else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.invalidReceipt + } + switch entry.state { + case .expired: + throw PeekabooBridgeBrowserCapabilityNamespaceError.namespaceExpired + case .closed: + if allowsClosedNamespace { + return receipt.payload.namespaceID + } + throw PeekabooBridgeBrowserCapabilityNamespaceError.namespaceClosed + case .open, .closing: + return receipt.payload.namespaceID + } + } + + private func validateReceipt( + _ receipt: PeekabooBridgeBrowserCapabilityNamespaceReceipt, + principal: PeekabooBridgeBrowserCapabilityPrincipal, + at now: Int64) throws + { + let payload = receipt.payload + guard payload.schemaVersion == 1, + Self.isVersion4(payload.namespaceID), + Self.isVersion4(payload.registryGenerationID), + payload.issuedAtUnixMilliseconds > 0, + payload.expiresAtUnixMilliseconds > payload.issuedAtUnixMilliseconds, + payload.signatureInputsAreCanonical + else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.invalidReceipt + } + guard payload.listenerInstanceID == self.signingContext.listenerAttestation.listenerInstanceID, + payload.listenerPublicKeySHA256 == PeekabooBridgeOperationReceiptCoding.sha256( + self.signingContext.listenerAttestation.publicKey) + else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.listenerMismatch + } + guard payload.registryGenerationID == self.registryGenerationID else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.registryGenerationMismatch + } + guard payload.principal == principal else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.principalMismatch + } + let (lifetime, lifetimeOverflow) = payload.expiresAtUnixMilliseconds.subtractingReportingOverflow( + payload.issuedAtUnixMilliseconds) + guard !lifetimeOverflow, + lifetime <= self.configuration.maximumLifetimeMilliseconds + else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.invalidReceipt + } + let (latestAllowedIssue, issueOverflow) = now.addingReportingOverflow( + self.configuration.maximumFutureSkewMilliseconds) + guard !issueOverflow, payload.issuedAtUnixMilliseconds <= latestAllowedIssue else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.receiptNotYetValid + } + guard now < payload.expiresAtUnixMilliseconds else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.receiptExpired + } + try self.signingContext.validateSignature(receipt) + } + + private func reserveCapacity(excluding excludedNamespaceIDs: Set) throws { + while self.entries.count >= self.configuration.maximumNamespaceCount { + guard let removable = self.entries.values + .filter({ + !excludedNamespaceIDs.contains($0.receipt.payload.namespaceID) && + ($0.state == .closed || $0.state == .expired) && + $0.activeClaimIDs.isEmpty && + $0.drainWaiter == nil && + $0.outstandingDrainLeaseID == nil + }) + .min(by: { $0.ordinal < $1.ordinal }) + else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.namespaceCapacityExceeded + } + self.entries[removable.receipt.payload.namespaceID] = nil + } + } + + private func expireEntries(at now: Int64) { + for entry in self.entries.values + where entry.state != .closed && now >= entry.receipt.payload.expiresAtUnixMilliseconds + { + entry.state = .expired + self.resumeNamespaceDrainWaiterIfDrained(entry) + } + self.resumeAllDrainWaiterIfDrained() + } + + private func finishTerminalStateIfDrained(_ entry: Entry) { + guard entry.activeClaimIDs.isEmpty else { return } + if entry.state == .closing { + entry.state = .closed + } + self.resumeNamespaceDrainWaiterIfDrained(entry) + } + + private func resumeNamespaceDrainWaiterIfDrained(_ entry: Entry) { + guard entry.activeClaimIDs.isEmpty else { return } + guard let waiter = entry.drainWaiter else { return } + entry.drainWaiter = nil + entry.outstandingDrainLeaseID = nil + waiter.continuation.resume() + } + + private func resumeAllDrainWaiterIfDrained() { + guard !self.entries.values.contains(where: { !$0.activeClaimIDs.isEmpty }) else { return } + guard let waiter = self.allDrainWaiter else { return } + self.allDrainWaiter = nil + waiter.continuation.resume() + } + + private func cancelNamespaceDrainWaiter(namespaceID: UUID, waiterID: UInt64) { + guard let entry = self.entries[namespaceID], entry.drainWaiter?.id == waiterID else { return } + let waiter = entry.drainWaiter + entry.drainWaiter = nil + waiter?.continuation.resume(throwing: CancellationError()) + } + + private func cancelAllDrainWaiter(waiterID: UInt64) { + guard self.allDrainWaiter?.id == waiterID else { return } + let waiter = self.allDrainWaiter + self.allDrainWaiter = nil + waiter?.continuation.resume(throwing: CancellationError()) + } + + private func nextDrainLeaseID() -> UInt64 { + self.drainLeaseOrdinal &+= 1 + if self.drainLeaseOrdinal == 0 { + self.drainLeaseOrdinal &+= 1 + } + return self.drainLeaseOrdinal + } + + private func nextDrainWaiterID() -> UInt64 { + self.nextDrainLeaseID() + } + + private func nextUniqueNamespaceID() throws -> UUID { + for _ in 0..<32 { + let candidate = self.uuidGenerator() + if Self.isVersion4(candidate), self.entries[candidate] == nil, candidate != self.registryGenerationID { + return candidate + } + } + throw PeekabooBridgeBrowserCapabilityNamespaceError.invalidConfiguration + } + + private func lifecycleError(_ state: LifecycleState) -> PeekabooBridgeBrowserCapabilityNamespaceError { + switch state { + case .open: + .claimMismatch + case .closing: + .namespaceClosing + case .closed: + .namespaceClosed + case .expired: + .namespaceExpired + } + } + + private static func validatePrincipal( + _ principal: PeekabooBridgeBrowserCapabilityPrincipal, + expectedUserIdentifier: uid_t) throws + { + guard principal.effectiveUserIdentifier == expectedUserIdentifier, + (1...128).contains(principal.teamIdentifier.utf8.count), + (1...512).contains(principal.bundleIdentifier.utf8.count), + principal.teamIdentifier.unicodeScalars.allSatisfy({ + CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-")).contains($0) + }), + principal.bundleIdentifier.unicodeScalars.allSatisfy({ + CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-")).contains($0) + }), + principal.codeSignatureHash.count == 40, + principal.codeSignatureHash == principal.codeSignatureHash.lowercased(), + principal.codeSignatureHash.unicodeScalars.allSatisfy({ + CharacterSet(charactersIn: "0123456789abcdef").contains($0) + }) + else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.invalidPrincipal + } + } + + private static func isVersion4(_ value: UUID) -> Bool { + let bytes = withUnsafeBytes(of: value.uuid) { Array($0) } + return bytes.count == 16 && bytes[6] >> 4 == 4 && bytes[8] >> 6 == 2 + } + + private static func isNonzero(_ value: UUID) -> Bool { + value != self.zeroUUID + } + + private static let zeroUUID = UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) + + private struct DrainWaiter { + let id: UInt64 + let continuation: CheckedContinuation + } + + private final class Entry: @unchecked Sendable { + let receipt: PeekabooBridgeBrowserCapabilityNamespaceReceipt + var state: LifecycleState + let allowsNativeBrowserWindowBinding: Bool + let ordinal: UInt64 + var claimedIDs: Set = [] + var activeClaimIDs: Set = [] + var outstandingDrainLeaseID: UInt64? + var drainWaiter: DrainWaiter? + + init( + receipt: PeekabooBridgeBrowserCapabilityNamespaceReceipt, + state: LifecycleState, + allowsNativeBrowserWindowBinding: Bool, + ordinal: UInt64) + { + self.receipt = receipt + self.state = state + self.allowsNativeBrowserWindowBinding = allowsNativeBrowserWindowBinding + self.ordinal = ordinal + } + } +} + +extension PeekabooBridgeBrowserCapabilityNamespaceReceiptPayload { + fileprivate var signatureInputsAreCanonical: Bool { + self.listenerPublicKeySHA256.count == 64 && + self.listenerPublicKeySHA256 == self.listenerPublicKeySHA256.lowercased() && + self.listenerPublicKeySHA256.unicodeScalars.allSatisfy { + CharacterSet(charactersIn: "0123456789abcdef").contains($0) + } + } +} diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationReceipts.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationReceipts.swift index a6868f32d..f0f7d68ac 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationReceipts.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationReceipts.swift @@ -1219,6 +1219,12 @@ final class PeekabooBridgeOperationReceiptAuthority: @unchecked Sendable { }.value } + func signBrowserCapabilityNamespacePayload( + _ payload: PeekabooBridgeBrowserCapabilityNamespaceReceiptPayload) throws -> Data + { + try self.signCanonical(payload) + } + private func signCanonical(_ payload: some Encodable) throws -> Data { self.signingLock.lock() defer { self.signingLock.unlock() } diff --git a/Core/PeekabooCore/Tests/PeekabooTests/PeekabooBridgeBrowserCapabilityNamespaceAuthorityTests.swift b/Core/PeekabooCore/Tests/PeekabooTests/PeekabooBridgeBrowserCapabilityNamespaceAuthorityTests.swift new file mode 100644 index 000000000..95654ed20 --- /dev/null +++ b/Core/PeekabooCore/Tests/PeekabooTests/PeekabooBridgeBrowserCapabilityNamespaceAuthorityTests.swift @@ -0,0 +1,535 @@ +import CryptoKit +import Darwin +import Foundation +import Testing +@testable import PeekabooBridge + +@Suite(.serialized) +struct PeekabooBridgeBrowserCapabilityNamespaceAuthorityTests { + @Test + func `reusable receipt signs canonical principal and rejects claim replay`() async throws { + let fixture = try NamespaceAuthorityFixture( + uuids: [Self.uuid(1), Self.uuid(2)], + maximumClaimCount: 3) + let receipt = try await fixture.authority.open( + principal: fixture.principal, + admission: fixture.namespaceAdmission, + lifetimeMilliseconds: 10000) + try await fixture.authority.verify(receipt, principal: fixture.principal) + + let canonical = try PeekabooBridgeOperationReceiptCoding.canonicalData(receipt) + let decoded = try JSONDecoder.peekabooBridgeDecoder().decode( + PeekabooBridgeBrowserCapabilityNamespaceReceipt.self, + from: canonical) + #expect(try PeekabooBridgeOperationReceiptCoding.canonicalData(decoded) == canonical) + #expect(receipt.payload.namespaceID == Self.uuid(2)) + #expect(receipt.payload.registryGenerationID == Self.uuid(1)) + #expect(receipt.payload.principal == fixture.principal) + + let claimID = Self.claimID(1) + let claim = try await fixture.authority.claim( + receipt, + principal: fixture.principal, + claimID: claimID, + admission: fixture.backgroundAdmission) + #expect(claim.authorization.namespaceID == receipt.payload.namespaceID) + #expect(claim.authorization.executionPolicy == .backgroundOnly) + #expect(claim.authorization.allowsNativeBrowserWindowBinding) + + let replay = await #expect(throws: PeekabooBridgeBrowserCapabilityNamespaceError.self) { + try await fixture.authority.claim( + receipt, + principal: fixture.principal, + claimID: claimID, + admission: fixture.backgroundAdmission) + } + #expect(replay == .replayedClaim) + try await fixture.authority.complete(claim) + + let second = try await fixture.authority.claim( + receipt, + principal: fixture.principal, + claimID: Self.claimID(2), + admission: fixture.foregroundAdmission) + #expect(second.authorization.executionPolicy == .foregroundAllowed) + try await fixture.authority.complete(second) + + let third = try await fixture.authority.claim( + receipt, + principal: fixture.principal, + claimID: Self.claimID(3), + admission: fixture.backgroundAdmission) + #expect(third.authorization.executionPolicy == .backgroundOnly) + try await fixture.authority.complete(third) + } + + @Test + func `receipt refuses copied principals wrong listeners wrong keys and forged signatures`() async throws { + let fixture = try NamespaceAuthorityFixture(uuids: [Self.uuid(10), Self.uuid(11)]) + let receipt = try await fixture.authority.open( + principal: fixture.principal, + admission: fixture.namespaceAdmission, + lifetimeMilliseconds: 10000) + + let foreignPrincipal = PeekabooBridgeBrowserCapabilityPrincipal( + effectiveUserIdentifier: fixture.principal.effectiveUserIdentifier, + teamIdentifier: fixture.principal.teamIdentifier, + bundleIdentifier: "boo.peekaboo.foreign-cli", + codeSignatureHash: String(repeating: "b", count: 40)) + let principalError = await #expect(throws: PeekabooBridgeBrowserCapabilityNamespaceError.self) { + try await fixture.authority.verify(receipt, principal: foreignPrincipal) + } + #expect(principalError == .principalMismatch) + + let forged = PeekabooBridgeBrowserCapabilityNamespaceReceipt( + payload: receipt.payload, + signature: Data(repeating: 0xAA, count: receipt.signature.count)) + let signatureError = await #expect(throws: PeekabooBridgeBrowserCapabilityNamespaceError.self) { + try await fixture.authority.verify(forged, principal: fixture.principal) + } + #expect(signatureError == .invalidSignature) + + let foreignListener = try DeterministicNamespaceSigner(seed: 0x44, listenerInstanceID: Self.uuid(20)) + let foreignAuthority = try PeekabooBridgeBrowserCapabilityNamespaceAuthority( + signingContext: foreignListener.context, + hostEffectiveUserIdentifier: fixture.principal.effectiveUserIdentifier, + configuration: fixture.configuration, + clock: fixture.clock.now, + uuidGenerator: DeterministicUUIDGenerator([Self.uuid(21)]).next) + let listenerError = await #expect(throws: PeekabooBridgeBrowserCapabilityNamespaceError.self) { + try await foreignAuthority.verify(receipt, principal: fixture.principal) + } + #expect(listenerError == .listenerMismatch) + + let foreignKey = try DeterministicNamespaceSigner( + seed: 0x55, + listenerInstanceID: fixture.signer.listenerAttestation.listenerInstanceID) + let foreignKeyAuthority = try PeekabooBridgeBrowserCapabilityNamespaceAuthority( + signingContext: foreignKey.context, + hostEffectiveUserIdentifier: fixture.principal.effectiveUserIdentifier, + configuration: fixture.configuration, + clock: fixture.clock.now, + uuidGenerator: DeterministicUUIDGenerator([Self.uuid(22)]).next) + let keyError = await #expect(throws: PeekabooBridgeBrowserCapabilityNamespaceError.self) { + try await foreignKeyAuthority.verify(receipt, principal: fixture.principal) + } + #expect(keyError == .listenerMismatch) + } + + @Test + func `bounded issue and expiry times fail closed and update lifecycle`() async throws { + let fixture = try NamespaceAuthorityFixture(uuids: [Self.uuid(30), Self.uuid(31)]) + let receipt = try await fixture.authority.open( + principal: fixture.principal, + admission: fixture.namespaceAdmission, + lifetimeMilliseconds: 1000) + + let futurePayload = PeekabooBridgeBrowserCapabilityNamespaceReceiptPayload( + schemaVersion: receipt.payload.schemaVersion, + namespaceID: receipt.payload.namespaceID, + listenerInstanceID: receipt.payload.listenerInstanceID, + listenerPublicKeySHA256: receipt.payload.listenerPublicKeySHA256, + registryGenerationID: receipt.payload.registryGenerationID, + principal: receipt.payload.principal, + issuedAtUnixMilliseconds: fixture.clock.value + + fixture.configuration.maximumFutureSkewMilliseconds + 1, + expiresAtUnixMilliseconds: fixture.clock.value + + fixture.configuration.maximumFutureSkewMilliseconds + 501) + let futureReceipt = try fixture.signer.context.sign(futurePayload) + let futureError = await #expect(throws: PeekabooBridgeBrowserCapabilityNamespaceError.self) { + try await fixture.authority.verify(futureReceipt, principal: fixture.principal) + } + #expect(futureError == .receiptNotYetValid) + + fixture.clock.advance(by: 1000) + let expiryError = await #expect(throws: PeekabooBridgeBrowserCapabilityNamespaceError.self) { + try await fixture.authority.verify(receipt, principal: fixture.principal) + } + #expect(expiryError == .receiptExpired) + #expect(await fixture.authority.lifecycleState(namespaceID: receipt.payload.namespaceID) == .expired) + } + + @Test + func `claim exhaustion rolls to a fresh namespace and leaves predecessor closed`() async throws { + let fixture = try NamespaceAuthorityFixture( + uuids: [Self.uuid(40), Self.uuid(41), Self.uuid(42)], + maximumClaimCount: 1) + let first = try await fixture.authority.open( + principal: fixture.principal, + admission: fixture.namespaceAdmission, + lifetimeMilliseconds: 10000) + let firstClaim = try await fixture.authority.claim( + first, + principal: fixture.principal, + claimID: Self.claimID(40), + admission: fixture.backgroundAdmission) + #expect(await fixture.authority.lifecycleState(namespaceID: first.payload.namespaceID) == .closing) + try await fixture.authority.complete(firstClaim) + #expect(await fixture.authority.lifecycleState(namespaceID: first.payload.namespaceID) == .closed) + + let successor = try await fixture.authority.rollover( + first, + principal: fixture.principal, + admission: fixture.namespaceAdmission, + lifetimeMilliseconds: 10000) + #expect(successor.payload.namespaceID == Self.uuid(42)) + #expect(successor.payload.namespaceID != first.payload.namespaceID) + #expect(await fixture.authority.lifecycleState(namespaceID: successor.payload.namespaceID) == .open) + + let staleError = await #expect(throws: PeekabooBridgeBrowserCapabilityNamespaceError.self) { + try await fixture.authority.claim( + first, + principal: fixture.principal, + claimID: Self.claimID(41), + admission: fixture.backgroundAdmission) + } + #expect(staleError == .namespaceClosed) + let successorClaim = try await fixture.authority.claim( + successor, + principal: fixture.principal, + claimID: Self.claimID(42), + admission: fixture.backgroundAdmission) + try await fixture.authority.complete(successorClaim) + } + + @Test + func `close revokes first and concurrently drains every active claim`() async throws { + let fixture = try NamespaceAuthorityFixture(uuids: [Self.uuid(50), Self.uuid(51)]) + let receipt = try await fixture.authority.open( + principal: fixture.principal, + admission: fixture.namespaceAdmission, + lifetimeMilliseconds: 10000) + let first = try await fixture.authority.claim( + receipt, + principal: fixture.principal, + claimID: Self.claimID(50), + admission: fixture.backgroundAdmission) + let second = try await fixture.authority.claim( + receipt, + principal: fixture.principal, + claimID: Self.claimID(51), + admission: fixture.backgroundAdmission) + let identity = try await fixture.authority.beginClose(receipt, principal: fixture.principal) + #expect(identity.namespaceID == receipt.payload.namespaceID) + #expect(await fixture.authority.lifecycleState(namespaceID: receipt.payload.namespaceID) == .closing) + + let closedClaim = await #expect(throws: PeekabooBridgeBrowserCapabilityNamespaceError.self) { + try await fixture.authority.claim( + receipt, + principal: fixture.principal, + claimID: Self.claimID(52), + admission: fixture.backgroundAdmission) + } + #expect(closedClaim == .namespaceClosing) + + let drainFinished = CompletionProbe() + let drain = Task { + try await fixture.authority.awaitDrained(identity: identity) + await drainFinished.markFinished() + } + await Task.yield() + #expect(await !drainFinished.isFinished) + try await fixture.authority.complete(first) + await Task.yield() + #expect(await !drainFinished.isFinished) + try await fixture.authority.complete(second) + try await drain.value + #expect(await drainFinished.isFinished) + #expect(await fixture.authority.lifecycleState(namespaceID: receipt.payload.namespaceID) == .closed) + } + + @Test + func `host restart invalidates old generation receipts and outstanding claims`() async throws { + let fixture = try NamespaceAuthorityFixture(uuids: [Self.uuid(60), Self.uuid(61)]) + let receipt = try await fixture.authority.open( + principal: fixture.principal, + admission: fixture.namespaceAdmission, + lifetimeMilliseconds: 10000) + let claim = try await fixture.authority.claim( + receipt, + principal: fixture.principal, + claimID: Self.claimID(60), + admission: fixture.backgroundAdmission) + let identity = try await fixture.authority.beginClose(receipt, principal: fixture.principal) + let waitingClose = Task { + try await fixture.authority.awaitDrained(identity: identity) + } + await Task.yield() + #expect(await fixture.authority.invalidateForRestart() == 1) + + let waitingCloseError = await #expect(throws: PeekabooBridgeBrowserCapabilityNamespaceError.self) { + try await waitingClose.value + } + #expect(waitingCloseError == .registryInvalidated) + #expect(await fixture.authority.activeClaimCount(namespaceID: receipt.payload.namespaceID) == 1) + + let invalidated = await #expect(throws: PeekabooBridgeBrowserCapabilityNamespaceError.self) { + try await fixture.authority.verify(receipt, principal: fixture.principal) + } + #expect(invalidated == .registryInvalidated) + let completionError = await #expect(throws: PeekabooBridgeBrowserCapabilityNamespaceError.self) { + try await fixture.authority.complete(claim) + } + #expect(completionError == .registryInvalidated) + #expect(await fixture.authority.activeClaimCount(namespaceID: receipt.payload.namespaceID) == 0) + + let replacement = try PeekabooBridgeBrowserCapabilityNamespaceAuthority( + signingContext: fixture.signer.context, + hostEffectiveUserIdentifier: fixture.principal.effectiveUserIdentifier, + configuration: fixture.configuration, + clock: fixture.clock.now, + uuidGenerator: DeterministicUUIDGenerator([Self.uuid(62)]).next) + let staleGeneration = await #expect(throws: PeekabooBridgeBrowserCapabilityNamespaceError.self) { + try await replacement.verify(receipt, principal: fixture.principal) + } + #expect(staleGeneration == .registryGenerationMismatch) + } + + @Test + func `global drain freezes admission until every active claim completes`() async throws { + let fixture = try NamespaceAuthorityFixture( + uuids: [Self.uuid(70), Self.uuid(71), Self.uuid(72)]) + let receipt = try await fixture.authority.open( + principal: fixture.principal, + admission: fixture.namespaceAdmission, + lifetimeMilliseconds: 10000) + let claim = try await fixture.authority.claim( + receipt, + principal: fixture.principal, + claimID: Self.claimID(70), + admission: fixture.backgroundAdmission) + let drain = Task { + try await fixture.authority.drainAll() + } + for _ in 0..<100 { + if await fixture.authority.lifecycleState(namespaceID: receipt.payload.namespaceID) == .closing { + break + } + await Task.yield() + } + #expect(await fixture.authority.lifecycleState(namespaceID: receipt.payload.namespaceID) == .closing) + + let openError = await #expect(throws: PeekabooBridgeBrowserCapabilityNamespaceError.self) { + try await fixture.authority.open( + principal: fixture.principal, + admission: fixture.namespaceAdmission, + lifetimeMilliseconds: 10000) + } + #expect(openError == .registryDraining) + let claimError = await #expect(throws: PeekabooBridgeBrowserCapabilityNamespaceError.self) { + try await fixture.authority.claim( + receipt, + principal: fixture.principal, + claimID: Self.claimID(71), + admission: fixture.backgroundAdmission) + } + #expect(claimError == .registryDraining) + + try await fixture.authority.complete(claim) + try await drain.value + #expect(await fixture.authority.lifecycleState(namespaceID: receipt.payload.namespaceID) == .closed) + } + + @Test + func `cancelled close wait removes its waiter and permits one bounded retry`() async throws { + let fixture = try NamespaceAuthorityFixture(uuids: [Self.uuid(80), Self.uuid(81)]) + let receipt = try await fixture.authority.open( + principal: fixture.principal, + admission: fixture.namespaceAdmission, + lifetimeMilliseconds: 10000) + let claim = try await fixture.authority.claim( + receipt, + principal: fixture.principal, + claimID: Self.claimID(80), + admission: fixture.backgroundAdmission) + let identity = try await fixture.authority.beginClose(receipt, principal: fixture.principal) + let cancelled = Task { + try await fixture.authority.awaitDrained(identity: identity) + } + await Task.yield() + cancelled.cancel() + await #expect(throws: CancellationError.self) { + try await cancelled.value + } + + let retry = Task { + try await fixture.authority.awaitDrained(identity: identity) + } + await Task.yield() + try await fixture.authority.complete(claim) + try await retry.value + #expect(await fixture.authority.lifecycleState(namespaceID: receipt.payload.namespaceID) == .closed) + } + + @Test + func `already drained close identity survives terminal registry eviction`() async throws { + let fixture = try NamespaceAuthorityFixture( + uuids: [ + Self.uuid(90), Self.uuid(91), Self.uuid(92), Self.uuid(93), + Self.uuid(94), Self.uuid(95), + ]) + let receipt = try await fixture.authority.open( + principal: fixture.principal, + admission: fixture.namespaceAdmission, + lifetimeMilliseconds: 10000) + let identity = try await fixture.authority.beginClose(receipt, principal: fixture.principal) + + for _ in 0..<4 { + _ = try await fixture.authority.open( + principal: fixture.principal, + admission: fixture.namespaceAdmission, + lifetimeMilliseconds: 10000) + } + #expect(await fixture.authority.lifecycleState(namespaceID: receipt.payload.namespaceID) == nil) + try await fixture.authority.awaitDrained(identity: identity) + } + + private static func uuid(_ suffix: UInt16) -> UUID { + UUID(uuidString: String(format: "00000000-0000-4000-8000-%012x", suffix))! + } + + private static func claimID(_ suffix: UInt16) -> UUID { + UUID(uuidString: String(format: "10000000-0000-8000-8000-%012x", suffix))! + } +} + +private struct NamespaceAuthorityFixture { + let signer: DeterministicNamespaceSigner + let clock: DeterministicUnixClock + let principal: PeekabooBridgeBrowserCapabilityPrincipal + let configuration: PeekabooBridgeBrowserCapabilityNamespaceAuthority.Configuration + let authority: PeekabooBridgeBrowserCapabilityNamespaceAuthority + let namespaceAdmission: PeekabooBridgeBrowserCapabilityNamespaceAdmission + let backgroundAdmission: PeekabooBridgeBrowserCapabilityClaimAdmission + let foregroundAdmission: PeekabooBridgeBrowserCapabilityClaimAdmission + + init(uuids: [UUID], maximumClaimCount: Int = 16) throws { + let userIdentifier = uid_t(501) + let clock = DeterministicUnixClock(1_000_000) + let signer = try DeterministicNamespaceSigner( + seed: 0x22, + listenerInstanceID: UUID(uuidString: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!) + let principal = PeekabooBridgeBrowserCapabilityPrincipal( + effectiveUserIdentifier: userIdentifier, + teamIdentifier: "TEAMID1234", + bundleIdentifier: "boo.peekaboo.cli", + codeSignatureHash: String(repeating: "a", count: 40)) + let configuration = PeekabooBridgeBrowserCapabilityNamespaceAuthority.Configuration( + maximumNamespaceCount: 4, + maximumLifetimeMilliseconds: 10000, + maximumClaimCountPerNamespace: maximumClaimCount, + maximumFutureSkewMilliseconds: 100) + let generator = DeterministicUUIDGenerator(uuids) + let namespaceAdmission = try #require(PeekabooBridgeBrowserCapabilityNamespaceAdmission( + isLocalExecutionHost: true, + isAuthenticatedPeer: true, + hasNativeCapableService: true)) + let backgroundAdmission = try #require(PeekabooBridgeBrowserCapabilityClaimAdmission( + executionPolicy: .backgroundOnly, + isLocalExecutionHost: true, + isAuthenticatedPeer: true)) + let foregroundAdmission = try #require(PeekabooBridgeBrowserCapabilityClaimAdmission( + executionPolicy: .foregroundAllowed, + isLocalExecutionHost: true, + isAuthenticatedPeer: true, + hasScopedForegroundAuthorization: true)) + self.signer = signer + self.clock = clock + self.principal = principal + self.configuration = configuration + self.namespaceAdmission = namespaceAdmission + self.backgroundAdmission = backgroundAdmission + self.foregroundAdmission = foregroundAdmission + self.authority = try PeekabooBridgeBrowserCapabilityNamespaceAuthority( + signingContext: signer.context, + hostEffectiveUserIdentifier: userIdentifier, + configuration: configuration, + clock: clock.now, + uuidGenerator: generator.next) + } +} + +private struct DeterministicNamespaceSigner { + let privateKey: Curve25519.Signing.PrivateKey + let listenerAttestation: PeekabooBridgeListenerAttestation + let context: PeekabooBridgeBrowserCapabilityNamespaceSigningContext + + init(seed: UInt8, listenerInstanceID: UUID) throws { + let privateKey = try Curve25519.Signing.PrivateKey( + rawRepresentation: Data((0..<32).map { seed &+ UInt8($0) })) + let publicKey = privateKey.publicKey.rawRepresentation + let unsigned = PeekabooBridgeListenerAttestation.UnsignedPayload( + schemaVersion: 1, + listenerInstanceID: listenerInstanceID, + publicKey: publicKey, + host: .init( + processIdentifier: 123, + processStartIdentity: 456, + codeSignatureHash: String(repeating: "c", count: 40)), + createdAtUnixMilliseconds: 999_000, + receiptArchiveDirectory: "/tmp/peekaboo-namespace-tests") + let listenerAttestation = try PeekabooBridgeListenerAttestation( + listenerInstanceID: unsigned.listenerInstanceID, + publicKey: unsigned.publicKey, + host: unsigned.host, + createdAtUnixMilliseconds: unsigned.createdAtUnixMilliseconds, + receiptArchiveDirectory: unsigned.receiptArchiveDirectory, + signature: privateKey.signature( + for: PeekabooBridgeOperationReceiptCoding.canonicalData(unsigned))) + self.privateKey = privateKey + self.listenerAttestation = listenerAttestation + self.context = try PeekabooBridgeBrowserCapabilityNamespaceSigningContext( + listenerAttestation: listenerAttestation, + signCanonicalPayload: { payload in + try privateKey.signature( + for: PeekabooBridgeOperationReceiptCoding.canonicalData(payload)) + }) + } +} + +private final class DeterministicUnixClock: @unchecked Sendable { + private let lock = NSLock() + private var storage: Int64 + + init(_ value: Int64) { + self.storage = value + } + + var value: Int64 { + self.lock.withLock { self.storage } + } + + func now() -> Int64 { + self.value + } + + func advance(by milliseconds: Int64) { + self.lock.withLock { + self.storage += milliseconds + } + } +} + +private final class DeterministicUUIDGenerator: @unchecked Sendable { + private let lock = NSLock() + private var values: [UUID] + + init(_ values: [UUID]) { + self.values = values + } + + func next() -> UUID { + self.lock.withLock { + precondition(!self.values.isEmpty, "Deterministic UUID fixture exhausted") + return self.values.removeFirst() + } + } +} + +private actor CompletionProbe { + private(set) var isFinished = false + + func markFinished() { + self.isFinished = true + } +} From ed3d4b49f397c03cc4cbdb4796ab7835131d19c7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 18:08:19 -0700 Subject: [PATCH 11/14] feat(cli): add durable browser namespace state --- .../Commands/Base/CommandRuntime.swift | 3 + .../MCP/BrowserCommand+Namespace.swift | 1051 +++++++++++++++++ .../Commands/MCP/BrowserCommand.swift | 96 +- .../InvalidInputOrderingCLITests.swift | 5 +- .../BrowserCLINamespaceCommandTests.swift | 569 +++++++++ ...BrowserCLINamespaceReceiptStoreTests.swift | 300 +++++ .../PreRuntimeInvalidInputOrderingTests.swift | 2 +- 7 files changed, 2014 insertions(+), 12 deletions(-) create mode 100644 Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand+Namespace.swift create mode 100644 Apps/CLI/Tests/CoreCLITests/BrowserCLINamespaceCommandTests.swift create mode 100644 Apps/CLI/Tests/CoreCLITests/BrowserCLINamespaceReceiptStoreTests.swift diff --git a/Apps/CLI/Sources/PeekabooCLI/Commands/Base/CommandRuntime.swift b/Apps/CLI/Sources/PeekabooCLI/Commands/Base/CommandRuntime.swift index f702fde4e..ed51d908b 100644 --- a/Apps/CLI/Sources/PeekabooCLI/Commands/Base/CommandRuntime.swift +++ b/Apps/CLI/Sources/PeekabooCLI/Commands/Base/CommandRuntime.swift @@ -48,6 +48,9 @@ struct CommandRuntimeOptions { var requiredElementActionOperations: Set = [] var requiresInspectAccessibilityTree = false var requiresBrowserMCP = false + /// Protocol 1.38 durable browser capability namespaces require a negotiated remote Bridge owner. + /// They must never fall back to the caller-local or legacy raw browser provider. + var requiresBrowserCapabilityNamespace = false var requiresApplicationLaunchOptions = false var requiresSafeBackgroundApplicationLaunchNoOp = false var requiresNewApplicationInstanceLaunch = false diff --git a/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand+Namespace.swift b/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand+Namespace.swift new file mode 100644 index 000000000..856a2a80c --- /dev/null +++ b/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand+Namespace.swift @@ -0,0 +1,1051 @@ +import CoreFoundation +import Darwin +import Foundation +import PeekabooFoundation +import TachikomaMCP + +struct BrowserCLINamespaceBindWindowRequest: Equatable, Sendable { + let pageID: String + let processIdentifier: Int32 + let windowID: UInt32 +} + +enum BrowserCLINamespaceControlAction: String, Sendable { + case create = "namespace_create" + case close = "namespace_close" +} + +enum BrowserCLINamespaceExecutionMode: Sendable { + case backgroundOnly + case foregroundAllowed +} + +struct BrowserCLINamespaceHighLevelActionRequest: @unchecked Sendable { + let action: BrowserAction + let arguments: [String: Any] + let executionMode: BrowserCLINamespaceExecutionMode +} + +struct BrowserCLINamespaceCreateResult: Sendable { + /// Complete signed receipt bytes are private state and must never be copied into `response`. + let namespaceReceiptData: Data + let response: ToolResponse +} + +protocol BrowserCLINamespaceBridgeAdapter: Sendable { + /// Creates one authenticated Bridge namespace. The caller must persist the returned complete receipt + /// through ``BrowserCLINamespaceReceiptStore`` before treating creation as durable. + func createNamespace() async throws -> BrowserCLINamespaceCreateResult + + /// Executes only through an authenticated Bridge 1.38 capability namespace. + /// The adapter owns wire decoding, receipt signature/principal validation, and listener matching. + func bindWindow( + request: BrowserCLINamespaceBindWindowRequest, + namespaceReceiptData: Data + ) async throws -> ToolResponse + + /// Executes only one action from the closed Bridge 1.38 high-level action enum. + /// Raw provider calls and legacy browser transport are not representable through this method. + func executeAction( + request: BrowserCLINamespaceHighLevelActionRequest, + namespaceReceiptData: Data + ) async throws -> ToolResponse + + /// Closes the exact namespace. A non-error response must mean the host confirmed status `closed`; + /// the caller removes its receipt file only after that response. + func closeNamespace(namespaceReceiptData: Data) async throws -> ToolResponse +} + +@MainActor +protocol BrowserCLINamespaceBridgeAdapterProviding: AnyObject { + /// Present only when the selected RemotePeekabooServices was built from a negotiated Bridge 1.38 client. + var browserCLINamespaceBridgeAdapter: any BrowserCLINamespaceBridgeAdapter { get } +} + +/// The Bridge 1.38 integration makes only its negotiated `RemotePeekabooServices` conform to the +/// provider above. This base-scoped lane deliberately has no production fallback; until the wire +/// stack supplies that conformance, valid requests fail closed with `adapterUnavailable`. +enum BrowserCLINamespaceEnvironment { + #if DEBUG + @TaskLocal private static var testBridgeAdapterOverride: (any BrowserCLINamespaceBridgeAdapter)? + #endif + + @MainActor + static func adapter(for runtime: CommandRuntime) -> (any BrowserCLINamespaceBridgeAdapter)? { + guard runtime.services.executionHost == .remote, + runtime.selectedRemoteSocketPath != nil + else { return nil } + if let provider = runtime.services as? any BrowserCLINamespaceBridgeAdapterProviding { + return provider.browserCLINamespaceBridgeAdapter + } + #if DEBUG + return self.testBridgeAdapterOverride + #else + return nil + #endif + } + + #if DEBUG + static func withBridgeAdapter( + _ adapter: any BrowserCLINamespaceBridgeAdapter, + operation: () async throws -> T + ) async rethrows -> T { + try await self.$testBridgeAdapterOverride.withValue(adapter) { + try await operation() + } + } + #endif +} + +enum BrowserCLINamespaceLifecycle { + static func create( + adapter: any BrowserCLINamespaceBridgeAdapter, + store: BrowserCLINamespaceReceiptStore + ) async throws -> ToolResponse { + try store.validateCanSave() + let creation = try await adapter.createNamespace() + if creation.response.isError { + do { + try await self.rollbackCreatedNamespace( + adapter: adapter, + receipt: creation.namespaceReceiptData + ) + } catch { + throw BrowserCLINamespacePostDispatchError.creationRollbackFailed(error.localizedDescription) + } + return creation.response + } + do { + try store.save(creation.namespaceReceiptData) + } catch { + let persistenceError = error + do { + try await self.rollbackCreatedNamespace( + adapter: adapter, + receipt: creation.namespaceReceiptData + ) + } catch { + throw BrowserCLINamespacePostDispatchError.creationRollbackFailed(error.localizedDescription) + } + throw BrowserCLINamespacePostDispatchError.creationRolledBack( + persistenceError.localizedDescription + ) + } + return creation.response + } + + static func close( + adapter: any BrowserCLINamespaceBridgeAdapter, + store: BrowserCLINamespaceReceiptStore + ) async throws -> ToolResponse { + let receipt = try store.load() + let response = try await adapter.closeNamespace(namespaceReceiptData: receipt) + if !response.isError { + do { + try store.remove(expectedReceipt: receipt) + } catch { + throw BrowserCLINamespacePostDispatchError.closedReceiptCleanupFailed( + error.localizedDescription + ) + } + } + return response + } + + private static func rollbackCreatedNamespace( + adapter: any BrowserCLINamespaceBridgeAdapter, + receipt: Data + ) async throws { + let rollback = try await adapter.closeNamespace(namespaceReceiptData: receipt) + guard !rollback.isError else { + throw BrowserCLINamespaceReceiptStoreError.writeFailed( + "created namespace could not be persisted or closed and will expire" + ) + } + } +} + +enum BrowserCLINamespacePostDispatchError: LocalizedError, ResultEnvelopeError, Equatable { + case creationRolledBack(String) + case creationRollbackFailed(String) + case closedReceiptCleanupFailed(String) + + nonisolated var errorDescription: String? { + switch self { + case let .creationRolledBack(cause): + "The Bridge namespace was created and closed, but its receipt could not be persisted: \(cause)" + case let .creationRollbackFailed(cause): + "The Bridge namespace was created but could not be persisted or closed; it will expire: \(cause)" + case let .closedReceiptCleanupFailed(cause): + "The Bridge namespace was closed, but its local receipt could not be removed: \(cause)" + } + } + + nonisolated var envelopeCode: ErrorCode? { + .INTERACTION_FAILED + } + + nonisolated var envelopeEffect: ActionEffect? { + .partial + } + + nonisolated var envelopeHint: String? { + switch self { + case .creationRolledBack: + "The rollback was confirmed; fix the private receipt path before creating a new namespace." + case .creationRollbackFailed: + "Do not retry automatically. Let the unpersisted namespace expire before creating another one." + case .closedReceiptCleanupFailed: + "Do not overwrite the receipt. Remove it only after confirming it names the namespace just closed." + } + } + + nonisolated var envelopeRetrySafe: Bool? { + switch self { + case .creationRolledBack: true + case .creationRollbackFailed, .closedReceiptCleanupFailed: false + } + } + + nonisolated var envelopeMutationDispatched: Bool? { + true + } +} + +enum BrowserCLINamespaceCommandError: LocalizedError, ResultEnvelopeError, Equatable { + case missingSelectors + case invalidPageReference + case invalidProcessIdentifier + case invalidWindowID + case missingNamespaceFile + case invalidNamespaceFile + case unsupportedNamespaceAction(String) + case unsupportedArguments([String]) + case localExecutionRefused + case bridgeHostRequired(String) + case adapterUnavailable + + nonisolated var errorDescription: String? { + switch self { + case .missingSelectors: + "browser bind-window requires --page-id, --pid, and --window-id." + case .invalidPageReference: + "browser bind-window --page-id must be an opaque bp1 capability from this Bridge namespace." + case .invalidProcessIdentifier: + "browser bind-window --pid must be a positive Int32." + case .invalidWindowID: + "browser bind-window --window-id must be a positive UInt32." + case .missingNamespaceFile: + "Browser namespace actions require an explicit --namespace-file." + case .invalidNamespaceFile: + "--namespace-file must resolve to an absolute browser namespace receipt path." + case let .unsupportedNamespaceAction(action): + "Browser action '\(action)' is not in the closed Bridge 1.38 namespace action set." + case let .unsupportedArguments(arguments): + "browser bind-window does not accept \(arguments.joined(separator: ", "))." + case .localExecutionRefused: + "browser bind-window requires an authenticated Bridge 1.38 namespace; --no-remote is not supported." + case let .bridgeHostRequired(message): + message + case .adapterUnavailable: + "The selected Bridge host did not provide its negotiated 1.38 browser namespace adapter." + } + } + + nonisolated var envelopeCode: ErrorCode? { + switch self { + case .bridgeHostRequired, .adapterUnavailable: .BRIDGE_UNAVAILABLE + default: .VALIDATION_ERROR + } + } + + nonisolated var envelopeEffect: ActionEffect? { + nil + } + + nonisolated var envelopeRetrySafe: Bool? { + true + } + + nonisolated var envelopeMutationDispatched: Bool? { + false + } + + nonisolated var envelopeHint: String? { + switch self { + case .missingSelectors, .invalidPageReference, .invalidProcessIdentifier, .invalidWindowID: + "Pass exactly the opaque page capability, Chrome PID, and native WindowServer ID returned for " + + "this namespace." + case .missingNamespaceFile, .invalidNamespaceFile: + "Pass the exact owner-private receipt file created for this authenticated Bridge namespace." + case .unsupportedNamespaceAction: + "Use one documented high-level browser action; raw call/provider tools are intentionally unavailable." + case .unsupportedArguments: + "Remove unrelated browser options; bind-window accepts only --namespace-file and its three selectors." + case .localExecutionRefused: + "Remove --no-remote and use the authenticated Bridge that issued the stored namespace receipt." + case .bridgeHostRequired: + "Select the exact authenticated Bridge that issued the receipt; local and legacy hosts are refused." + case .adapterUnavailable: + "Upgrade the selected Bridge host and retry only after it negotiates browser capability namespaces." + } + } +} + +extension BrowserCommand { + var usesBrowserCapabilityNamespace: Bool { + self.normalizedAction == BrowserProcessLocalAction.bindWindow || + BrowserCLINamespaceControlAction(rawValue: self.normalizedAction) != nil || + self.namespaceFile != nil + } + + func validateBrowserCapabilityNamespaceActionBeforeRuntime( + environment: [String: String] = ProcessInfo.processInfo.environment + ) throws { + try self.requireRemoteNamespaceRouting(environment: environment) + let store = try self.namespaceReceiptStore() + if let control = BrowserCLINamespaceControlAction(rawValue: self.normalizedAction) { + let unsupported = self.namespaceUnsupportedArguments(allowsBindSelectors: false) + guard unsupported.isEmpty else { + throw BrowserCLINamespaceCommandError.unsupportedArguments(unsupported) + } + switch control { + case .create: + try store.validateCanSaveBeforeRuntime() + case .close: + _ = try store.load() + } + return + } + if self.normalizedAction == BrowserProcessLocalAction.bindWindow { + _ = try self.namespaceBindWindowRequest(environment: environment) + _ = try store.load() + return + } + _ = try self.namespaceHighLevelActionRequest() + _ = try store.load() + } + + mutating func runBrowserCapabilityNamespaceAction() async throws { + let store = try self.namespaceReceiptStore() + let adapter = try self.namespaceAdapter() + if let control = BrowserCLINamespaceControlAction(rawValue: self.normalizedAction) { + switch control { + case .create: + try await self.runNamespaceCreate(adapter: adapter, store: store) + case .close: + try await self.runNamespaceClose(adapter: adapter, store: store) + } + return + } + if self.normalizedAction == BrowserProcessLocalAction.bindWindow { + let request = try self.namespaceBindWindowRequest() + let receipt = try store.load() + let response = try await adapter.bindWindow( + request: request, + namespaceReceiptData: receipt + ) + try self.outputNamespaceResponse(response) + return + } + let request = try self.namespaceHighLevelActionRequest() + let receipt = try store.load() + if Self.actionMayMutate(request.action.rawValue) { + self.resolvedRuntime.beginInteractionMutation() + } + let response = try await adapter.executeAction( + request: request, + namespaceReceiptData: receipt + ) + try self.outputNamespaceResponse(response) + } + + func namespaceBindWindowRequest( + environment: [String: String] = ProcessInfo.processInfo.environment + ) throws -> BrowserCLINamespaceBindWindowRequest { + let unsupported = self.namespaceUnsupportedArguments(allowsBindSelectors: true) + guard unsupported.isEmpty else { + throw BrowserCLINamespaceCommandError.unsupportedArguments(unsupported) + } + try self.requireRemoteNamespaceRouting(environment: environment) + _ = try self.namespaceReceiptStore() + guard let pageReference = self.pageId, + let processIdentifier = self.pid, + let windowID = self.windowId + else { + throw BrowserCLINamespaceCommandError.missingSelectors + } + guard Self.isOpaqueBrowserPageReference(pageReference) else { + throw BrowserCLINamespaceCommandError.invalidPageReference + } + guard let exactProcessIdentifier = Int32(exactly: processIdentifier), exactProcessIdentifier > 0 else { + throw BrowserCLINamespaceCommandError.invalidProcessIdentifier + } + guard let exactWindowID = UInt32(exactly: windowID), exactWindowID > 0 else { + throw BrowserCLINamespaceCommandError.invalidWindowID + } + return BrowserCLINamespaceBindWindowRequest( + pageID: pageReference, + processIdentifier: exactProcessIdentifier, + windowID: exactWindowID + ) + } + + func namespaceHighLevelActionRequest() throws -> BrowserCLINamespaceHighLevelActionRequest { + guard let action = BrowserAction(rawValue: self.normalizedAction), + action.rawValue != BrowserAction.call.rawValue + else { + throw BrowserCLINamespaceCommandError.unsupportedNamespaceAction(self.action) + } + var arguments = try self.arguments() + arguments.removeValue(forKey: "action") + return BrowserCLINamespaceHighLevelActionRequest( + action: action, + arguments: arguments, + executionMode: self.foreground ? .foregroundAllowed : .backgroundOnly + ) + } + + private func namespaceAdapter() throws -> any BrowserCLINamespaceBridgeAdapter { + guard self.runtimeOptions.requiresBrowserCapabilityNamespace, + self.services.executionHost == .remote, + self.resolvedRuntime.selectedRemoteSocketPath != nil + else { + throw BrowserCLINamespaceCommandError.bridgeHostRequired( + self.resolvedRuntime.requiredHostFailure ?? + "browser bind-window did not select an authenticated remote Bridge 1.38 namespace host." + ) + } + guard let adapter = BrowserCLINamespaceEnvironment.adapter(for: self.resolvedRuntime) else { + throw BrowserCLINamespaceCommandError.adapterUnavailable + } + return adapter + } + + private mutating func runNamespaceCreate( + adapter: any BrowserCLINamespaceBridgeAdapter, + store: BrowserCLINamespaceReceiptStore + ) async throws { + let response = try await BrowserCLINamespaceLifecycle.create(adapter: adapter, store: store) + try self.outputNamespaceResponse(response) + } + + private mutating func runNamespaceClose( + adapter: any BrowserCLINamespaceBridgeAdapter, + store: BrowserCLINamespaceReceiptStore + ) async throws { + let response = try await BrowserCLINamespaceLifecycle.close(adapter: adapter, store: store) + try self.outputNamespaceResponse(response) + } + + private func outputNamespaceResponse(_ response: ToolResponse) throws { + try MCPToolCommandOutput.output( + tool: "browser", + response: response, + jsonOutput: self.jsonOutput, + logger: self.outputLogger + ) + } + + private func requireRemoteNamespaceRouting(environment: [String: String]) throws { + guard !self.runtimeOptions.remoteIsolationRequested, environment["PEEKABOO_NO_REMOTE"] == nil else { + throw BrowserCLINamespaceCommandError.localExecutionRefused + } + } + + func namespaceReceiptStore() throws -> BrowserCLINamespaceReceiptStore { + guard let namespaceFile = self.namespaceFile? + .trimmingCharacters(in: .whitespacesAndNewlines), + !namespaceFile.isEmpty + else { + throw BrowserCLINamespaceCommandError.missingNamespaceFile + } + do { + return try BrowserCLINamespaceReceiptStore(resolvingPath: namespaceFile) + } catch { + throw BrowserCLINamespaceCommandError.invalidNamespaceFile + } + } + + private func namespaceUnsupportedArguments(allowsBindSelectors: Bool) -> [String] { + var arguments: [String] = [] + func append(_ present: Bool, _ name: String) { + if present { + arguments.append(name) + } + } + + append(self.channel != nil, "--channel") + append(self.browserUrl != nil, "--browser-url") + append(!allowsBindSelectors && self.pageId != nil, "--page-id") + append(!allowsBindSelectors && self.pid != nil, "--pid") + append(!allowsBindSelectors && self.windowId != nil, "--window-id") + append(self.url != nil, "--url") + append(self.navigationType != nil, "--navigation-type") + append(self.uid != nil, "--uid") + append(self.toUid != nil, "--to-uid") + append(self.text != nil, "--text") + append(self.value != nil, "--value") + append(self.key != nil, "--key") + append(self.submitKey != nil, "--submit-key") + append(self.dialogAction != nil, "--dialog-action") + append(self.includeSnapshot, "--include-snapshot") + append(self.double, "--double") + append(self.bringToFront, "--bring-to-front") + append(self.noBringToFront, "--no-bring-to-front") + append(self.background, "--background") + append(self.foreground, "--foreground") + append(self.timeout != nil, "--timeout") + append(self.pageSize != nil, "--page-size") + append(self.pageIndex != nil, "--page-index") + append(!self.types.isEmpty, "--type") + append(!self.resourceTypes.isEmpty, "--resource-type") + append(self.includePreserved, "--include-preserved") + append(self.messageId != nil, "--message-id") + append(self.requestId != nil, "--request-id") + append(self.requestFilePath != nil, "--request-file-path") + append(self.responseFilePath != nil, "--response-file-path") + append(self.path != nil, "--path") + append(self.format != nil, "--format") + append(self.quality != nil, "--quality") + append(self.fullPage, "--full-page") + append(self.traceAction != nil, "--trace-action") + append(self.noReload, "--no-reload") + append(self.noAutoStop, "--no-auto-stop") + append(self.insightSetId != nil, "--insight-set-id") + append(self.insightName != nil, "--insight-name") + append(self.mcpTool != nil, "--mcp-tool") + append(self.mcpArgsJson != nil, "--mcp-args-json") + append(self.runtimeOptions.inputStrategy != nil, "--input-strategy") + append(self.runtimeOptions.captureEnginePreference != nil, "--capture-engine") + return arguments.sorted() + } + + static func isOpaqueBrowserPageReference(_ value: String) -> Bool { + self.isOpaqueBrowserReference(value, prefix: "bp1") + } + + static func isOpaqueBrowserElementReference(_ value: String) -> Bool { + self.isOpaqueBrowserReference(value, prefix: "be1") + } + + private static func isOpaqueBrowserReference(_ value: String, prefix: String) -> Bool { + let expectedPrefix = prefix + "_" + guard value.hasPrefix(expectedPrefix) else { return false } + let token = value.dropFirst(expectedPrefix.count) + return token.count == 32 && token.allSatisfy { character in + guard let ascii = character.asciiValue else { return false } + return (48...57).contains(ascii) || (97...102).contains(ascii) + } + } +} + +struct BrowserCLINamespaceReceiptStore: Sendable { + static let maximumReceiptBytes: off_t = 16 * 1024 + + let fileURL: URL + + init(fileURL: URL) { + self.fileURL = fileURL + } + + init(resolvingPath path: String) throws { + guard !path.utf8.contains(0) else { + throw BrowserCLINamespaceReceiptStoreError.unsafeState("state path is invalid") + } + let expanded = (path as NSString).expandingTildeInPath + guard expanded.hasPrefix("/") else { + throw BrowserCLINamespaceReceiptStoreError.unsafeState("state path must be absolute") + } + let resolved = URL(fileURLWithPath: expanded, isDirectory: false).standardizedFileURL + guard !resolved.lastPathComponent.isEmpty, + resolved.lastPathComponent != ".", + resolved.lastPathComponent != ".." + else { + throw BrowserCLINamespaceReceiptStoreError.unsafeState("state path is invalid") + } + self.fileURL = resolved + } + + func load() throws -> Data { + let directory = try self.openPrivateDirectory(createIfMissing: false) + defer { Darwin.close(directory) } + let descriptor = self.fileURL.lastPathComponent.withCString { name in + openat(directory, name, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + } + guard descriptor >= 0 else { + if errno == ENOENT { + throw BrowserCLINamespaceReceiptStoreError.missing + } + throw BrowserCLINamespaceReceiptStoreError.unsafeState(Self.openFailureReason(errno)) + } + defer { Darwin.close(descriptor) } + + let before = try Self.validatedFileMetadata(descriptor) + let data = try Self.readExactly(descriptor, expectedSize: Int(before.st_size)) + var after = stat() + guard fstat(descriptor, &after) == 0, Self.sameFile(before, after), Int64(data.count) == before.st_size else { + throw BrowserCLINamespaceReceiptStoreError.unsafeState("state changed while it was being read") + } + try Self.validateCanonicalReceipt(data) + return data + } + + /// Establishes the private parent directory and proves the exact destination is still unused. + /// `save` repeats this check atomically, so namespace creation can preflight before remote admission. + func validateCanSave() throws { + let directory = try self.openPrivateDirectory(createIfMissing: true) + defer { Darwin.close(directory) } + try self.requireDestinationAbsent(in: directory) + } + + /// Request-only preflight used before host discovery. A missing parent is allowed and is created only + /// during command execution; an existing parent and destination are inspected without mutation. + func validateCanSaveBeforeRuntime() throws { + do { + let directory = try self.openPrivateDirectory(createIfMissing: false) + defer { Darwin.close(directory) } + try self.requireDestinationAbsent(in: directory) + } catch BrowserCLINamespaceReceiptStoreError.missing { + return + } + } + + func save(_ canonicalReceipt: Data) throws { + try Self.validateCanonicalReceipt(canonicalReceipt) + let directory = try self.openPrivateDirectory(createIfMissing: true) + defer { Darwin.close(directory) } + try self.requireDestinationAbsent(in: directory) + + let destinationName = self.fileURL.lastPathComponent + let temporaryName = ".\(destinationName).\(UUID().uuidString.lowercased()).tmp" + let descriptor = temporaryName.withCString { name in + openat( + directory, + name, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + S_IRUSR | S_IWUSR + ) + } + guard descriptor >= 0 else { + throw BrowserCLINamespaceReceiptStoreError.writeFailed("temporary state could not be created") + } + var temporaryStillExists = true + defer { + Darwin.close(descriptor) + if temporaryStillExists { + _ = temporaryName.withCString { unlinkat(directory, $0, 0) } + } + } + + guard fchmod(descriptor, S_IRUSR | S_IWUSR) == 0 else { + throw BrowserCLINamespaceReceiptStoreError.writeFailed("temporary state permissions could not be set") + } + _ = try Self.validatedFileMetadata(descriptor, expectedSize: 0) + try Self.writeExactly(canonicalReceipt, to: descriptor) + guard fsync(descriptor) == 0 else { + throw BrowserCLINamespaceReceiptStoreError.writeFailed("temporary state could not be synchronized") + } + _ = try Self.validatedFileMetadata(descriptor, expectedSize: off_t(canonicalReceipt.count)) + + let renameResult = temporaryName.withCString { source in + destinationName.withCString { destination in + renameatx_np(directory, source, directory, destination, UInt32(RENAME_EXCL)) + } + } + guard renameResult == 0 else { + if errno == EEXIST { + throw BrowserCLINamespaceReceiptStoreError.alreadyExists + } + throw BrowserCLINamespaceReceiptStoreError.writeFailed("state could not be published atomically") + } + // `renameatx_np(RENAME_EXCL)` is the commit point. The already-fsynced, validated inode is now the + // exact destination, and no later diagnostic may turn this into a persistence failure that rolls + // back the remote namespace while leaving its receipt published. + temporaryStillExists = false + _ = fsync(directory) + } + + func remove(expectedReceipt: Data) throws { + try Self.validateCanonicalReceipt(expectedReceipt) + let directory = try self.openPrivateDirectory(createIfMissing: false) + defer { Darwin.close(directory) } + let name = self.fileURL.lastPathComponent + let descriptor = name.withCString { value in + openat(directory, value, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + } + guard descriptor >= 0 else { + if errno == ENOENT { + return + } + throw BrowserCLINamespaceReceiptStoreError.unsafeState(Self.openFailureReason(errno)) + } + defer { Darwin.close(descriptor) } + let opened = try Self.validatedFileMetadata(descriptor) + let openedData = try Self.readExactly(descriptor, expectedSize: Int(opened.st_size)) + var afterRead = stat() + guard fstat(descriptor, &afterRead) == 0, + Self.sameFile(opened, afterRead), + openedData == expectedReceipt + else { + throw BrowserCLINamespaceReceiptStoreError.receiptMismatch + } + try Self.validateCanonicalReceipt(openedData) + let quarantineName = ".\(name).\(UUID().uuidString.lowercased()).closing" + let renameResult = name.withCString { source in + quarantineName.withCString { destination in + renameatx_np(directory, source, directory, destination, UInt32(RENAME_EXCL)) + } + } + guard renameResult == 0 else { + throw BrowserCLINamespaceReceiptStoreError.writeFailed("state could not be removed") + } + let quarantineDescriptor = quarantineName.withCString { value in + openat(directory, value, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + } + guard quarantineDescriptor >= 0 else { + throw BrowserCLINamespaceReceiptStoreError.writeFailed("quarantined state could not be reopened") + } + defer { Darwin.close(quarantineDescriptor) } + let quarantined = try Self.validatedFileMetadata(quarantineDescriptor) + guard opened.st_dev == quarantined.st_dev, opened.st_ino == quarantined.st_ino else { + _ = quarantineName.withCString { source in + name.withCString { destination in + renameatx_np(directory, source, directory, destination, UInt32(RENAME_EXCL)) + } + } + throw BrowserCLINamespaceReceiptStoreError.writeFailed( + "state changed before it could be removed safely" + ) + } + guard quarantineName.withCString({ unlinkat(directory, $0, 0) }) == 0 else { + throw BrowserCLINamespaceReceiptStoreError.writeFailed("quarantined state could not be removed") + } + _ = fsync(directory) + } + + static func validateCanonicalReceipt(_ data: Data) throws { + guard !data.isEmpty, data.count <= Int(self.maximumReceiptBytes) else { + throw BrowserCLINamespaceReceiptStoreError.invalidState( + "receipt must be nonempty and at most \(self.maximumReceiptBytes) bytes" + ) + } + let object: Any + do { + object = try JSONSerialization.jsonObject(with: data, options: []) + } catch { + throw BrowserCLINamespaceReceiptStoreError.invalidState("receipt is not valid JSON") + } + guard let receipt = object as? [String: Any], Set(receipt.keys) == ["payload", "signature"], + let payload = receipt["payload"] as? [String: Any], + Set(payload.keys) == [ + "schemaVersion", + "namespaceID", + "listenerInstanceID", + "listenerPublicKeySHA256", + "registryGenerationID", + "principal", + "issuedAtUnixMilliseconds", + "expiresAtUnixMilliseconds", + ], + let principal = payload["principal"] as? [String: Any], + Set(principal.keys) == [ + "effectiveUserIdentifier", + "teamIdentifier", + "bundleIdentifier", + "codeSignatureHash", + ] + else { + throw BrowserCLINamespaceReceiptStoreError.invalidState("receipt schema is not exact") + } + guard Self.exactInteger(payload["schemaVersion"]) == 1, + Self.validVersion4UUID(payload["namespaceID"]), + Self.validUUID(payload["listenerInstanceID"]), + Self.validHexDigest(payload["listenerPublicKeySHA256"]), + Self.validVersion4UUID(payload["registryGenerationID"]), + let issued = Self.exactInteger(payload["issuedAtUnixMilliseconds"]), issued > 0, + let expires = Self.exactInteger(payload["expiresAtUnixMilliseconds"]), expires > issued, + let effectiveUserIdentifier = Self.exactInteger(principal["effectiveUserIdentifier"]), + UInt32(exactly: effectiveUserIdentifier) != nil, + effectiveUserIdentifier == Int64(geteuid()), + Self.validSigningIdentifier(principal["teamIdentifier"], maximumUTF8Bytes: 128), + Self.validSigningIdentifier(principal["bundleIdentifier"], maximumUTF8Bytes: 512), + Self.validHex(principal["codeSignatureHash"], count: 40), + let signature = receipt["signature"] as? String, + let signatureData = Data(base64Encoded: signature), + signatureData.count == 64, + signatureData.base64EncodedString() == signature + else { + throw BrowserCLINamespaceReceiptStoreError.invalidState("receipt fields are not canonical") + } + guard let canonical = try? JSONSerialization.data( + withJSONObject: object, + options: [.sortedKeys, .withoutEscapingSlashes] + ), + canonical == data + else { + throw BrowserCLINamespaceReceiptStoreError.invalidState("receipt JSON is not canonical") + } + } + + private func openPrivateDirectory(createIfMissing: Bool) throws -> Int32 { + let directoryURL = self.fileURL.deletingLastPathComponent().standardizedFileURL + guard !self.fileURL.lastPathComponent.isEmpty, + self.fileURL.lastPathComponent != ".", + self.fileURL.lastPathComponent != ".." + else { + throw BrowserCLINamespaceReceiptStoreError.unsafeState("state path is invalid") + } + var info = stat() + if lstat(directoryURL.path, &info) != 0 { + guard createIfMissing, errno == ENOENT, + mkdir(directoryURL.path, S_IRWXU) == 0 + else { + if errno == ENOENT { + throw BrowserCLINamespaceReceiptStoreError.missing + } + throw BrowserCLINamespaceReceiptStoreError.unsafeState("state directory is unavailable") + } + } + let descriptor = directoryURL.path.withCString { + Darwin.open($0, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + } + guard descriptor >= 0 else { + throw BrowserCLINamespaceReceiptStoreError.unsafeState("state directory cannot be opened securely") + } + guard fstat(descriptor, &info) == 0, + info.st_mode & S_IFMT == S_IFDIR, + info.st_uid == geteuid(), + info.st_mode & 0o777 == 0o700 + else { + Darwin.close(descriptor) + throw BrowserCLINamespaceReceiptStoreError.unsafeState( + "state directory must be owned by the current user with mode 0700" + ) + } + do { + try Self.requireNoExtendedACL(descriptor) + } catch { + Darwin.close(descriptor) + throw error + } + return descriptor + } + + private func requireDestinationAbsent(in directory: Int32) throws { + let descriptor = self.fileURL.lastPathComponent.withCString { name in + openat(directory, name, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + } + if descriptor < 0 { + guard errno == ENOENT else { + throw BrowserCLINamespaceReceiptStoreError.unsafeState(Self.openFailureReason(errno)) + } + return + } + defer { Darwin.close(descriptor) } + _ = try Self.validatedFileMetadata(descriptor) + throw BrowserCLINamespaceReceiptStoreError.alreadyExists + } + + private static func validatedFileMetadata(_ descriptor: Int32, expectedSize: off_t? = nil) throws -> stat { + var info = stat() + guard fstat(descriptor, &info) == 0, + info.st_mode & S_IFMT == S_IFREG, + info.st_uid == geteuid(), + info.st_mode & 0o777 == 0o600, + info.st_nlink == 1, + info.st_size >= 0, + info.st_size <= self.maximumReceiptBytes, + expectedSize.map({ info.st_size == $0 }) ?? (info.st_size > 0) + else { + throw BrowserCLINamespaceReceiptStoreError.unsafeState( + "state must be one owner-only regular file with mode 0600 and a bounded size" + ) + } + try self.requireNoExtendedACL(descriptor) + return info + } + + private static func requireNoExtendedACL(_ descriptor: Int32) throws { + errno = 0 + guard let acl = acl_get_fd_np(descriptor, ACL_TYPE_EXTENDED) else { + if errno == ENOENT { + return + } + throw BrowserCLINamespaceReceiptStoreError.unsafeState( + "state access controls could not be inspected" + ) + } + acl_free(UnsafeMutableRawPointer(acl)) + throw BrowserCLINamespaceReceiptStoreError.unsafeState( + "state must not contain extended access-control entries" + ) + } + + private static func readExactly(_ descriptor: Int32, expectedSize: Int) throws -> Data { + var data = Data() + data.reserveCapacity(expectedSize) + var buffer = [UInt8](repeating: 0, count: min(expectedSize, 4096)) + while data.count < expectedSize { + let count = buffer.withUnsafeMutableBytes { bytes in + Darwin.read(descriptor, bytes.baseAddress, min(bytes.count, expectedSize - data.count)) + } + if count > 0 { + data.append(contentsOf: buffer.prefix(count)) + } else if count == -1, errno == EINTR { + continue + } else { + throw BrowserCLINamespaceReceiptStoreError.unsafeState("state changed while it was being read") + } + } + var trailingByte: UInt8 = 0 + while true { + let count = withUnsafeMutablePointer(to: &trailingByte) { Darwin.read(descriptor, $0, 1) } + if count == 0 { + return data + } + if count == -1, errno == EINTR { + continue + } + throw BrowserCLINamespaceReceiptStoreError.unsafeState("state changed while it was being read") + } + } + + private static func writeExactly(_ data: Data, to descriptor: Int32) throws { + try data.withUnsafeBytes { bytes in + guard let baseAddress = bytes.baseAddress else { return } + var offset = 0 + while offset < bytes.count { + let count = Darwin.write(descriptor, baseAddress.advanced(by: offset), bytes.count - offset) + if count > 0 { + offset += count + } else if count == -1, errno == EINTR { + continue + } else { + throw BrowserCLINamespaceReceiptStoreError.writeFailed("temporary state could not be written") + } + } + } + } + + private static func sameFile(_ lhs: stat, _ rhs: stat) -> Bool { + lhs.st_dev == rhs.st_dev && + lhs.st_ino == rhs.st_ino && + lhs.st_uid == rhs.st_uid && + lhs.st_mode == rhs.st_mode && + lhs.st_nlink == rhs.st_nlink && + lhs.st_size == rhs.st_size && + lhs.st_mtimespec.tv_sec == rhs.st_mtimespec.tv_sec && + lhs.st_mtimespec.tv_nsec == rhs.st_mtimespec.tv_nsec && + lhs.st_ctimespec.tv_sec == rhs.st_ctimespec.tv_sec && + lhs.st_ctimespec.tv_nsec == rhs.st_ctimespec.tv_nsec + } + + private static func exactInteger(_ value: Any?) -> Int64? { + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + let integer = Int64(exactly: number.doubleValue), + NSNumber(value: integer) == number + else { return nil } + return integer + } + + private static func validUUID(_ value: Any?) -> Bool { + guard let value = value as? String else { return false } + return UUID(uuidString: value) != nil && UUID(uuidString: value)?.uuidString == value + } + + private static func validVersion4UUID(_ value: Any?) -> Bool { + guard let value = value as? String, + let uuid = UUID(uuidString: value), + uuid.uuidString == value + else { return false } + let bytes = withUnsafeBytes(of: uuid.uuid) { Array($0) } + return bytes[6] >> 4 == 4 && bytes[8] >> 6 == 2 && bytes.contains { $0 != 0 } + } + + private static func validHexDigest(_ value: Any?) -> Bool { + self.validHex(value, count: 64) + } + + private static func validHex(_ value: Any?, count: Int) -> Bool { + guard let value = value as? String, value.count == count else { return false } + return value.utf8.allSatisfy { (48...57).contains($0) || (97...102).contains($0) } + } + + private static func validSigningIdentifier(_ value: Any?, maximumUTF8Bytes: Int) -> Bool { + guard let value = value as? String else { return false } + let characters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-")) + return (1...maximumUTF8Bytes).contains(value.utf8.count) && value.unicodeScalars.allSatisfy { + characters.contains($0) + } + } + + private static func openFailureReason(_ errorNumber: Int32) -> String { + switch errorNumber { + case ELOOP: "symbolic links are not accepted" + case ENOENT: "state does not exist" + default: "state cannot be opened securely" + } + } +} + +enum BrowserCLINamespaceReceiptStoreError: LocalizedError, ResultEnvelopeError, Equatable { + case missing + case alreadyExists + case unsafeState(String) + case invalidState(String) + case receiptMismatch + case writeFailed(String) + + nonisolated var errorDescription: String? { + switch self { + case .missing: + "No durable browser capability namespace receipt is stored." + case .alreadyExists: + "A durable browser capability namespace receipt already exists at that path." + case let .unsafeState(reason): + "Browser capability namespace state is unsafe: \(reason)." + case let .invalidState(reason): + "Browser capability namespace state is invalid: \(reason)." + case .receiptMismatch: + "Browser capability namespace state no longer matches the namespace that was closed." + case let .writeFailed(reason): + "Browser capability namespace state could not be updated: \(reason)." + } + } + + nonisolated var envelopeCode: ErrorCode? { + switch self { + case .missing, .unsafeState, .writeFailed: .FILE_IO_ERROR + case .alreadyExists, .invalidState, .receiptMismatch: .VALIDATION_ERROR + } + } + + nonisolated var envelopeEffect: ActionEffect? { + nil + } + + nonisolated var envelopeHint: String? { + switch self { + case .missing: + "Create a fresh authenticated Bridge 1.38 browser namespace before retrying." + case .alreadyExists: + "Close the existing Bridge namespace and remove its validated receipt before creating another one." + case .unsafeState, .invalidState: + "Remove the state only after closing its Bridge namespace, then create a fresh namespace." + case .receiptMismatch: + "Keep the newer receipt; do not remove or overwrite a namespace created by another invocation." + case .writeFailed: + "Keep the existing namespace state and retry after checking the private state directory." + } + } + + nonisolated var envelopeRetrySafe: Bool? { + true + } + + nonisolated var envelopeMutationDispatched: Bool? { + false + } +} diff --git a/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand.swift b/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand.swift index a3cb8aeff..7b0ea3a7c 100644 --- a/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand.swift +++ b/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand.swift @@ -47,7 +47,10 @@ InjectedRuntimeBackedCommand { var action = "status" var channel: String? var browserUrl: String? - var pageId: Int? + var pageId: String? + var pid: Int? + var windowId: Int? + var namespaceFile: String? var url: String? var navigationType: String? var uid: String? @@ -106,15 +109,26 @@ InjectedRuntimeBackedCommand { peekaboo browser connect --channel stable --foreground peekaboo browser new-page --url https://example.com peekaboo browser snapshot --page-id 2 --path /tmp/page.txt + peekaboo browser namespace-create --namespace-file /private/path/browser-namespace.json + peekaboo browser list-pages --namespace-file /private/path/browser-namespace.json + peekaboo browser bind-window --namespace-file /private/path/browser-namespace.json \ + --page-id bp1_0123456789abcdef0123456789abcdef --pid 123 --window-id 456 + peekaboo browser namespace-close --namespace-file /private/path/browser-namespace.json Browser actions reuse an existing exact connection by default and never auto-connect. Connecting or allowing any foreground browser effect requires explicit --foreground. + Durable namespace actions require an explicit owner-only receipt file and a negotiated Bridge 1.38 host. """ ) mutating func setRuntimeOptions(_ options: CommandRuntimeOptions) { var options = options - options.requiresBrowserMCP = true + // A durable bind-window request is owned exclusively by the authenticated Bridge + // namespace adapter. It must never make the legacy browser service eligible. + let usesNamespace = self.usesBrowserCapabilityNamespace + options.requiresBrowserMCP = !usesNamespace + options.requiresBrowserCapabilityNamespace = usesNamespace + options.ignoresCaptureEnginePreference = usesNamespace self.runtimeOptions = options } @@ -123,6 +137,10 @@ InjectedRuntimeBackedCommand { self.logger.setJsonOutputMode(self.jsonOutput) do { + if self.usesBrowserCapabilityNamespace { + try await self.runBrowserCapabilityNamespaceAction() + return + } let arguments = try self.arguments() if Self.actionMayMutate(self.action) { self.resolvedRuntime.beginInteractionMutation() @@ -151,6 +169,11 @@ InjectedRuntimeBackedCommand { let normalized = rawAction .trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: "-", with: "_") + // Binding mutates only the Bridge-owned capability namespace, not the desktop. + if normalized == BrowserProcessLocalAction.bindWindow || + BrowserCLINamespaceControlAction(rawValue: normalized) != nil { + return false + } guard let action = BrowserAction(rawValue: normalized) else { return false } switch action { case .status, .disconnect, .listPages, .waitFor, .snapshot, .console, .network, .screenshot: @@ -166,19 +189,30 @@ InjectedRuntimeBackedCommand { } func validateBeforeRuntime() throws { + if self.usesBrowserCapabilityNamespace { + try self.validateBrowserCapabilityNamespaceActionBeforeRuntime() + return + } _ = try self.arguments() } private func arguments() throws -> [String: Any] { - let normalizedAction = self.action - .trimmingCharacters(in: .whitespacesAndNewlines) - .replacingOccurrences(of: "-", with: "_") + let normalizedAction = self.normalizedAction if normalizedAction == BrowserProcessLocalAction.bindWindow { throw BrowserCommandInputError.nativeWindowBindingRequiresNamespace() } + if BrowserCLINamespaceControlAction(rawValue: normalizedAction) != nil { + throw ValidationError("Browser namespace lifecycle actions require the Bridge namespace adapter") + } guard BrowserAction(rawValue: normalizedAction) != nil else { throw ValidationError("Unsupported browser action '\(self.action)'") } + if self.namespaceFile != nil, normalizedAction == BrowserAction.call.rawValue { + throw ValidationError("--namespace-file does not support the raw browser call action") + } + if self.pid != nil || self.windowId != nil { + throw ValidationError("--pid and --window-id are supported only by browser bind-window") + } if let channel, BrowserMCPChannel(rawValue: channel) == nil { let choices = BrowserMCPChannel.allCases.map(\.rawValue).joined(separator: "|") throw ValidationError("Unsupported browser channel '\(channel)' (expected \(choices))") @@ -192,9 +226,10 @@ InjectedRuntimeBackedCommand { var arguments: [String: Any] = ["action": normalizedAction] self.add(self.channel, as: "channel", to: &arguments) self.add(self.browserUrl, as: "browser_url", to: &arguments) - self.add(self.pageId, as: "page_id", to: &arguments) + try self.addPageID(to: &arguments) self.add(self.url, as: "url", to: &arguments) self.add(self.navigationType, as: "navigation_type", to: &arguments) + try self.validateNamespaceElementReferences() self.add(self.uid, as: "uid", to: &arguments) self.add(self.toUid, as: "to_uid", to: &arguments) self.add(self.text, as: "text", to: &arguments) @@ -253,6 +288,37 @@ InjectedRuntimeBackedCommand { return arguments } + private func addPageID(to arguments: inout [String: Any]) throws { + guard let pageId = self.pageId else { return } + if self.namespaceFile != nil { + guard Self.isOpaqueBrowserPageReference(pageId) else { + throw BrowserCLINamespaceCommandError.invalidPageReference + } + self.add(pageId, as: "page_id", to: &arguments) + return + } + guard let legacyPageID = Int(pageId) else { + throw ValidationError("--page-id must be an integer for browser action '\(self.action)'") + } + self.add(legacyPageID, as: "page_id", to: &arguments) + } + + private func validateNamespaceElementReferences() throws { + guard self.namespaceFile != nil else { return } + if let uid = self.uid, !Self.isOpaqueBrowserElementReference(uid) { + throw ValidationError("--uid must be an opaque be1 capability from this browser namespace") + } + if let toUid = self.toUid, !Self.isOpaqueBrowserElementReference(toUid) { + throw ValidationError("--to-uid must be an opaque be1 capability from this browser namespace") + } + } + + var normalizedAction: String { + self.action + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "-", with: "_") + } + private func add(_ value: String?, as key: String, to arguments: inout [String: Any]) { guard let value, !value.isEmpty else { return } arguments[key] = value @@ -291,7 +357,18 @@ extension BrowserCommand: CommanderSignatureProviding { help: "Exact loopback DevTools HTTP endpoint for connect", long: "browser-url" ), - .commandOption("pageId", help: "Chrome DevTools page ID", long: "page-id"), + .commandOption( + "pageId", + help: "Page ID; bind-window requires an opaque bp1 capability", + long: "page-id" + ), + .commandOption("pid", help: "Exact Chrome PID for bind-window", long: "pid"), + .commandOption("windowId", help: "Exact WindowServer ID for bind-window", long: "window-id"), + .commandOption( + "namespaceFile", + help: "Absolute owner-only Bridge browser namespace receipt file (mode 0600)", + long: "namespace-file" + ), .commandOption("url", help: "URL for navigate/new-page", long: "url"), .commandOption( "navigationType", @@ -382,7 +459,10 @@ extension BrowserCommand: CommanderBindableCommand { self.action = values.positionalValue(at: 0) ?? "status" self.channel = values.singleOption("channel") self.browserUrl = values.singleOption("browserUrl") - self.pageId = try values.decodeOption("pageId", as: Int.self) + self.pageId = values.singleOption("pageId") + self.pid = try values.decodeOption("pid", as: Int.self) + self.windowId = try values.decodeOption("windowId", as: Int.self) + self.namespaceFile = values.singleOption("namespaceFile") self.url = values.singleOption("url") self.navigationType = values.singleOption("navigationType") self.uid = values.singleOption("uid") diff --git a/Apps/CLI/Tests/CLIRuntimeTests/InvalidInputOrderingCLITests.swift b/Apps/CLI/Tests/CLIRuntimeTests/InvalidInputOrderingCLITests.swift index 477f17bf1..cdd40c07b 100644 --- a/Apps/CLI/Tests/CLIRuntimeTests/InvalidInputOrderingCLITests.swift +++ b/Apps/CLI/Tests/CLIRuntimeTests/InvalidInputOrderingCLITests.swift @@ -124,9 +124,8 @@ struct InvalidInputOrderingCLITests { JSONCase( arguments: ["browser", "bind-window", "--json"], code: "VALIDATION_ERROR", - message: "browser bind-window is not available to standalone CLI invocations.", - hint: "Use one process-local MCP or Agent browser session. Durable CLI binding requires an " + - "authenticated Bridge 1.38 browser namespace receipt." + message: "Browser namespace actions require an explicit --namespace-file.", + hint: "Pass the exact owner-private receipt file created for this authenticated Bridge namespace." ), JSONCase( arguments: [ diff --git a/Apps/CLI/Tests/CoreCLITests/BrowserCLINamespaceCommandTests.swift b/Apps/CLI/Tests/CoreCLITests/BrowserCLINamespaceCommandTests.swift new file mode 100644 index 000000000..6441da78b --- /dev/null +++ b/Apps/CLI/Tests/CoreCLITests/BrowserCLINamespaceCommandTests.swift @@ -0,0 +1,569 @@ +import Commander +import Foundation +import TachikomaMCP +import Testing +@testable import PeekabooCLI + +@MainActor +struct BrowserCLINamespaceCommandTests { + private static let pageReference = "bp1_0123456789abcdef0123456789abcdef" + + @Test + func `bind window parses exactly three selectors and disables legacy browser routing`() throws { + let command = try Self.command(options: [ + "pageId": [Self.pageReference], + "pid": ["123"], + "windowId": ["456"], + ]) + + #expect(try command.namespaceBindWindowRequest() == BrowserCLINamespaceBindWindowRequest( + pageID: Self.pageReference, + processIdentifier: 123, + windowID: 456 + )) + #expect(!command.runtimeOptions.requiresBrowserMCP) + #expect(command.runtimeOptions.requiresBrowserCapabilityNamespace) + #expect(command.runtimeOptions.ignoresCaptureEnginePreference) + #expect(!BrowserCommand.actionMayMutate("bind-window")) + #expect(!BrowserCommand.actionMayMutate("bind_window")) + } + + @Test + func `bind window requires every selector before runtime discovery`() throws { + for omitted in ["pageId", "pid", "windowId"] { + var options = [ + "pageId": [Self.pageReference], + "pid": ["123"], + "windowId": ["456"], + ] + options.removeValue(forKey: omitted) + let command = try Self.command(options: options) + #expect(throws: BrowserCLINamespaceCommandError.missingSelectors) { + try command.validateBeforeRuntime() + } + } + } + + @Test(arguments: [ + "bp1_0123456789abcdef0123456789abcde", + "bp1_0123456789abcdef0123456789abcdef0", + "bp1_0123456789abcdef0123456789abcdeg", + "bp1_0123456789ABCDEF0123456789ABCDEF", + "1", + "", + ]) + func `bind window refuses noncanonical page capabilities`(_ pageReference: String) throws { + let command = try Self.command(options: [ + "pageId": [pageReference], + "pid": ["123"], + "windowId": ["456"], + ]) + #expect(throws: BrowserCLINamespaceCommandError.invalidPageReference) { + try command.validateBeforeRuntime() + } + } + + @Test(arguments: ["0", "-1", "2147483648"]) + func `bind window refuses invalid process selectors`(_ processIdentifier: String) throws { + let command = try Self.command(options: [ + "pageId": [Self.pageReference], + "pid": [processIdentifier], + "windowId": ["456"], + ]) + #expect(throws: BrowserCLINamespaceCommandError.invalidProcessIdentifier) { + try command.validateBeforeRuntime() + } + } + + @Test(arguments: ["0", "-1", "4294967296"]) + func `bind window refuses invalid native window selectors`(_ windowID: String) throws { + let command = try Self.command(options: [ + "pageId": [Self.pageReference], + "pid": ["123"], + "windowId": [windowID], + ]) + #expect(throws: BrowserCLINamespaceCommandError.invalidWindowID) { + try command.validateBeforeRuntime() + } + } + + @Test(arguments: IrrelevantArgument.fixtures) + private func `bind window rejects every irrelevant browser argument`(_ argument: IrrelevantArgument) throws { + var parsed = ParsedValues( + positional: ["bind-window"], + options: [ + "pageId": [Self.pageReference], + "pid": ["123"], + "windowId": ["456"], + "namespaceFile": ["/private/tmp/fixture-browser-namespace.json"], + ], + flags: [] + ) + argument.apply(to: &parsed) + let command = try CommanderCLIBinder.instantiateCommand(ofType: BrowserCommand.self, parsedValues: parsed) + #expect(throws: BrowserCLINamespaceCommandError.self) { + try command.validateBeforeRuntime() + } + } + + @Test + func `bind window refuses local routing while allowing output and exact socket controls`() throws { + let local = try Self.command( + options: [ + "pageId": [Self.pageReference], + "pid": ["123"], + "windowId": ["456"], + ], + flags: ["no-remote"] + ) + #expect(throws: BrowserCLINamespaceCommandError.localExecutionRefused) { + try local.validateBeforeRuntime() + } + + let ambient = try Self.command(options: [ + "pageId": [Self.pageReference], + "pid": ["123"], + "windowId": ["456"], + ]) + #expect(throws: BrowserCLINamespaceCommandError.localExecutionRefused) { + try ambient.namespaceBindWindowRequest(environment: ["PEEKABOO_NO_REMOTE": "1"]) + } + + let remote = try Self.command( + options: [ + "pageId": [Self.pageReference], + "pid": ["123"], + "windowId": ["456"], + "bridge-socket": ["/private/tmp/fixture.sock"], + "namespaceFile": ["/private/tmp/fixture-browser-namespace.json"], + ], + flags: ["jsonOutput", "verbose"] + ) + #expect(try remote.namespaceBindWindowRequest().windowID == 456) + #expect(remote.runtimeOptions.bridgeSocketPath == "/private/tmp/fixture.sock") + } + + @Test + func `ordinary browser actions retain numeric page IDs and legacy browser routing`() throws { + let command = try CommanderCLIBinder.instantiateCommand( + ofType: BrowserCommand.self, + parsedValues: ParsedValues( + positional: ["snapshot"], + options: ["pageId": ["7"]], + flags: [] + ) + ) + #expect(command.pageId == "7") + #expect(command.runtimeOptions.requiresBrowserMCP) + try command.validateBeforeRuntime() + + let opaque = try CommanderCLIBinder.instantiateCommand( + ofType: BrowserCommand.self, + parsedValues: ParsedValues( + positional: ["snapshot"], + options: ["pageId": [Self.pageReference]], + flags: [] + ) + ) + #expect(throws: ValidationError.self) { + try opaque.validateBeforeRuntime() + } + } + + @Test + func `bind window requires an explicit existing namespace file before runtime discovery`() throws { + let missingOption = try CommanderCLIBinder.instantiateCommand( + ofType: BrowserCommand.self, + parsedValues: ParsedValues( + positional: ["bind-window"], + options: [ + "pageId": [Self.pageReference], + "pid": ["123"], + "windowId": ["456"], + ], + flags: [] + ) + ) + #expect(throws: BrowserCLINamespaceCommandError.missingNamespaceFile) { + try missingOption.validateBeforeRuntime() + } + + let privateDirectory = FileManager.default.temporaryDirectory.appendingPathComponent( + "peekaboo-missing-namespace-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: privateDirectory, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: privateDirectory.path + ) + defer { try? FileManager.default.removeItem(at: privateDirectory) } + let missingPath = privateDirectory.appendingPathComponent("receipt.json").path + let missingFile = try Self.command(options: [ + "pageId": [Self.pageReference], + "pid": ["123"], + "windowId": ["456"], + "namespaceFile": [missingPath], + ]) + #expect(throws: BrowserCLINamespaceReceiptStoreError.missing) { + try missingFile.validateBeforeRuntime() + } + } + + @Test + func `adapter seam keeps create bind and close in one explicit authority owner`() async throws { + let receipt = Data("canonical-receipt-fixture".utf8) + let adapter = RecordingNamespaceAdapter(receipt: receipt) + let creation = try await adapter.createNamespace() + #expect(creation.namespaceReceiptData == receipt) + _ = try await adapter.bindWindow( + request: BrowserCLINamespaceBindWindowRequest( + pageID: Self.pageReference, + processIdentifier: 123, + windowID: 456 + ), + namespaceReceiptData: creation.namespaceReceiptData + ) + _ = try await adapter.executeAction( + request: BrowserCLINamespaceHighLevelActionRequest( + action: .listPages, + arguments: [:], + executionMode: .backgroundOnly + ), + namespaceReceiptData: creation.namespaceReceiptData + ) + _ = try await adapter.closeNamespace(namespaceReceiptData: creation.namespaceReceiptData) + #expect(await adapter.operations == ["create", "bind", "execute", "close"]) + } + + @Test + func `namespace lifecycle and closed high level actions select only namespace routing`() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "peekaboo-cli-namespace-command-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) + defer { try? FileManager.default.removeItem(at: root) } + let namespacePath = root.appendingPathComponent("namespace.json").path + + let create = try CommanderCLIBinder.instantiateCommand( + ofType: BrowserCommand.self, + parsedValues: ParsedValues( + positional: ["namespace-create"], + options: ["namespaceFile": [namespacePath]], + flags: [] + ) + ) + #expect(create.runtimeOptions.requiresBrowserCapabilityNamespace) + #expect(!create.runtimeOptions.requiresBrowserMCP) + try create.validateBeforeRuntime() + + let store = try create.namespaceReceiptStore() + try store.save(BrowserCLINamespaceReceiptStoreTests.fixture()) + + let listPages = try CommanderCLIBinder.instantiateCommand( + ofType: BrowserCommand.self, + parsedValues: ParsedValues( + positional: ["list-pages"], + options: ["namespaceFile": [namespacePath]], + flags: [] + ) + ) + try listPages.validateBeforeRuntime() + let request = try listPages.namespaceHighLevelActionRequest() + #expect(request.action.rawValue == BrowserAction.listPages.rawValue) + #expect(request.arguments.isEmpty) + if case .backgroundOnly = request.executionMode {} else { + Issue.record("Namespace list-pages must remain background-only") + } + + let snapshot = try CommanderCLIBinder.instantiateCommand( + ofType: BrowserCommand.self, + parsedValues: ParsedValues( + positional: ["snapshot"], + options: [ + "namespaceFile": [namespacePath], + "pageId": [Self.pageReference], + ], + flags: [] + ) + ) + let snapshotRequest = try snapshot.namespaceHighLevelActionRequest() + #expect(snapshotRequest.arguments["page_id"] as? String == Self.pageReference) + + let rawPage = try CommanderCLIBinder.instantiateCommand( + ofType: BrowserCommand.self, + parsedValues: ParsedValues( + positional: ["snapshot"], + options: [ + "namespaceFile": [namespacePath], + "pageId": ["7"], + ], + flags: [] + ) + ) + #expect(throws: BrowserCLINamespaceCommandError.invalidPageReference) { + try rawPage.namespaceHighLevelActionRequest() + } + + let foregroundConnect = try CommanderCLIBinder.instantiateCommand( + ofType: BrowserCommand.self, + parsedValues: ParsedValues( + positional: ["connect"], + options: ["namespaceFile": [namespacePath]], + flags: ["foreground"] + ) + ) + let connectRequest = try foregroundConnect.namespaceHighLevelActionRequest() + #expect(connectRequest.action == .connect) + if case .foregroundAllowed = connectRequest.executionMode {} else { + Issue.record("Explicit foreground consent must reach the namespace adapter") + } + + let close = try CommanderCLIBinder.instantiateCommand( + ofType: BrowserCommand.self, + parsedValues: ParsedValues( + positional: ["namespace-close"], + options: ["namespaceFile": [namespacePath]], + flags: [] + ) + ) + try close.validateBeforeRuntime() + } + + @Test + func `namespace routing refuses raw call unknown actions and lifecycle argument leakage`() throws { + let namespacePath = "/private/tmp/fixture-browser-namespace.json" + for action in ["call", "frobnicate"] { + let command = try CommanderCLIBinder.instantiateCommand( + ofType: BrowserCommand.self, + parsedValues: ParsedValues( + positional: [action], + options: [ + "namespaceFile": [namespacePath], + "mcpTool": ["get_tab_id"], + ], + flags: [] + ) + ) + #expect(throws: (any Error).self) { + try command.namespaceHighLevelActionRequest() + } + } + + let createWithSelector = try CommanderCLIBinder.instantiateCommand( + ofType: BrowserCommand.self, + parsedValues: ParsedValues( + positional: ["namespace-create"], + options: [ + "namespaceFile": [namespacePath], + "pageId": [Self.pageReference], + ], + flags: [] + ) + ) + #expect(throws: BrowserCLINamespaceCommandError.self) { + try createWithSelector.validateBrowserCapabilityNamespaceActionBeforeRuntime() + } + } + + @Test + func `namespace lifecycle persists creation and removes only confirmed close`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "peekaboo-cli-namespace-lifecycle-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) + defer { try? FileManager.default.removeItem(at: root) } + let store = BrowserCLINamespaceReceiptStore(fileURL: root.appendingPathComponent("namespace.json")) + let receipt = try BrowserCLINamespaceReceiptStoreTests.fixture() + + let createAdapter = RecordingNamespaceAdapter(receipt: receipt) + let creation = try await BrowserCLINamespaceLifecycle.create(adapter: createAdapter, store: store) + #expect(!creation.isError) + #expect(try store.load() == receipt) + + let refusingClose = RecordingNamespaceAdapter(receipt: receipt, closeIsError: true) + let refusal = try await BrowserCLINamespaceLifecycle.close(adapter: refusingClose, store: store) + #expect(refusal.isError) + #expect(try store.load() == receipt) + + let closingAdapter = RecordingNamespaceAdapter(receipt: receipt) + let close = try await BrowserCLINamespaceLifecycle.close(adapter: closingAdapter, store: store) + #expect(!close.isError) + #expect(!FileManager.default.fileExists(atPath: store.fileURL.path)) + } + + @Test + func `post dispatch namespace failures never claim no mutation`() { + let rolledBack = BrowserCLINamespacePostDispatchError.creationRolledBack("fixture") + let rollbackFailed = BrowserCLINamespacePostDispatchError.creationRollbackFailed("fixture") + let cleanupFailed = BrowserCLINamespacePostDispatchError.closedReceiptCleanupFailed("fixture") + + #expect(rolledBack.envelopeMutationDispatched == true) + #expect(rolledBack.envelopeRetrySafe == true) + #expect(rollbackFailed.envelopeMutationDispatched == true) + #expect(rollbackFailed.envelopeRetrySafe == false) + #expect(cleanupFailed.envelopeMutationDispatched == true) + #expect(cleanupFailed.envelopeRetrySafe == false) + #expect(cleanupFailed.envelopeEffect == .partial) + } + + private static func command( + options: [String: [String]], + flags: Set = [] + ) throws -> BrowserCommand { + var options = options + if options["namespaceFile"] == nil { + options["namespaceFile"] = ["/private/tmp/fixture-browser-namespace.json"] + } + return try CommanderCLIBinder.instantiateCommand( + ofType: BrowserCommand.self, + parsedValues: ParsedValues( + positional: ["bind-window"], + options: options, + flags: flags + ) + ) + } +} + +private actor RecordingNamespaceAdapter: BrowserCLINamespaceBridgeAdapter { + let receipt: Data + let closeIsError: Bool + var operations: [String] = [] + + init(receipt: Data, closeIsError: Bool = false) { + self.receipt = receipt + self.closeIsError = closeIsError + } + + func createNamespace() async -> BrowserCLINamespaceCreateResult { + self.operations.append("create") + return BrowserCLINamespaceCreateResult( + namespaceReceiptData: self.receipt, + response: .text("created") + ) + } + + func bindWindow( + request: BrowserCLINamespaceBindWindowRequest, + namespaceReceiptData: Data + ) async throws -> ToolResponse { + guard request.pageID == "bp1_0123456789abcdef0123456789abcdef", + namespaceReceiptData == self.receipt + else { + throw BrowserCLINamespaceCommandError.invalidPageReference + } + self.operations.append("bind") + return .text("bound") + } + + func closeNamespace(namespaceReceiptData: Data) async throws -> ToolResponse { + guard namespaceReceiptData == self.receipt else { + throw BrowserCLINamespaceReceiptStoreError.invalidState("wrong receipt") + } + self.operations.append("close") + if self.closeIsError { + return ToolResponse(content: [], isError: true) + } + return .text("closed") + } + + func executeAction( + request: BrowserCLINamespaceHighLevelActionRequest, + namespaceReceiptData: Data + ) async throws -> ToolResponse { + guard request.action.rawValue == BrowserAction.listPages.rawValue, + namespaceReceiptData == self.receipt + else { + throw BrowserCLINamespaceCommandError.unsupportedNamespaceAction(request.action.rawValue) + } + self.operations.append("execute") + return .text("executed") + } +} + +private struct IrrelevantArgument: Sendable, CustomTestStringConvertible { + let optionLabel: String? + let optionValue: String? + let flagLabel: String? + + var testDescription: String { + self.optionLabel ?? self.flagLabel ?? "invalid-fixture" + } + + static let fixtures: [Self] = [ + .option("channel", "stable"), + .option("browserUrl", "http://127.0.0.1:9222"), + .option("url", "https://example.com"), + .option("navigationType", "url"), + .option("uid", "be1_0123456789abcdef0123456789abcdef"), + .option("toUid", "be1_0123456789abcdef0123456789abcdef"), + .option("text", "fixture"), + .option("value", "fixture"), + .option("key", "Return"), + .option("submitKey", "Return"), + .option("dialogAction", "accept"), + .flag("includeSnapshot"), + .flag("double"), + .flag("bringToFront"), + .flag("noBringToFront"), + .flag("background"), + .flag("foreground"), + .option("timeout", "1s"), + .option("pageSize", "10"), + .option("pageIndex", "1"), + .option("types", "error"), + .option("resourceTypes", "script"), + .flag("includePreserved"), + .option("messageId", "1"), + .option("requestId", "1"), + .option("requestFilePath", "/private/tmp/request"), + .option("responseFilePath", "/private/tmp/response"), + .option("path", "/private/tmp/path"), + .option("format", "png"), + .option("quality", "80"), + .flag("fullPage"), + .option("traceAction", "start"), + .flag("noReload"), + .flag("noAutoStop"), + .option("insightSetId", "fixture"), + .option("insightName", "fixture"), + .option("mcpTool", "fixture"), + .option("mcpArgsJson", "{}"), + .option("inputStrategy", "actionOnly"), + .option("captureEngine", "classic"), + ] + + static func option(_ label: String, _ value: String) -> Self { + Self(optionLabel: label, optionValue: value, flagLabel: nil) + } + + static func flag(_ label: String) -> Self { + Self(optionLabel: nil, optionValue: nil, flagLabel: label) + } + + func apply(to values: inout ParsedValues) { + if let optionLabel, let optionValue { + values.options[optionLabel] = [optionValue] + } + if let flagLabel { + values.flags.insert(flagLabel) + } + } +} diff --git a/Apps/CLI/Tests/CoreCLITests/BrowserCLINamespaceReceiptStoreTests.swift b/Apps/CLI/Tests/CoreCLITests/BrowserCLINamespaceReceiptStoreTests.swift new file mode 100644 index 000000000..74faac56d --- /dev/null +++ b/Apps/CLI/Tests/CoreCLITests/BrowserCLINamespaceReceiptStoreTests.swift @@ -0,0 +1,300 @@ +import Darwin +import Foundation +import Testing +@testable import PeekabooCLI + +struct BrowserCLINamespaceReceiptStoreTests { + @Test + func `store publishes and reloads only canonical mode 0600 receipt bytes`() throws { + let fixture = try Self.fixture() + let (store, directory) = try Self.makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + + try store.validateCanSave() + try store.save(fixture) + #expect(try store.load() == fixture) + + var info = stat() + #expect(lstat(store.fileURL.path, &info) == 0) + #expect(info.st_mode & S_IFMT == S_IFREG) + #expect(info.st_uid == geteuid()) + #expect(info.st_mode & 0o777 == 0o600) + #expect(info.st_nlink == 1) + #expect(throws: BrowserCLINamespaceReceiptStoreError.alreadyExists) { + try store.validateCanSave() + } + #expect(throws: BrowserCLINamespaceReceiptStoreError.alreadyExists) { + try store.save(fixture) + } + #expect(try store.load() == fixture) + } + + @Test + func `store refuses symlink permissive hardlinked and nonregular state`() throws { + let fixture = try Self.fixture() + + do { + let (store, directory) = try Self.makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + let target = directory.appendingPathComponent("target") + try fixture.write(to: target) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: target.path) + try FileManager.default.createSymbolicLink(at: store.fileURL, withDestinationURL: target) + #expect(throws: BrowserCLINamespaceReceiptStoreError.self) { try store.load() } + #expect(throws: BrowserCLINamespaceReceiptStoreError.self) { try store.save(fixture) } + } + + do { + let (store, directory) = try Self.makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + try fixture.write(to: store.fileURL) + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: store.fileURL.path) + #expect(throws: BrowserCLINamespaceReceiptStoreError.self) { try store.load() } + #expect(throws: BrowserCLINamespaceReceiptStoreError.self) { try store.save(fixture) } + } + + do { + let (store, directory) = try Self.makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + let alias = directory.appendingPathComponent("alias") + try fixture.write(to: store.fileURL) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: store.fileURL.path) + #expect(link(store.fileURL.path, alias.path) == 0) + #expect(throws: BrowserCLINamespaceReceiptStoreError.self) { try store.load() } + #expect(throws: BrowserCLINamespaceReceiptStoreError.self) { try store.save(fixture) } + } + + do { + let (store, directory) = try Self.makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + #expect(mkfifo(store.fileURL.path, S_IRUSR | S_IWUSR) == 0) + #expect(throws: BrowserCLINamespaceReceiptStoreError.self) { try store.load() } + #expect(throws: BrowserCLINamespaceReceiptStoreError.self) { try store.save(fixture) } + } + } + + @Test + func `store refuses nonprivate state directories`() throws { + let fixture = try Self.fixture() + let (store, directory) = try Self.makeStore(directoryMode: 0o755) + defer { try? FileManager.default.removeItem(at: directory) } + + #expect(throws: BrowserCLINamespaceReceiptStoreError.self) { try store.save(fixture) } + + let (aclStore, aclDirectory) = try Self.makeStore() + defer { try? FileManager.default.removeItem(at: aclDirectory) } + let chmod = Process() + chmod.executableURL = URL(fileURLWithPath: "/bin/chmod") + chmod.arguments = ["+a", "everyone allow delete_child", aclDirectory.path] + try chmod.run() + chmod.waitUntilExit() + #expect(chmod.terminationStatus == 0) + #expect(throws: BrowserCLINamespaceReceiptStoreError.self) { try aclStore.save(fixture) } + } + + @Test + func `store rejects widened ACLs and oversized files before decoding`() throws { + let fixture = try Self.fixture() + let (aclStore, aclDirectory) = try Self.makeStore() + defer { try? FileManager.default.removeItem(at: aclDirectory) } + try fixture.write(to: aclStore.fileURL) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: aclStore.fileURL.path) + let chmod = Process() + chmod.executableURL = URL(fileURLWithPath: "/bin/chmod") + chmod.arguments = ["+a", "everyone allow read", aclStore.fileURL.path] + try chmod.run() + chmod.waitUntilExit() + #expect(chmod.terminationStatus == 0) + #expect(throws: BrowserCLINamespaceReceiptStoreError.self) { try aclStore.load() } + + let (oversizedStore, oversizedDirectory) = try Self.makeStore() + defer { try? FileManager.default.removeItem(at: oversizedDirectory) } + let oversized = Data( + repeating: UInt8(ascii: "x"), + count: Int(BrowserCLINamespaceReceiptStore.maximumReceiptBytes) + 1 + ) + try oversized.write(to: oversizedStore.fileURL) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: oversizedStore.fileURL.path + ) + #expect(throws: BrowserCLINamespaceReceiptStoreError.self) { try oversizedStore.load() } + } + + @Test + func `explicit state paths expand tilde but reject relative and nul paths`() throws { + let relative = "fixture/namespace.json" + #expect(throws: BrowserCLINamespaceReceiptStoreError.self) { + _ = try BrowserCLINamespaceReceiptStore(resolvingPath: relative) + } + #expect(throws: BrowserCLINamespaceReceiptStoreError.self) { + _ = try BrowserCLINamespaceReceiptStore(resolvingPath: "/private/tmp/bad\0path") + } + + let expanded = try BrowserCLINamespaceReceiptStore(resolvingPath: "~/.peekaboo/fixture.json") + #expect(expanded.fileURL.path == FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".peekaboo/fixture.json").path) + } + + @Test + func `store removes only validated owner private state`() throws { + let fixture = try Self.fixture() + let (store, directory) = try Self.makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + + try store.save(fixture) + let newerReceipt = try Self.fixture(namespaceID: "BBBBBBBB-CCCC-4DDD-8EEE-FFFFFFFFFFFF") + #expect(throws: BrowserCLINamespaceReceiptStoreError.receiptMismatch) { + try store.remove(expectedReceipt: newerReceipt) + } + #expect(try store.load() == fixture) + try store.remove(expectedReceipt: fixture) + #expect(!FileManager.default.fileExists(atPath: store.fileURL.path)) + try store.remove(expectedReceipt: fixture) + } + + @Test + func `canonical parser rejects whitespace key drift field drift and oversized state`() throws { + let fixture = try Self.fixture() + var whitespacePrefixed = Data(" \n".utf8) + whitespacePrefixed.append(fixture) + #expect(throws: BrowserCLINamespaceReceiptStoreError.self) { + try BrowserCLINamespaceReceiptStore.validateCanonicalReceipt(whitespacePrefixed) + } + + let object = try #require(JSONSerialization.jsonObject(with: fixture) as? [String: Any]) + var extra = object + extra["unexpected"] = true + let extraData = try JSONSerialization.data(withJSONObject: extra, options: [.sortedKeys]) + #expect(throws: BrowserCLINamespaceReceiptStoreError.self) { + try BrowserCLINamespaceReceiptStore.validateCanonicalReceipt(extraData) + } + + var missing = object + missing.removeValue(forKey: "signature") + let missingData = try JSONSerialization.data(withJSONObject: missing, options: [.sortedKeys]) + #expect(throws: BrowserCLINamespaceReceiptStoreError.self) { + try BrowserCLINamespaceReceiptStore.validateCanonicalReceipt(missingData) + } + + let oversized = Data( + repeating: UInt8(ascii: "x"), + count: Int(BrowserCLINamespaceReceiptStore.maximumReceiptBytes) + 1 + ) + #expect(throws: BrowserCLINamespaceReceiptStoreError.self) { + try BrowserCLINamespaceReceiptStore.validateCanonicalReceipt(oversized) + } + } + + @Test + func `canonical parser binds the current uid and exact receipt field forms`() throws { + let fixture = try Self.fixture() + let object = try #require(JSONSerialization.jsonObject(with: fixture) as? [String: Any]) + let payload = try #require(object["payload"] as? [String: Any]) + let principal = try #require(payload["principal"] as? [String: Any]) + + for mutation in ReceiptMutation.allCases { + var mutatedObject = object + var mutatedPayload = payload + var mutatedPrincipal = principal + mutation.apply( + object: &mutatedObject, + payload: &mutatedPayload, + principal: &mutatedPrincipal + ) + mutatedPayload["principal"] = mutatedPrincipal + mutatedObject["payload"] = mutatedPayload + let data = try JSONSerialization.data( + withJSONObject: mutatedObject, + options: [.sortedKeys, .withoutEscapingSlashes] + ) + #expect(throws: BrowserCLINamespaceReceiptStoreError.self) { + try BrowserCLINamespaceReceiptStore.validateCanonicalReceipt(data) + } + } + } + + static func fixture( + namespaceID: String = "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE" + ) throws -> Data { + try JSONSerialization.data(withJSONObject: [ + "payload": [ + "schemaVersion": 1, + "namespaceID": namespaceID, + "listenerInstanceID": "11111111-2222-3333-4444-555555555555", + "listenerPublicKeySHA256": String(repeating: "a", count: 64), + "registryGenerationID": "66666666-7777-4888-9999-AAAAAAAAAAAA", + "principal": [ + "effectiveUserIdentifier": Int(geteuid()), + "teamIdentifier": "FIXTURETEAM", + "bundleIdentifier": "boo.peekaboo.cli.fixture", + "codeSignatureHash": String(repeating: "b", count: 40), + ], + "issuedAtUnixMilliseconds": 1_800_000_000_000, + "expiresAtUnixMilliseconds": 1_800_000_060_000, + ], + "signature": Data(repeating: 7, count: 64).base64EncodedString(), + ], options: [.sortedKeys, .withoutEscapingSlashes]) + } + + private static func makeStore(directoryMode: Int = 0o700) throws + -> (BrowserCLINamespaceReceiptStore, URL) { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "peekaboo-browser-namespace-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: false, + attributes: [.posixPermissions: directoryMode] + ) + try FileManager.default.setAttributes( + [.posixPermissions: directoryMode], + ofItemAtPath: directory.path + ) + return ( + BrowserCLINamespaceReceiptStore(fileURL: directory.appendingPathComponent("receipt.json")), + directory + ) + } +} + +private enum ReceiptMutation: CaseIterable { + case schemaVersion + case namespaceID + case listenerInstanceID + case listenerDigest + case generationID + case issuedAt + case expiration + case userIdentifier + case teamIdentifier + case bundleIdentifier + case codeSignatureHash + case signature + case noncanonicalSignature + + func apply( + object: inout [String: Any], + payload: inout [String: Any], + principal: inout [String: Any] + ) { + switch self { + case .schemaVersion: payload["schemaVersion"] = 2 + case .namespaceID: payload["namespaceID"] = "not-a-uuid" + case .listenerInstanceID: payload["listenerInstanceID"] = "not-a-uuid" + case .listenerDigest: payload["listenerPublicKeySHA256"] = String(repeating: "A", count: 64) + case .generationID: payload["registryGenerationID"] = "not-a-uuid" + case .issuedAt: payload["issuedAtUnixMilliseconds"] = true + case .expiration: payload["expiresAtUnixMilliseconds"] = payload["issuedAtUnixMilliseconds"] + case .userIdentifier: principal["effectiveUserIdentifier"] = Int(geteuid()) + 1 + case .teamIdentifier: principal["teamIdentifier"] = "" + case .bundleIdentifier: principal["bundleIdentifier"] = "invalid_bundle" + case .codeSignatureHash: principal["codeSignatureHash"] = String(repeating: "g", count: 40) + case .signature: object["signature"] = Data(repeating: 7, count: 63).base64EncodedString() + case .noncanonicalSignature: + let canonical = Data(repeating: 7, count: 64).base64EncodedString() + object["signature"] = String(canonical.dropLast(3)) + "x==" + } + } +} diff --git a/Apps/CLI/Tests/CoreCLITests/PreRuntimeInvalidInputOrderingTests.swift b/Apps/CLI/Tests/CoreCLITests/PreRuntimeInvalidInputOrderingTests.swift index 4ef27518a..b92e1c681 100644 --- a/Apps/CLI/Tests/CoreCLITests/PreRuntimeInvalidInputOrderingTests.swift +++ b/Apps/CLI/Tests/CoreCLITests/PreRuntimeInvalidInputOrderingTests.swift @@ -17,7 +17,7 @@ struct PreRuntimeInvalidInputOrderingTests { ), ( ["peekaboo", "browser", "bind-window", "--json"], - "browser bind-window is not available to standalone CLI invocations." + "Browser namespace actions require an explicit --namespace-file." ), ( [ From 70d030ee49df8bd86618a8fa2eb6153c18154f50 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 19:09:08 -0700 Subject: [PATCH 12/14] feat(bridge): add durable browser capability namespaces --- .../Base/Runtime/BridgeCapabilityPolicy.swift | 16 +- .../Base/Runtime/RuntimeHostResolver.swift | 17 +- .../MCP/BrowserCommand+Namespace.swift | 152 +++++++++- .../Commands/MCP/BrowserCommand.swift | 2 +- .../BrowserCLINamespaceCommandTests.swift | 91 +++++- .../BrowserMCPAuthenticatedSessionPool.swift | 47 ++- ...rMCPScopedNamespaceResponseSanitizer.swift | 5 + .../BrowserMCPScopedNamespaceRuntime.swift | 27 +- .../MCPToolResponseMetadataProjector.swift | 17 +- ...eBrowserCapabilityNamespaceAuthority.swift | 132 ++++++--- ...idgeBrowserCapabilityNamespaceModels.swift | 13 +- ...geClient+BrowserCapabilityNamespaces.swift | 86 +++++- .../PeekabooBridge/PeekabooBridgeClient.swift | 2 +- .../PeekabooBridgeConnectedRequest.swift | 24 +- .../PeekabooBridgeHost+Clients.swift | 11 +- .../PeekabooBridge/PeekabooBridgeHost.swift | 50 +++- ...eekabooBridgeRequest+DesktopMutation.swift | 32 +- ...geServer+BrowserCapabilityNamespaces.swift | 239 +++++++++++++++ .../PeekabooBridgeServer+Handlers.swift | 21 +- .../PeekabooBridge/PeekabooBridgeServer.swift | 37 ++- .../PeekabooBridgeServiceProviding.swift | 55 ++++ .../PeekabooCore/Daemon/PeekabooDaemon.swift | 2 + .../PeekabooServices+BrowserBridge.swift | 130 ++++++++ .../Support/PeekabooServices.swift | 4 + .../Support/RemotePeekabooServices.swift | 3 + ...rowserMCPScopedNamespaceRuntimeTests.swift | 57 +++- .../BrowserMCPSessionManagerTests.swift | 14 + .../BrowserCapabilityNamespaceWireTests.swift | 14 + ...serCapabilityNamespaceHandshakeTests.swift | 277 ++++++++++++++++++ ...serCapabilityNamespaceAuthorityTests.swift | 47 +++ .../PeekabooTests/PeekabooBridgeTests.swift | 11 + .../PeekabooTests/PeekabooDaemonTests.swift | 9 + .../DesktopActionOutcome+Projection.swift | 17 ++ 33 files changed, 1535 insertions(+), 126 deletions(-) create mode 100644 Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+BrowserCapabilityNamespaces.swift diff --git a/Apps/CLI/Sources/PeekabooCLI/Commands/Base/Runtime/BridgeCapabilityPolicy.swift b/Apps/CLI/Sources/PeekabooCLI/Commands/Base/Runtime/BridgeCapabilityPolicy.swift index e8ff418da..91648f8b1 100644 --- a/Apps/CLI/Sources/PeekabooCLI/Commands/Base/Runtime/BridgeCapabilityPolicy.swift +++ b/Apps/CLI/Sources/PeekabooCLI/Commands/Base/Runtime/BridgeCapabilityPolicy.swift @@ -56,7 +56,7 @@ enum BridgeCapabilityPolicy { return false } - if options.requiresBrowserMCP, !self.supportsBrowserMCP(for: handshake) { + if !self.supportsBrowserRequirements(for: handshake, options: options) { return false } @@ -129,6 +129,20 @@ enum BridgeCapabilityPolicy { return true } + private static func supportsBrowserRequirements( + for handshake: PeekabooBridgeHandshakeResponse, + options: CommandRuntimeOptions + ) -> Bool { + if options.requiresBrowserMCP, !self.supportsBrowserMCP(for: handshake) { + return false + } + if options.requiresBrowserCapabilityNamespace, + !PeekabooBridgeClient.supportsBrowserCapabilityNamespaces(handshake) { + return false + } + return true + } + private static func supportsObservationRequirements( for handshake: PeekabooBridgeHandshakeResponse, options: CommandRuntimeOptions diff --git a/Apps/CLI/Sources/PeekabooCLI/Commands/Base/Runtime/RuntimeHostResolver.swift b/Apps/CLI/Sources/PeekabooCLI/Commands/Base/Runtime/RuntimeHostResolver.swift index 08e42f8ca..ea70b6d00 100644 --- a/Apps/CLI/Sources/PeekabooCLI/Commands/Base/Runtime/RuntimeHostResolver.swift +++ b/Apps/CLI/Sources/PeekabooCLI/Commands/Base/Runtime/RuntimeHostResolver.swift @@ -413,6 +413,10 @@ enum RuntimeHostResolver { } static func requiredHostFailure(explicitSocket: String?, options: CommandRuntimeOptions) -> String? { + if options.requiresBrowserCapabilityNamespace { + return "No authenticated on-demand Bridge host negotiated protocol 1.38 browser capability " + + "namespaces and exact native-window binding. Update and relaunch Peekaboo before retrying." + } if options.requiresExactWindowPixelFocusTyping { return "No compatible Bridge host advertises atomic exact-window pixel-focus typing. " + "Update and relaunch Peekaboo, then observe the exact target again before retrying." @@ -533,6 +537,10 @@ enum RuntimeHostResolver { return .local(snapshotInvalidationRemoteSocketPaths: []) } + if options.requiresBrowserCapabilityNamespace { + return .remote + } + if self.inputPolicyRequiresLocal( options: options, environment: environment, @@ -629,6 +637,7 @@ enum RuntimeHostResolver { return options.requiresScreenCapturePermission || options.requiresInspectAccessibilityTree || options.requiresBrowserMCP || + options.requiresBrowserCapabilityNamespace || options.requiresImplicitSnapshotInvalidation || options.usesPerToolSnapshotInvalidation || options.requiresForegroundModifierClickSnapshotLease || @@ -667,7 +676,7 @@ enum RuntimeHostResolver { daemons.append(ImplicitRemoteCandidate( socketPath: socketPath, requireReusableDaemon: true, - requiredHostKind: nil, + requiredHostKind: options.requiresBrowserCapabilityNamespace ? .onDemand : nil, requiresValidatedHistoricalDaemon: false )) } @@ -687,6 +696,10 @@ enum RuntimeHostResolver { requiresValidatedHistoricalDaemon: false ) + if options.requiresBrowserCapabilityNamespace { + return daemons + } + if options.requiresApplicationRelaunch || options.requiresSurvivingApplicationHost { return daemons } @@ -823,6 +836,8 @@ enum RuntimeHostResolver { ) return RemotePeekabooServices( client: client, + supportsBrowserCapabilityNamespaces: + PeekabooBridgeClient.supportsBrowserCapabilityNamespaces(handshake), supportsTargetedHotkeys: targetedHotkey.isEnabled, supportsProcessGenerationPinnedHotkeys: BridgeCapabilityPolicy.supportsProcessGenerationPinnedHotkeys(for: handshake), diff --git a/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand+Namespace.swift b/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand+Namespace.swift index 856a2a80c..31be4a323 100644 --- a/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand+Namespace.swift +++ b/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand+Namespace.swift @@ -1,6 +1,9 @@ import CoreFoundation import Darwin import Foundation +import MCP +import PeekabooBridge +import PeekabooCore import PeekabooFoundation import TachikomaMCP @@ -59,7 +62,7 @@ protocol BrowserCLINamespaceBridgeAdapter: Sendable { @MainActor protocol BrowserCLINamespaceBridgeAdapterProviding: AnyObject { /// Present only when the selected RemotePeekabooServices was built from a negotiated Bridge 1.38 client. - var browserCLINamespaceBridgeAdapter: any BrowserCLINamespaceBridgeAdapter { get } + var browserCLINamespaceBridgeAdapter: (any BrowserCLINamespaceBridgeAdapter)? { get } } /// The Bridge 1.38 integration makes only its negotiated `RemotePeekabooServices` conform to the @@ -97,6 +100,151 @@ enum BrowserCLINamespaceEnvironment { #endif } +extension RemotePeekabooServices: BrowserCLINamespaceBridgeAdapterProviding { + var browserCLINamespaceBridgeAdapter: (any BrowserCLINamespaceBridgeAdapter)? { + self.browserCapabilityNamespaceClient.map(RemoteBrowserCLINamespaceBridgeAdapter.init) + } +} + +struct RemoteBrowserCLINamespaceBridgeAdapter: BrowserCLINamespaceBridgeAdapter { + let client: PeekabooBridgeClient + + func createNamespace() async throws -> BrowserCLINamespaceCreateResult { + let receipt = try await self.client.createBrowserCapabilityNamespace() + let receiptData = try await self.client.canonicalBrowserCapabilityNamespaceReceiptData(receipt) + return BrowserCLINamespaceCreateResult( + namespaceReceiptData: receiptData, + response: .text("Browser capability namespace created.") + ) + } + + func bindWindow( + request: BrowserCLINamespaceBindWindowRequest, + namespaceReceiptData: Data + ) async throws -> ToolResponse { + let receipt = try await self.client.decodeBrowserCapabilityNamespaceReceipt(namespaceReceiptData) + let response = try await self.client.executeBrowserCapabilityNamespace(.init( + namespaceReceipt: receipt, + action: .bindWindow(.init( + pageID: request.pageID, + processIdentifier: request.processIdentifier, + windowID: request.windowID + )) + )) + return try Self.toolResponse(response) + } + + func executeAction( + request: BrowserCLINamespaceHighLevelActionRequest, + namespaceReceiptData: Data + ) async throws -> ToolResponse { + let receipt = try await self.client.decodeBrowserCapabilityNamespaceReceipt(namespaceReceiptData) + guard let action = PeekabooBridgeBrowserHighLevelAction(rawValue: request.action.rawValue) else { + throw BrowserCLINamespaceCommandError.unsupportedNamespaceAction(request.action.rawValue) + } + let arguments = try request.arguments.mapValues(Self.bridgeJSONValue) + let executionMode: PeekabooBridgeBrowserCapabilityExecutionMode = switch request.executionMode { + case .backgroundOnly: + .backgroundOnly + case .foregroundAllowed: + .foregroundAllowed + } + let result = try await self.client.executeBrowserCapabilityNamespaceResult(.init( + namespaceReceipt: receipt, + executionMode: executionMode, + action: .executeAction(.init(action: action, arguments: arguments)) + )) + return try Self.toolResponse(result.payload, verifiedOutcome: result.outcome) + } + + func closeNamespace(namespaceReceiptData: Data) async throws -> ToolResponse { + let receipt = try await self.client.decodeBrowserCapabilityNamespaceReceipt(namespaceReceiptData) + _ = try await self.client.closeBrowserCapabilityNamespace(receipt) + return .text("Browser capability namespace closed.") + } + + static func toolResponse( + _ response: PeekabooBridgeBrowserCapabilityNamespaceActionResponse, + verifiedOutcome: DesktopActionOutcome? = nil + ) throws -> ToolResponse { + let decodedMeta = try response.meta.map { try self.decode($0, as: Value.self) } + let meta = try self.metadata(decodedMeta, replacingOutcomeWith: verifiedOutcome) + return try ToolResponse( + content: response.content.map { try self.decode($0, as: Tool.Content.self) }, + isError: response.isError, + meta: meta, + structuredContent: response.structuredContent.map { try self.decode($0, as: Value.self) } + ) + } + + private static func metadata( + _ metadata: Value?, + replacingOutcomeWith outcome: DesktopActionOutcome? + ) throws -> Value? { + guard let outcome else { return metadata } + var fields: [String: Value] = switch metadata { + case let .object(existing): + existing + case let .some(providerMetadata): + ["provider_meta": providerMetadata] + case nil: + [:] + } + for key in DesktopActionOutcome.Projection.fieldNames { + fields.removeValue(forKey: key) + } + let data = try JSONEncoder().encode(outcome.projection) + let object = try JSONSerialization.jsonObject(with: data) + guard case let .object(canonicalFields) = Value.from(object) else { + throw BrowserCLINamespaceCommandError.adapterUnavailable + } + fields.merge(canonicalFields) { _, canonical in canonical } + return .object(fields) + } + + private static func decode( + _ value: PeekabooBridgeJSONValue, + as _: Value.Type + ) throws -> Value { + try JSONDecoder().decode(Value.self, from: JSONEncoder().encode(value)) + } + + private static func bridgeJSONValue(_ value: Any) throws -> PeekabooBridgeJSONValue { + switch value { + case is NSNull: + .null + case let value as Bool: + .bool(value) + case let value as Int: + .int(value) + case let value as Int32: + .int(Int(value)) + case let value as UInt32: + .int(Int(value)) + case let value as Double: + .double(value) + case let value as NSNumber: + if CFGetTypeID(value) == CFBooleanGetTypeID() { + .bool(value.boolValue) + } else if value.doubleValue.rounded() == value.doubleValue { + .int(value.intValue) + } else { + .double(value.doubleValue) + } + case let value as String: + .string(value) + case let value as [Any]: + try .array(value.map(self.bridgeJSONValue)) + case let value as [String: Any]: + try .object(value.mapValues(self.bridgeJSONValue)) + default: + throw BrowserCLINamespaceCommandError.unsupportedNamespaceAction( + "argument type \(String(describing: type(of: value)))" + ) + } + } +} + enum BrowserCLINamespaceLifecycle { static func create( adapter: any BrowserCLINamespaceBridgeAdapter, @@ -302,8 +450,8 @@ extension BrowserCommand { func validateBrowserCapabilityNamespaceActionBeforeRuntime( environment: [String: String] = ProcessInfo.processInfo.environment ) throws { - try self.requireRemoteNamespaceRouting(environment: environment) let store = try self.namespaceReceiptStore() + try self.requireRemoteNamespaceRouting(environment: environment) if let control = BrowserCLINamespaceControlAction(rawValue: self.normalizedAction) { let unsupported = self.namespaceUnsupportedArguments(allowsBindSelectors: false) guard unsupported.isEmpty else { diff --git a/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand.swift b/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand.swift index 7b0ea3a7c..3d593d158 100644 --- a/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand.swift +++ b/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand.swift @@ -196,7 +196,7 @@ InjectedRuntimeBackedCommand { _ = try self.arguments() } - private func arguments() throws -> [String: Any] { + func arguments() throws -> [String: Any] { let normalizedAction = self.normalizedAction if normalizedAction == BrowserProcessLocalAction.bindWindow { throw BrowserCommandInputError.nativeWindowBindingRequiresNamespace() diff --git a/Apps/CLI/Tests/CoreCLITests/BrowserCLINamespaceCommandTests.swift b/Apps/CLI/Tests/CoreCLITests/BrowserCLINamespaceCommandTests.swift index 6441da78b..f17b972c4 100644 --- a/Apps/CLI/Tests/CoreCLITests/BrowserCLINamespaceCommandTests.swift +++ b/Apps/CLI/Tests/CoreCLITests/BrowserCLINamespaceCommandTests.swift @@ -1,5 +1,8 @@ import Commander import Foundation +import PeekabooBridge +import PeekabooCore +import PeekabooFoundation import TachikomaMCP import Testing @testable import PeekabooCLI @@ -8,6 +11,72 @@ import Testing struct BrowserCLINamespaceCommandTests { private static let pageReference = "bp1_0123456789abcdef0123456789abcdef" + @Test + func `remote adapter replaces local outcome metadata with verified bridge truth`() throws { + let local = DesktopActionOutcome.dispatchedUnverified( + route: .local, + delivery: .init(mechanism: .browserProtocol, mode: .background), + evidence: .deliveryAccepted, + unitCount: .one + ) + let verified = local.routed(to: .bridge) + let localData = try JSONEncoder().encode(local.projection) + var localFields = try #require(JSONSerialization.jsonObject(with: localData) as? [String: Any]) + localFields["provider_note"] = "preserved" + let wireMetaData = try JSONSerialization.data(withJSONObject: localFields) + let wireMeta = try JSONDecoder().decode(PeekabooBridgeJSONValue.self, from: wireMetaData) + + let response = try RemoteBrowserCLINamespaceBridgeAdapter.toolResponse(.init( + content: [], + isError: false, + meta: wireMeta + ), verifiedOutcome: verified) + let fields = try #require(response.meta?.objectValue) + + #expect(fields["route"]?.stringValue == "bridge") + #expect(fields["provider_note"]?.stringValue == "preserved") + #expect(fields["state"]?.stringValue == verified.state.rawValue) + } + + @Test + func `namespace runtime selects only on demand Bridge candidates without local fallback`() { + var options = CommandRuntimeOptions() + options.requiresBrowserCapabilityNamespace = true + options.preferRemote = false + let decision = RuntimeHostResolver.initialRoutingDecision( + options: options, + environment: [:], + configurationInput: nil, + knownSnapshotInvalidationRemoteSocketPaths: [] + ) + #expect(decision == .remote) + + let candidates = RuntimeHostResolver.implicitRemoteCandidates( + options: options, + daemonSocketPath: "/tmp/peekaboo-daemon.sock", + buildScopedDaemonSocketPath: "/tmp/peekaboo-build.sock", + historicalBuildScopedDaemonSocketPaths: ["/tmp/peekaboo-old.sock"] + ) + #expect(!candidates.isEmpty) + #expect(candidates.allSatisfy { $0.requiredHostKind == .onDemand }) + #expect(!candidates.contains { $0.socketPath == PeekabooBridgeConstants.peekabooSocketPath }) + } + + @Test + func `remote services expose namespace adapter only after negotiated construction`() { + let client = PeekabooBridgeClient(socketPath: "/tmp/peekaboo-unused-browser-namespace.sock") + let legacy = RemotePeekabooServices(client: client) + let current = RemotePeekabooServices( + client: client, + supportsBrowserCapabilityNamespaces: true + ) + + #expect((legacy as any BrowserCLINamespaceBridgeAdapterProviding) + .browserCLINamespaceBridgeAdapter == nil) + #expect((current as any BrowserCLINamespaceBridgeAdapterProviding) + .browserCLINamespaceBridgeAdapter != nil) + } + @Test func `bind window parses exactly three selectors and disables legacy browser routing`() throws { let command = try Self.command(options: [ @@ -218,7 +287,7 @@ struct BrowserCLINamespaceCommandTests { func `adapter seam keeps create bind and close in one explicit authority owner`() async throws { let receipt = Data("canonical-receipt-fixture".utf8) let adapter = RecordingNamespaceAdapter(receipt: receipt) - let creation = try await adapter.createNamespace() + let creation = await adapter.createNamespace() #expect(creation.namespaceReceiptData == receipt) _ = try await adapter.bindWindow( request: BrowserCLINamespaceBindWindowRequest( @@ -237,7 +306,7 @@ struct BrowserCLINamespaceCommandTests { namespaceReceiptData: creation.namespaceReceiptData ) _ = try await adapter.closeNamespace(namespaceReceiptData: creation.namespaceReceiptData) - #expect(await adapter.operations == ["create", "bind", "execute", "close"]) + #expect(adapter.operations == ["create", "bind", "execute", "close"]) } @Test @@ -255,7 +324,7 @@ struct BrowserCLINamespaceCommandTests { defer { try? FileManager.default.removeItem(at: root) } let namespacePath = root.appendingPathComponent("namespace.json").path - let create = try CommanderCLIBinder.instantiateCommand( + var create = try CommanderCLIBinder.instantiateCommand( ofType: BrowserCommand.self, parsedValues: ParsedValues( positional: ["namespace-create"], @@ -263,6 +332,7 @@ struct BrowserCLINamespaceCommandTests { flags: [] ) ) + create.setRuntimeOptions(CommandRuntimeOptions()) #expect(create.runtimeOptions.requiresBrowserCapabilityNamespace) #expect(!create.runtimeOptions.requiresBrowserMCP) try create.validateBeforeRuntime() @@ -431,7 +501,7 @@ struct BrowserCLINamespaceCommandTests { if options["namespaceFile"] == nil { options["namespaceFile"] = ["/private/tmp/fixture-browser-namespace.json"] } - return try CommanderCLIBinder.instantiateCommand( + var command = try CommanderCLIBinder.instantiateCommand( ofType: BrowserCommand.self, parsedValues: ParsedValues( positional: ["bind-window"], @@ -439,10 +509,18 @@ struct BrowserCLINamespaceCommandTests { flags: flags ) ) + var runtimeOptions = CommandRuntimeOptions() + runtimeOptions.remoteIsolationRequested = flags.contains("no-remote") + runtimeOptions.bridgeSocketPath = options["bridge-socket"]?.last + runtimeOptions.jsonOutput = flags.contains("jsonOutput") + runtimeOptions.verbose = flags.contains("verbose") + command.setRuntimeOptions(runtimeOptions) + return command } } -private actor RecordingNamespaceAdapter: BrowserCLINamespaceBridgeAdapter { +@MainActor +private final class RecordingNamespaceAdapter: BrowserCLINamespaceBridgeAdapter { let receipt: Data let closeIsError: Bool var operations: [String] = [] @@ -498,7 +576,7 @@ private actor RecordingNamespaceAdapter: BrowserCLINamespaceBridgeAdapter { } } -private struct IrrelevantArgument: Sendable, CustomTestStringConvertible { +private nonisolated struct IrrelevantArgument: Sendable, CustomTestStringConvertible { let optionLabel: String? let optionValue: String? let flagLabel: String? @@ -558,6 +636,7 @@ private struct IrrelevantArgument: Sendable, CustomTestStringConvertible { Self(optionLabel: nil, optionValue: nil, flagLabel: label) } + @MainActor func apply(to values: inout ParsedValues) { if let optionLabel, let optionValue { values.options[optionLabel] = [optionValue] diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPAuthenticatedSessionPool.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPAuthenticatedSessionPool.swift index 90db62a92..61dfafece 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPAuthenticatedSessionPool.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPAuthenticatedSessionPool.swift @@ -26,11 +26,42 @@ final class BrowserMCPAuthenticatedSessionPool { let mutationGate: MCPToolSnapshotExecutionGate } - struct SessionID: Hashable, Sendable { + struct SessionID: Hashable, @unchecked Sendable { fileprivate let rawValue: UUID + private let lifetime: SessionLifetime init() { self.rawValue = UUID() + self.lifetime = SessionLifetime() + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue == rhs.rawValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.rawValue) + } + + fileprivate var hasEnded: Bool { + self.lifetime.hasEnded + } + + fileprivate func markEnded() { + self.lifetime.markEnded() + } + } + + private final class SessionLifetime: @unchecked Sendable { + private let lock = NSLock() + private var ended = false + + var hasEnded: Bool { + self.lock.withLock { self.ended } + } + + func markEnded() { + self.lock.withLock { self.ended = true } } } @@ -39,7 +70,6 @@ final class BrowserMCPAuthenticatedSessionPool { private let serverNamePrefix: String private let factory: Factory private var sessions: [SessionID: SessionState] = [:] - private var endedSessions = Set() private var endingSessions: [SessionID: Task] = [:] private var namedSessions: [String: SessionID] = [:] private var targetOwners: [TargetKey: TargetOwner] = [:] @@ -53,7 +83,7 @@ final class BrowserMCPAuthenticatedSessionPool { } func manager(for sessionID: SessionID) -> BrowserMCPSessionManager? { - guard !self.endedSessions.contains(sessionID) else { return nil } + guard !sessionID.hasEnded else { return nil } if let state = self.sessions[sessionID] { return state.manager } @@ -89,7 +119,7 @@ final class BrowserMCPAuthenticatedSessionPool { await ending.value return } - self.endedSessions.insert(sessionID) + sessionID.markEnded() self.namedSessions = self.namedSessions.filter { $0.value != sessionID } guard let state = self.sessions.removeValue(forKey: sessionID) else { self.targetOwners = self.targetOwners.filter { $0.value != .session(sessionID) } @@ -114,7 +144,7 @@ final class BrowserMCPAuthenticatedSessionPool { } func bind(_ sessionID: SessionID, to receipt: BrowserMCPConnectionReceipt) throws { - guard !self.endedSessions.contains(sessionID), self.sessions[sessionID] != nil else { + guard !sessionID.hasEnded, self.sessions[sessionID] != nil else { throw BrowserMCPConnectionError.sessionEnded } let keys = Self.targetKeys(for: receipt) @@ -164,6 +194,13 @@ final class BrowserMCPAuthenticatedSessionPool { self.sessions.isEmpty } + var retainedSessionIdentityCount: Int { + Set(self.sessions.keys) + .union(self.endingSessions.keys) + .union(self.namedSessions.values) + .count + } + private static func targetKeys(for receipt: BrowserMCPConnectionReceipt) -> Set { var keys = Set() if let processIdentifier = receipt.processIdentifier, diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceResponseSanitizer.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceResponseSanitizer.swift index 9261aa93e..7cb9c3fbc 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceResponseSanitizer.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceResponseSanitizer.swift @@ -512,6 +512,11 @@ enum BrowserMCPScopedNamespaceResponseSanitizer { private static func processTargetIdentity(from meta: Value?) -> DesktopTargetIdentity? { guard case let .object(fields)? = meta else { return nil } + if let receipt = fields["connection_receipt"]?.objectValue, + let identity = self.processIdentity(from: receipt) + { + return identity + } if let execution = fields[BrowserMCPExecutionEvidence.metadataKey]?.objectValue, let receipt = execution["connection_receipt"]?.objectValue, let identity = self.processIdentity(from: receipt) diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceRuntime.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceRuntime.swift index 36fc43fb3..60d1d47b3 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceRuntime.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceRuntime.swift @@ -102,7 +102,6 @@ public final class BrowserMCPScopedNamespaceRuntime { private enum Slot { case active(any BrowserMCPScopedNamespaceSession) case closing(Task) - case ended } private enum Phase: Equatable { @@ -158,8 +157,6 @@ public final class BrowserMCPScopedNamespaceRuntime { throw BrowserMCPScopedNamespaceRuntimeError.namespaceAlreadyExists case .closing: throw BrowserMCPScopedNamespaceRuntimeError.namespaceClosing - case .ended: - throw BrowserMCPScopedNamespaceRuntimeError.namespaceEnded } } self.slots[namespaceID] = try .active(self.makeSession(namespaceID)) @@ -189,8 +186,6 @@ public final class BrowserMCPScopedNamespaceRuntime { session = activeSession case .closing: throw BrowserMCPScopedNamespaceRuntimeError.namespaceClosing - case .ended: - throw BrowserMCPScopedNamespaceRuntimeError.namespaceEnded case nil: throw BrowserMCPScopedNamespaceRuntimeError.namespaceUnknown } @@ -207,13 +202,11 @@ public final class BrowserMCPScopedNamespaceRuntime { /// Duplicate close callers join the same task. Ended identities remain tombstoned for this runtime generation and /// cannot accidentally acquire a fresh capability map. public func close(_ namespaceID: BrowserMCPScopedNamespaceID) async throws { - guard self.slots[namespaceID] != nil else { - throw BrowserMCPScopedNamespaceRuntimeError.namespaceUnknown - } + guard self.slots[namespaceID] != nil else { return } guard let task = self.beginClose(namespaceID) else { return } await task.value if case .closing? = self.slots[namespaceID] { - self.slots[namespaceID] = .ended + self.slots.removeValue(forKey: namespaceID) } } @@ -232,7 +225,7 @@ public final class BrowserMCPScopedNamespaceRuntime { await task.value } for namespaceID in namespaceIDs where self.slots[namespaceID].map(Self.isClosing) == true { - self.slots[namespaceID] = .ended + self.slots.removeValue(forKey: namespaceID) } self.phase = .ended } @@ -240,6 +233,14 @@ public final class BrowserMCPScopedNamespaceRuntime { await retirementTask.value } + /// Reopens the empty runtime after one Bridge listener generation has fully retired. + public func beginNextHostGeneration() { + precondition(self.phase == .ended) + precondition(self.slots.isEmpty) + self.retirementTask = nil + self.phase = .active + } + private func beginClose(_ namespaceID: BrowserMCPScopedNamespaceID) -> Task? { switch self.slots[namespaceID] { case let .active(session): @@ -250,7 +251,7 @@ public final class BrowserMCPScopedNamespaceRuntime { return task case let .closing(existing): return existing - case .ended, nil: + case nil: return nil } } @@ -261,6 +262,10 @@ public final class BrowserMCPScopedNamespaceRuntime { } return false } + + var namespaceCount: Int { + self.slots.count + } } @MainActor diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/MCP/Server/MCPToolResponseMetadataProjector.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/MCP/Server/MCPToolResponseMetadataProjector.swift index 7fcc5ca6e..275d326f4 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/MCP/Server/MCPToolResponseMetadataProjector.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/MCP/Server/MCPToolResponseMetadataProjector.swift @@ -4,22 +4,7 @@ import PeekabooFoundation import TachikomaMCP enum MCPToolResponseMetadataProjector { - static let actionOutcomeKeys: Set = [ - "delivery_mechanism", - "delivery_mode", - "dispatch_state", - "dispatched_unit_count", - "effect", - "escalation", - "evidence", - "mutation_dispatched", - "refusal_reason", - "requires_fresh_observation", - "retry_safe", - "retry_safety", - "route", - "state", - ] + static let actionOutcomeKeys = DesktopActionOutcome.Projection.fieldNames static let requiredActionOutcomeKeys: Set = [ "dispatch_state", diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceAuthority.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceAuthority.swift index e508789f7..12c405912 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceAuthority.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceAuthority.swift @@ -203,6 +203,8 @@ extension PeekabooBridgeOperationReceiptAuthority { /// attested Bridge request ID, allowing distinct requests to execute concurrently without turning the namespace into /// a one-shot token. No BrowserMCPService type crosses this boundary. actor PeekabooBridgeBrowserCapabilityNamespaceAuthority { + private typealias DrainContinuation = CheckedContinuation + struct Configuration: Equatable, Sendable { static let hardMaximumNamespaceCount = 1024 static let hardMaximumLifetimeMilliseconds: Int64 = 60 * 60 * 1000 @@ -358,7 +360,7 @@ actor PeekabooBridgeBrowserCapabilityNamespaceAuthority { guard let predecessor = self.entries[namespaceID] else { throw PeekabooBridgeBrowserCapabilityNamespaceError.namespaceNotFound } - guard predecessor.outstandingDrainLeaseID == nil, predecessor.drainWaiter == nil else { + guard predecessor.outstandingDrainLeaseID == nil, predecessor.drainWaiters.isEmpty else { throw PeekabooBridgeBrowserCapabilityNamespaceError.namespaceClosing } guard predecessor.state == .open || @@ -454,14 +456,34 @@ actor PeekabooBridgeBrowserCapabilityNamespaceAuthority { -> PeekabooBridgeBrowserCapabilityNamespaceIdentity { try self.requireLiveRegistry() - let namespaceID = try self.validateRegisteredReceipt( - receipt, - principal: principal, - at: self.clock()) + let now = self.clock() + self.expireEntries(at: now) + try Self.validatePrincipal(principal, expectedUserIdentifier: self.hostEffectiveUserIdentifier) + try self.validateReceipt(receipt, principal: principal, at: now, allowsExpired: true) + let namespaceID = receipt.payload.namespaceID guard let entry = self.entries[namespaceID] else { - throw PeekabooBridgeBrowserCapabilityNamespaceError.namespaceNotFound + // Open entries are never evicted. A valid same-generation receipt missing from the bounded registry can + // therefore only name an already-terminal namespace whose close acknowledgement was lost. + return PeekabooBridgeBrowserCapabilityNamespaceIdentity( + namespaceID: namespaceID, + registryGenerationID: self.registryGenerationID, + principal: principal, + allowsNativeBrowserWindowBinding: true, + drainLeaseID: nil) } - guard entry.state == .open || entry.state == .closing else { + guard entry.receipt == receipt else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.invalidReceipt + } + if entry.state == .closed || entry.state == .expired, entry.activeClaimIDs.isEmpty { + entry.state = .closed + return PeekabooBridgeBrowserCapabilityNamespaceIdentity( + namespaceID: namespaceID, + registryGenerationID: self.registryGenerationID, + principal: principal, + allowsNativeBrowserWindowBinding: entry.allowsNativeBrowserWindowBinding, + drainLeaseID: entry.outstandingDrainLeaseID) + } + guard entry.state == .open || entry.state == .closing || entry.state == .expired else { throw self.lifecycleError(entry.state) } let drainLeaseID: UInt64? @@ -501,8 +523,14 @@ actor PeekabooBridgeBrowserCapabilityNamespaceAuthority { else { throw PeekabooBridgeBrowserCapabilityNamespaceError.principalMismatch } - guard entry.outstandingDrainLeaseID == drainLeaseID else { - throw PeekabooBridgeBrowserCapabilityNamespaceError.claimMismatch + if entry.outstandingDrainLeaseID != drainLeaseID { + guard entry.outstandingDrainLeaseID == nil, + entry.activeClaimIDs.isEmpty, + entry.state == .closed || entry.state == .expired + else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.claimMismatch + } + return } guard entry.state != .open else { throw PeekabooBridgeBrowserCapabilityNamespaceError.namespaceClosing @@ -512,16 +540,13 @@ actor PeekabooBridgeBrowserCapabilityNamespaceAuthority { self.finishTerminalStateIfDrained(entry) return } - guard entry.drainWaiter == nil else { - throw PeekabooBridgeBrowserCapabilityNamespaceError.drainAlreadyAwaited - } let waiterID = self.nextDrainWaiterID() try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in + try await withCheckedThrowingContinuation { (continuation: DrainContinuation) in if Task.isCancelled { continuation.resume(throwing: CancellationError()) } else { - entry.drainWaiter = DrainWaiter(id: waiterID, continuation: continuation) + entry.drainWaiters[waiterID] = continuation } } } onCancel: { @@ -544,22 +569,15 @@ actor PeekabooBridgeBrowserCapabilityNamespaceAuthority { /// Stops all namespaces, waits for in-flight authority claims, and leaves no accepting entry. func drainAll() async throws { - try self.requireLiveRegistry() + try self.beginDrainingAll() try Task.checkCancellation() - self.drainingAll = true - let now = self.clock() - self.expireEntries(at: now) - for entry in self.entries.values where entry.state == .open { - entry.state = entry.activeClaimIDs.isEmpty ? .closed : .closing - self.resumeNamespaceDrainWaiterIfDrained(entry) - } guard self.entries.values.contains(where: { !$0.activeClaimIDs.isEmpty }) else { return } guard self.allDrainWaiter == nil else { throw PeekabooBridgeBrowserCapabilityNamespaceError.drainAlreadyAwaited } let waiterID = self.nextDrainWaiterID() try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in + try await withCheckedThrowingContinuation { (continuation: DrainContinuation) in if Task.isCancelled { continuation.resume(throwing: CancellationError()) } else { @@ -574,6 +592,18 @@ actor PeekabooBridgeBrowserCapabilityNamespaceAuthority { try Task.checkCancellation() } + /// Freezes namespace admission synchronously without waiting for already-claimed work. + func beginDrainingAll() throws { + try self.requireLiveRegistry() + self.drainingAll = true + let now = self.clock() + self.expireEntries(at: now) + for entry in self.entries.values where entry.state == .open { + entry.state = entry.activeClaimIDs.isEmpty ? .closed : .closing + self.resumeNamespaceDrainWaiterIfDrained(entry) + } + } + /// Immediately invalidates this generation. A replacement authority must mint a new generation and namespace IDs. @discardableResult func invalidateForRestart() -> Int { @@ -584,10 +614,11 @@ actor PeekabooBridgeBrowserCapabilityNamespaceAuthority { for entry in self.entries.values { entry.state = .closed entry.outstandingDrainLeaseID = nil - let waiter = entry.drainWaiter - entry.drainWaiter = nil - waiter?.continuation.resume( - throwing: PeekabooBridgeBrowserCapabilityNamespaceError.registryInvalidated) + let waiters = Array(entry.drainWaiters.values) + entry.drainWaiters.removeAll() + for waiter in waiters { + waiter.resume(throwing: PeekabooBridgeBrowserCapabilityNamespaceError.registryInvalidated) + } } let allWaiter = self.allDrainWaiter self.allDrainWaiter = nil @@ -605,6 +636,28 @@ actor PeekabooBridgeBrowserCapabilityNamespaceAuthority { self.entries[namespaceID]?.activeClaimIDs.count } + func retainedNamespaceCount() -> Int { + self.entries.count + } + + func terminalNamespaceIDsRequiringRuntimeRetirement() throws -> [UUID] { + try self.requireLiveRegistry() + self.expireEntries(at: self.clock()) + return self.entries.values + .filter { ($0.state == .closed || $0.state == .expired) && !$0.runtimeRetired } + .sorted { $0.ordinal < $1.ordinal } + .map(\.receipt.payload.namespaceID) + } + + func markRuntimeRetired(namespaceID: UUID) throws { + try self.requireLiveRegistry() + guard let entry = self.entries[namespaceID] else { return } + guard entry.state == .closed || entry.state == .expired else { + throw PeekabooBridgeBrowserCapabilityNamespaceError.namespaceClosing + } + entry.runtimeRetired = true + } + func verify( _ receipt: PeekabooBridgeBrowserCapabilityNamespaceReceipt, principal: PeekabooBridgeBrowserCapabilityPrincipal) throws @@ -685,7 +738,8 @@ actor PeekabooBridgeBrowserCapabilityNamespaceAuthority { private func validateReceipt( _ receipt: PeekabooBridgeBrowserCapabilityNamespaceReceipt, principal: PeekabooBridgeBrowserCapabilityPrincipal, - at now: Int64) throws + at now: Int64, + allowsExpired: Bool = false) throws { let payload = receipt.payload guard payload.schemaVersion == 1, @@ -721,7 +775,7 @@ actor PeekabooBridgeBrowserCapabilityNamespaceAuthority { guard !issueOverflow, payload.issuedAtUnixMilliseconds <= latestAllowedIssue else { throw PeekabooBridgeBrowserCapabilityNamespaceError.receiptNotYetValid } - guard now < payload.expiresAtUnixMilliseconds else { + guard allowsExpired || now < payload.expiresAtUnixMilliseconds else { throw PeekabooBridgeBrowserCapabilityNamespaceError.receiptExpired } try self.signingContext.validateSignature(receipt) @@ -733,8 +787,9 @@ actor PeekabooBridgeBrowserCapabilityNamespaceAuthority { .filter({ !excludedNamespaceIDs.contains($0.receipt.payload.namespaceID) && ($0.state == .closed || $0.state == .expired) && + $0.runtimeRetired && $0.activeClaimIDs.isEmpty && - $0.drainWaiter == nil && + $0.drainWaiters.isEmpty && $0.outstandingDrainLeaseID == nil }) .min(by: { $0.ordinal < $1.ordinal }) @@ -765,10 +820,10 @@ actor PeekabooBridgeBrowserCapabilityNamespaceAuthority { private func resumeNamespaceDrainWaiterIfDrained(_ entry: Entry) { guard entry.activeClaimIDs.isEmpty else { return } - guard let waiter = entry.drainWaiter else { return } - entry.drainWaiter = nil + let waiters = Array(entry.drainWaiters.values) + entry.drainWaiters.removeAll() entry.outstandingDrainLeaseID = nil - waiter.continuation.resume() + waiters.forEach { $0.resume() } } private func resumeAllDrainWaiterIfDrained() { @@ -779,10 +834,10 @@ actor PeekabooBridgeBrowserCapabilityNamespaceAuthority { } private func cancelNamespaceDrainWaiter(namespaceID: UUID, waiterID: UInt64) { - guard let entry = self.entries[namespaceID], entry.drainWaiter?.id == waiterID else { return } - let waiter = entry.drainWaiter - entry.drainWaiter = nil - waiter?.continuation.resume(throwing: CancellationError()) + guard let entry = self.entries[namespaceID], + let waiter = entry.drainWaiters.removeValue(forKey: waiterID) + else { return } + waiter.resume(throwing: CancellationError()) } private func cancelAllDrainWaiter(waiterID: UInt64) { @@ -874,7 +929,8 @@ actor PeekabooBridgeBrowserCapabilityNamespaceAuthority { var claimedIDs: Set = [] var activeClaimIDs: Set = [] var outstandingDrainLeaseID: UInt64? - var drainWaiter: DrainWaiter? + var drainWaiters: [UInt64: DrainContinuation] = [:] + var runtimeRetired = false init( receipt: PeekabooBridgeBrowserCapabilityNamespaceReceipt, diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceModels.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceModels.swift index 00f673581..5359d1e32 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceModels.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceModels.swift @@ -189,7 +189,18 @@ public struct PeekabooBridgeBrowserCapabilityNamespaceCreateRequest: Codable, Eq _ = encoder.container(keyedBy: EmptyCodingKey.self) } - private enum EmptyCodingKey: String, CodingKey {} + private struct EmptyCodingKey: CodingKey { + let stringValue: String + let intValue: Int? + + init?(stringValue: String) { + nil + } + + init?(intValue: Int) { + nil + } + } } /// Per-call authority. A namespace never permanently acquires foreground permission. diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeClient+BrowserCapabilityNamespaces.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeClient+BrowserCapabilityNamespaces.swift index b493f28da..f9331fc7b 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeClient+BrowserCapabilityNamespaces.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeClient+BrowserCapabilityNamespaces.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import PeekabooAutomationKit import PeekabooFoundation @@ -10,6 +11,7 @@ extension PeekabooBridgeClient { let response = try await self.send(.browserCreateCapabilityNamespace(.init())) switch response { case let .browserCapabilityNamespaceCreated(receipt): + try self.validateBrowserCapabilityNamespaceReceipt(receipt) return receipt case let .error(envelope): throw envelope @@ -20,6 +22,28 @@ extension PeekabooBridgeClient { } } + public func canonicalBrowserCapabilityNamespaceReceiptData( + _ receipt: PeekabooBridgeBrowserCapabilityNamespaceReceipt) throws -> Data + { + try self.validateBrowserCapabilityNamespaceReceipt(receipt) + return try PeekabooBridgeOperationReceiptCoding.canonicalData(receipt) + } + + public func decodeBrowserCapabilityNamespaceReceipt( + _ data: Data) throws -> PeekabooBridgeBrowserCapabilityNamespaceReceipt + { + let receipt = try self.decoder.decode( + PeekabooBridgeBrowserCapabilityNamespaceReceipt.self, + from: data) + guard try PeekabooBridgeOperationReceiptCoding.canonicalData(receipt) == data else { + throw PeekabooBridgeErrorEnvelope( + code: .invalidRequest, + message: "Browser capability namespace receipt bytes are not canonical") + } + try self.validateBrowserCapabilityNamespaceReceipt(receipt) + return receipt + } + public func executeBrowserCapabilityNamespace( _ request: PeekabooBridgeBrowserCapabilityNamespaceRequest) async throws -> PeekabooBridgeBrowserCapabilityNamespaceActionResponse @@ -40,7 +64,7 @@ extension PeekabooBridgeClient { if request.executionMode == .backgroundOnly, request.requestsForegroundDelivery { throw DesktopActionFailure.preDispatchRefusal( route: .bridge, - reason: .foregroundRequired, + reason: .foregroundConsentRequired, message: "This browser namespace action requires explicit foreground authority.", hint: "Retry only with foreground_allowed when interrupting the user is intentional.") } @@ -59,14 +83,15 @@ extension PeekabooBridgeClient { message: "Unexpected browser capability namespace action response") } } - let result = try await self.actionResult( - for: bridgeRequest, - expectedResponse: "browser capability namespace action", - operationReceiptRequirement: .required) - { response in - guard case let .browserCapabilityNamespaceAction(payload) = response else { return nil } - return payload - } + let result: UIAutomationActionResult = + try await self.actionResult( + for: bridgeRequest, + expectedResponse: "browser capability namespace action", + operationReceiptRequirement: .required) + { response in + guard case let .browserCapabilityNamespaceAction(payload) = response else { return nil } + return payload + } try Self.validateBrowserCapabilityNamespaceResponse(result.payload, request: request) return result.desktopActionResult } @@ -111,6 +136,49 @@ extension PeekabooBridgeClient { } } + private func validateBrowserCapabilityNamespaceReceipt( + _ receipt: PeekabooBridgeBrowserCapabilityNamespaceReceipt) throws + { + try self.requireBrowserCapabilityNamespace(nativeWindowBinding: false) + guard let listenerAttestation = self.operationAttestation else { + throw PeekabooBridgeErrorEnvelope( + code: .unauthorizedClient, + message: "Browser capability namespace receipt validation requires an attested Bridge session") + } + try listenerAttestation.validateSignature() + let payload = receipt.payload + guard payload.listenerInstanceID == listenerAttestation.listenerInstanceID, + payload.listenerPublicKeySHA256 == PeekabooBridgeOperationReceiptCoding.sha256( + listenerAttestation.publicKey), + payload.issuedAtUnixMilliseconds > 0, + payload.expiresAtUnixMilliseconds > payload.issuedAtUnixMilliseconds, + !payload.principal.teamIdentifier.isEmpty, + !payload.principal.bundleIdentifier.isEmpty, + !payload.principal.codeSignatureHash.isEmpty + else { + throw PeekabooBridgeErrorEnvelope( + code: .invalidRequest, + message: "Browser capability namespace receipt contradicts the active Bridge listener") + } + let publicKey: Curve25519.Signing.PublicKey + do { + publicKey = try Curve25519.Signing.PublicKey( + rawRepresentation: listenerAttestation.publicKey) + } catch { + throw PeekabooBridgeErrorEnvelope( + code: .unauthorizedClient, + message: "Bridge listener public key is invalid") + } + guard try publicKey.isValidSignature( + receipt.signature, + for: PeekabooBridgeOperationReceiptCoding.canonicalData(payload)) + else { + throw PeekabooBridgeErrorEnvelope( + code: .unauthorizedClient, + message: "Browser capability namespace receipt signature is invalid") + } + } + private static func validateBrowserCapabilityNamespaceResponse( _ response: PeekabooBridgeBrowserCapabilityNamespaceActionResponse, request: PeekabooBridgeBrowserCapabilityNamespaceRequest) throws diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeClient.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeClient.swift index db8112b54..87faa4fad 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeClient.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeClient.swift @@ -1081,7 +1081,7 @@ public actor PeekabooBridgeClient { operations.isSubset(of: Set(handshake.supportedOperations)) } - static func supportsBrowserCapabilityNamespaces( + public static func supportsBrowserCapabilityNamespaces( _ handshake: PeekabooBridgeHandshakeResponse) -> Bool { let operations = PeekabooBridgeOperation.browserCapabilityNamespaceOperations diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeConnectedRequest.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeConnectedRequest.swift index cb20462c0..b09d33a69 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeConnectedRequest.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeConnectedRequest.swift @@ -290,6 +290,7 @@ enum PeekabooBridgeConnectedRequest { let connection: PeekabooBridgeConnectionLiveness let requestTracker: PeekabooBridgeRequestTracker let operationReceiptAuthority: PeekabooBridgeOperationReceiptAuthority? + let browserCapabilityNamespaceAuthority: PeekabooBridgeBrowserCapabilityNamespaceAuthority? let operationSessionAuthorizationPin: PeekabooBridgeOperationReceiptAuthority.SessionAuthorizationPin? } @@ -396,14 +397,25 @@ enum PeekabooBridgeConnectedRequest { context.connection.canReceiveResponse() } let operation: @Sendable () async -> Data = { - await context.server.handleDecoded(request, peer: context.peer) + let requestID: UUID? = if case let .attestedOperation(payload) = request { + payload.requestID + } else { + nil + } + return await PeekabooBridgeRequestContext.$attestedOperationRequestID.withValue(requestID) { + await context.server.handleDecoded(request, peer: context.peer) + } } - let response = await PeekabooBridgeRequestContext.$operationReceiptAuthority.withValue( - context.operationReceiptAuthority) + let response = await PeekabooBridgeRequestContext.$browserCapabilityNamespaceAuthority.withValue( + context.browserCapabilityNamespaceAuthority) { - await PeekabooBridgeRequestContext.$clientConnectionProbe.withValue( - connectionProbe, - operation: operation) + await PeekabooBridgeRequestContext.$operationReceiptAuthority.withValue( + context.operationReceiptAuthority) + { + await PeekabooBridgeRequestContext.$clientConnectionProbe.withValue( + connectionProbe, + operation: operation) + } } context.requestTracker.finish(trackedRequest) race.finish(.response(response)) diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeHost+Clients.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeHost+Clients.swift index 604adfd37..cb8765030 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeHost+Clients.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeHost+Clients.swift @@ -98,10 +98,14 @@ extension PeekabooBridgeHost { return } defer { context.admissionRefusalLimiter.finish() } - let responseData = await PeekabooBridgeRequestContext.$operationReceiptAuthority.withValue( - context.operationReceiptAuthority) + let responseData = await PeekabooBridgeRequestContext.$browserCapabilityNamespaceAuthority.withValue( + context.browserCapabilityNamespaceAuthority) { - await context.server.encodeAdmissionRefusal(request, peer: peer) + await PeekabooBridgeRequestContext.$operationReceiptAuthority.withValue( + context.operationReceiptAuthority) + { + await context.server.encodeAdmissionRefusal(request, peer: peer) + } } try PeekabooBridgeSocketIO.writeAll( fd: fd, @@ -127,6 +131,7 @@ extension PeekabooBridgeHost { connection: connection, requestTracker: context.requestTracker, operationReceiptAuthority: context.operationReceiptAuthority, + browserCapabilityNamespaceAuthority: context.browserCapabilityNamespaceAuthority, operationSessionAuthorizationPin: authorizationPin)) else { return diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeHost.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeHost.swift index 2f1b0739a..bfe6b0678 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeHost.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeHost.swift @@ -45,6 +45,7 @@ struct PeekabooBridgeClientContext: @unchecked Sendable { let admissionRefusalLimiter: PeekabooBridgeCapacityLimiter let acceptedConnectionLimiter: PeekabooBridgeCapacityLimiter let operationReceiptAuthority: PeekabooBridgeOperationReceiptAuthority? + let browserCapabilityNamespaceAuthority: PeekabooBridgeBrowserCapabilityNamespaceAuthority? let authentication: PeekabooBridgeHostAuthentication } @@ -97,10 +98,11 @@ actor PeekabooBridgeConnectionTracker { private func waitForPeekabooBridgeRequestsToDrain( _ tracker: PeekabooBridgeRequestTracker, + additionalCondition: @escaping @Sendable () -> Bool = { true }, timeoutSeconds: TimeInterval) async -> Bool { let deadline = ContinuousClock.now.advanced(by: .seconds(timeoutSeconds)) - while tracker.activeCount > 0 { + while tracker.activeCount > 0 || !additionalCondition() { guard ContinuousClock.now < deadline else { return false } await withCheckedContinuation { continuation in DispatchQueue.global().asyncAfter(deadline: .now() + 0.01) { @@ -111,6 +113,19 @@ private func waitForPeekabooBridgeRequestsToDrain( return true } +private final class PeekabooBridgeNamespaceRetirementState: @unchecked Sendable { + private let lock = NSLock() + private var finished = false + + var isFinished: Bool { + self.lock.withLock { self.finished } + } + + func finish() { + self.lock.withLock { self.finished = true } + } +} + /// Converts listener readability into a coalesced async sequence. /// /// A UNIX listener is level-triggered: one notification can represent several queued clients, so the accept loop @@ -444,6 +459,7 @@ public final actor PeekabooBridgeHost { private var ownershipCleanupTask: Task? private var operationReceiptAuthority: PeekabooBridgeOperationReceiptAuthority? private var lifecycleGeneration: UInt64 = 0 + private var browserCapabilityNamespaceAuthority: PeekabooBridgeBrowserCapabilityNamespaceAuthority? private let connectionTracker = PeekabooBridgeConnectionTracker() private let requestTracker: PeekabooBridgeRequestTracker private let bodyReadLimiter: PeekabooBridgeCapacityLimiter @@ -527,7 +543,8 @@ public final actor PeekabooBridgeHost { guard self.ownershipCleanupTask == nil, self.leaseFD == -1, self.requestTracker.activeCount == 0, - self.operationReceiptAuthority == nil + self.operationReceiptAuthority == nil, + self.browserCapabilityNamespaceAuthority == nil else { throw PeekabooBridgeHostError.requestsStillDraining( path: self.socketPath, @@ -572,6 +589,17 @@ public final actor PeekabooBridgeHost { } else { operationReceiptAuthority = nil } + if self.server.browserCapabilityNamespacesAvailable, let operationReceiptAuthority { + do { + self.browserCapabilityNamespaceAuthority = try PeekabooBridgeBrowserCapabilityNamespaceAuthority( + signingContext: operationReceiptAuthority.browserCapabilityNamespaceSigningContext()) + } catch { + close(self.listenFD) + self.listenFD = -1 + self.releaseOwnership() + throw error + } + } let fd = self.listenFD let listenerReadiness = PeekabooBridgeListenerReadiness(fileDescriptor: fd) @@ -589,6 +617,7 @@ public final actor PeekabooBridgeHost { admissionRefusalLimiter: self.admissionRefusalLimiter, acceptedConnectionLimiter: self.acceptedConnectionLimiter, operationReceiptAuthority: operationReceiptAuthority, + browserCapabilityNamespaceAuthority: self.browserCapabilityNamespaceAuthority, authentication: self.authentication) self.acceptTask = Task.detached(priority: .userInitiated) { @@ -637,7 +666,16 @@ public final actor PeekabooBridgeHost { pendingRequestCount: snapshot.count, oldestRequestAgeSeconds: snapshot.oldestAgeSeconds) } + let namespaceAuthority = self.browserCapabilityNamespaceAuthority + try? await namespaceAuthority?.beginDrainingAll() self.requestTracker.stopAcceptingAndCancelAll() + let namespaceRetirementState = PeekabooBridgeNamespaceRetirementState() + let namespaceRetirementTask = Task { + await self.server.closeAllBrowserCapabilityNamespaces() + try? await namespaceAuthority?.drainAll() + _ = await namespaceAuthority?.invalidateForRestart() + namespaceRetirementState.finish() + } let acceptTask = self.acceptTask acceptTask?.cancel() self.acceptTask = nil @@ -657,6 +695,7 @@ public final actor PeekabooBridgeHost { await self.connectionTracker.waitForIdle() guard await waitForPeekabooBridgeRequestsToDrain( self.requestTracker, + additionalCondition: { namespaceRetirementState.isFinished }, timeoutSeconds: self.requestDrainTimeoutSec) else { let snapshot = self.requestTracker.drainSnapshot @@ -665,12 +704,18 @@ public final actor PeekabooBridgeHost { Self.logger.error("\(message, privacy: .public)") self.ownershipCleanupTask = Task { [self] in await self.requestTracker.waitForIdle() + await namespaceRetirementTask.value + await self.server.beginNextBrowserCapabilityNamespaceGeneration() + self.browserCapabilityNamespaceAuthority = nil self.releaseRetainedOwnership() } return .ownershipRetained( pendingRequestCount: snapshot.count, oldestRequestAgeSeconds: snapshot.oldestAgeSeconds) } + await namespaceRetirementTask.value + await self.server.beginNextBrowserCapabilityNamespaceGeneration() + self.browserCapabilityNamespaceAuthority = nil self.releaseOwnership() return .stopped } @@ -698,6 +743,7 @@ public final actor PeekabooBridgeHost { } self.socketIdentity = nil self.operationReceiptAuthority = nil + self.browserCapabilityNamespaceAuthority = nil if self.leaseFD != -1 { if canClearLeaseIdentity { do { diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeRequest+DesktopMutation.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeRequest+DesktopMutation.swift index 234707092..8b8df7523 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeRequest+DesktopMutation.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeRequest+DesktopMutation.swift @@ -144,15 +144,26 @@ extension PeekabooBridgeRequest { } func validateBrowserCapabilityExecutionMode() throws { - guard case let .browserCapabilityNamespace(payload) = self.unwrappedOperationRequest, - payload.requestsForegroundDelivery, - payload.executionMode != .foregroundAllowed - else { return } - throw DesktopActionFailure.preDispatchRefusal( - route: .bridge, - reason: .foregroundRequired, - message: "This browser namespace action requires explicit foreground authority.", - hint: "Retry only with foreground_allowed when interrupting the user is intentional.") + guard case let .browserCapabilityNamespace(payload) = self.unwrappedOperationRequest else { return } + if payload.requestsForegroundDelivery, + payload.executionMode != .foregroundAllowed + { + throw DesktopActionFailure.preDispatchRefusal( + route: .bridge, + reason: .foregroundConsentRequired, + message: "This browser namespace action requires explicit foreground authority.", + hint: "Retry only with foreground_allowed when interrupting the user is intentional.") + } + if case let .executeAction(action) = payload.action, + action.action == .connect, + action.arguments["browser_url"] != nil + { + throw DesktopActionFailure.preDispatchRefusal( + route: .bridge, + reason: .invalidRequest, + message: "Browser capability namespaces do not accept explicit DevTools endpoints.", + hint: "Connect by signed local process/channel discovery, then bind an opaque page to a native window.") + } } var requiresRequestPinnedExactWindowScrollReceipt: Bool { @@ -227,6 +238,9 @@ extension PeekabooBridgeRequest { enum PeekabooBridgeRequestContext { @TaskLocal static var clientConnectionProbe: (@Sendable () -> Bool)? @TaskLocal static var operationReceiptAuthority: PeekabooBridgeOperationReceiptAuthority? + @TaskLocal static var browserCapabilityNamespaceAuthority: + PeekabooBridgeBrowserCapabilityNamespaceAuthority? + @TaskLocal static var attestedOperationRequestID: UUID? @TaskLocal static var usesAttestedOperationResultSemantics = false @TaskLocal static var negotiatedSessionCapabilities: PeekabooBridgeNegotiatedSessionCapabilities? diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+BrowserCapabilityNamespaces.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+BrowserCapabilityNamespaces.swift new file mode 100644 index 000000000..546351dca --- /dev/null +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+BrowserCapabilityNamespaces.swift @@ -0,0 +1,239 @@ +import Foundation +import PeekabooFoundation + +@MainActor +extension PeekabooBridgeServer { + private struct BrowserCapabilityNamespaceContext { + let authority: PeekabooBridgeBrowserCapabilityNamespaceAuthority + let service: any PeekabooBridgeBrowserCapabilityNamespaceProviding + let principal: PeekabooBridgeBrowserCapabilityPrincipal + } + + func handleBrowserCapabilityNamespaceCreate( + _ request: PeekabooBridgeBrowserCapabilityNamespaceCreateRequest, + peer: PeekabooBridgePeer?) async throws -> PeekabooBridgeHandledResponse + { + _ = request + let context = try self.browserCapabilityNamespaceContext(peer: peer, mutatesDesktop: false) + try await self.retireExpiredBrowserCapabilityNamespaces(context) + guard let admission = PeekabooBridgeBrowserCapabilityNamespaceAdmission( + isLocalExecutionHost: self.hostKind == .onDemand, + isAuthenticatedPeer: peer != nil, + hasNativeCapableService: context.service.supportsNativeBrowserWindowBinding) + else { + throw Self.browserCapabilityNamespacePreDispatchRefusal( + PeekabooBridgeBrowserCapabilityNamespaceError.unauthenticatedNamespaceAdmission, + mutatesDesktop: false) + } + let receipt: PeekabooBridgeBrowserCapabilityNamespaceReceipt + do { + receipt = try await context.authority.open( + principal: context.principal, + admission: admission, + lifetimeMilliseconds: PeekabooBridgeBrowserCapabilityNamespaceAuthority + .Configuration.current.maximumLifetimeMilliseconds) + } catch let error as PeekabooBridgeBrowserCapabilityNamespaceError { + throw Self.browserCapabilityNamespacePreDispatchRefusal(error, mutatesDesktop: false) + } + + do { + try await context.service.openBrowserCapabilityNamespace( + namespaceID: receipt.payload.namespaceID) + } catch { + try? await context.authority.close(receipt, principal: context.principal) + try? await context.authority.markRuntimeRetired(namespaceID: receipt.payload.namespaceID) + throw error + } + return .init(response: .browserCapabilityNamespaceCreated(receipt)) + } + + func handleBrowserCapabilityNamespaceAction( + _ request: PeekabooBridgeBrowserCapabilityNamespaceRequest, + peer: PeekabooBridgePeer?) async throws -> PeekabooBridgeHandledResponse + { + let context = try self.browserCapabilityNamespaceContext( + peer: peer, + mutatesDesktop: !request.isReadOnly) + try await self.retireExpiredBrowserCapabilityNamespaces(context) + guard let requestID = PeekabooBridgeRequestContext.attestedOperationRequestID, + let admission = PeekabooBridgeBrowserCapabilityClaimAdmission( + executionPolicy: request.executionMode, + isLocalExecutionHost: self.hostKind == .onDemand, + isAuthenticatedPeer: peer != nil, + hasScopedForegroundAuthorization: request.executionMode == .foregroundAllowed) + else { + throw Self.browserCapabilityNamespacePreDispatchRefusal( + PeekabooBridgeBrowserCapabilityNamespaceError.unauthenticatedClaimAdmission, + mutatesDesktop: !request.isReadOnly) + } + + let claim: PeekabooBridgeBrowserCapabilityNamespaceClaim + do { + claim = try await context.authority.claim( + request.namespaceReceipt, + principal: context.principal, + claimID: requestID, + admission: admission) + } catch let error as PeekabooBridgeBrowserCapabilityNamespaceError { + throw Self.browserCapabilityNamespacePreDispatchRefusal( + error, + mutatesDesktop: !request.isReadOnly) + } + + let result: PeekabooBridgeBrowserCapabilityNamespaceServiceResult + do { + result = try await context.service.executeBrowserCapabilityNamespace( + namespaceID: claim.authorization.namespaceID, + request: request) + } catch { + do { + try await context.authority.complete(claim) + } catch let completionError as PeekabooBridgeBrowserCapabilityNamespaceError { + throw Self.browserCapabilityNamespaceCompletionFailure(completionError) + } + throw error + } + do { + try await context.authority.complete(claim) + } catch let error as PeekabooBridgeBrowserCapabilityNamespaceError { + throw Self.browserCapabilityNamespaceCompletionFailure(error) + } + + var handled = PeekabooBridgeHandledResponse( + response: .browserCapabilityNamespaceAction(result.response), + targetIdentity: result.targetIdentity) + if !request.isReadOnly, let outcome = result.outcome { + let target: PeekabooBridgeHandledResponse.Mutation.TargetDisposition = + result.targetIdentity.map(PeekabooBridgeHandledResponse.Mutation.TargetDisposition.handlerResolved) ?? + .external + handled = handled.finalizingMutation(outcome: outcome, target: target) + } + return handled + } + + func handleBrowserCapabilityNamespaceClose( + _ request: PeekabooBridgeBrowserCapabilityNamespaceCloseRequest, + peer: PeekabooBridgePeer?) async throws -> PeekabooBridgeHandledResponse + { + let context = try self.browserCapabilityNamespaceContext(peer: peer, mutatesDesktop: false) + try await self.retireExpiredBrowserCapabilityNamespaces(context) + let identity: PeekabooBridgeBrowserCapabilityNamespaceIdentity + do { + identity = try await context.authority.beginClose( + request.namespaceReceipt, + principal: context.principal) + } catch let error as PeekabooBridgeBrowserCapabilityNamespaceError { + throw Self.browserCapabilityNamespacePreDispatchRefusal(error, mutatesDesktop: false) + } + + var runtimeError: (any Error)? + do { + try await context.service.closeBrowserCapabilityNamespace(namespaceID: identity.namespaceID) + } catch { + runtimeError = error + } + do { + try await context.authority.awaitDrained(identity: identity) + } catch let error as PeekabooBridgeBrowserCapabilityNamespaceError { + throw Self.browserCapabilityNamespaceCompletionFailure(error) + } + if let runtimeError { + throw runtimeError + } + try await context.authority.markRuntimeRetired(namespaceID: identity.namespaceID) + return .init(response: .browserCapabilityNamespaceClosed(.init(namespaceID: identity.namespaceID))) + } + + private func retireExpiredBrowserCapabilityNamespaces( + _ context: BrowserCapabilityNamespaceContext) async throws + { + let namespaceIDs = try await context.authority.terminalNamespaceIDsRequiringRuntimeRetirement() + for namespaceID in namespaceIDs { + do { + try await context.service.closeBrowserCapabilityNamespace(namespaceID: namespaceID) + } catch let envelope as PeekabooBridgeErrorEnvelope where envelope.code == .notFound { + // An idempotent runtime may already have released this exact namespace. + } + try await context.authority.markRuntimeRetired(namespaceID: namespaceID) + } + } + + private func browserCapabilityNamespaceContext( + peer: PeekabooBridgePeer?, + mutatesDesktop: Bool) throws + -> BrowserCapabilityNamespaceContext + { + guard PeekabooBridgeRequestContext.usesAttestedOperationResultSemantics, + PeekabooBridgeRequestContext.negotiatedSessionCapabilities?.browserCapabilityNamespaces == true, + PeekabooBridgeRequestContext.negotiatedSessionCapabilities?.nativeBrowserWindowBinding == true, + self.hostKind == .onDemand, + let peer, + let authority = PeekabooBridgeRequestContext.browserCapabilityNamespaceAuthority, + let service = self.services as? any PeekabooBridgeBrowserCapabilityNamespaceProviding, + service.supportsBrowserCapabilityNamespaces, + service.supportsNativeBrowserWindowBinding + else { + throw Self.browserCapabilityNamespacePreDispatchRefusal( + PeekabooBridgeBrowserCapabilityNamespaceError.unauthenticatedClaimAdmission, + mutatesDesktop: mutatesDesktop) + } + do { + return try BrowserCapabilityNamespaceContext( + authority: authority, + service: service, + principal: PeekabooBridgeBrowserCapabilityNamespaceAuthority.principal(for: peer)) + } catch let error as PeekabooBridgeBrowserCapabilityNamespaceError { + throw Self.browserCapabilityNamespacePreDispatchRefusal(error, mutatesDesktop: mutatesDesktop) + } + } + + private static func browserCapabilityNamespaceCompletionFailure( + _ error: PeekabooBridgeBrowserCapabilityNamespaceError) -> PeekabooBridgeErrorEnvelope + { + PeekabooBridgeErrorEnvelope( + code: .internalError, + message: "Browser capability namespace completion could not be finalized", + details: error.localizedDescription) + } + + private static func browserCapabilityNamespacePreDispatchRefusal( + _ error: PeekabooBridgeBrowserCapabilityNamespaceError, + mutatesDesktop: Bool) -> PeekabooBridgeErrorEnvelope + { + let code: PeekabooBridgeErrorCode + let reason: DesktopActionOutcome.RefusalReason + switch error { + case .invalidPrincipal, .unauthenticatedNamespaceAdmission, .unauthenticatedClaimAdmission, + .invalidSignature, .principalMismatch: + code = .unauthorizedClient + reason = .transportSessionUnavailable + case .invalidReceipt, .listenerMismatch, .registryGenerationMismatch, .receiptNotYetValid, + .replayedClaim, .claimMismatch, .drainAlreadyAwaited: + code = .invalidRequest + reason = .invalidRequest + case .receiptExpired, .namespaceNotFound, .namespaceClosing, .namespaceClosed, .namespaceExpired: + code = .notFound + reason = .targetUnavailable + case .registryInvalidated, .registryDraining: + code = .versionMismatch + reason = .transportSessionUnavailable + case .invalidConfiguration, .namespaceCapacityExceeded, .claimCapacityExceeded: + code = .serverBusy + reason = .runtimeIncompatible + } + guard mutatesDesktop else { + return PeekabooBridgeErrorEnvelope( + code: code, + message: error.localizedDescription) + } + return PeekabooBridgeErrorEnvelope( + code: code, + actionFailure: .preDispatchRefusal( + route: .bridge, + reason: reason, + message: error.localizedDescription, + hint: reason == .targetUnavailable + ? "Create a new browser capability namespace before retrying." + : "Reconnect the on-demand Bridge session before retrying.")) + } +} diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+Handlers.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+Handlers.swift index 7f46a72d6..125e30dd4 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+Handlers.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+Handlers.swift @@ -50,12 +50,21 @@ extension PeekabooBridgeServer { return try await .init(response: self.handleBrowserRequest(request)) } return try await self.handleBrowserExecute(payload) - case .browserCreateCapabilityNamespace, - .browserCapabilityNamespace, - .browserCloseCapabilityNamespace: - throw PeekabooBridgeErrorEnvelope( - code: .operationNotSupported, - message: "This Bridge host has no browser capability namespace runtime") + case .browserCreateCapabilityNamespace: + guard case let .browserCreateCapabilityNamespace(payload) = request else { + throw Self.invalidRequest(for: request) + } + return try await self.handleBrowserCapabilityNamespaceCreate(payload, peer: peer) + case .browserCapabilityNamespace: + guard case let .browserCapabilityNamespace(payload) = request else { + throw Self.invalidRequest(for: request) + } + return try await self.handleBrowserCapabilityNamespaceAction(payload, peer: peer) + case .browserCloseCapabilityNamespace: + guard case let .browserCloseCapabilityNamespace(payload) = request else { + throw Self.invalidRequest(for: request) + } + return try await self.handleBrowserCapabilityNamespaceClose(payload, peer: peer) case .captureScreen, .captureWindow, .captureFrontmost, .captureArea: return try await .init(response: self.handleCaptureRequest(request)) case .desktopObservation: diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer.swift index f65ee95c3..0ee24c21a 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer.swift @@ -113,6 +113,7 @@ public final class PeekabooBridgeServer { let allowedOperations: Set let hostIdentity: PeekabooBridgeHostIdentity? private(set) var hostCapabilities: Set + nonisolated let browserCapabilityNamespacesAvailable: Bool let servingSocketPath: String? var agentExecutionRunner: (any PeekabooBridgeAgentExecutionRunning)? let daemonControl: (any PeekabooDaemonControlProviding)? @@ -220,6 +221,9 @@ public final class PeekabooBridgeServer { resolvedHostCapabilities.insert(PeekabooBridgeHostCapability.nativeBrowserConnectionBinding) } let browserNamespaceService = services as? any PeekabooBridgeBrowserCapabilityNamespaceProviding + let browserNamespaceRuntimePrepared = Self.prepareBrowserCapabilityNamespaceRuntime( + browserNamespaceService, + hostKind: hostKind) resolvedHostCapabilities = protocolBrowserNamespaceCapabilities( resolvedHostCapabilities, support: .init( @@ -227,9 +231,9 @@ public final class PeekabooBridgeServer { maximumProtocolVersion: supportedVersions.upperBound, allowedOperations: self.allowedOperations, supportsBrowserCapabilityNamespaces: - browserNamespaceService?.supportsBrowserCapabilityNamespaces == true, + browserNamespaceRuntimePrepared, supportsNativeBrowserWindowBinding: - browserNamespaceService?.supportsNativeBrowserWindowBinding == true)) + browserNamespaceRuntimePrepared)) if supportedVersions.upperBound >= PeekabooBridgeConstants.producerBoundSnapshotReferencesVersion, services.snapshots.supportsProducerBoundSnapshotReferences, self.allowedOperations.contains(.ownsSnapshot) @@ -358,6 +362,8 @@ public final class PeekabooBridgeServer { resolvedHostCapabilities.insert( PeekabooBridgeHostCapability.processGenerationPinnedApplicationHide) } + self.browserCapabilityNamespacesAvailable = resolvedHostCapabilities.contains( + PeekabooBridgeHostCapability.browserCapabilityNamespaces) self.hostCapabilities = resolvedHostCapabilities self.daemonControl = daemonControl self.desktopMutationWatermarkStore = desktopMutationWatermarkStore @@ -470,6 +476,23 @@ public final class PeekabooBridgeServer { } } + private static func prepareBrowserCapabilityNamespaceRuntime( + _ service: (any PeekabooBridgeBrowserCapabilityNamespaceProviding)?, + hostKind: PeekabooBridgeHostKind) -> Bool + { + guard hostKind == .onDemand, + let service, + service.supportsBrowserCapabilityNamespaces, + service.supportsNativeBrowserWindowBinding + else { return false } + do { + try service.prepareBrowserCapabilityNamespaceRuntime() + return true + } catch { + return false + } + } + #if DEBUG func setAgentExecutionRunnerForTesting(_ runner: (any PeekabooBridgeAgentExecutionRunning)?) { self.agentExecutionRunner = runner @@ -488,6 +511,16 @@ public final class PeekabooBridgeServer { } #endif + func closeAllBrowserCapabilityNamespaces() async { + await (self.services as? any PeekabooBridgeBrowserCapabilityNamespaceProviding)? + .closeAllBrowserCapabilityNamespaces() + } + + func beginNextBrowserCapabilityNamespaceGeneration() { + (self.services as? any PeekabooBridgeBrowserCapabilityNamespaceProviding)? + .beginNextBrowserCapabilityNamespaceGeneration() + } + func handleProjectedAction( _ payload: PeekabooBridgeProjectedActionRequest, peer: PeekabooBridgePeer?) async -> Data diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServiceProviding.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServiceProviding.swift index 4dc2631ba..8cfc9a85c 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServiceProviding.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServiceProviding.swift @@ -1,3 +1,4 @@ +import Foundation import PeekabooAutomationKit import PeekabooFoundation @@ -53,6 +54,32 @@ public protocol PeekabooBridgeBrowserConnectionResultProviding: PeekabooBridgeSe public protocol PeekabooBridgeBrowserCapabilityNamespaceProviding: PeekabooBridgeServiceProviding { var supportsBrowserCapabilityNamespaces: Bool { get } var supportsNativeBrowserWindowBinding: Bool { get } + + func prepareBrowserCapabilityNamespaceRuntime() throws + func openBrowserCapabilityNamespace(namespaceID: UUID) async throws + func executeBrowserCapabilityNamespace( + namespaceID: UUID, + request: PeekabooBridgeBrowserCapabilityNamespaceRequest) async throws + -> PeekabooBridgeBrowserCapabilityNamespaceServiceResult + func closeBrowserCapabilityNamespace(namespaceID: UUID) async throws + func closeAllBrowserCapabilityNamespaces() async + func beginNextBrowserCapabilityNamespaceGeneration() +} + +public struct PeekabooBridgeBrowserCapabilityNamespaceServiceResult: Sendable { + public let response: PeekabooBridgeBrowserCapabilityNamespaceActionResponse + public let targetIdentity: DesktopTargetIdentity? + public let outcome: DesktopActionOutcome? + + public init( + response: PeekabooBridgeBrowserCapabilityNamespaceActionResponse, + targetIdentity: DesktopTargetIdentity? = nil, + outcome: DesktopActionOutcome? = nil) + { + self.response = response + self.targetIdentity = targetIdentity + self.outcome = outcome + } } extension PeekabooBridgeBrowserCapabilityNamespaceProviding { @@ -63,6 +90,34 @@ extension PeekabooBridgeBrowserCapabilityNamespaceProviding { public var supportsNativeBrowserWindowBinding: Bool { false } + + public func prepareBrowserCapabilityNamespaceRuntime() throws { + throw PeekabooBridgeErrorEnvelope( + code: .operationNotSupported, + message: "Browser capability namespaces are unavailable on this host") + } + + public func openBrowserCapabilityNamespace(namespaceID _: UUID) async throws { + throw PeekabooBridgeErrorEnvelope( + code: .operationNotSupported, + message: "Browser capability namespaces are unavailable on this host") + } + + public func executeBrowserCapabilityNamespace( + namespaceID _: UUID, + request _: PeekabooBridgeBrowserCapabilityNamespaceRequest) async throws + -> PeekabooBridgeBrowserCapabilityNamespaceServiceResult + { + throw PeekabooBridgeErrorEnvelope( + code: .operationNotSupported, + message: "Browser capability namespaces are unavailable on this host") + } + + public func closeBrowserCapabilityNamespace(namespaceID _: UUID) async throws {} + + public func closeAllBrowserCapabilityNamespaces() async {} + + public func beginNextBrowserCapabilityNamespaceGeneration() {} } extension PeekabooBridgeBrowserConnectionResultProviding { diff --git a/Core/PeekabooCore/Sources/PeekabooCore/Daemon/PeekabooDaemon.swift b/Core/PeekabooCore/Sources/PeekabooCore/Daemon/PeekabooDaemon.swift index 991b2ec7f..276c9b48f 100644 --- a/Core/PeekabooCore/Sources/PeekabooCore/Daemon/PeekabooDaemon.swift +++ b/Core/PeekabooCore/Sources/PeekabooCore/Daemon/PeekabooDaemon.swift @@ -54,6 +54,7 @@ public final class PeekabooDaemon: PeekabooConditionalDaemonControlProviding { mode: .auto, bridgeSocketPath: bridgeSocketPath, allowlistedTeams: [], + allowedOperations: PeekabooBridgeOperation.onDemandDefaultAllowlist, windowTrackingEnabled: true, windowPollInterval: windowPollInterval, hostKind: .onDemand, @@ -68,6 +69,7 @@ public final class PeekabooDaemon: PeekabooConditionalDaemonControlProviding { mode: .manual, bridgeSocketPath: bridgeSocketPath, allowlistedTeams: [], + allowedOperations: PeekabooBridgeOperation.onDemandDefaultAllowlist, windowTrackingEnabled: true, windowPollInterval: windowPollInterval, hostKind: .onDemand) diff --git a/Core/PeekabooCore/Sources/PeekabooCore/Support/PeekabooServices+BrowserBridge.swift b/Core/PeekabooCore/Sources/PeekabooCore/Support/PeekabooServices+BrowserBridge.swift index 30ec3f160..eb373675e 100644 --- a/Core/PeekabooCore/Sources/PeekabooCore/Support/PeekabooServices+BrowserBridge.swift +++ b/Core/PeekabooCore/Sources/PeekabooCore/Support/PeekabooServices+BrowserBridge.swift @@ -255,6 +255,136 @@ extension PeekabooServices: PeekabooBridgeBrowserConnectionResultProviding { } } +@MainActor +extension PeekabooServices: PeekabooBridgeBrowserCapabilityNamespaceProviding { + public var supportsBrowserCapabilityNamespaces: Bool { + self.browser is BrowserMCPService + } + + public var supportsNativeBrowserWindowBinding: Bool { + guard let browser = self.browser as? BrowserMCPService else { return false } + return browser.supportsNativeBrowserConnectionBinding + } + + public func prepareBrowserCapabilityNamespaceRuntime() throws { + guard self.browserCapabilityNamespaceRuntime == nil else { return } + self.browserCapabilityNamespaceRuntime = try BrowserMCPScopedNamespaceRuntime(context: MCPToolContext( + services: self, + executionPolicy: .backgroundOnly)) + } + + public func openBrowserCapabilityNamespace(namespaceID: UUID) async throws { + guard let runtime = self.browserCapabilityNamespaceRuntime else { + throw PeekabooBridgeErrorEnvelope( + code: .operationNotSupported, + message: "The local browser namespace runtime is unavailable") + } + do { + try runtime.open(.init(rawValue: namespaceID)) + } catch let error as BrowserMCPScopedNamespaceRuntimeError { + throw Self.browserCapabilityNamespaceRuntimeRefusal(error, mutatesDesktop: false) + } + } + + public func executeBrowserCapabilityNamespace( + namespaceID: UUID, + request: PeekabooBridgeBrowserCapabilityNamespaceRequest) async throws + -> PeekabooBridgeBrowserCapabilityNamespaceServiceResult + { + guard let runtime = self.browserCapabilityNamespaceRuntime else { + throw PeekabooBridgeErrorEnvelope( + code: .operationNotSupported, + message: "The local browser namespace runtime is unavailable") + } + let arguments = ToolArguments(raw: request.toolArguments.mapValues { $0.toAny() }) + let policy: BrowserMCPScopedNamespaceExecutionPolicy = switch request.executionMode { + case .backgroundOnly: + .backgroundOnly + case .foregroundAllowed: + .explicitlyForegroundAllowed + } + let result: BrowserMCPScopedNamespaceExecutionResult + do { + result = try await runtime.execute( + in: .init(rawValue: namespaceID), + arguments: arguments, + policy: policy) + } catch let error as BrowserMCPScopedNamespaceRuntimeError { + throw Self.browserCapabilityNamespaceRuntimeRefusal( + error, + mutatesDesktop: !request.isReadOnly) + } + let response = result.response + let nativeReceipt = result.nativeWindowReceipt.map { + PeekabooBridgeBrowserNativeWindowReceipt( + pageReference: $0.pageReference, + processIdentifier: $0.processIdentifier, + processStartIdentityDecimal: String($0.processStartIdentity), + windowID: $0.windowID, + bounds: $0.bounds) + } + let bridgeResponse = try PeekabooBridgeBrowserCapabilityNamespaceActionResponse( + content: response.content.map { try PeekabooBridgeJSONValue.fromCodable($0) }, + isError: response.isError, + meta: response.meta.map { try PeekabooBridgeJSONValue.fromCodable($0) }, + structuredContent: response.structuredContent.map { try PeekabooBridgeJSONValue.fromCodable($0) }, + nativeWindowReceipt: nativeReceipt) + return PeekabooBridgeBrowserCapabilityNamespaceServiceResult( + response: bridgeResponse, + targetIdentity: result.targetIdentity, + outcome: result.outcome) + } + + public func closeBrowserCapabilityNamespace(namespaceID: UUID) async throws { + guard let runtime = self.browserCapabilityNamespaceRuntime else { return } + do { + try await runtime.close(.init(rawValue: namespaceID)) + } catch let error as BrowserMCPScopedNamespaceRuntimeError { + throw Self.browserCapabilityNamespaceRuntimeRefusal(error, mutatesDesktop: false) + } + } + + public func closeAllBrowserCapabilityNamespaces() async { + guard let runtime = self.browserCapabilityNamespaceRuntime else { return } + await runtime.closeAll() + } + + public func beginNextBrowserCapabilityNamespaceGeneration() { + self.browserCapabilityNamespaceRuntime?.beginNextHostGeneration() + } + + private static func browserCapabilityNamespaceRuntimeRefusal( + _ error: BrowserMCPScopedNamespaceRuntimeError, + mutatesDesktop: Bool) -> PeekabooBridgeErrorEnvelope + { + let code: PeekabooBridgeErrorCode + let reason: DesktopActionOutcome.RefusalReason + switch error { + case .namespaceUnknown, .namespaceClosing, .namespaceEnded: + code = .notFound + reason = .targetUnavailable + case .namespaceAlreadyExists: + code = .invalidRequest + reason = .invalidRequest + case .localExecutionRequired, .localBrowserServiceRequired, .scopedSessionUnavailable: + code = .operationNotSupported + reason = .runtimeIncompatible + } + guard mutatesDesktop else { + return PeekabooBridgeErrorEnvelope(code: code, message: error.localizedDescription) + } + return PeekabooBridgeErrorEnvelope( + code: code, + actionFailure: .preDispatchRefusal( + route: .bridge, + reason: reason, + message: error.localizedDescription, + hint: reason == .targetUnavailable + ? "Create a new browser capability namespace before retrying." + : "Update and relaunch the on-demand Peekaboo host before retrying.")) + } +} + extension PeekabooBridgeJSONValue { static func fromCodable(_ value: some Encodable) throws -> PeekabooBridgeJSONValue { let data = try JSONEncoder().encode(value) diff --git a/Core/PeekabooCore/Sources/PeekabooCore/Support/PeekabooServices.swift b/Core/PeekabooCore/Sources/PeekabooCore/Support/PeekabooServices.swift index e174b332c..f3d28d88e 100644 --- a/Core/PeekabooCore/Sources/PeekabooCore/Support/PeekabooServices.swift +++ b/Core/PeekabooCore/Sources/PeekabooCore/Support/PeekabooServices.swift @@ -112,6 +112,10 @@ public final class PeekabooServices { /// Browser MCP client for Chrome DevTools automation public let browser: any BrowserMCPClientProviding + /// Listener-generation owner of caller-scoped Bridge 1.38 browser children. + /// Bridge start prepares it after listener authentication exists; stop detaches it before draining. + var browserCapabilityNamespaceRuntime: BrowserMCPScopedNamespaceRuntime? + /// Operations whose concrete native service owns the desktop lane at its dispatch leaf. private let nativeDesktopOperationLaneOperations: Set diff --git a/Core/PeekabooCore/Sources/PeekabooCore/Support/RemotePeekabooServices.swift b/Core/PeekabooCore/Sources/PeekabooCore/Support/RemotePeekabooServices.swift index c3c29062d..d4f9010ff 100644 --- a/Core/PeekabooCore/Sources/PeekabooCore/Support/RemotePeekabooServices.swift +++ b/Core/PeekabooCore/Sources/PeekabooCore/Support/RemotePeekabooServices.swift @@ -27,12 +27,14 @@ public final class RemotePeekabooServices: PeekabooServiceProviding { public let screens: any ScreenServiceProtocol public let browser: any BrowserMCPClientProviding public let agent: (any AgentServiceProtocol)? + public let browserCapabilityNamespaceClient: PeekabooBridgeClient? private let client: PeekabooBridgeClient private let supportsPostEventPermissionRequest: Bool public init( client: PeekabooBridgeClient, + supportsBrowserCapabilityNamespaces: Bool = false, supportsTargetedHotkeys: Bool = false, supportsProcessGenerationPinnedHotkeys: Bool = false, targetedHotkeyUnavailableReason: String? = nil, @@ -89,6 +91,7 @@ public final class RemotePeekabooServices: PeekabooServiceProviding { desktopMutationWatermarkStore: DesktopMutationWatermarkStore? = nil) { self.client = client + self.browserCapabilityNamespaceClient = supportsBrowserCapabilityNamespaces ? client : nil self.supportsPostEventPermissionRequest = supportsPostEventPermissionRequest let supportsRemoteDesktopObservationOCR = supportsDesktopObservation && supportsDesktopObservationOCR let supportsRemoteCaptureEnginePreference = supportsDesktopObservation && diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPScopedNamespaceRuntimeTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPScopedNamespaceRuntimeTests.swift index 555cdf15f..685bc09db 100644 --- a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPScopedNamespaceRuntimeTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPScopedNamespaceRuntimeTests.swift @@ -1,5 +1,6 @@ import Foundation import MCP +import PeekabooFoundation import TachikomaMCP import Testing @testable import PeekabooAgentRuntime @@ -38,7 +39,7 @@ struct BrowserMCPScopedNamespaceRuntimeTests { } @Test - func `close publishes closing then joins one drain and tombstones the identity`() async throws { + func `close publishes closing then joins one drain and releases the identity`() async throws { let fixture = NamespaceRuntimeFixture() let namespaceID = Self.namespaceID(3) try fixture.runtime.open(namespaceID) @@ -89,17 +90,28 @@ struct BrowserMCPScopedNamespaceRuntimeTests { _ = try await fixture.runtime.execute( in: namespaceID, arguments: ToolArguments(raw: ["action": "status"])) - Issue.record("Expected ended namespace to reject new work") + Issue.record("Expected closed namespace to reject new work") } catch let error as BrowserMCPScopedNamespaceRuntimeError { - #expect(error == .namespaceEnded) - } - #expect(throws: BrowserMCPScopedNamespaceRuntimeError.namespaceEnded) { - try fixture.runtime.open(namespaceID) + #expect(error == .namespaceUnknown) } try await fixture.runtime.close(namespaceID) #expect(session.closeCount == 1) } + @Test + func `sequential namespace closes retain no runtime identities`() async throws { + let fixture = NamespaceRuntimeFixture() + for index in 1...96 { + let namespaceID = BrowserMCPScopedNamespaceID(rawValue: UUID()) + try fixture.runtime.open(namespaceID) + _ = try await fixture.runtime.execute( + in: namespaceID, + arguments: ToolArguments(raw: ["action": "status"])) + try await fixture.runtime.close(namespaceID) + #expect(fixture.runtime.namespaceCount == 0, "retained namespace at cycle \(index)") + } + } + @Test func `independent namespaces overlap while each retains its own session`() async throws { let fixture = NamespaceRuntimeFixture() @@ -270,6 +282,39 @@ struct BrowserMCPScopedNamespaceRuntimeTests { } } + @Test + func `foreground connect projects its top level process receipt as exact target`() async throws { + let fixture = NamespaceRuntimeFixture() + let namespaceID = Self.namespaceID(14) + try fixture.runtime.open(namespaceID) + let session = try #require(fixture.sessions[namespaceID]) + let outcome = DesktopActionOutcome.dispatchedUnverified( + delivery: .init(mechanism: .browserProtocol, mode: .foreground), + evidence: .deliveryAccepted, + unitCount: .one) + session.response = try ToolResponse.text( + "connected", + meta: MCPToolResponseMetadataProjector.metadata( + merging: [ + "connection_receipt": .object([ + "pid": .int(42), + "process_start_identity_decimal": .string("1001"), + "browser_url": .string("http://127.0.0.1:9222"), + ]), + ], + outcome: outcome)) + + let connected = try await fixture.runtime.execute( + in: namespaceID, + arguments: ToolArguments(raw: ["action": "connect"]), + policy: .explicitlyForegroundAllowed) + + #expect(connected.targetIdentity?.processIdentity.processIdentifier == 42) + #expect(connected.targetIdentity?.processIdentity.processStartIdentity == 1001) + #expect(connected.outcome == outcome) + #expect(!Self.dump(connected.response).contains("127.0.0.1:9222")) + } + @Test func `recursive scrubber removes host IDs and fails closed on raw provider capabilities`() async throws { let fixture = NamespaceRuntimeFixture() diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPSessionManagerTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPSessionManagerTests.swift index dfaad7f96..0f6d5ba4d 100644 --- a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPSessionManagerTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPSessionManagerTests.swift @@ -1076,6 +1076,20 @@ struct BrowserMCPSessionManagerTests { } } + @Test + func `sequential authenticated sessions retain no ended identities`() async { + let pool = BrowserMCPAuthenticatedSessionPool { _ in + Self.exactSession(manager: MockBrowserMCPManager()) + } + for index in 1...96 { + let sessionID = BrowserMCPAuthenticatedSessionPool.SessionID() + _ = pool.manager(for: sessionID) + await pool.end(sessionID) + #expect(pool.retainedSessionIdentityCount == 0, "retained session at cycle \(index)") + #expect(pool.manager(for: sessionID) == nil) + } + } + @Test func `root and scoped sessions cannot claim the same exact target in either order`() throws { let pool = BrowserMCPAuthenticatedSessionPool { _ in diff --git a/Core/PeekabooCore/Tests/PeekabooBridgeTests/BrowserCapabilityNamespaceWireTests.swift b/Core/PeekabooCore/Tests/PeekabooBridgeTests/BrowserCapabilityNamespaceWireTests.swift index 0b46b7d66..c120f3c4a 100644 --- a/Core/PeekabooCore/Tests/PeekabooBridgeTests/BrowserCapabilityNamespaceWireTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooBridgeTests/BrowserCapabilityNamespaceWireTests.swift @@ -27,6 +27,20 @@ struct BrowserCapabilityNamespaceWireTests { #expect(Self.namespaceOperations.isSubset(of: PeekabooBridgeOperation.onDemandDefaultAllowlist)) } + @Test + func `namespace connect refuses explicit provider endpoints before dispatch`() { + let request = PeekabooBridgeRequest.browserCapabilityNamespace(.init( + namespaceReceipt: Self.receipt(), + executionMode: .foregroundAllowed, + action: .executeAction(.init( + action: .connect, + arguments: ["browser_url": .string("http://127.0.0.1:9222")])))) + + #expect(throws: DesktopActionFailure.self) { + try request.validateBrowserCapabilityExecutionMode() + } + } + @Test func `signed namespace receipt round trips without private browser identifiers`() throws { let receipt = Self.receipt() diff --git a/Core/PeekabooCore/Tests/PeekabooTests/BrowserCapabilityNamespaceHandshakeTests.swift b/Core/PeekabooCore/Tests/PeekabooTests/BrowserCapabilityNamespaceHandshakeTests.swift index d44437a1a..05bd2cfe6 100644 --- a/Core/PeekabooCore/Tests/PeekabooTests/BrowserCapabilityNamespaceHandshakeTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooTests/BrowserCapabilityNamespaceHandshakeTests.swift @@ -24,6 +24,14 @@ struct BrowserCapabilityNamespaceHandshakeTests { server: server, allowedTeamIDs: [], requestTimeoutSec: 2) + await host.setAuthenticationForTesting(.init( + liveIdentity: { try PeekabooBridgeSocketIO.livePeerIdentity(fd: $0) }, + coldPeer: { identity, _ in + PeekabooBridgePeer( + liveIdentity: identity, + bundleIdentifier: "dev.peekaboo.browser-namespace-client", + teamIdentifier: TrustedBridgeClientFixture.teamIdentifier) + })) try await host.startChecked() defer { Task { await host.stop() } } @@ -77,6 +85,188 @@ struct BrowserCapabilityNamespaceHandshakeTests { PeekabooBridgeHostCapability.nativeBrowserWindowBinding) != true) } + @Test + @MainActor + func `namespace lifecycle survives listener restart and rejects the retired receipt`() async throws { + let socketPath = "/tmp/peekaboo-browser-namespace-lifecycle-\(UUID().uuidString).sock" + let services = StubServices() + let server = PeekabooBridgeServer( + services: services, + hostKind: .onDemand, + allowlistedTeams: [], + allowlistedBundles: [], + allowedOperations: PeekabooBridgeOperation.onDemandDefaultAllowlist) + let host = PeekabooBridgeHost( + socketPath: socketPath, + server: server, + allowedTeamIDs: [], + requestTimeoutSec: 2) + await host.setAuthenticationForTesting(.init( + liveIdentity: { try PeekabooBridgeSocketIO.livePeerIdentity(fd: $0) }, + coldPeer: { identity, _ in + PeekabooBridgePeer( + liveIdentity: identity, + bundleIdentifier: "dev.peekaboo.browser-namespace-lifecycle-client", + teamIdentifier: TrustedBridgeClientFixture.teamIdentifier) + })) + try await host.startChecked() + + let firstClient = TrustedBridgeClientFixture.make(socketPath: socketPath, requestTimeoutSec: 2) + _ = try await firstClient.handshake(client: .init( + bundleIdentifier: "dev.peekaboo.browser-namespace-lifecycle", + teamIdentifier: nil, + processIdentifier: getpid())) + services.browserNamespaceOpenError = PeekabooBridgeErrorEnvelope( + code: .internalError, + message: "Injected namespace runtime-open failure") + await #expect(throws: PeekabooBridgeErrorEnvelope.self) { + _ = try await firstClient.createBrowserCapabilityNamespace() + } + services.browserNamespaceOpenError = nil + #expect(services.browserNamespaceOpenedIDs.isEmpty) + let firstReceipt = try await firstClient.createBrowserCapabilityNamespace() + let firstReceiptData = try await firstClient.canonicalBrowserCapabilityNamespaceReceiptData(firstReceipt) + #expect(try await firstClient.decodeBrowserCapabilityNamespaceReceipt(firstReceiptData) == firstReceipt) + let action = PeekabooBridgeBrowserCapabilityNamespaceRequest( + namespaceReceipt: firstReceipt, + action: .executeAction(.init(action: .status))) + let actionResponse = try await firstClient.executeBrowserCapabilityNamespace(action) + #expect(!actionResponse.isError) + services.browserNamespaceOutcome = .dispatchedUnverified( + delivery: .init(mechanism: .browserProtocol, mode: .background), + evidence: .deliveryAccepted, + unitCount: .one) + services.browserNamespaceTargetIdentity = try DesktopTargetIdentity( + processIdentity: .init(processIdentifier: 42, processStartIdentity: 1001)) + let mutation = try await firstClient.executeBrowserCapabilityNamespaceResult(.init( + namespaceReceipt: firstReceipt, + action: .executeAction(.init( + action: .click, + arguments: ["page_id": .string(Self.pageReference)])))) + #expect(mutation.outcome?.route == .bridge) + #expect(mutation.outcome?.dispatchState.unitCount == .one) + #expect(services.browserNamespacePrepareCount == 1) + #expect(services.browserNamespaceOpenedIDs == [firstReceipt.payload.namespaceID]) + #expect(services.browserNamespaceExecutedIDs == [ + firstReceipt.payload.namespaceID, + firstReceipt.payload.namespaceID, + ]) + _ = try await firstClient.closeBrowserCapabilityNamespace(firstReceipt) + let repeatedClose = try await firstClient.closeBrowserCapabilityNamespace(firstReceipt) + #expect(repeatedClose.namespaceID == firstReceipt.payload.namespaceID) + #expect(services.browserNamespaceClosedIDs == [firstReceipt.payload.namespaceID]) + + #expect(await host.stop() == .stopped) + #expect(services.browserNamespaceCloseAllCount == 1) + #expect(services.browserNamespaceOpenedIDs.isEmpty) + + try await host.startChecked() + defer { Task { await host.stop() } } + let replacementClient = TrustedBridgeClientFixture.make(socketPath: socketPath, requestTimeoutSec: 2) + _ = try await replacementClient.handshake(client: .init( + bundleIdentifier: "dev.peekaboo.browser-namespace-lifecycle", + teamIdentifier: nil, + processIdentifier: getpid())) + await #expect(throws: PeekabooBridgeErrorEnvelope.self) { + _ = try await replacementClient.executeBrowserCapabilityNamespace(action) + } + let replacementReceipt = try await replacementClient.createBrowserCapabilityNamespace() + #expect(replacementReceipt.payload.listenerInstanceID != firstReceipt.payload.listenerInstanceID) + #expect(services.browserNamespacePrepareCount == 1) + let closed = try await replacementClient.closeBrowserCapabilityNamespace(replacementReceipt) + #expect(closed.namespaceID == replacementReceipt.payload.namespaceID) + #expect(services.browserNamespaceClosedIDs == [ + firstReceipt.payload.namespaceID, + replacementReceipt.payload.namespaceID, + ]) + } + + @Test + @MainActor + func `blocked namespace retirement obeys host drain timeout and retains ownership`() async throws { + let socketPath = "/tmp/peekaboo-browser-namespace-stop-\(UUID().uuidString).sock" + let services = StubServices() + let retirementBarrier = BrowserNamespaceLifecycleBarrier() + services.browserNamespaceCloseAllHandler = { + await retirementBarrier.block() + } + let server = PeekabooBridgeServer( + services: services, + hostKind: .onDemand, + allowlistedTeams: [], + allowlistedBundles: [], + allowedOperations: PeekabooBridgeOperation.onDemandDefaultAllowlist) + let host = PeekabooBridgeHost( + socketPath: socketPath, + server: server, + allowedTeamIDs: [], + requestDrainTimeoutSec: 0.05) + try await host.startChecked() + + let stop = Task { await host.stop() } + await retirementBarrier.waitUntilBlocked() + guard case let .ownershipRetained(pendingRequestCount, _) = await stop.value else { + Issue.record("Expected blocked namespace retirement to retain listener ownership") + return + } + #expect(pendingRequestCount == 0) + await retirementBarrier.release() + await host.waitUntilFullyStopped() + let retainsOwnership = await host.isRetainingOwnershipForRequestsForTesting + #expect(!retainsOwnership) + } + + @Test + @MainActor + func `late old generation create cannot enter the reopened runtime`() async throws { + let socketPath = "/tmp/peekaboo-browser-namespace-create-stop-\(UUID().uuidString).sock" + let services = StubServices() + let openBarrier = BrowserNamespaceLifecycleBarrier() + services.browserNamespaceOpenHandler = { + await openBarrier.block() + } + let server = PeekabooBridgeServer( + services: services, + hostKind: .onDemand, + allowlistedTeams: [], + allowlistedBundles: [], + allowedOperations: PeekabooBridgeOperation.onDemandDefaultAllowlist) + let host = PeekabooBridgeHost( + socketPath: socketPath, + server: server, + allowedTeamIDs: [], + requestDrainTimeoutSec: 0.05) + await host.setAuthenticationForTesting(.init( + liveIdentity: { try PeekabooBridgeSocketIO.livePeerIdentity(fd: $0) }, + coldPeer: { identity, _ in + PeekabooBridgePeer( + liveIdentity: identity, + bundleIdentifier: "dev.peekaboo.browser-namespace-create-stop-client", + teamIdentifier: TrustedBridgeClientFixture.teamIdentifier) + })) + try await host.startChecked() + let client = TrustedBridgeClientFixture.make(socketPath: socketPath, requestTimeoutSec: 2) + _ = try await client.handshake(client: .init( + bundleIdentifier: "dev.peekaboo.browser-namespace-create-stop", + teamIdentifier: nil, + processIdentifier: getpid())) + + let create = Task { try await client.createBrowserCapabilityNamespace() } + await openBarrier.waitUntilBlocked() + let stop = Task { await host.stop() } + guard case .ownershipRetained = await stop.value else { + Issue.record("Expected blocked old-generation create to retain listener ownership") + return + } + await openBarrier.release() + await #expect(throws: PeekabooBridgeErrorEnvelope.self) { + _ = try await create.value + } + await host.waitUntilFullyStopped() + #expect(services.browserNamespaceOpenedIDs.isEmpty) + #expect(services.browserNamespaceRuntimeAccepting) + } + @Test func `signed bound mutation target requires matching typed native receipt`() throws { let namespaceRequest = PeekabooBridgeRequest.browserCapabilityNamespace(.init( @@ -177,4 +367,91 @@ extension StubServices: PeekabooBridgeBrowserCapabilityNamespaceProviding { var supportsNativeBrowserWindowBinding: Bool { true } + + func prepareBrowserCapabilityNamespaceRuntime() throws { + self.browserNamespacePrepareCount += 1 + self.browserNamespaceRuntimeAccepting = true + } + + func openBrowserCapabilityNamespace(namespaceID: UUID) async throws { + await self.browserNamespaceOpenHandler?() + guard self.browserNamespaceRuntimeAccepting else { + throw PeekabooBridgeErrorEnvelope(code: .notFound, message: "Namespace runtime is retired") + } + if let browserNamespaceOpenError { + throw browserNamespaceOpenError + } + self.browserNamespaceOpenedIDs.insert(namespaceID) + } + + func executeBrowserCapabilityNamespace( + namespaceID: UUID, + request _: PeekabooBridgeBrowserCapabilityNamespaceRequest) async throws + -> PeekabooBridgeBrowserCapabilityNamespaceServiceResult + { + guard self.browserNamespaceOpenedIDs.contains(namespaceID) else { + throw PeekabooBridgeErrorEnvelope(code: .notFound, message: "Namespace is not open") + } + self.browserNamespaceExecutedIDs.append(namespaceID) + return .init( + response: .init( + content: [.object([ + "type": .string("text"), + "text": .string("ok"), + ])], + isError: false), + targetIdentity: self.browserNamespaceTargetIdentity, + outcome: self.browserNamespaceOutcome) + } + + func closeBrowserCapabilityNamespace(namespaceID: UUID) async throws { + if self.browserNamespaceClosedIDs.contains(namespaceID) { + return + } + guard self.browserNamespaceOpenedIDs.remove(namespaceID) != nil else { + throw PeekabooBridgeErrorEnvelope(code: .notFound, message: "Namespace is not open") + } + self.browserNamespaceClosedIDs.append(namespaceID) + } + + func closeAllBrowserCapabilityNamespaces() async { + self.browserNamespaceCloseAllCount += 1 + self.browserNamespaceRuntimeAccepting = false + await self.browserNamespaceCloseAllHandler?() + self.browserNamespaceOpenedIDs.removeAll() + } + + func beginNextBrowserCapabilityNamespaceGeneration() { + self.browserNamespaceRuntimeAccepting = true + } +} + +private actor BrowserNamespaceLifecycleBarrier { + private var blocked = false + private var released = false + private var blockedWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func block() async { + self.blocked = true + self.blockedWaiters.forEach { $0.resume() } + self.blockedWaiters.removeAll() + guard !self.released else { return } + await withCheckedContinuation { continuation in + self.releaseWaiters.append(continuation) + } + } + + func waitUntilBlocked() async { + guard !self.blocked else { return } + await withCheckedContinuation { continuation in + self.blockedWaiters.append(continuation) + } + } + + func release() { + self.released = true + self.releaseWaiters.forEach { $0.resume() } + self.releaseWaiters.removeAll() + } } diff --git a/Core/PeekabooCore/Tests/PeekabooTests/PeekabooBridgeBrowserCapabilityNamespaceAuthorityTests.swift b/Core/PeekabooCore/Tests/PeekabooTests/PeekabooBridgeBrowserCapabilityNamespaceAuthorityTests.swift index 95654ed20..979efd6e2 100644 --- a/Core/PeekabooCore/Tests/PeekabooTests/PeekabooBridgeBrowserCapabilityNamespaceAuthorityTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooTests/PeekabooBridgeBrowserCapabilityNamespaceAuthorityTests.swift @@ -227,6 +227,12 @@ struct PeekabooBridgeBrowserCapabilityNamespaceAuthorityTests { try await fixture.authority.awaitDrained(identity: identity) await drainFinished.markFinished() } + let duplicateIdentity = try await fixture.authority.beginClose( + receipt, + principal: fixture.principal) + let duplicateDrain = Task { + try await fixture.authority.awaitDrained(identity: duplicateIdentity) + } await Task.yield() #expect(await !drainFinished.isFinished) try await fixture.authority.complete(first) @@ -234,8 +240,11 @@ struct PeekabooBridgeBrowserCapabilityNamespaceAuthorityTests { #expect(await !drainFinished.isFinished) try await fixture.authority.complete(second) try await drain.value + try await duplicateDrain.value #expect(await drainFinished.isFinished) #expect(await fixture.authority.lifecycleState(namespaceID: receipt.payload.namespaceID) == .closed) + let repeated = try await fixture.authority.beginClose(receipt, principal: fixture.principal) + try await fixture.authority.awaitDrained(identity: repeated) } @Test @@ -358,6 +367,14 @@ struct PeekabooBridgeBrowserCapabilityNamespaceAuthorityTests { await Task.yield() try await fixture.authority.complete(claim) try await retry.value + let terminalNamespaceIDs = try await fixture.authority.terminalNamespaceIDsRequiringRuntimeRetirement() + #expect(terminalNamespaceIDs == [ + receipt.payload.namespaceID, + ]) + let responseLossRetry = try await fixture.authority.beginClose( + receipt, + principal: fixture.principal) + try await fixture.authority.awaitDrained(identity: responseLossRetry) #expect(await fixture.authority.lifecycleState(namespaceID: receipt.payload.namespaceID) == .closed) } @@ -373,6 +390,7 @@ struct PeekabooBridgeBrowserCapabilityNamespaceAuthorityTests { admission: fixture.namespaceAdmission, lifetimeMilliseconds: 10000) let identity = try await fixture.authority.beginClose(receipt, principal: fixture.principal) + try await fixture.authority.markRuntimeRetired(namespaceID: receipt.payload.namespaceID) for _ in 0..<4 { _ = try await fixture.authority.open( @@ -382,6 +400,35 @@ struct PeekabooBridgeBrowserCapabilityNamespaceAuthorityTests { } #expect(await fixture.authority.lifecycleState(namespaceID: receipt.payload.namespaceID) == nil) try await fixture.authority.awaitDrained(identity: identity) + fixture.clock.advance(by: 10001) + let repeated = try await fixture.authority.beginClose(receipt, principal: fixture.principal) + try await fixture.authority.awaitDrained(identity: repeated) + } + + @Test + func `expired runtime retirement sweep bounds abandoned namespaces`() async throws { + let fixture = try NamespaceAuthorityFixture( + uuids: (100..<220).map { Self.uuid(UInt16($0)) }) + var runtimeNamespaceIDs = Set() + + for _ in 0..<24 { + for _ in 0..<4 { + let receipt = try await fixture.authority.open( + principal: fixture.principal, + admission: fixture.namespaceAdmission, + lifetimeMilliseconds: 10000) + runtimeNamespaceIDs.insert(receipt.payload.namespaceID) + } + fixture.clock.advance(by: 10001) + let expired = try await fixture.authority.terminalNamespaceIDsRequiringRuntimeRetirement() + #expect(expired.count == 4) + for namespaceID in expired { + #expect(runtimeNamespaceIDs.remove(namespaceID) != nil) + try await fixture.authority.markRuntimeRetired(namespaceID: namespaceID) + } + #expect(runtimeNamespaceIDs.isEmpty) + #expect(await fixture.authority.retainedNamespaceCount() <= 4) + } } private static func uuid(_ suffix: UInt16) -> UUID { diff --git a/Core/PeekabooCore/Tests/PeekabooTests/PeekabooBridgeTests.swift b/Core/PeekabooCore/Tests/PeekabooTests/PeekabooBridgeTests.swift index 3c0d8936e..888b9219b 100644 --- a/Core/PeekabooCore/Tests/PeekabooTests/PeekabooBridgeTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooTests/PeekabooBridgeTests.swift @@ -2026,6 +2026,17 @@ final class StubServices: PeekabooBridgeServiceProviding { var browserCompletedCallCount: Int? var browserDispatchedCallCount: Int? var preservesBrowserReceiptChannel = false + var browserNamespacePrepareCount = 0 + var browserNamespaceOpenedIDs: Set = [] + var browserNamespaceExecutedIDs: [UUID] = [] + var browserNamespaceClosedIDs: [UUID] = [] + var browserNamespaceCloseAllCount = 0 + var browserNamespaceCloseAllHandler: (@MainActor @Sendable () async -> Void)? + var browserNamespaceOpenError: (any Error)? + var browserNamespaceOpenHandler: (@MainActor @Sendable () async -> Void)? + var browserNamespaceRuntimeAccepting = false + var browserNamespaceOutcome: DesktopActionOutcome? + var browserNamespaceTargetIdentity: DesktopTargetIdentity? private let ownedDesktopOperationLanes: Set var browserResponseContent: [PeekabooBridgeJSONValue] = [ .object([ diff --git a/Core/PeekabooCore/Tests/PeekabooTests/PeekabooDaemonTests.swift b/Core/PeekabooCore/Tests/PeekabooTests/PeekabooDaemonTests.swift index 542c1f7ef..fb53d5483 100644 --- a/Core/PeekabooCore/Tests/PeekabooTests/PeekabooDaemonTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooTests/PeekabooDaemonTests.swift @@ -17,6 +17,15 @@ struct PeekabooDaemonTests { #expect(configuration.bridgeSocketPath == PeekabooBridgeConstants.daemonSocketPath) } + @Test + func `production on-demand daemon modes include browser namespace operations`() { + for configuration in [PeekabooDaemon.Configuration.auto(), .manual()] { + #expect(configuration.hostKind == .onDemand) + #expect(PeekabooBridgeOperation.browserCapabilityNamespaceOperations.isSubset(of: + configuration.allowedOperations)) + } + } + @Test func `auto daemon reports activity and idle deadline`() async { let daemon = PeekabooDaemon(configuration: .init( diff --git a/Core/PeekabooFoundation/Sources/PeekabooFoundation/DesktopActionOutcome+Projection.swift b/Core/PeekabooFoundation/Sources/PeekabooFoundation/DesktopActionOutcome+Projection.swift index 208c913fa..9c28ae910 100644 --- a/Core/PeekabooFoundation/Sources/PeekabooFoundation/DesktopActionOutcome+Projection.swift +++ b/Core/PeekabooFoundation/Sources/PeekabooFoundation/DesktopActionOutcome+Projection.swift @@ -7,6 +7,23 @@ extension DesktopActionOutcome { /// fieldwise initializer: callers project a validated outcome, while decoding reconstructs and /// validates that outcome before accepting the compatibility booleans. public struct Projection: Codable, Equatable, Sendable { + public static let fieldNames: Set = [ + "delivery_mechanism", + "delivery_mode", + "dispatch_state", + "dispatched_unit_count", + "effect", + "escalation", + "evidence", + "mutation_dispatched", + "refusal_reason", + "requires_fresh_observation", + "retry_safe", + "retry_safety", + "route", + "state", + ] + public let outcome: DesktopActionOutcome public var state: State { From 0ec6ae75f0a470df03117a03c25a694036be4d88 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 23:48:57 -0700 Subject: [PATCH 13/14] fix(browser): harden durable namespace execution --- Apps/CLI/CHANGELOG.md | 5 +- .../CLI/CommanderRuntimeExecutor.swift | 12 +- .../MCP/BrowserCommand+Namespace.swift | 19 ++ .../Commands/MCP/BrowserCommand.swift | 15 +- .../BrowserCLINamespaceCommandTests.swift | 104 ++++++++++- CHANGELOG.md | 4 +- .../BrowserMCPDevToolsControlSession.swift | 32 +++- .../BrowserMCPPageRoutingContract.swift | 13 ++ ...rMCPScopedNamespaceResponseSanitizer.swift | 61 +++++++ .../BrowserMCPScopedNamespaceRuntime.swift | 1 + .../Browser/BrowserMCPService.swift | 8 +- .../Browser/BrowserMCPSessionManager.swift | 65 +++++-- ...CapabilityNamespaceReceiptValidation.swift | 12 ++ .../PeekabooBridgeHandledResponse.swift | 7 + ...PeekabooBridgeOperationReceiptModels.swift | 18 +- ...eOperationReceiptOpaqueBrowserTarget.swift | 23 +++ .../PeekabooBridgeOperationReceipts.swift | 75 ++++++++ ...ekabooBridgeOperationResultSemantics.swift | 24 ++- ...eekabooBridgeRequest+DesktopMutation.swift | 10 -- ...geServer+BrowserCapabilityNamespaces.swift | 8 +- .../PeekabooBridgeServer+Handshake.swift | 14 +- ...ekabooBridgeServer+OperationReceipts.swift | 30 +++- .../PeekabooBridgeServiceProviding.swift | 3 + .../PeekabooServices+BrowserBridge.swift | 16 ++ ...rowserMCPDevToolsControlSessionTests.swift | 41 ++++- ...rowserMCPScopedNamespaceRuntimeTests.swift | 40 +++++ .../BrowserMCPSessionManagerTests.swift | 32 ++++ ...rNativeWindowBindingCoordinatorTests.swift | 91 ++++++++++ .../BrowserCapabilityNamespaceWireTests.swift | 169 +++++++++++++++++- ...serCapabilityNamespaceHandshakeTests.swift | 54 ++++++ .../PeekabooTests/PeekabooBridgeTests.swift | 1 + .../BrowserToolActionSemantics.swift | 19 ++ docs/browser-mcp.md | 23 +-- docs/commands/browser.md | 46 ++++- 34 files changed, 991 insertions(+), 104 deletions(-) create mode 100644 Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationReceiptOpaqueBrowserTarget.swift diff --git a/Apps/CLI/CHANGELOG.md b/Apps/CLI/CHANGELOG.md index 8aa5de13e..9c201c198 100644 --- a/Apps/CLI/CHANGELOG.md +++ b/Apps/CLI/CHANGELOG.md @@ -16,12 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `app list --include-installed` and MCP `includeInstalled` as native PID-free installed-application sidecars with declared UI/background classification. +- Add durable Bridge 1.38 browser namespaces for opaque page capabilities and exact native PID-generation/window binding across CLI invocations. - Report per-window `combined_eligible`, `pixels_only`, or `unknown` observation eligibility, including screenshot-only recovery. - Add `type --at` for atomic exact-window background focus-only Accessibility input plus typing from one fresh screenshot snapshot. - Add `click --modifiers ... --foreground` with exact snapshot preflight and truthful cursor/focus restoration reporting. ### Changed -- Refuse standalone `browser bind-window` before runtime discovery until Bridge can carry an authenticated persistent browser namespace; process-local MCP and Agent sessions own the exact binding instead. +- Require the exact issuing `--bridge-socket` alongside the owner-private namespace file on every durable browser namespace invocation. - Read `config credential set` secrets from no-echo prompts, stdin, or owner-only files; let `config provider add` also accept non-secret references; retain deprecated argv compatibility. - Skip provider discovery and Agent construction for caller-local commands that cannot invoke the Agent. - Avoid reopening and hashing Bridge screenshot artifacts twice before CLI or MCP consumption while retaining signed client verification and use-time publication checks. @@ -35,7 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Report background text, editable special keys, and clears with their actual AXValue, event, or composite delivery; count only real key events as key presses; preserve the planned receiver literal after escape processing; and require protocol 1.36 before AX-capable remote type requests. - Revalidate exact-window focused elements and the application's internal key window before typing, reject parent targets with attached sheets while preserving independently identified exact sheet targets, confirm clear-plus-literal text only from a generation-bound value change after bounded event settlement, keep pixel-focus setup confirmation separate from its typing leaf, and stop reporting no-change, missing, or dispatched-but-unverified outcomes as typed characters. - Require explicit standalone CLI foreground consent for application focus/switch and Dock visibility changes, and reject contradictory app-switch selectors before runtime discovery. -- Scope process-local persistent MCP and Agent browser refs to one caller, provider child epoch, page, snapshot, and document generation; require the pinned provider's structured capability data, reserve targets before provider setup, preserve post-dispatch failure evidence while withholding invalid refs, and let different background session lanes overlap under origin-recoverable durable cross-process invalidation while same-target access and Bridge-backed opaque refs remain fail closed. +- Scope browser refs to one caller, provider child epoch, page, snapshot, and document generation; require the pinned provider's structured capability data, reserve targets before provider setup, preserve post-dispatch failure evidence while withholding invalid refs, and let different background session lanes overlap under origin-recoverable durable invalidation while legacy Bridge calls remain fail closed and explicit Bridge 1.38 namespaces carry authenticated durable authority. - Bind Bridge 1.34 Chrome channel connections to an exact live Chrome bundle, native process-owned DevTools listener, and approval-gated WebSocket under one 90-second deadline, verifying `Browser.getVersion` once without legacy HTTP discovery or repeated permission probes and failing closed on helper-service names, file, socket, generation, or endpoint drift. - Authenticate native Chrome channels against Google Team ID `EQHXZ8M8AV`, pin the exact signed identifier and CDHash for the process generation, and enumerate the target process's complete listener inventory independently of Peekaboo's file-descriptor limit. - Honor the configured default save directory for pathless pixel-only `see` captures and add collision-resistant generated filenames for concurrent callers, while preserving explicit paths and stdout streaming. Thanks @PollyBot13 for #607. diff --git a/Apps/CLI/Sources/PeekabooCLI/CLI/CommanderRuntimeExecutor.swift b/Apps/CLI/Sources/PeekabooCLI/CLI/CommanderRuntimeExecutor.swift index f2d16af2d..1f557659a 100644 --- a/Apps/CLI/Sources/PeekabooCLI/CLI/CommanderRuntimeExecutor.swift +++ b/Apps/CLI/Sources/PeekabooCLI/CLI/CommanderRuntimeExecutor.swift @@ -65,10 +65,14 @@ enum CommanderRuntimeExecutor { runtimeFactory: RuntimeFactory ) async throws { if var runtimeCommand = command as? any AsyncRuntimeCommand { - let runtimeOptions = try CommanderCLIBinder.makeRuntimeOptions( - from: resolved.parsedValues, - commandType: resolved.type - ) + let runtimeOptions = if let configurable = runtimeCommand as? any RuntimeOptionsConfigurable { + configurable.runtimeOptions + } else { + try CommanderCLIBinder.makeRuntimeOptions( + from: resolved.parsedValues, + commandType: resolved.type + ) + } if self.shouldExportCaptureEnginePreference(runtimeOptions), let capturePreference = runtimeOptions.captureEnginePreference { // Respect explicit engine choice; also allow disabling CG globally. diff --git a/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand+Namespace.swift b/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand+Namespace.swift index 31be4a323..5c6bbb6dd 100644 --- a/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand+Namespace.swift +++ b/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand+Namespace.swift @@ -367,6 +367,7 @@ enum BrowserCLINamespaceCommandError: LocalizedError, ResultEnvelopeError, Equat case invalidWindowID case missingNamespaceFile case invalidNamespaceFile + case missingBridgeSocket case unsupportedNamespaceAction(String) case unsupportedArguments([String]) case localExecutionRefused @@ -387,6 +388,8 @@ enum BrowserCLINamespaceCommandError: LocalizedError, ResultEnvelopeError, Equat "Browser namespace actions require an explicit --namespace-file." case .invalidNamespaceFile: "--namespace-file must resolve to an absolute browser namespace receipt path." + case .missingBridgeSocket: + "Durable browser namespace actions require the exact issuing --bridge-socket on every invocation." case let .unsupportedNamespaceAction(action): "Browser action '\(action)' is not in the closed Bridge 1.38 namespace action set." case let .unsupportedArguments(arguments): @@ -426,6 +429,9 @@ enum BrowserCLINamespaceCommandError: LocalizedError, ResultEnvelopeError, Equat "this namespace." case .missingNamespaceFile, .invalidNamespaceFile: "Pass the exact owner-private receipt file created for this authenticated Bridge namespace." + case .missingBridgeSocket: + "Repeat the same explicit --bridge-socket used by namespace-create; " + + "another listener will reject the receipt." case .unsupportedNamespaceAction: "Use one documented high-level browser action; raw call/provider tools are intentionally unavailable." case .unsupportedArguments: @@ -459,8 +465,10 @@ extension BrowserCommand { } switch control { case .create: + try self.requireExplicitNamespaceBridgeSocket() try store.validateCanSaveBeforeRuntime() case .close: + try self.requireExplicitNamespaceBridgeSocket() _ = try store.load() } return @@ -471,6 +479,7 @@ extension BrowserCommand { return } _ = try self.namespaceHighLevelActionRequest() + try self.requireExplicitNamespaceBridgeSocket() _ = try store.load() } @@ -532,6 +541,7 @@ extension BrowserCommand { guard let exactWindowID = UInt32(exactly: windowID), exactWindowID > 0 else { throw BrowserCLINamespaceCommandError.invalidWindowID } + try self.requireExplicitNamespaceBridgeSocket() return BrowserCLINamespaceBindWindowRequest( pageID: pageReference, processIdentifier: exactProcessIdentifier, @@ -601,6 +611,15 @@ extension BrowserCommand { } } + private func requireExplicitNamespaceBridgeSocket() throws { + guard let socketPath = self.runtimeOptions.bridgeSocketPath? + .trimmingCharacters(in: .whitespacesAndNewlines), + !socketPath.isEmpty + else { + throw BrowserCLINamespaceCommandError.missingBridgeSocket + } + } + func namespaceReceiptStore() throws -> BrowserCLINamespaceReceiptStore { guard let namespaceFile = self.namespaceFile? .trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand.swift b/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand.swift index 3d593d158..f8c1580d9 100644 --- a/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand.swift +++ b/Apps/CLI/Sources/PeekabooCLI/Commands/MCP/BrowserCommand.swift @@ -109,15 +109,20 @@ InjectedRuntimeBackedCommand { peekaboo browser connect --channel stable --foreground peekaboo browser new-page --url https://example.com peekaboo browser snapshot --page-id 2 --path /tmp/page.txt - peekaboo browser namespace-create --namespace-file /private/path/browser-namespace.json - peekaboo browser list-pages --namespace-file /private/path/browser-namespace.json - peekaboo browser bind-window --namespace-file /private/path/browser-namespace.json \ + peekaboo browser namespace-create --namespace-file ~/.peekaboo/browser-namespaces/work.json \ + --bridge-socket "$HOME/Library/Application Support/Peekaboo/daemon.sock" + peekaboo browser list-pages --namespace-file ~/.peekaboo/browser-namespaces/work.json \ + --bridge-socket "$HOME/Library/Application Support/Peekaboo/daemon.sock" + peekaboo browser bind-window --namespace-file ~/.peekaboo/browser-namespaces/work.json \ + --bridge-socket "$HOME/Library/Application Support/Peekaboo/daemon.sock" \ --page-id bp1_0123456789abcdef0123456789abcdef --pid 123 --window-id 456 - peekaboo browser namespace-close --namespace-file /private/path/browser-namespace.json + peekaboo browser namespace-close --namespace-file ~/.peekaboo/browser-namespaces/work.json \ + --bridge-socket "$HOME/Library/Application Support/Peekaboo/daemon.sock" Browser actions reuse an existing exact connection by default and never auto-connect. Connecting or allowing any foreground browser effect requires explicit --foreground. - Durable namespace actions require an explicit owner-only receipt file and a negotiated Bridge 1.38 host. + Durable namespace actions require an explicit owner-only receipt file and the same issuing Bridge 1.38 socket + on every invocation. """ ) diff --git a/Apps/CLI/Tests/CoreCLITests/BrowserCLINamespaceCommandTests.swift b/Apps/CLI/Tests/CoreCLITests/BrowserCLINamespaceCommandTests.swift index f17b972c4..b4a8a9fe6 100644 --- a/Apps/CLI/Tests/CoreCLITests/BrowserCLINamespaceCommandTests.swift +++ b/Apps/CLI/Tests/CoreCLITests/BrowserCLINamespaceCommandTests.swift @@ -62,6 +62,50 @@ struct BrowserCLINamespaceCommandTests { #expect(!candidates.contains { $0.socketPath == PeekabooBridgeConstants.peekabooSocketPath }) } + @Test + func `production executor preserves configured namespace runtime requirements`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "peekaboo-cli-namespace-runtime-options-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) + defer { try? FileManager.default.removeItem(at: root) } + let resolved = try CommanderRuntimeRouter.resolve(argv: [ + "peekaboo", + "browser", + "namespace-create", + "--namespace-file", + root.appendingPathComponent("namespace.json").path, + "--bridge-socket", + "/private/tmp/peekaboo-namespace-runtime-options.sock", + "--json", + ]) + var receivedOptions: CommandRuntimeOptions? + + do { + try await CommanderRuntimeExecutor.run( + resolved: resolved, + runtimeFactory: .init { options in + receivedOptions = options + throw NamespaceRuntimeConstructionProbe.reached + } + ) + Issue.record("Expected runtime construction probe") + } catch NamespaceRuntimeConstructionProbe.reached { + // Expected after the configured options reach runtime construction. + } + + let options = try #require(receivedOptions) + #expect(options.requiresBrowserCapabilityNamespace) + #expect(!options.requiresBrowserMCP) + #expect(options.ignoresCaptureEnginePreference) + } + @Test func `remote services expose namespace adapter only after negotiated construction`() { let client = PeekabooBridgeClient(socketPath: "/tmp/peekaboo-unused-browser-namespace.sock") @@ -212,6 +256,39 @@ struct BrowserCLINamespaceCommandTests { #expect(remote.runtimeOptions.bridgeSocketPath == "/private/tmp/fixture.sock") } + @Test + func `namespace actions require the issuing Bridge socket on every invocation`() throws { + let namespaceFile = "/private/tmp/fixture-browser-namespace.json" + let requests: [(action: String, options: [String: [String]])] = [ + ("namespace-create", ["namespaceFile": [namespaceFile]]), + ("namespace-close", ["namespaceFile": [namespaceFile]]), + ("list-pages", ["namespaceFile": [namespaceFile]]), + ( + "bind-window", + [ + "pageId": [Self.pageReference], + "pid": ["123"], + "windowId": ["456"], + "namespaceFile": [namespaceFile], + ] + ), + ] + + for request in requests { + let command = try CommanderCLIBinder.instantiateCommand( + ofType: BrowserCommand.self, + parsedValues: ParsedValues( + positional: [request.action], + options: request.options, + flags: [] + ) + ) + #expect(throws: BrowserCLINamespaceCommandError.missingBridgeSocket) { + try command.validateBeforeRuntime() + } + } + } + @Test func `ordinary browser actions retain numeric page IDs and legacy browser routing`() throws { let command = try CommanderCLIBinder.instantiateCommand( @@ -323,16 +400,22 @@ struct BrowserCLINamespaceCommandTests { try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) defer { try? FileManager.default.removeItem(at: root) } let namespacePath = root.appendingPathComponent("namespace.json").path + let socketPath = "/private/tmp/fixture-browser-namespace.sock" var create = try CommanderCLIBinder.instantiateCommand( ofType: BrowserCommand.self, parsedValues: ParsedValues( positional: ["namespace-create"], - options: ["namespaceFile": [namespacePath]], + options: [ + "namespaceFile": [namespacePath], + "bridge-socket": [socketPath], + ], flags: [] ) ) - create.setRuntimeOptions(CommandRuntimeOptions()) + var createRuntimeOptions = CommandRuntimeOptions() + createRuntimeOptions.bridgeSocketPath = socketPath + create.setRuntimeOptions(createRuntimeOptions) #expect(create.runtimeOptions.requiresBrowserCapabilityNamespace) #expect(!create.runtimeOptions.requiresBrowserMCP) try create.validateBeforeRuntime() @@ -344,7 +427,10 @@ struct BrowserCLINamespaceCommandTests { ofType: BrowserCommand.self, parsedValues: ParsedValues( positional: ["list-pages"], - options: ["namespaceFile": [namespacePath]], + options: [ + "namespaceFile": [namespacePath], + "bridge-socket": [socketPath], + ], flags: [] ) ) @@ -403,7 +489,10 @@ struct BrowserCLINamespaceCommandTests { ofType: BrowserCommand.self, parsedValues: ParsedValues( positional: ["namespace-close"], - options: ["namespaceFile": [namespacePath]], + options: [ + "namespaceFile": [namespacePath], + "bridge-socket": [socketPath], + ], flags: [] ) ) @@ -501,6 +590,9 @@ struct BrowserCLINamespaceCommandTests { if options["namespaceFile"] == nil { options["namespaceFile"] = ["/private/tmp/fixture-browser-namespace.json"] } + if options["bridge-socket"] == nil { + options["bridge-socket"] = ["/private/tmp/fixture.sock"] + } var command = try CommanderCLIBinder.instantiateCommand( ofType: BrowserCommand.self, parsedValues: ParsedValues( @@ -519,6 +611,10 @@ struct BrowserCLINamespaceCommandTests { } } +private enum NamespaceRuntimeConstructionProbe: Error { + case reached +} + @MainActor private final class RecordingNamespaceAdapter: BrowserCLINamespaceBridgeAdapter { let receipt: Data diff --git a/CHANGELOG.md b/CHANGELOG.md index b7724a726..fc963b899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ### Added - Add opt-in native installed-application inventory to CLI and MCP as a PID-free sidecar, with declared UI/background classification and no Spotlight, AppleScript, or private APIs. -- Add process-local MCP and Agent binding between opaque Chrome page capabilities and exact native PID-generation/window receipts, with final tab/window revalidation before every bound mutation and no raw CDP ID disclosure. +- Add process-local MCP/Agent and durable Bridge 1.38 browser namespaces that bind opaque Chrome page capabilities to exact native PID-generation/window receipts, with final tab/window revalidation before every bound mutation and no raw CDP ID disclosure. - Let trusted MCP hosts explicitly authorize foreground UI for one server process while keeping background-only as the default. Thanks @Austin1serb for #612. - Report per-window `combined_eligible`, `pixels_only`, or `unknown` observation eligibility in CLI and MCP, including screenshot-only recovery. - Add an embedding-only Bridge protocol 1.32 API for signed, process-generation-bound observation. @@ -35,7 +35,7 @@ - Require process-generation receipts for process-scoped `action` and `set-value` snapshots, revalidate them before dispatch, and preserve their canonical target metadata through MCP and signed Bridge results. - Bind `action` and `set-value` snapshots, resolved AX elements, outcomes, and signed Bridge 1.37 results to one process generation; suppress their Bridge operations for unsupported providers, reject downgraded or receiptless sessions before provider dispatch, and refuse PID reuse, foreign elements, or targetless success before retry. - Require explicit standalone CLI foreground consent for application focus/switch and Dock visibility changes, and reject contradictory app-switch selectors before runtime discovery. -- Scope process-local MCP and Agent browser refs to one caller, provider child epoch, page, snapshot, and document generation; require the pinned provider's structured capability data, reserve exact targets before permission-bearing setup, preserve post-dispatch failure evidence while withholding invalid refs, and let independent background session lanes overlap under origin-recoverable durable cross-process invalidation while same-target access and Bridge-backed opaque refs remain fail closed. +- Scope browser refs to one caller, provider child epoch, page, snapshot, and document generation; require the pinned provider's structured capability data, reserve exact targets before permission-bearing setup, preserve post-dispatch failure evidence while withholding invalid refs, and let independent background session lanes overlap under origin-recoverable durable invalidation while legacy Bridge calls remain fail closed and explicit Bridge 1.38 namespaces carry authenticated durable authority. - Bind Bridge 1.34 Chrome channel connections to an exact live Chrome bundle, native process-owned DevTools listener, and approval-gated WebSocket under one 90-second deadline, verifying `Browser.getVersion` once without legacy HTTP discovery or repeated permission probes and failing closed on helper-service names, file, socket, generation, or endpoint drift. - Authenticate native Chrome channels against Google Team ID `EQHXZ8M8AV`, pin the exact signed identifier and CDHash for the process generation, and enumerate the target process's complete listener inventory independently of Peekaboo's file-descriptor limit. - Honor the configured default save directory for pathless pixel-only `see` captures and add collision-resistant generated filenames for concurrent callers, while preserving explicit paths and stdout streaming. Thanks @PollyBot13 for #607. diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPDevToolsControlSession.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPDevToolsControlSession.swift index 8ebbc1cf7..586d96056 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPDevToolsControlSession.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPDevToolsControlSession.swift @@ -100,7 +100,9 @@ struct BrowserMCPDevToolsControlTransportFactory: Sendable { } actor BrowserMCPDevToolsControlSession { - private static let maximumPayloadBytes = 64 * 1024 + private static let maximumRequestPayloadBytes = 64 * 1024 + private static let maximumResponsePayloadBytes = 1024 * 1024 + private static let maximumTargetCount = 4096 private static let maximumTargetIDBytes = 1024 private static let approvalTimeout: Duration = .seconds(60) @@ -129,6 +131,18 @@ actor BrowserMCPDevToolsControlSession { private struct EmptyParameters: Encodable {} + private struct TargetFilterEntry: Encodable { + let type: String? + let exclude: Bool + } + + private struct TargetsParameters: Encodable { + let filter = [ + TargetFilterEntry(type: "page", exclude: false), + TargetFilterEntry(type: nil, exclude: true), + ] + } + private struct TargetParameters: Encodable { let targetId: String } @@ -256,9 +270,13 @@ actor BrowserMCPDevToolsControlSession { func getTargets(deadline: ContinuousClock.Instant) async throws -> [BrowserMCPDevToolsTargetInfo] { let request = try self.makeRequest( method: "Target.getTargets", - parameters: EmptyParameters(), + parameters: TargetsParameters(), deadline: deadline) let result: TargetsResult = try await self.perform(request) + guard result.targetInfos.count <= Self.maximumTargetCount else { + throw self.malformedResponse( + "Target.getTargets exceeded the bounded page-target inventory") + } var targetIDs = Set() return try result.targetInfos.map { target in guard !target.targetId.isEmpty, @@ -355,7 +373,7 @@ actor BrowserMCPDevToolsControlSession { self.receiveTask = Task { [weak self] in while !Task.isCancelled { do { - let data = try await transport.receive(maximumPayloadBytes: Self.maximumPayloadBytes) + let data = try await transport.receive(maximumPayloadBytes: Self.maximumResponsePayloadBytes) guard let self else { return } await self.receive(data) } catch is CancellationError { @@ -395,7 +413,7 @@ actor BrowserMCPDevToolsControlSession { } self.nextRequestID += 1 let payload = try JSONEncoder().encode(Command(id: id, method: method, params: parameters)) - guard payload.count <= Self.maximumPayloadBytes else { + guard payload.count <= Self.maximumRequestPayloadBytes else { throw BrowserMCPDevToolsControlError.malformedResponse( "the \(method) request exceeded 64 KiB") } @@ -476,9 +494,9 @@ actor BrowserMCPDevToolsControlSession { private func receive(_ data: Data) { guard self.controlState == .open else { return } - guard data.count <= Self.maximumPayloadBytes else { + guard data.count <= Self.maximumResponsePayloadBytes else { let failure = BrowserMCPDevToolsControlError.malformedResponse( - "a WebSocket payload exceeded 64 KiB") + "a WebSocket payload exceeded 1 MiB") self.terminate(state: .failed(failure), pendingError: failure) return } @@ -659,7 +677,7 @@ private final class BrowserMCPURLSessionControlTransport: BrowserMCPDevToolsCont } guard data.count <= maximumPayloadBytes else { throw BrowserMCPDevToolsControlError.malformedResponse( - "a WebSocket payload exceeded 64 KiB") + "a WebSocket payload exceeded the bounded response size") } return data } diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPPageRoutingContract.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPPageRoutingContract.swift index b5e546e6e..e88571218 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPPageRoutingContract.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPPageRoutingContract.swift @@ -201,6 +201,19 @@ enum BrowserMCPPageRoutingContract { } } + static func executionDelivery( + for calls: some Collection) -> DesktopActionOutcome.Delivery + { + let foreground = calls.contains { call in + BrowserToolActionSemantics.requestsForegroundDelivery(toolName: call.toolName) { name in + call.arguments[name] as? Bool + } + } + return .init( + mechanism: .browserProtocol, + mode: foreground ? .foreground : .background) + } + static func capabilityContract( for toolName: String, arguments: [String: Any] = [:]) -> BrowserMCPToolCapabilityContract? diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceResponseSanitizer.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceResponseSanitizer.swift index 7cb9c3fbc..ffe558373 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceResponseSanitizer.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceResponseSanitizer.swift @@ -39,6 +39,7 @@ enum BrowserMCPScopedNamespaceResponseSanitizer { arguments: ToolArguments, policy: BrowserMCPScopedNamespaceExecutionPolicy) -> BrowserMCPScopedNamespaceExecutionResult { + let externalBrowserConnectionReceipt = self.externalBrowserConnectionReceipt(from: response.meta) var foundUnsafeCapability = false let content = response.content.map { self.scrubContent($0, foundUnsafeCapability: &foundUnsafeCapability) @@ -74,6 +75,7 @@ enum BrowserMCPScopedNamespaceResponseSanitizer { return BrowserMCPScopedNamespaceExecutionResult( response: sanitized, targetIdentity: targetIdentity, + externalBrowserConnectionReceipt: externalBrowserConnectionReceipt, outcome: outcome, nativeWindowReceipt: nativeWindowReceipt) } @@ -529,6 +531,65 @@ enum BrowserMCPScopedNamespaceResponseSanitizer { return nil } + /// Recovers only a complete exact external-browser receipt from Peekaboo-owned metadata before + /// host-only endpoint fields are removed from the public namespace response. + private static func externalBrowserConnectionReceipt(from meta: Value?) -> BrowserMCPConnectionReceipt? { + guard case let .object(fields)? = meta else { return nil } + let receiptFields = fields["connection_receipt"]?.objectValue ?? + fields[BrowserMCPExecutionEvidence.metadataKey]?.objectValue?["connection_receipt"]?.objectValue + guard let receiptFields, + receiptFields["pid"] == nil, + receiptFields["process_start_identity"] == nil, + receiptFields["process_start_identity_decimal"] == nil, + receiptFields["bundle_id"] == nil, + let browserURL = receiptFields["browser_url"]?.stringValue, + let browserID = receiptFields["browser_id"]?.stringValue, + !browserID.isEmpty, + let browserVersion = receiptFields["browser_version"]?.stringValue, + !browserVersion.isEmpty, + let protocolVersion = receiptFields["protocol_version"]?.stringValue, + !protocolVersion.isEmpty, + let endpoint = BrowserLoopbackEndpoint(browserURL: browserURL), + let webSocketDebuggerURL = self.externalBrowserWebSocketURL( + fields: receiptFields, + endpoint: endpoint, + browserID: browserID) + else { return nil } + let channel: BrowserMCPChannel? + if let rawChannel = receiptFields["channel"]?.stringValue { + guard let parsed = BrowserMCPChannel(rawValue: rawChannel) else { return nil } + channel = parsed + } else { + channel = nil + } + return BrowserMCPConnectionReceipt( + channel: channel, + browserURL: endpoint.canonicalBrowserURL, + webSocketDebuggerURL: webSocketDebuggerURL, + devToolsBrowserID: browserID, + browserVersion: browserVersion, + protocolVersion: protocolVersion) + } + + private static func externalBrowserWebSocketURL( + fields: [String: Value], + endpoint: BrowserLoopbackEndpoint, + browserID: String) -> String? + { + if let published = fields["websocket_debugger_url"]?.stringValue, + !endpoint.matchesWebSocketDebuggerURL(published, browserID: browserID) + { + return nil + } + guard var components = URLComponents(string: endpoint.canonicalBrowserURL) else { return nil } + components.scheme = "ws" + components.path = "/devtools/browser/\(browserID)" + guard let synthesized = components.url?.absoluteString, + endpoint.matchesWebSocketDebuggerURL(synthesized, browserID: browserID) + else { return nil } + return synthesized + } + private static func processIdentity(from fields: [String: Value]) -> DesktopTargetIdentity? { guard let process = self.processReceipt(from: fields) else { return nil } return try? DesktopTargetIdentity(processIdentity: process) diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceRuntime.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceRuntime.swift index 60d1d47b3..07c1fb53c 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceRuntime.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPScopedNamespaceRuntime.swift @@ -47,6 +47,7 @@ public struct BrowserMCPScopedNamespaceNativeWindowReceipt: Equatable, Sendable public struct BrowserMCPScopedNamespaceExecutionResult: Sendable { public let response: ToolResponse public let targetIdentity: DesktopTargetIdentity? + public let externalBrowserConnectionReceipt: BrowserMCPConnectionReceipt? public let outcome: DesktopActionOutcome? public let nativeWindowReceipt: BrowserMCPScopedNamespaceNativeWindowReceipt? } diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPService.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPService.swift index 5fb231867..2b16d44c6 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPService.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPService.swift @@ -836,6 +836,7 @@ public final class BrowserMCPService: BrowserMCPClientProviding, BrowserMCPActio let projected = try result.projectingMutationProgress(for: calls) let executionOutcome: DesktopActionOutcome? = if plannedMutationCount > 0 { projected.actionFailure?.outcome ?? Self.successOutcome( + calls: calls, dispatchedCallCount: plannedMutationCount) } else if projected.connectionOutcome != nil { projected.actionFailure?.outcome @@ -958,12 +959,15 @@ public final class BrowserMCPService: BrowserMCPClientProviding, BrowserMCPActio headless: headless) } - private static func successOutcome(dispatchedCallCount: Int) -> DesktopActionOutcome { + private static func successOutcome( + calls: [BrowserMCPMappedCall], + dispatchedCallCount: Int) -> DesktopActionOutcome + { guard let unitCount = DesktopActionOutcome.DispatchUnitCount(dispatchedCallCount) else { preconditionFailure("A successful browser execution must dispatch at least one call") } return .dispatchedUnverified( - delivery: .init(mechanism: .browserProtocol, mode: .background), + delivery: BrowserMCPPageRoutingContract.executionDelivery(for: calls), evidence: .deliveryAccepted, unitCount: unitCount) } diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPSessionManager.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPSessionManager.swift index 1005e7520..41103340b 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPSessionManager.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPSessionManager.swift @@ -527,16 +527,22 @@ final class BrowserMCPSessionManager: @unchecked Sendable { capabilities: capabilities, manager: self, deadline: request.deadline) - let nativeWindowReceipt = try await BrowserNativeWindowBindingCoordinator - .revalidateHoldingAuthorities( - pageReference: request.pageReference, - context: context, - control: control, - receiptProviders: receiptProviders) - try Self.requireNativeBindingDeadline(request.deadline) + var nativeWindowReceipt: BrowserNativeWindowReceipt? let result = try await self.executePreparedSequenceUnlocked( request.calls, - preparation: preparation) + preparation: preparation, + beforeMutatingCall: { _ in + nativeWindowReceipt = try await BrowserNativeWindowBindingCoordinator + .revalidateHoldingAuthorities( + pageReference: request.pageReference, + context: context, + control: control, + receiptProviders: receiptProviders) + try Self.requireNativeBindingDeadline(request.deadline) + }) + guard let nativeWindowReceipt else { + throw BrowserNativeWindowBindingCoordinatorError.invalidPageCapability + } return BrowserNativeWindowBoundExecution( result: result, nativeWindowReceipt: nativeWindowReceipt) @@ -619,7 +625,9 @@ final class BrowserMCPSessionManager: @unchecked Sendable { private func executePreparedSequenceUnlocked( _ calls: [BrowserMCPMappedCall], - preparation: BrowserMCPPreparedExecution) async throws -> BrowserMCPExecutionResult + preparation: BrowserMCPPreparedExecution, + beforeMutatingCall: (@MainActor (BrowserMCPMappedCall) async throws -> Void)? = nil) async throws + -> BrowserMCPExecutionResult { let sessionBinding = preparation.sessionBinding let receipt = sessionBinding.connectionReceipt @@ -634,7 +642,11 @@ final class BrowserMCPSessionManager: @unchecked Sendable { for (index, call) in calls.enumerated() { let current: ToolResponse do { - current = try await self.execute(call) + current = try await self.execute( + call, + beforeProviderDispatch: Self.actionSemantics(call) == .mutating + ? beforeMutatingCall + : nil) } catch let failure as BrowserMCPCallFailure { failureStage = .call(index: index) switch failure { @@ -652,6 +664,7 @@ final class BrowserMCPSessionManager: @unchecked Sendable { } actionFailure = Self.partialSequenceFailure( completedCallCount: completedCallCount, + delivery: BrowserMCPPageRoutingContract.executionDelivery(for: calls.prefix(index)), cause: cause) response = .error(actionFailure?.message ?? "Browser sequence stopped") case let .mayHaveDispatched(cause): @@ -659,6 +672,7 @@ final class BrowserMCPSessionManager: @unchecked Sendable { actionFailure = Self.indeterminateSequenceFailure( dispatchedCallCount: dispatchedCallCount, completedCallCount: completedCallCount, + delivery: BrowserMCPPageRoutingContract.executionDelivery(for: calls.prefix(...index)), cause: cause) response = .error(actionFailure?.message ?? "Browser sequence completion is unknown") shouldValidateConnection = false @@ -671,6 +685,7 @@ final class BrowserMCPSessionManager: @unchecked Sendable { actionFailure = Self.indeterminateSequenceFailure( dispatchedCallCount: dispatchedCallCount, completedCallCount: completedCallCount, + delivery: BrowserMCPPageRoutingContract.executionDelivery(for: calls.prefix(...index)), cause: error) response = .error(actionFailure?.message ?? "Browser sequence completion is unknown") shouldValidateConnection = false @@ -686,6 +701,7 @@ final class BrowserMCPSessionManager: @unchecked Sendable { actionFailure = Self.indeterminateSequenceFailure( dispatchedCallCount: dispatchedCallCount, completedCallCount: completedCallCount, + delivery: BrowserMCPPageRoutingContract.executionDelivery(for: calls.prefix(...index)), causeDescription: "The browser tool returned an error response.") break } @@ -705,6 +721,7 @@ final class BrowserMCPSessionManager: @unchecked Sendable { actionFailure = Self.indeterminateSequenceFailure( dispatchedCallCount: dispatchedCallCount, completedCallCount: completedCallCount, + delivery: BrowserMCPPageRoutingContract.executionDelivery(for: calls), cause: error) response = .error(actionFailure?.message ?? "Browser connection completion is unknown") await self.clearConnection() @@ -883,10 +900,12 @@ final class BrowserMCPSessionManager: @unchecked Sendable { private static func partialSequenceFailure( completedCallCount: Int, + delivery: DesktopActionOutcome.Delivery, cause: any Error) -> DesktopActionFailure { self.partialSequenceFailure( completedCallCount: completedCallCount, + delivery: delivery, causeDescription: self.errorDescription(cause)) } @@ -928,10 +947,11 @@ final class BrowserMCPSessionManager: @unchecked Sendable { private static func partialSequenceFailure( completedCallCount: Int, + delivery: DesktopActionOutcome.Delivery, causeDescription: String) -> DesktopActionFailure { .partial( - delivery: .init(mechanism: .browserProtocol, mode: .background), + delivery: delivery, unitCount: self.dispatchUnitCount(completedCallCount), message: "Browser execution stopped after \(completedCallCount) completed tool call(s).", hint: "Do not retry the whole sequence; observe the browser and resume only the unfinished suffix.", @@ -941,24 +961,27 @@ final class BrowserMCPSessionManager: @unchecked Sendable { private static func indeterminateSequenceFailure( dispatchedCallCount: Int, completedCallCount: Int? = nil, + delivery: DesktopActionOutcome.Delivery, cause: any Error) -> DesktopActionFailure { self.indeterminateSequenceFailure( dispatchedCallCount: dispatchedCallCount, completedCallCount: completedCallCount, + delivery: delivery, causeDescription: self.errorDescription(cause)) } private static func indeterminateSequenceFailure( dispatchedCallCount: Int, completedCallCount: Int? = nil, + delivery: DesktopActionOutcome.Delivery, causeDescription: String) -> DesktopActionFailure { let progress = completedCallCount.map { "\($0) completed, \(dispatchedCallCount) dispatched or accepted" } ?? "\(dispatchedCallCount) dispatched or accepted" return .indeterminate( - delivery: .init(mechanism: .browserProtocol, mode: .background), + delivery: delivery, evidence: .completionUnknown, unitCount: self.dispatchUnitCount(dispatchedCallCount), message: "Browser execution stopped with \(progress) tool call(s).", @@ -977,13 +1000,23 @@ final class BrowserMCPSessionManager: @unchecked Sendable { (error as? any LocalizedError)?.errorDescription ?? error.localizedDescription } - private func execute(_ call: BrowserMCPMappedCall) async throws -> ToolResponse { + private func execute( + _ call: BrowserMCPMappedCall, + beforeProviderDispatch: (@MainActor (BrowserMCPMappedCall) async throws -> Void)? = nil) async throws + -> ToolResponse + { do { try Task.checkCancellation() } catch { throw BrowserMCPCallFailure.preDispatch(error) } guard call.toolName == "upload_file" else { + do { + try await beforeProviderDispatch?(call) + try Task.checkCancellation() + } catch { + throw BrowserMCPCallFailure.preDispatch(error) + } do { return try await self.manager.executeTool( serverName: self.serverName, @@ -1012,6 +1045,12 @@ final class BrowserMCPSessionManager: @unchecked Sendable { } var stagedArguments = call.arguments stagedArguments["filePath"] = stagedUpload.filePath + do { + try await beforeProviderDispatch?(call) + try Task.checkCancellation() + } catch { + throw BrowserMCPCallFailure.preDispatch(error) + } let uploadID = UUID() self.activeUploadID = uploadID do { diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceReceiptValidation.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceReceiptValidation.swift index 518882e57..cae83e7ae 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceReceiptValidation.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeBrowserCapabilityNamespaceReceiptValidation.swift @@ -12,6 +12,18 @@ enum PeekabooBridgeBrowserCapabilityNamespaceReceiptValidation { !PeekabooBridgeOperationResultSemantics.isNoDispatchFailure(response) else { return } let responseReceipt = response.browserCapabilityNamespaceResponse?.nativeWindowReceipt + if case let .browserCapabilityNamespace(signedTarget) = payload.target { + guard responseReceipt == nil, + case let .browserCapabilityNamespace(namespaceRequest) = request.unwrappedOperationRequest, + namespaceRequest.namespaceReceipt.payload.namespaceID == signedTarget.namespaceID, + namespaceRequest.namespaceReceipt.payload.registryGenerationID == signedTarget.registryGenerationID, + signedTarget.isCanonical + else { + throw PeekabooBridgeOperationReceiptError.receiptMismatch( + "opaque browser namespace target") + } + return + } guard case let .window(signedWindow) = payload.target else { guard responseReceipt == nil else { throw PeekabooBridgeOperationReceiptError.receiptMismatch( diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeHandledResponse.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeHandledResponse.swift index 115733c81..f8b675306 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeHandledResponse.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeHandledResponse.swift @@ -17,6 +17,8 @@ struct PeekabooBridgeHandledResponse: Sendable { case external /// Browser dispatch was bound to this exact non-process DevTools connection. case externalBrowser(PeekabooBridgeBrowserConnectionReceipt) + /// Namespace dispatch was bound to an opaque digest of one exact external browser connection. + case browserCapabilityNamespace(PeekabooBridgeBrowserCapabilityNamespaceTargetReceipt) } let outcome: DesktopActionOutcome @@ -44,6 +46,11 @@ struct PeekabooBridgeHandledResponse: Sendable { return receipt } + var browserCapabilityNamespaceTarget: PeekabooBridgeBrowserCapabilityNamespaceTargetReceipt? { + guard case let .browserCapabilityNamespace(receipt) = self.mutation?.target else { return nil } + return receipt + } + init( response: PeekabooBridgeResponse, mutation: Mutation? = nil, diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationReceiptModels.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationReceiptModels.swift index dfb114fa9..016f0682d 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationReceiptModels.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationReceiptModels.swift @@ -224,6 +224,13 @@ public struct PeekabooBridgeOperationReceiptPayload: Codable, Equatable, Sendabl } return } + if case let .browserCapabilityNamespace(receipt) = target { + guard self.focusedElement == nil, receipt.isCanonical else { + throw PeekabooBridgeOperationReceiptError.receiptMismatch( + "browser capability namespace target identity") + } + return + } let identity = try self.resolvedTargetIdentity() if target != .global, identity == nil { throw PeekabooBridgeOperationReceiptError.receiptMismatch("stable target identity") @@ -275,7 +282,7 @@ public struct PeekabooBridgeOperationReceiptPayload: Codable, Equatable, Sendabl bounds: window.capturedBounds ?? .null, focusedElement: self.focusedElement), ]) - case .browser: + case .browser, .browserCapabilityNamespace: guard self.focusedElement == nil else { throw PeekabooBridgeOperationReceiptError.receiptMismatch("browser target focus") } @@ -805,7 +812,7 @@ enum PeekabooBridgeOperationReceiptSemantics { case let .window(window): expectedProcess = window.processIdentity expectedWindow = window - case .global, .browser, nil: + case .global, .browser, .browserCapabilityNamespace, nil: throw PeekabooBridgeOperationReceiptError.receiptMismatch("selected-leaf target attribution") } guard evidence.allSatisfy({ $0.selectedProcessIdentity == expectedProcess }), @@ -884,11 +891,8 @@ enum PeekabooBridgeOperationReceiptSemantics { request: request, response: response, plan: plan) - if case .browser = payload.target, - ![PeekabooBridgeOperation.browserConnect, .browserExecute].contains(request.operation) - { - throw PeekabooBridgeOperationReceiptError.receiptMismatch( - "browser target used outside external browser execution") + if try self.validateOpaqueBrowserTarget(payload.target, operation: request.operation) { + return } let signedIdentity = try payload.resolvedTargetIdentity() let targetPolicy = plan.target.policy diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationReceiptOpaqueBrowserTarget.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationReceiptOpaqueBrowserTarget.swift new file mode 100644 index 000000000..0e1b9e81c --- /dev/null +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationReceiptOpaqueBrowserTarget.swift @@ -0,0 +1,23 @@ +extension PeekabooBridgeOperationReceiptSemantics { + static func validateOpaqueBrowserTarget( + _ target: PeekabooBridgeOperationTargetReceipt?, + operation: PeekabooBridgeOperation) throws -> Bool + { + switch target { + case .browserCapabilityNamespace: + guard operation == .browserCapabilityNamespace else { + throw PeekabooBridgeOperationReceiptError.receiptMismatch( + "browser namespace target used outside namespace execution") + } + return true + case .browser: + guard [PeekabooBridgeOperation.browserConnect, .browserExecute].contains(operation) else { + throw PeekabooBridgeOperationReceiptError.receiptMismatch( + "browser target used outside external browser execution") + } + return false + case .global, .process, .window, nil: + return false + } + } +} diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationReceipts.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationReceipts.swift index f0f7d68ac..02a838ceb 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationReceipts.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationReceipts.swift @@ -51,6 +51,64 @@ public struct PeekabooBridgeOperationProcessIdentity: Codable, Equatable, Sendab } } +/// Opaque attestation for an exact external browser owned by one scoped namespace. +/// +/// The canonical connection receipt stays host-private. Its digest proves stable target continuity +/// without publishing the DevTools WebSocket or browser identity as reusable authority. +public struct PeekabooBridgeBrowserCapabilityNamespaceTargetReceipt: Codable, Equatable, Sendable { + public let namespaceID: UUID + public let registryGenerationID: UUID + public let connectionReceiptSHA256: String + + public init?( + namespaceID: UUID, + registryGenerationID: UUID, + externalConnectionReceipt: PeekabooBridgeBrowserConnectionReceipt) + { + guard externalConnectionReceipt.isCanonicalExternalTarget, + let digest = try? PeekabooBridgeOperationReceiptCoding.sha256(externalConnectionReceipt) + else { return nil } + self.namespaceID = namespaceID + self.registryGenerationID = registryGenerationID + self.connectionReceiptSHA256 = digest + guard self.isCanonical else { return nil } + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case namespaceID + case registryGenerationID + case connectionReceiptSHA256 + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.namespaceID = try container.decode(UUID.self, forKey: .namespaceID) + self.registryGenerationID = try container.decode(UUID.self, forKey: .registryGenerationID) + self.connectionReceiptSHA256 = try container.decode(String.self, forKey: .connectionReceiptSHA256) + guard self.isCanonical else { + throw DecodingError.dataCorruptedError( + forKey: .connectionReceiptSHA256, + in: container, + debugDescription: "Browser namespace target receipt is incomplete or malformed") + } + } + + var isCanonical: Bool { + Self.isVersion4(self.namespaceID) && + Self.isVersion4(self.registryGenerationID) && + self.connectionReceiptSHA256.utf8.count == 64 && + self.connectionReceiptSHA256.contains { $0 != "0" } && + self.connectionReceiptSHA256.utf8.allSatisfy { byte in + (0x30...0x39).contains(byte) || (0x61...0x66).contains(byte) + } + } + + private static func isVersion4(_ value: UUID) -> Bool { + let bytes = withUnsafeBytes(of: value.uuid) { Array($0) } + return bytes[6] >> 4 == 4 && bytes[8] >> 6 == 2 && bytes.contains { $0 != 0 } + } +} + /// The canonical stable target coalesced from request, response, and execution-owner evidence. /// /// Leaf services remain responsible for validating their native target immediately before dispatch; @@ -60,6 +118,7 @@ public enum PeekabooBridgeOperationTargetReceipt: Codable, Equatable, Sendable { case process(ApplicationProcessIdentity) case window(WindowMutationIdentity) case browser(PeekabooBridgeBrowserConnectionReceipt) + case browserCapabilityNamespace(PeekabooBridgeBrowserCapabilityNamespaceTargetReceipt) private enum CodingKeys: String, CodingKey { case kind @@ -69,6 +128,7 @@ public enum PeekabooBridgeOperationTargetReceipt: Codable, Equatable, Sendable { case capturedBounds case isMinimized case browserConnectionReceipt + case browserCapabilityNamespaceTargetReceipt } private enum Kind: String, Codable { @@ -76,6 +136,7 @@ public enum PeekabooBridgeOperationTargetReceipt: Codable, Equatable, Sendable { case process case window case browser + case browserCapabilityNamespace } public init(from decoder: any Decoder) throws { @@ -118,6 +179,10 @@ public enum PeekabooBridgeOperationTargetReceipt: Codable, Equatable, Sendable { debugDescription: "Bridge browser target receipt is incomplete or inconsistent") } self = .browser(receipt) + case .browserCapabilityNamespace: + self = try .browserCapabilityNamespace(container.decode( + PeekabooBridgeBrowserCapabilityNamespaceTargetReceipt.self, + forKey: .browserCapabilityNamespaceTargetReceipt)) } } @@ -152,6 +217,16 @@ public enum PeekabooBridgeOperationTargetReceipt: Codable, Equatable, Sendable { } try container.encode(Kind.browser, forKey: .kind) try container.encode(receipt, forKey: .browserConnectionReceipt) + case let .browserCapabilityNamespace(receipt): + guard receipt.isCanonical else { + throw EncodingError.invalidValue( + receipt, + .init( + codingPath: container.codingPath, + debugDescription: "Bridge browser namespace target receipt is incomplete or malformed")) + } + try container.encode(Kind.browserCapabilityNamespace, forKey: .kind) + try container.encode(receipt, forKey: .browserCapabilityNamespaceTargetReceipt) } } diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationResultSemantics.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationResultSemantics.swift index 548887e36..28adabbbf 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationResultSemantics.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperationResultSemantics.swift @@ -1441,9 +1441,9 @@ extension PeekabooBridgeOperationResultSemantics { .suspectedNoop, ] switch request.operation { - case .agentExecutionTrace: - return [.dispatchedUnverified] - case .requestPostEventPermission, .browserExecute, .browserCapabilityNamespace, + case .agentExecutionTrace, .browserCapabilityNamespace: + return self.closedProtocolAllowedSuccessStates(for: request) + case .requestPostEventPermission, .browserExecute, .swipe, .drag, .moveMouse, .clickMenuItem, .clickMenuItemByName, .clickMenuExtra, .clickMenuBarItemNamed, .clickMenuBarItemIndex, @@ -1513,6 +1513,19 @@ extension PeekabooBridgeOperationResultSemantics { } } + private static func closedProtocolAllowedSuccessStates( + for request: PeekabooBridgeRequest) -> [DesktopActionOutcome.State] + { + guard request.operation == .browserCapabilityNamespace else { + return [.dispatchedUnverified] + } + guard case let .browserCapabilityNamespace(namespace) = request.unwrappedOperationRequest, + case let .executeAction(action) = namespace.action, + action.action == .connect + else { return [.dispatchedUnverified] } + return [.confirmedNoChange, .dispatchedUnverified] + } + private static func successResponsePolicy(for request: PeekabooBridgeRequest) -> SuccessResponsePolicy { switch request.operation { case .unhideApplication, .dialogClickButton, .backgroundDialogClickButton, @@ -2314,12 +2327,13 @@ extension PeekabooBridgeOperationResultSemantics { mutation.outcome.state == .confirmedNoChange && mutation.outcome.dispatchState == .none && mutation.outcome.delivery == nil - case (.external, .handlerResolved), (.external, .responseResolved), (.external, .externalBrowser): + case (.external, .handlerResolved), (.external, .responseResolved), (.external, .externalBrowser), + (.external, .browserCapabilityNamespace): // A process/window identity is an accepted conservative target for an external // object. Bare `.external` only names the need and is not itself target evidence. true case (.notApplicable, _), (.requestDependent, _), (.handlerResolvedOrGlobal, _), - (_, .external), (_, .externalBrowser), + (_, .external), (_, .externalBrowser), (_, .browserCapabilityNamespace), (_, .global), (_, .requestPinned), (_, .responseResolved), (_, .handlerResolved): false diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeRequest+DesktopMutation.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeRequest+DesktopMutation.swift index 8b8df7523..4dfad0e02 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeRequest+DesktopMutation.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeRequest+DesktopMutation.swift @@ -154,16 +154,6 @@ extension PeekabooBridgeRequest { message: "This browser namespace action requires explicit foreground authority.", hint: "Retry only with foreground_allowed when interrupting the user is intentional.") } - if case let .executeAction(action) = payload.action, - action.action == .connect, - action.arguments["browser_url"] != nil - { - throw DesktopActionFailure.preDispatchRefusal( - route: .bridge, - reason: .invalidRequest, - message: "Browser capability namespaces do not accept explicit DevTools endpoints.", - hint: "Connect by signed local process/channel discovery, then bind an opaque page to a native window.") - } } var requiresRequestPinnedExactWindowScrollReceipt: Bool { diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+BrowserCapabilityNamespaces.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+BrowserCapabilityNamespaces.swift index 546351dca..0e698fa46 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+BrowserCapabilityNamespaces.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+BrowserCapabilityNamespaces.swift @@ -103,9 +103,13 @@ extension PeekabooBridgeServer { response: .browserCapabilityNamespaceAction(result.response), targetIdentity: result.targetIdentity) if !request.isReadOnly, let outcome = result.outcome { - let target: PeekabooBridgeHandledResponse.Mutation.TargetDisposition = - result.targetIdentity.map(PeekabooBridgeHandledResponse.Mutation.TargetDisposition.handlerResolved) ?? + let target = if let targetIdentity = result.targetIdentity { + PeekabooBridgeHandledResponse.Mutation.TargetDisposition.handlerResolved(targetIdentity) + } else if let browserTargetReceipt = result.browserTargetReceipt { + .browserCapabilityNamespace(browserTargetReceipt) + } else { .external + } handled = handled.finalizingMutation(outcome: outcome, target: target) } return handled diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+Handshake.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+Handshake.swift index 2f2e2079a..9a636d8bc 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+Handshake.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+Handshake.swift @@ -75,17 +75,15 @@ extension PeekabooBridgeServer { var enabledOps = compatibleOperations.enabled let clientCapabilities = Set(payload.clientCapabilities ?? []) let browserNamespaceOperations = PeekabooBridgeOperation.browserCapabilityNamespaceOperations - let browserNamespaceService = self.services as? any PeekabooBridgeBrowserCapabilityNamespaceProviding let supportsBrowserCapabilityNamespaces = PeekabooBridgeBrowserCapabilityNamespaceNegotiation.sessionCanNegotiateCapabilities(.init( host: .init( hostKind: self.hostKind, maximumProtocolVersion: negotiated, allowedOperations: Set(advertisedOps).intersection(enabledOps), - supportsBrowserCapabilityNamespaces: - browserNamespaceService?.supportsBrowserCapabilityNamespaces == true, - supportsNativeBrowserWindowBinding: - browserNamespaceService?.supportsNativeBrowserWindowBinding == true), + supportsBrowserCapabilityNamespaces: self.browserCapabilityNamespacesAvailable, + supportsNativeBrowserWindowBinding: self.hostCapabilities.contains( + PeekabooBridgeHostCapability.nativeBrowserWindowBinding)), usesAttestedOperationReceipts: supportsAttestedOperationReceipts, clientCapabilities: clientCapabilities)) if !supportsBrowserCapabilityNamespaces { @@ -459,11 +457,7 @@ extension PeekabooBridgeServer { var operations = self.allowedOperations // Retain the wire enum for old-client decoding, but current hosts never advertise or execute the probe. operations.remove(._appleScriptProbe) - let browserNamespaceService = self.services as? any PeekabooBridgeBrowserCapabilityNamespaceProviding - if self.hostKind != .onDemand || - browserNamespaceService?.supportsBrowserCapabilityNamespaces != true || - browserNamespaceService?.supportsNativeBrowserWindowBinding != true - { + if !self.browserCapabilityNamespacesAvailable { operations.subtract(PeekabooBridgeOperation.browserCapabilityNamespaceOperations) } if self.daemonControl == nil { diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+OperationReceipts.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+OperationReceipts.swift index 2ad91ab4c..40207c3ba 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+OperationReceipts.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+OperationReceipts.swift @@ -23,6 +23,27 @@ private enum OperationReceiptRequestCarriage { case invalidProjected(any Error) } +extension PeekabooBridgeHandledResponse { + fileprivate func validatedOpaqueTarget( + for plan: PeekabooBridgeOperationResultSemantics.PeekabooBridgeRequestPlan) throws + -> PeekabooBridgeOperationTargetReceipt? + { + if let browserReceipt = self.externalBrowserTarget { + guard browserReceipt.isCanonicalExternalTarget, + self.response.browserExecutionConnectionReceipt == browserReceipt + else { throw DesktopTargetIdentityError.incompleteExactWindow } + return .browser(browserReceipt) + } + guard let namespaceTarget = self.browserCapabilityNamespaceTarget, + namespaceTarget.isCanonical, + case let .browserCapabilityNamespace(namespaceRequest) = plan.request.unwrappedOperationRequest, + namespaceRequest.namespaceReceipt.payload.namespaceID == namespaceTarget.namespaceID, + namespaceRequest.namespaceReceipt.payload.registryGenerationID == namespaceTarget.registryGenerationID + else { return nil } + return .browserCapabilityNamespace(namespaceTarget) + } +} + @MainActor extension PeekabooBridgeServer { func handleAttestedOperation( @@ -137,16 +158,11 @@ extension PeekabooBridgeServer { response = handled.response target = nil focusedElement = nil - } else if let browserReceipt = handled.externalBrowserTarget, + } else if let opaqueTarget = try handled.validatedOpaqueTarget(for: plan), !PeekabooBridgeOperationResultSemantics.isNoDispatchFailure(handled.response) { - guard browserReceipt.isCanonicalExternalTarget, - handled.response.browserExecutionConnectionReceipt == browserReceipt - else { - throw DesktopTargetIdentityError.incompleteExactWindow - } response = handled.response - target = .browser(browserReceipt) + target = opaqueTarget focusedElement = nil } else { let resolved = try PeekabooBridgeOperationTargetAttribution.resolve( diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServiceProviding.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServiceProviding.swift index 8cfc9a85c..6a154a559 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServiceProviding.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServiceProviding.swift @@ -69,15 +69,18 @@ public protocol PeekabooBridgeBrowserCapabilityNamespaceProviding: PeekabooBridg public struct PeekabooBridgeBrowserCapabilityNamespaceServiceResult: Sendable { public let response: PeekabooBridgeBrowserCapabilityNamespaceActionResponse public let targetIdentity: DesktopTargetIdentity? + public let browserTargetReceipt: PeekabooBridgeBrowserCapabilityNamespaceTargetReceipt? public let outcome: DesktopActionOutcome? public init( response: PeekabooBridgeBrowserCapabilityNamespaceActionResponse, targetIdentity: DesktopTargetIdentity? = nil, + browserTargetReceipt: PeekabooBridgeBrowserCapabilityNamespaceTargetReceipt? = nil, outcome: DesktopActionOutcome? = nil) { self.response = response self.targetIdentity = targetIdentity + self.browserTargetReceipt = browserTargetReceipt self.outcome = outcome } } diff --git a/Core/PeekabooCore/Sources/PeekabooCore/Support/PeekabooServices+BrowserBridge.swift b/Core/PeekabooCore/Sources/PeekabooCore/Support/PeekabooServices+BrowserBridge.swift index eb373675e..b6f3373f6 100644 --- a/Core/PeekabooCore/Sources/PeekabooCore/Support/PeekabooServices+BrowserBridge.swift +++ b/Core/PeekabooCore/Sources/PeekabooCore/Support/PeekabooServices+BrowserBridge.swift @@ -329,9 +329,25 @@ extension PeekabooServices: PeekabooBridgeBrowserCapabilityNamespaceProviding { meta: response.meta.map { try PeekabooBridgeJSONValue.fromCodable($0) }, structuredContent: response.structuredContent.map { try PeekabooBridgeJSONValue.fromCodable($0) }, nativeWindowReceipt: nativeReceipt) + let browserTargetReceipt: PeekabooBridgeBrowserCapabilityNamespaceTargetReceipt? + if let external = result.externalBrowserConnectionReceipt { + guard let opaque = PeekabooBridgeBrowserCapabilityNamespaceTargetReceipt( + namespaceID: namespaceID, + registryGenerationID: request.namespaceReceipt.payload.registryGenerationID, + externalConnectionReceipt: Self.bridgeReceipt(from: external)) + else { + throw PeekabooBridgeErrorEnvelope( + code: .internalError, + message: "The scoped browser returned an invalid external target receipt") + } + browserTargetReceipt = opaque + } else { + browserTargetReceipt = nil + } return PeekabooBridgeBrowserCapabilityNamespaceServiceResult( response: bridgeResponse, targetIdentity: result.targetIdentity, + browserTargetReceipt: browserTargetReceipt, outcome: result.outcome) } diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPDevToolsControlSessionTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPDevToolsControlSessionTests.swift index 4dcbd281f..c3397f0ef 100644 --- a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPDevToolsControlSessionTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPDevToolsControlSessionTests.swift @@ -16,6 +16,11 @@ struct BrowserMCPDevToolsControlSessionTests { result: ["product": "Chrome/151.0", "protocolVersion": "1.3"])), ] case "Target.getTargets": + let filter = request.params["filter"] as? [[String: Any]] + #expect(filter?.count == 2) + #expect(filter?.first?["type"] as? String == "page") + #expect(filter?.first?["exclude"] as? Bool == false) + #expect(filter?.last?["exclude"] as? Bool == true) return [.success(Self.response( id: request.id, result: [ @@ -89,6 +94,40 @@ struct BrowserMCPDevToolsControlSessionTests { await connection.session.close() } + @Test + func `valid page inventory larger than legacy 64 KiB remains usable`() async throws { + let pageCount = 1500 + let transport = FakeControlTransport { command in + let request = try Self.decodeCommand(command) + if request.method == "Browser.getVersion" { + return [.success(Self.response( + id: request.id, + result: ["product": "Chrome/151.0", "protocolVersion": "1.3"]))] + } + let targets = (0.. 64 * 1024) + #expect(response.count < 1024 * 1024) + return [.success(response)] + } + let opener = FakeControlTransportOpener(transport: transport) + let connection = try await self.connect(opener) + + let targets = try await connection.session.getTargets(deadline: Self.deadline(seconds: 2)) + + #expect(targets.count == pageCount) + #expect(await connection.session.state() == .open) + #expect(opener.openCount == 1) + await connection.session.close() + } + @Test func `unexpected response identity kills control without reopening`() async throws { let transport = FakeControlTransport { command in @@ -128,7 +167,7 @@ struct BrowserMCPDevToolsControlSessionTests { id: request.id, result: ["product": "Chrome/151.0", "protocolVersion": "1.3"]))] } - return [.success(Data(repeating: 0x20, count: 64 * 1024 + 1))] + return [.success(Data(repeating: 0x20, count: 1024 * 1024 + 1))] } let opener = FakeControlTransportOpener(transport: transport) let connection = try await self.connect(opener) diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPScopedNamespaceRuntimeTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPScopedNamespaceRuntimeTests.swift index 685bc09db..5c81f6c6d 100644 --- a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPScopedNamespaceRuntimeTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPScopedNamespaceRuntimeTests.swift @@ -315,6 +315,46 @@ struct BrowserMCPScopedNamespaceRuntimeTests { #expect(!Self.dump(connected.response).contains("127.0.0.1:9222")) } + @Test + func `external connect retains only host private exact target evidence`() async throws { + let fixture = NamespaceRuntimeFixture() + let namespaceID = Self.namespaceID(15) + try fixture.runtime.open(namespaceID) + let session = try #require(fixture.sessions[namespaceID]) + let outcome = DesktopActionOutcome.dispatchedUnverified( + delivery: .init(mechanism: .browserProtocol, mode: .foreground), + evidence: .deliveryAccepted, + unitCount: .one) + session.response = try ToolResponse.text( + "connected", + meta: MCPToolResponseMetadataProjector.metadata( + merging: [ + "connection_receipt": .object([ + "browser_url": .string("http://127.0.0.1:9222/"), + "browser_id": .string("private-browser"), + "browser_version": .string("Chrome/151.0"), + "protocol_version": .string("1.3"), + ]), + ], + outcome: outcome)) + + let connected = try await fixture.runtime.execute( + in: namespaceID, + arguments: ToolArguments(raw: ["action": "connect", "browser_url": "http://127.0.0.1:9222"]), + policy: .explicitlyForegroundAllowed) + + let receipt = try #require(connected.externalBrowserConnectionReceipt) + #expect(receipt.browserURL == "http://127.0.0.1:9222/") + #expect(receipt.webSocketDebuggerURL == + "ws://127.0.0.1:9222/devtools/browser/private-browser") + #expect(receipt.devToolsBrowserID == "private-browser") + #expect(connected.targetIdentity == nil) + #expect(connected.outcome == outcome) + let publicDump = Self.dump(connected.response) + #expect(!publicDump.contains("127.0.0.1:9222")) + #expect(!publicDump.contains("private-browser")) + } + @Test func `recursive scrubber removes host IDs and fails closed on raw provider capabilities`() async throws { let fixture = NamespaceRuntimeFixture() diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPSessionManagerTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPSessionManagerTests.swift index 0f6d5ba4d..3d533b3ff 100644 --- a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPSessionManagerTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserMCPSessionManagerTests.swift @@ -1832,10 +1832,42 @@ extension BrowserMCPSessionManagerTests { channel: .stable) #expect(result.outcome?.state == .dispatchedUnverified) + #expect(result.outcome?.delivery == .init(mechanism: .browserProtocol, mode: .background)) #expect(result.outcome?.dispatchState.unitCount?.rawValue == 2) #expect(manager.executedTools == ["click", "type_text"]) } + @Test + func `page fronting mutations report foreground provider delivery`() async throws { + let manager = MockBrowserMCPManager() + let session = Self.exactSession(manager: manager) + _ = try await session.connect(channel: .stable) + manager.executedTools.removeAll() + let service = BrowserMCPService(sessionManager: session) + let calls = [ + BrowserMCPMappedCall( + toolName: "select_page", + arguments: ["pageId": 7, "bringToFront": true]), + BrowserMCPMappedCall( + toolName: "new_page", + arguments: ["url": "https://example.test/", "background": false]), + ] + + for call in calls { + let result = try await service.executeSequenceWithOutcome([call], channel: .stable) + #expect(result.outcome?.state == .dispatchedUnverified) + #expect(result.outcome?.delivery == .init(mechanism: .browserProtocol, mode: .foreground)) + #expect(result.outcome?.dispatchState.unitCount == .one) + } + + #expect(manager.executedTools == ["select_page", "new_page"]) + + manager.executeHandler = { _, _ in ToolResponse.error("provider refused after entry") } + let failed = try await service.executeSequenceWithOutcome([calls[0]], channel: .stable) + #expect(failed.outcome?.state == .indeterminate) + #expect(failed.outcome?.delivery == .init(mechanism: .browserProtocol, mode: .foreground)) + } + @Test func `action result service auto connects a mutating sequence when allowed`() async throws { let manager = MockBrowserMCPManager() diff --git a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserNativeWindowBindingCoordinatorTests.swift b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserNativeWindowBindingCoordinatorTests.swift index d7b15e7c5..f73473fb4 100644 --- a/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserNativeWindowBindingCoordinatorTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooAgentRuntimeTests/BrowserNativeWindowBindingCoordinatorTests.swift @@ -80,6 +80,9 @@ struct BrowserNativeWindowBindingCoordinatorTests { ToolResponse.error("unexpected bound tool") } } + let validationsBefore = try fixture.transport.sentCommands() + .map(BrowserMCPDevToolsControlSessionTests.decodeCommand) + .count(where: { $0.method == "Target.getTargets" }) let execution = try await fixture.capabilities.withExclusiveOperation { try await fixture.manager.executeNativeWindowBoundSequence( @@ -99,6 +102,94 @@ struct BrowserNativeWindowBindingCoordinatorTests { #expect(!execution.result.response.isError) #expect(execution.nativeWindowReceipt == proof.nativeWindowReceipt) #expect(fixture.provider.executedTools.suffix(2) == ["take_snapshot", "click"]) + let validationsAfter = try fixture.transport.sentCommands() + .map(BrowserMCPDevToolsControlSessionTests.decodeCommand) + .count(where: { $0.method == "Target.getTargets" }) + #expect(validationsAfter == validationsBefore + 1) + await fixture.control.close() + } + + @Test + func `bound multi call mutation revalidates immediately before every provider leaf`() async throws { + let fixture = try await Self.fixture() + _ = try await BrowserNativeWindowBindingCoordinator.bind( + pageReference: fixture.pageReference, + nativeTarget: Self.nativeTarget, + context: fixture.context, + dependencies: Self.dependencies()) + fixture.provider.executeHandler = { _, _ in ToolResponse.text("ok") } + let validationsBefore = try fixture.transport.sentCommands() + .map(BrowserMCPDevToolsControlSessionTests.decodeCommand) + .count(where: { $0.method == "Target.getTargets" }) + + let execution = try await fixture.capabilities.withExclusiveOperation { + try await fixture.manager.executeNativeWindowBoundSequence( + .init( + calls: [ + BrowserMCPMappedCall(toolName: "click", arguments: ["pageId": 7, "uid": "1_0"]), + BrowserMCPMappedCall(toolName: "type_text", arguments: ["pageId": 7, "text": "value"]), + ], + channel: .stable, + sessionBinding: fixture.sessionBinding, + elementPreflight: nil, + pageReference: fixture.pageReference, + deadline: Self.deadline), + capabilities: fixture.capabilities, + receiptProviders: Self.providers()) + } + + let validationsAfter = try fixture.transport.sentCommands() + .map(BrowserMCPDevToolsControlSessionTests.decodeCommand) + .count(where: { $0.method == "Target.getTargets" }) + #expect(validationsAfter == validationsBefore + 2) + #expect(execution.result.completedCallCount == 2) + #expect(execution.result.dispatchedCallCount == 2) + #expect(fixture.provider.executedTools.suffix(2) == ["click", "type_text"]) + await fixture.control.close() + } + + @Test + func `target drift between bound calls preserves the completed prefix and skips the second leaf`() async throws { + let moved = LockedBoolean() + let fixture = try await Self.fixture(windowID: { moved.value ? 42 : 41 }) + _ = try await BrowserNativeWindowBindingCoordinator.bind( + pageReference: fixture.pageReference, + nativeTarget: Self.nativeTarget, + context: fixture.context, + dependencies: Self.dependencies()) + fixture.provider.executeHandler = { toolName, _ in + if toolName == "click" { + moved.value = true + } + return ToolResponse.text("ok") + } + + let execution = try await fixture.capabilities.withExclusiveOperation { + try await fixture.manager.executeNativeWindowBoundSequence( + .init( + calls: [ + BrowserMCPMappedCall(toolName: "click", arguments: ["pageId": 7, "uid": "1_0"]), + BrowserMCPMappedCall(toolName: "type_text", arguments: ["pageId": 7, "text": "value"]), + ], + channel: .stable, + sessionBinding: fixture.sessionBinding, + elementPreflight: nil, + pageReference: fixture.pageReference, + deadline: Self.deadline), + capabilities: fixture.capabilities, + receiptProviders: Self.providers()) + } + + #expect(execution.result.completedCallCount == 1) + #expect(execution.result.dispatchedCallCount == 1) + #expect(execution.result.actionFailure?.outcome.state == .partial) + #expect(execution.result.actionFailure?.outcome.retrySafety == .unsafe) + #expect(fixture.provider.executedTools.suffix(1) == ["click"]) + await #expect(throws: BrowserToolNativeWindowBindingError.stalePageReference) { + _ = try await fixture.capabilities.nativeWindowBinding( + pageReference: fixture.pageReference, + sessionBinding: fixture.sessionBinding) + } await fixture.control.close() } diff --git a/Core/PeekabooCore/Tests/PeekabooBridgeTests/BrowserCapabilityNamespaceWireTests.swift b/Core/PeekabooCore/Tests/PeekabooBridgeTests/BrowserCapabilityNamespaceWireTests.swift index c120f3c4a..1215372cd 100644 --- a/Core/PeekabooCore/Tests/PeekabooBridgeTests/BrowserCapabilityNamespaceWireTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooBridgeTests/BrowserCapabilityNamespaceWireTests.swift @@ -28,7 +28,7 @@ struct BrowserCapabilityNamespaceWireTests { } @Test - func `namespace connect refuses explicit provider endpoints before dispatch`() { + func `namespace connect admits exact explicit provider endpoints with foreground authority`() { let request = PeekabooBridgeRequest.browserCapabilityNamespace(.init( namespaceReceipt: Self.receipt(), executionMode: .foregroundAllowed, @@ -36,11 +36,145 @@ struct BrowserCapabilityNamespaceWireTests { action: .connect, arguments: ["browser_url": .string("http://127.0.0.1:9222")])))) - #expect(throws: DesktopActionFailure.self) { + #expect(throws: Never.self) { try request.validateBrowserCapabilityExecutionMode() } } + @Test + func `opaque external browser target round trips without connection authority`() throws { + let namespace = Self.receipt() + let external = PeekabooBridgeBrowserConnectionReceipt( + browserURL: "http://127.0.0.1:9222/", + webSocketDebuggerURL: "ws://127.0.0.1:9222/devtools/browser/private-browser-id", + devToolsBrowserID: "private-browser-id", + browserVersion: "Chrome/151.0", + protocolVersion: "1.3") + let opaque = try #require(PeekabooBridgeBrowserCapabilityNamespaceTargetReceipt( + namespaceID: namespace.payload.namespaceID, + registryGenerationID: namespace.payload.registryGenerationID, + externalConnectionReceipt: external)) + let target = PeekabooBridgeOperationTargetReceipt.browserCapabilityNamespace(opaque) + let data = try JSONEncoder.peekabooBridgeEncoder().encode(target) + let decoded = try JSONDecoder.peekabooBridgeDecoder().decode( + PeekabooBridgeOperationTargetReceipt.self, + from: data) + let text = try #require(String(data: data, encoding: .utf8)) + + #expect(decoded == target) + #expect(text.contains(opaque.connectionReceiptSHA256)) + #expect(!text.contains("127.0.0.1")) + #expect(!text.contains("webSocketDebuggerURL")) + #expect(!text.contains("private-browser-id")) + } + + @Test + func `opaque external target remains bound to its exact namespace generation`() throws { + let namespace = Self.receipt() + let external = PeekabooBridgeBrowserConnectionReceipt( + browserURL: "http://127.0.0.1:9222/", + webSocketDebuggerURL: "ws://127.0.0.1:9222/devtools/browser/private-browser-id", + devToolsBrowserID: "private-browser-id", + browserVersion: "Chrome/151.0", + protocolVersion: "1.3") + let opaque = try #require(PeekabooBridgeBrowserCapabilityNamespaceTargetReceipt( + namespaceID: namespace.payload.namespaceID, + registryGenerationID: namespace.payload.registryGenerationID, + externalConnectionReceipt: external)) + let request = PeekabooBridgeRequest.browserCapabilityNamespace(.init( + namespaceReceipt: namespace, + action: .executeAction(.init(action: .click)))) + let plan = PeekabooBridgeOperationResultSemantics.requestPlan(for: request, vocabulary: .current) + let response = PeekabooBridgeResponse.browserCapabilityNamespaceAction(.init( + content: [], + isError: false)) + + #expect(throws: Never.self) { + try PeekabooBridgeBrowserCapabilityNamespaceReceiptValidation.validateNativeTarget( + Self.operationPayload(target: .browserCapabilityNamespace(opaque)), + request: request, + response: response, + plan: plan) + } + let wrongNamespaceID = try #require(UUID(uuidString: "70000000-0000-4000-8000-000000000007")) + let wrongNamespace = try #require(PeekabooBridgeBrowserCapabilityNamespaceTargetReceipt( + namespaceID: wrongNamespaceID, + registryGenerationID: namespace.payload.registryGenerationID, + externalConnectionReceipt: external)) + #expect(throws: PeekabooBridgeOperationReceiptError.self) { + try PeekabooBridgeBrowserCapabilityNamespaceReceiptValidation.validateNativeTarget( + Self.operationPayload(target: .browserCapabilityNamespace(wrongNamespace)), + request: request, + response: response, + plan: plan) + } + } + + @Test + func `namespace connect alone admits confirmed no change success`() { + let receipt = Self.receipt() + let connect = PeekabooBridgeRequest.browserCapabilityNamespace(.init( + namespaceReceipt: receipt, + executionMode: .foregroundAllowed, + action: .executeAction(.init(action: .connect)))) + let click = PeekabooBridgeRequest.browserCapabilityNamespace(.init( + namespaceReceipt: receipt, + action: .executeAction(.init(action: .click)))) + let noChange = DesktopActionOutcome.confirmedNoChange(route: .bridge) + let response = PeekabooBridgeResponse.browserCapabilityNamespaceAction(.init( + content: [], + isError: false)) + + #expect(PeekabooBridgeOperationResultSemantics.successfulOutcomeMatchesContract( + noChange, + response: response, + request: connect)) + #expect(!PeekabooBridgeOperationResultSemantics.successfulOutcomeMatchesContract( + noChange, + response: response, + request: click)) + } + + @Test + func `namespace page fronting admits only foreground browser delivery`() { + let receipt = Self.receipt() + let foreground = DesktopActionOutcome.dispatchedUnverified( + route: .bridge, + delivery: .init(mechanism: .browserProtocol, mode: .foreground), + evidence: .deliveryAccepted, + unitCount: .one) + let background = DesktopActionOutcome.dispatchedUnverified( + route: .bridge, + delivery: .init(mechanism: .browserProtocol, mode: .background), + evidence: .deliveryAccepted, + unitCount: .one) + let requests = [ + PeekabooBridgeRequest.browserCapabilityNamespace(.init( + namespaceReceipt: receipt, + executionMode: .foregroundAllowed, + action: .executeAction(.init( + action: .selectPage, + arguments: ["bring_to_front": .bool(true)])))), + PeekabooBridgeRequest.browserCapabilityNamespace(.init( + namespaceReceipt: receipt, + executionMode: .foregroundAllowed, + action: .executeAction(.init( + action: .newPage, + arguments: ["background": .bool(false)])))), + ] + + for request in requests { + #expect(PeekabooBridgeOperationResultSemantics.successfulOutcomeMatchesContract( + foreground, + response: .browserCapabilityNamespaceAction(.init(content: [], isError: false)), + request: request)) + #expect(!PeekabooBridgeOperationResultSemantics.successfulOutcomeMatchesContract( + background, + response: .browserCapabilityNamespaceAction(.init(content: [], isError: false)), + request: request)) + } + } + @Test func `signed namespace receipt round trips without private browser identifiers`() throws { let receipt = Self.receipt() @@ -379,10 +513,10 @@ struct BrowserCapabilityNamespaceWireTests { private static func receipt() -> PeekabooBridgeBrowserCapabilityNamespaceReceipt { .init( payload: .init( - namespaceID: UUID(uuidString: "10000000-0000-0000-0000-000000000001")!, + namespaceID: UUID(uuidString: "10000000-0000-4000-8000-000000000001")!, listenerInstanceID: UUID(uuidString: "20000000-0000-0000-0000-000000000002")!, listenerPublicKeySHA256: String(repeating: "a", count: 64), - registryGenerationID: UUID(uuidString: "30000000-0000-0000-0000-000000000003")!, + registryGenerationID: UUID(uuidString: "30000000-0000-4000-8000-000000000003")!, principal: .init( effectiveUserIdentifier: 501, teamIdentifier: "TEAMID1234", @@ -392,4 +526,31 @@ struct BrowserCapabilityNamespaceWireTests { expiresAtUnixMilliseconds: 1_800_000_300_000), signature: Data(repeating: 0x5A, count: 64)) } + + private static func operationPayload( + target: PeekabooBridgeOperationTargetReceipt) -> PeekabooBridgeOperationReceiptPayload + { + let sessionID = UUID(uuidString: "40000000-0000-0000-0000-000000000004")! + let sequence = PeekabooBridgeOperationSessionSequence(0) + return PeekabooBridgeOperationReceiptPayload( + requestID: PeekabooBridgeOperationReceiptCoding.deterministicRequestID( + sessionID: sessionID, + sequence: sequence), + sessionID: sessionID, + sessionSequence: sequence, + sessionAttestationSHA256: String(repeating: "a", count: 64), + listenerInstanceID: UUID(uuidString: "50000000-0000-0000-0000-000000000005")!, + listenerPublicKeySHA256: String(repeating: "b", count: 64), + host: .init(processIdentifier: 1, processStartIdentity: 2, codeSignatureHash: "host"), + clientInstanceID: UUID(uuidString: "60000000-0000-0000-0000-000000000006")!, + client: .init(processIdentifier: 3, processStartIdentity: 4, codeSignatureHash: "client"), + operation: .browserCapabilityNamespace, + requestSHA256: String(repeating: "c", count: 64), + responseSHA256: String(repeating: "d", count: 64), + target: target, + outcome: nil, + remainingClaimCount: 1, + startedAtUnixMilliseconds: 1, + completedAtUnixMilliseconds: 2) + } } diff --git a/Core/PeekabooCore/Tests/PeekabooTests/BrowserCapabilityNamespaceHandshakeTests.swift b/Core/PeekabooCore/Tests/PeekabooTests/BrowserCapabilityNamespaceHandshakeTests.swift index 05bd2cfe6..058187906 100644 --- a/Core/PeekabooCore/Tests/PeekabooTests/BrowserCapabilityNamespaceHandshakeTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooTests/BrowserCapabilityNamespaceHandshakeTests.swift @@ -53,6 +53,57 @@ struct BrowserCapabilityNamespaceHandshakeTests { #expect(handshake.operationSessionAttestation != nil) } + @Test + @MainActor + func `failed namespace runtime preparation suppresses the complete namespace surface`() async throws { + let socketPath = "/tmp/peekaboo-browser-namespace-preparation-failure-\(UUID().uuidString).sock" + let services = StubServices() + services.browserNamespacePrepareError = PeekabooBridgeErrorEnvelope( + code: .internalError, + message: "Injected namespace preparation failure") + let server = PeekabooBridgeServer( + services: services, + hostKind: .onDemand, + allowlistedTeams: [], + allowlistedBundles: [], + allowedOperations: PeekabooBridgeOperation.onDemandDefaultAllowlist) + let host = PeekabooBridgeHost( + socketPath: socketPath, + server: server, + allowedTeamIDs: [], + requestTimeoutSec: 2) + await host.setAuthenticationForTesting(.init( + liveIdentity: { try PeekabooBridgeSocketIO.livePeerIdentity(fd: $0) }, + coldPeer: { identity, _ in + PeekabooBridgePeer( + liveIdentity: identity, + bundleIdentifier: "dev.peekaboo.browser-namespace-preparation-failure-client", + teamIdentifier: TrustedBridgeClientFixture.teamIdentifier) + })) + try await host.startChecked() + defer { Task { await host.stop() } } + + let client = TrustedBridgeClientFixture.make(socketPath: socketPath, requestTimeoutSec: 2) + let handshake = try await client.handshake(client: .init( + bundleIdentifier: "dev.peekaboo.browser-namespace-preparation-failure", + teamIdentifier: nil, + processIdentifier: getpid())) + + #expect(services.browserNamespacePrepareCount == 1) + #expect(PeekabooBridgeOperation.browserCapabilityNamespaceOperations.isDisjoint(with: + Set(handshake.supportedOperations))) + #expect(PeekabooBridgeOperation.browserCapabilityNamespaceOperations.isDisjoint(with: + Set(handshake.enabledOperations ?? []))) + #expect(handshake.hostCapabilities?.contains( + PeekabooBridgeHostCapability.browserCapabilityNamespaces) != true) + #expect(handshake.hostCapabilities?.contains( + PeekabooBridgeHostCapability.nativeBrowserWindowBinding) != true) + await #expect(throws: PeekabooBridgeErrorEnvelope.self) { + _ = try await client.createBrowserCapabilityNamespace() + } + #expect(services.browserNamespaceOpenedIDs.isEmpty) + } + @Test @MainActor func `GUI host strips namespace operations and capabilities despite complete service`() async throws { @@ -370,6 +421,9 @@ extension StubServices: PeekabooBridgeBrowserCapabilityNamespaceProviding { func prepareBrowserCapabilityNamespaceRuntime() throws { self.browserNamespacePrepareCount += 1 + if let browserNamespacePrepareError { + throw browserNamespacePrepareError + } self.browserNamespaceRuntimeAccepting = true } diff --git a/Core/PeekabooCore/Tests/PeekabooTests/PeekabooBridgeTests.swift b/Core/PeekabooCore/Tests/PeekabooTests/PeekabooBridgeTests.swift index 888b9219b..a373d0e9e 100644 --- a/Core/PeekabooCore/Tests/PeekabooTests/PeekabooBridgeTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooTests/PeekabooBridgeTests.swift @@ -2027,6 +2027,7 @@ final class StubServices: PeekabooBridgeServiceProviding { var browserDispatchedCallCount: Int? var preservesBrowserReceiptChannel = false var browserNamespacePrepareCount = 0 + var browserNamespacePrepareError: (any Error)? var browserNamespaceOpenedIDs: Set = [] var browserNamespaceExecutedIDs: [UUID] = [] var browserNamespaceClosedIDs: [UUID] = [] diff --git a/Core/PeekabooFoundation/Sources/PeekabooFoundation/BrowserToolActionSemantics.swift b/Core/PeekabooFoundation/Sources/PeekabooFoundation/BrowserToolActionSemantics.swift index 55d90059a..8346a5f50 100644 --- a/Core/PeekabooFoundation/Sources/PeekabooFoundation/BrowserToolActionSemantics.swift +++ b/Core/PeekabooFoundation/Sources/PeekabooFoundation/BrowserToolActionSemantics.swift @@ -102,4 +102,23 @@ public enum BrowserToolActionSemantics: Equatable, Sendable { return nil } } + + /// Whether this mapped provider call is allowed to change the user's visible browser focus. + /// + /// This is derived from the provider-facing argument shape so result producers and transport + /// attestation describe the same leaf that was dispatched. An omitted `new_page` background + /// value stays conservative because the provider default may create a foreground page. + public static func requestsForegroundDelivery( + toolName: String, + booleanArgument: (String) -> Bool?) -> Bool + { + switch toolName { + case "select_page": + booleanArgument("bringToFront") == true + case "new_page": + booleanArgument("background") != true + default: + false + } + } } diff --git a/docs/browser-mcp.md b/docs/browser-mcp.md index 916367b1f..b65f9f8b0 100644 --- a/docs/browser-mcp.md +++ b/docs/browser-mcp.md @@ -104,10 +104,11 @@ Browser MCP state is owned by `BrowserMCPService` through `BrowserMCPSessionMana - In a local MCP process, the browser tool uses the `BrowserMCPService` from `MCPToolContext`. Public MCP and standalone Browser contexts default to background-only and require an existing live exact connection receipt; - they never auto-connect implicitly. -- In daemon-backed mode, `RemotePeekabooServices` can forward legacy CLI browser status/connect/execute calls over the - Bridge socket. Persistent opaque-reference Agent/MCP execution fails closed until Bridge can authenticate and carry - a caller-owned provider-child epoch. + they never auto-connect implicitly. Durable CLI calls can instead opt into one authenticated Bridge 1.38 namespace + by repeating its owner-private namespace file and exact issuing socket. +- In daemon-backed mode, `RemotePeekabooServices` forwards legacy CLI browser calls separately from explicit Bridge + 1.38 namespaces. Generic Bridge calls never gain opaque-reference authority; a namespace authenticates one + caller-owned provider-child epoch and closed high-level action surface. - The daemon owns the `chrome-devtools-mcp` child process and per-page snapshot UID state. - Separate CLI invocations require the same current-build reusable daemon. Peekaboo.app and older Bridge hosts are not eligible for browser session routing because they cannot attest the exact persistent connection receipt. @@ -137,22 +138,24 @@ Browser MCP state is owned by `BrowserMCPService` through `BrowserMCPSessionMana navigation, disconnect, connection replacement, and MCP-session teardown invalidate their complete subordinate namespace. Before element dispatch, the same provider gate takes a fresh snapshot and proves every provider UID is still present in the current document. References copied into another caller session fail before Chrome dispatch. -- Process-local MCP and Agent sessions with one native-channel Chrome receipt can bind an opaque page to an exact native +- Process-local MCP/Agent sessions and authenticated Bridge 1.38 namespaces with one native-channel Chrome receipt can + bind an opaque page to an exact native window using `{ "action": "bind_window", "page_id": "bp1_...", "pid": 123, "window_id": 456 }`. All three selectors are required; the process generation comes only from the exact connection receipt. Peekaboo privately correlates the page target and Chrome window geometry, then revalidates the native receipt, tab membership, retained control session, and provider child immediately before every bound mutation. A moved tab, resized/replaced window, restarted process/provider, or dead control session invalidates the binding and never falls back to unbound dispatch. - Explicit URLs, isolated profiles, remote/custom providers, and standalone CLI sessions cannot bind. + Explicit URLs and isolated profiles cannot bind. Standalone CLI can bind only through its explicit namespace file and + exact issuing `--bridge-socket`. - Independently authenticated process-local browser sessions own separate Chrome DevTools MCP children and FIFO execution/mutation gates, so one blocked session does not stall another while calls within each session remain ordered. Peekaboo reserves the canonical process/DevTools target before permission-bearing provider setup; two sessions therefore cannot even transiently connect or probe the same exact target. Isolated sessions launch and own distinct browser instances, so their intentionally receiptless children do not overlock one another. Foreground-capable browser setup/activation remains on the shared desktop lane, and failed snapshot invalidation is kept in one ordered - cross-session ledger that every browser and desktop mutation must drain before dispatch. Bridge currently retains its one authenticated browser connection because its status/connect/ - disconnect wire shape has no caller-session namespace; transport multiplexing is deferred without claiming a new - Bridge protocol version. + cross-session ledger that every browser and desktop mutation must drain before dispatch. Bridge 1.38 gives each + authenticated namespace its own runtime and provider-child epoch; legacy Bridge status/connect/disconnect remains a + separate compatibility surface and cannot mint or consume namespace capabilities. - Each process-local `peekaboo mcp serve` session owns and tears down its own browser child. A daemon-backed MCP session does not borrow the daemon's shared legacy browser connection or its raw page/element IDs. The background-only default therefore starts disconnected and cannot bootstrap browser control. To authorize setup for that exact scoped child, @@ -174,7 +177,7 @@ Common actions: - `navigate` - `wait_for` - `snapshot` -- `bind_window` (process-local MCP/Agent sessions only) +- `bind_window` (process-local MCP/Agent sessions or an authenticated Bridge 1.38 namespace) - `click` - `fill` - `type` diff --git a/docs/commands/browser.md b/docs/commands/browser.md index 1592ba545..173ce09c2 100644 --- a/docs/commands/browser.md +++ b/docs/commands/browser.md @@ -43,15 +43,43 @@ fail and require an explicit reconnect. Browser `type` and `press-key` require `--uid` from a fresh snapshot. Peekaboo focuses that exact page element and sends the keyboard operation as one daemon-owned sequence rather than inheriting whichever control another caller focused. Process-local persistent MCP and Agent callers receive opaque, session-owned page and element references instead of -these raw CLI compatibility values. Those references also bind the exact provider child, cannot cross caller sessions, -and expire after a newer snapshot, navigation, disconnect, connection replacement, or session end. Bridge-backed -opaque-reference sessions currently fail closed pending an authenticated browser-session wire namespace. - -`browser bind-window` is intentionally unavailable to standalone CLI invocations. A one-shot CLI process cannot retain -the caller-owned opaque page capability or native binding, and the daemon's legacy shared browser state is not a safe -substitute. Use one process-local MCP or Agent browser session. Durable CLI binding will require an authenticated Bridge -1.38 browser namespace receipt; until that wire contract exists, the CLI refuses before runtime discovery or provider -dispatch. +these raw CLI compatibility values. Those references bind the exact provider child, cannot cross caller sessions, and +expire after a newer snapshot, navigation, disconnect, connection replacement, or session end. A durable CLI workflow +uses the same authority model through an authenticated Bridge 1.38 namespace. + +## Durable Bridge namespaces + +Create a namespace in an owner-private state file, then pass both that file and the exact issuing Bridge socket on every +invocation. The destination must be absent. Peekaboo creates a missing final parent directory with mode `0700`; an +existing parent must already be owned by the current user, mode `0700`, and free of extended ACLs. The created receipt +is a bounded regular file with mode `0600`. + +```bash +NAMESPACE_FILE="$HOME/.peekaboo/browser-namespaces/work.json" +BRIDGE_SOCKET="$HOME/Library/Application Support/Peekaboo/daemon.sock" + +peekaboo browser namespace-create \ + --namespace-file "$NAMESPACE_FILE" --bridge-socket "$BRIDGE_SOCKET" +peekaboo browser connect --channel stable --foreground \ + --namespace-file "$NAMESPACE_FILE" --bridge-socket "$BRIDGE_SOCKET" +peekaboo browser list-pages \ + --namespace-file "$NAMESPACE_FILE" --bridge-socket "$BRIDGE_SOCKET" +peekaboo browser bind-window --page-id bp1_... --pid 123 --window-id 456 \ + --namespace-file "$NAMESPACE_FILE" --bridge-socket "$BRIDGE_SOCKET" +peekaboo browser snapshot --page-id bp1_... \ + --namespace-file "$NAMESPACE_FILE" --bridge-socket "$BRIDGE_SOCKET" +peekaboo browser namespace-close \ + --namespace-file "$NAMESPACE_FILE" --bridge-socket "$BRIDGE_SOCKET" +``` + +`connect` and any operation that intentionally brings a page forward still require `--foreground`; page-targeted work +remains background by default. The opaque `bp1_`/`be1_` references never expose provider target IDs. The namespace +receipt is bound to one Bridge listener generation and principal, so another socket or a restarted listener refuses it +before provider dispatch. `namespace-close` removes the local receipt only after that exact host confirms closure. + +Native `bind-window` requires a process-bound official Chrome channel connection. Explicit loopback URLs may be used +for unbound namespace actions but cannot claim native PID/window binding. Legacy browser commands without +`--namespace-file` retain their numeric page-ID compatibility path and cannot borrow namespace capabilities. `browser upload-file` requires `--page-id`, a fresh file-input `--uid`, and an absolute `--path` to a current-user regular file no larger than 100 MiB. Peekaboo never grants Chrome DevTools MCP unrestricted filesystem access. The daemon From d0b807e351c6bf14c7777ceafeda9d0a38a79667 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 27 Aug 2026 01:51:53 -0700 Subject: [PATCH 14/14] fix(browser): repair namespace compilation --- .../Browser/BrowserMCPSessionManager.swift | 14 +++++++++++--- ...oBridgeServer+BrowserCapabilityNamespaces.swift | 5 +++-- .../BrowserCapabilityNamespaceHandshakeTests.swift | 5 ++++- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPSessionManager.swift b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPSessionManager.swift index 41103340b..dc2c65427 100644 --- a/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPSessionManager.swift +++ b/Core/PeekabooCore/Sources/PeekabooAgentRuntime/Browser/BrowserMCPSessionManager.swift @@ -672,7 +672,7 @@ final class BrowserMCPSessionManager: @unchecked Sendable { actionFailure = Self.indeterminateSequenceFailure( dispatchedCallCount: dispatchedCallCount, completedCallCount: completedCallCount, - delivery: BrowserMCPPageRoutingContract.executionDelivery(for: calls.prefix(...index)), + delivery: BrowserMCPPageRoutingContract.executionDelivery(for: calls.prefix(index + 1)), cause: cause) response = .error(actionFailure?.message ?? "Browser sequence completion is unknown") shouldValidateConnection = false @@ -685,7 +685,7 @@ final class BrowserMCPSessionManager: @unchecked Sendable { actionFailure = Self.indeterminateSequenceFailure( dispatchedCallCount: dispatchedCallCount, completedCallCount: completedCallCount, - delivery: BrowserMCPPageRoutingContract.executionDelivery(for: calls.prefix(...index)), + delivery: BrowserMCPPageRoutingContract.executionDelivery(for: calls.prefix(index + 1)), cause: error) response = .error(actionFailure?.message ?? "Browser sequence completion is unknown") shouldValidateConnection = false @@ -701,7 +701,7 @@ final class BrowserMCPSessionManager: @unchecked Sendable { actionFailure = Self.indeterminateSequenceFailure( dispatchedCallCount: dispatchedCallCount, completedCallCount: completedCallCount, - delivery: BrowserMCPPageRoutingContract.executionDelivery(for: calls.prefix(...index)), + delivery: BrowserMCPPageRoutingContract.executionDelivery(for: calls.prefix(index + 1)), causeDescription: "The browser tool returned an error response.") break } @@ -1000,6 +1000,14 @@ final class BrowserMCPSessionManager: @unchecked Sendable { (error as? any LocalizedError)?.errorDescription ?? error.localizedDescription } + private static func actionSemantics(_ call: BrowserMCPMappedCall) + -> BrowserMCPPageRoutingContract.ActionSemantics + { + BrowserMCPPageRoutingContract.actionSemantics( + for: call.toolName, + arguments: call.arguments) ?? .mutating + } + private func execute( _ call: BrowserMCPMappedCall, beforeProviderDispatch: (@MainActor (BrowserMCPMappedCall) async throws -> Void)? = nil) async throws diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+BrowserCapabilityNamespaces.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+BrowserCapabilityNamespaces.swift index 0e698fa46..294199db2 100644 --- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+BrowserCapabilityNamespaces.swift +++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeServer+BrowserCapabilityNamespaces.swift @@ -106,9 +106,10 @@ extension PeekabooBridgeServer { let target = if let targetIdentity = result.targetIdentity { PeekabooBridgeHandledResponse.Mutation.TargetDisposition.handlerResolved(targetIdentity) } else if let browserTargetReceipt = result.browserTargetReceipt { - .browserCapabilityNamespace(browserTargetReceipt) + PeekabooBridgeHandledResponse.Mutation.TargetDisposition.browserCapabilityNamespace( + browserTargetReceipt) } else { - .external + PeekabooBridgeHandledResponse.Mutation.TargetDisposition.external } handled = handled.finalizingMutation(outcome: outcome, target: target) } diff --git a/Core/PeekabooCore/Tests/PeekabooTests/BrowserCapabilityNamespaceHandshakeTests.swift b/Core/PeekabooCore/Tests/PeekabooTests/BrowserCapabilityNamespaceHandshakeTests.swift index 058187906..4a9a67571 100644 --- a/Core/PeekabooCore/Tests/PeekabooTests/BrowserCapabilityNamespaceHandshakeTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooTests/BrowserCapabilityNamespaceHandshakeTests.swift @@ -98,9 +98,12 @@ struct BrowserCapabilityNamespaceHandshakeTests { PeekabooBridgeHostCapability.browserCapabilityNamespaces) != true) #expect(handshake.hostCapabilities?.contains( PeekabooBridgeHostCapability.nativeBrowserWindowBinding) != true) - await #expect(throws: PeekabooBridgeErrorEnvelope.self) { + let refusal = await #expect(throws: DesktopActionFailure.self) { _ = try await client.createBrowserCapabilityNamespace() } + #expect(refusal?.outcome.refusalReason == .runtimeIncompatible) + #expect(refusal?.outcome.dispatchState == DesktopActionOutcome.DispatchState.none) + #expect(refusal?.outcome.retrySafety == .safe) #expect(services.browserNamespaceOpenedIDs.isEmpty) }