From 7289560f9acf805ecc7fa2871df6efc6c6cd32dc Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Mon, 22 Jun 2026 21:22:47 -0700 Subject: [PATCH] fix(cdp): forward the real attach event so agent-driving works (H2 regression) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The H2 hardening rewrote the relay to actively Target.attachToTarget its page and SYNTHESIZE the client's Target.attachedToTarget instead of forwarding the real browser-level event. The synthesized event hard-coded `browserContextId: ""` — and Playwright's CRBrowser._onAttachedToTarget asserts browserContextId is non-empty, so `agent-browser` (Node + Playwright, brokered by Campus) crashed the moment it connected: Unhandled rejection: Error: targetInfo: {"attached":true,"targetId":"..."} at assert (playwright-core/.../assert.js) at CRBrowser._onAttachedToTarget (.../crBrowser.js:147) So every `campus webview ` ("agent-browser daemon closed the connection") broke. Fix: on first connect FORWARD the genuine browser-level attachedToTarget our active attach triggers — it carries the real, non-empty browserContextId (and everything else Playwright expects) — and cache that context id. Only on RECONNECT (page already attached, so no fresh event fires) do we synthesize, now reusing the cached real browserContextId. The active attach still triggers the event regardless of relay creation order, so the original "No page found" fix is preserved. Verified end-to-end: `campus webview snapshot` returns full page a11y trees for single and concurrent multi-tile drives against one shared host. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../macos/Classes/CdpRelay.swift | 45 ++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/flutter_cef_macos/macos/Classes/CdpRelay.swift b/packages/flutter_cef_macos/macos/Classes/CdpRelay.swift index 181f123..b4ea1e0 100644 --- a/packages/flutter_cef_macos/macos/Classes/CdpRelay.swift +++ b/packages/flutter_cef_macos/macos/Classes/CdpRelay.swift @@ -80,6 +80,10 @@ final class CdpRelay { private let scopeTargetId: String? private var ourSessionId: String? // learned from our target's attachedToTarget private var allowedSessions = Set() // our session + descendant sub-target sessions + // Captured from our page's real attachedToTarget event. Playwright's + // CRBrowser._onAttachedToTarget asserts targetInfo.browserContextId is non-empty, so + // the synthesized event MUST carry the real one (an empty string crashes the daemon). + private var ourBrowserContextId: String? private let filterLock = NSLock() // CEF-2b multiplex: this relay's identity in the shared pipe's CDP id space (the @@ -585,12 +589,19 @@ final class CdpRelay { let childSession = params?["sessionId"] as? String if sid == nil { // browser-level attach of a top-level target (a tile) guard attachedTid == tid else { return nil } // sibling tile — hide - // H2: learn our page session, but DON'T forward the raw browser-level event — - // we hand the client exactly one SYNTHESIZED attachedToTarget (beginPageAttach / - // handleSelfAttachResponse), so a real one our active attach may have triggered - // isn't delivered as a duplicate. + // H2: our active Target.attachToTarget (beginPageAttach) triggers THIS real + // browser-level event for our page. FORWARD it as-is — it carries the genuine + // targetInfo (incl. a non-empty browserContextId that Playwright's + // CRBrowser._onAttachedToTarget asserts on; a synthesized empty one crashes the + // daemon). Learn our session + cache the browserContextId so a later RECONNECT + // (page already attached → no fresh event) can synthesize a correct one. Only + // OUR own active attach triggers this, so it's never a duplicate. if let cs = childSession { allowedSessions.insert(cs); ourSessionId = cs } - return nil + if let bctx = (params?["targetInfo"] as? [String: Any])?["browserContextId"] as? String, + !bctx.isEmpty { + ourBrowserContextId = bctx + } + return json } guard let s = sid, allowedSessions.contains(s) else { return nil } // sub-target of ours if let cs = childSession { allowedSessions.insert(cs) } @@ -766,11 +777,12 @@ final class CdpRelay { if let s = jsonString(cmd) { sendToPipe(s) } } - /// H2: our scoped attachToTarget came back — record the page session, and (if the - /// client that issued it is still attached) hand it the synthesized attachedToTarget + - /// ack every queued setAutoAttach. If that client has since detached - /// (clientGeneration moved on), learn the session but write NOTHING — otherwise we'd - /// deliver a stale ack / spurious event to whatever client connected next. + /// H2: our scoped attachToTarget came back — record the page session and (if the + /// client that issued it is still attached) ack every queued setAutoAttach. The page's + /// attachedToTarget is delivered by FORWARDING the real browser-level event (see the + /// filter), not synthesized here — so the client gets the genuine targetInfo. If the + /// issuing client has since detached (clientGeneration moved on), learn the session but + /// write nothing (no stale ack to its successor). private func handleSelfAttachResponse(_ m: [String: Any]) { let sessionId = (m["result"] as? [String: Any])?["sessionId"] as? String multiplexLock.lock() @@ -781,11 +793,8 @@ final class CdpRelay { multiplexLock.unlock() if let s = sessionId { filterLock.lock(); allowedSessions.insert(s); ourSessionId = s; filterLock.unlock() - guard sameClient else { return } // issuing client gone — don't write to its successor - synthesizeAttachedToTarget(sessionId: s) - } else if !sameClient { - return } + guard sameClient else { return } // issuing client gone — don't write to its successor for ack in acks { synthesizeOk(ack) } } @@ -794,8 +803,14 @@ final class CdpRelay { /// auto-attach — mirrors synthesizeGetTargets' single-tile view. private func synthesizeAttachedToTarget(sessionId: String) { guard let tid = scopeTargetId else { return } + // Reuse the REAL browserContextId captured from our page's first attach event — + // Playwright's CRBrowser._onAttachedToTarget asserts it's non-empty (an empty string + // crashes the daemon). Only reached on RECONNECT, where the page is already attached + // so no fresh event fires and ourBrowserContextId is already cached. + filterLock.lock(); let bctx = ourBrowserContextId; filterLock.unlock() let info: [String: Any] = ["targetId": tid, "type": "page", "title": "", "url": "", - "attached": true, "canAccessOpener": false, "browserContextId": ""] + "attached": true, "canAccessOpener": false, + "browserContextId": bctx ?? ""] sendClientJson(["method": "Target.attachedToTarget", "params": ["sessionId": sessionId, "targetInfo": info, "waitingForDebugger": false]])