From 33f703f5b4509d522257cd58221bcd4e279a41b1 Mon Sep 17 00:00:00 2001 From: Luis Gustavo Wacker Date: Wed, 12 Aug 2026 15:50:47 -0300 Subject: [PATCH 1/3] fix: honor Fetch.enable urlPattern patterns (auto-continue non-matching requests) --- pkg/bridge/bridge.go | 9 +++++++++ pkg/bridge/events.go | 27 +++++++++++++++++++++++++++ pkg/bridge/fetch.go | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/pkg/bridge/bridge.go b/pkg/bridge/bridge.go index d7aa6ad..a769d16 100644 --- a/pkg/bridge/bridge.go +++ b/pkg/bridge/bridge.go @@ -55,6 +55,14 @@ type Bridge struct { deterministicMu sync.RWMutex deterministicScript string deterministicApplied map[string]bool // cdpSessionID → script installed + // fetchPatterns holds the URL patterns requested via Fetch.enable per CDP + // session ("" = browser-level). Requests that do NOT match any pattern are + // auto-continued instead of paused — Chrome semantics. Without this, + // Fetch.enable(patterns=[...]) pauses EVERY request (foxbridge passed only + // {enabled:true} to Juggler), stalling navigation forever for clients like + // the Hermes dialog-bridge supervisor that scope interception to one URL. + fetchPatternsMu sync.RWMutex + fetchPatterns map[string][]string // cdpSessionID → urlPattern globs } func (b *Bridge) cdpBrowserContextID(id string) string { @@ -98,6 +106,7 @@ func New(b backend.Backend, sessions *cdp.SessionManager, server *cdp.Server, is pdfStreams: make(map[string]string), pendingContextClear: make(map[string]bool), deterministicApplied: make(map[string]bool), + fetchPatterns: make(map[string][]string), } } diff --git a/pkg/bridge/events.go b/pkg/bridge/events.go index 37fe3a1..43abc51 100644 --- a/pkg/bridge/events.go +++ b/pkg/bridge/events.go @@ -942,6 +942,33 @@ func (b *Bridge) SetupEventSubscriptions() { cdpFrameID = b.cdpFrameIDForSession(cdpSessionID, ev.FrameID) } + // If Fetch.enable specified urlPatterns for this CDP session, only pause + // matching requests and auto-continue the rest (Chrome semantics). + // Without this, a client that scopes Fetch to a single URL would have + // every navigation request paused forever (no Fetch.continueRequest is + // ever sent for non-matching URLs), stalling Page.navigate indefinitely. + b.fetchPatternsMu.RLock() + patterns := b.fetchPatterns[cdpSessionID] + b.fetchPatternsMu.RUnlock() + if len(patterns) > 0 { + matched := false + for _, p := range patterns { + if globMatch(p, url) { + matched = true + break + } + } + if !matched { + log.Printf("[event] Browser.requestIntercepted auto-continued (no urlPattern match) requestId=%s url=%s cdpSession=%s", ev.RequestID, url, cdpSessionID) + if _, err := b.callJuggler("", "Browser.continueInterceptedRequest", map[string]interface{}{ + "requestId": ev.RequestID, + }); err != nil { + log.Printf("events: failed to auto-continue intercepted request %s: %v", ev.RequestID, err) + } + return + } + } + log.Printf("[event] Browser.requestIntercepted → Fetch.requestPaused requestId=%s url=%s cdpSession=%s", ev.RequestID, url, cdpSessionID) // Emit Network.requestWillBeSent BEFORE Fetch.requestPaused. diff --git a/pkg/bridge/fetch.go b/pkg/bridge/fetch.go index 4e3200a..11ea084 100644 --- a/pkg/bridge/fetch.go +++ b/pkg/bridge/fetch.go @@ -4,6 +4,8 @@ import ( "encoding/base64" "encoding/json" "fmt" + "regexp" + "strings" "github.com/VulpineOS/foxbridge/pkg/cdp" ) @@ -19,6 +21,26 @@ func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMe json.Unmarshal(msg.Params, ¶ms) } + // Chrome semantics: remember the urlPatterns for this CDP session so the + // Browser.requestIntercepted handler can auto-continue requests that do not + // match any pattern instead of pausing every request. + var patterns []string + for _, p := range params.Patterns { + var entry struct { + URLPattern string `json:"urlPattern"` + } + json.Unmarshal(p, &entry) + if entry.URLPattern != "" { + patterns = append(patterns, entry.URLPattern) + } + } + if len(patterns) == 0 { + patterns = nil // no patterns = intercept everything (current behavior) + } + b.fetchPatternsMu.Lock() + b.fetchPatterns[msg.SessionID] = patterns + b.fetchPatternsMu.Unlock() + jugglerParams := map[string]interface{}{ "enabled": true, } @@ -35,6 +57,10 @@ func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMe return json.RawMessage(`{}`), nil case "Fetch.disable": + b.fetchPatternsMu.Lock() + delete(b.fetchPatterns, msg.SessionID) + b.fetchPatternsMu.Unlock() + jugglerParams := map[string]interface{}{ "enabled": false, } @@ -351,3 +377,17 @@ func httpStatusText(code int) string { return "OK" } } + +// globMatch reports whether url matches pattern, where '*' matches any +// sequence of characters. Matching is case-insensitive and anchored to the +// full URL, mirroring Chrome's Fetch.enable urlPattern semantics. +func globMatch(pattern, url string) bool { + re := regexp.QuoteMeta(pattern) + re = strings.ReplaceAll(re, `\*`, ".*") + re = "^" + re + "$" + matched, err := regexp.MatchString("(?i)"+re, url) + if err != nil { + return false + } + return matched +} From 57ebbbc76f280f3417018429c95aee1701a2cdfc Mon Sep 17 00:00:00 2001 From: Luis Gustavo Wacker Date: Wed, 12 Aug 2026 16:49:05 -0300 Subject: [PATCH 2/3] fix: use Network.* Juggler domain + isIntercepted (real protocol names) The previous fix only patched Fetch.enable patterns into the Browser.requestIntercepted handler, but that event does not exist in the Juggler protocol. Requests are paused via the per-page NetworkObserver, which emits Network.requestWillBeSent with an isIntercepted flag. - Fetch.enable -> Network.setRequestInterception (page session) - Fetch.continueRequest -> Network.resumeInterceptedRequest - Fetch.fulfillRequest -> Network.fulfillInterceptedRequest - Fetch.failRequest -> Network.abortInterceptedRequest - emit Fetch.requestPaused from Network.requestWillBeSent when isIntercepted is set; auto-continue non-matching patterns --- pkg/bridge/events.go | 207 +++++++++++++------------------------------ pkg/bridge/fetch.go | 99 +++++++++++---------- 2 files changed, 114 insertions(+), 192 deletions(-) diff --git a/pkg/bridge/events.go b/pkg/bridge/events.go index 43abc51..320d88a 100644 --- a/pkg/bridge/events.go +++ b/pkg/bridge/events.go @@ -652,21 +652,74 @@ func (b *Bridge) SetupEventSubscriptions() { // Network.requestWillBeSent → Network.requestWillBeSent b.backend.Subscribe("Network.requestWillBeSent", func(jugglerSessionID string, params json.RawMessage) { var ev struct { - RequestID string `json:"requestId"` - FrameID string `json:"frameId"` - URL string `json:"url"` - Method string `json:"method"` - Headers map[string]string `json:"headers"` - IsNavigation bool `json:"isNavigationRequest"` - RedirectURL string `json:"redirectedFrom"` + RequestID string `json:"requestId"` + FrameID string `json:"frameId"` + URL string `json:"url"` + Method string `json:"method"` + Headers map[string]string `json:"headers"` + IsNavigation bool `json:"isNavigationRequest"` + RedirectURL string `json:"redirectedFrom"` + IsIntercepted bool `json:"isIntercepted"` + NavigationID string `json:"navigationId"` + InternalCause string `json:"internalCause"` } if err := json.Unmarshal(params, &ev); err != nil { return } + if ev.IsIntercepted { + log.Printf("[dbg] Network.requestWillBeSent INTERCEPTED url=%s req=%s frame=%s", ev.URL, ev.RequestID, ev.FrameID) + } else { + log.Printf("[dbg] Network.requestWillBeSent url=%s isIntercepted=%v", ev.URL, ev.IsIntercepted) + } + cdpSessionID := b.resolveCDPSession(jugglerSessionID) cdpFrameID := b.cdpFrameIDForJugglerSession(jugglerSessionID, ev.FrameID) + // Intercepted requests: Juggler signals interception through the + // isIntercepted flag on Network.requestWillBeSent (there is NO + // Browser.requestIntercepted event in the Juggler protocol). When a + // Fetch.enable pattern list is active, auto-continue (resume) + // requests that match none of the patterns — Chrome semantics — + // and only surface Fetch.requestPaused for pattern matches. + if ev.IsIntercepted { + b.fetchPatternsMu.RLock() + patterns := b.fetchPatterns[cdpSessionID] + b.fetchPatternsMu.RUnlock() + if len(patterns) > 0 { + matched := false + for _, p := range patterns { + if globMatch(p, ev.URL) { + matched = true + break + } + } + if !matched { + if _, err := b.callJuggler("", "Browser.resumeInterceptedRequest", map[string]interface{}{ + "requestId": ev.RequestID, + }); err != nil { + log.Printf("[event] auto-resume failed for %s: %v", ev.RequestID, err) + } + return + } + } + // Pattern matched (or no patterns = intercept all): surface the + // pause to the CDP client. + b.emitEvent("Fetch.requestPaused", map[string]interface{}{ + "requestId": ev.RequestID, + "networkId": ev.RequestID, + "request": map[string]interface{}{ + "url": ev.URL, + "method": ev.Method, + "headers": ev.Headers, + "initialPriority": "High", + "referrerPolicy": "strict-origin-when-cross-origin", + }, + "frameId": cdpFrameID, + "resourceType": "Other", + }, cdpSessionID) + } + cdpHeaders := map[string]string{} for k, v := range ev.Headers { cdpHeaders[k] = v @@ -865,146 +918,6 @@ func (b *Bridge) SetupEventSubscriptions() { cdpSessionID := b.resolveCDPSession(jugglerSessionID) b.emitEventRaw("Page.fileChooserOpened", params, cdpSessionID) }) - - // Browser.requestIntercepted → Fetch.requestPaused - b.backend.Subscribe("Browser.requestIntercepted", func(jugglerSessionID string, params json.RawMessage) { - var ev struct { - RequestID string `json:"requestId"` - // Juggler sends request fields at top level (not nested in "request") - URL string `json:"url"` - Method string `json:"method"` - Headers []struct { - Name string `json:"name"` - Value string `json:"value"` - } `json:"headers"` - // Nested format for backwards compatibility - Request struct { - URL string `json:"url"` - Method string `json:"method"` - Headers map[string]string `json:"headers"` - } `json:"request"` - FrameID string `json:"frameId"` - IsNavigationRequest bool `json:"isNavigationRequest"` - ResourceType string `json:"resourceType"` - } - if err := json.Unmarshal(params, &ev); err != nil { - log.Printf("events: failed to parse Browser.requestIntercepted: %v", err) - return - } - - cdpSessionID := b.resolveCDPSession(jugglerSessionID) - - // Browser.requestIntercepted is a browser-level event (no juggler session ID). - // Resolve the CDP session from the frameId so Puppeteer receives it on the page session. - if cdpSessionID == "" && ev.FrameID != "" { - if info, ok := b.sessions.GetByFrameID(ev.FrameID); ok { - cdpSessionID = info.SessionID - } - } - - // Last resort: find any page session to deliver the event - if cdpSessionID == "" { - for _, info := range b.sessions.All() { - if info.Type == "page" { - cdpSessionID = info.SessionID - break - } - } - } - - // Use top-level fields (new Juggler format) or nested request fields (fallback) - url := ev.URL - method := ev.Method - if url == "" { - url = ev.Request.URL - method = ev.Request.Method - } - - // Convert headers array [{name,value}] to map for CDP - headerMap := map[string]string{} - for _, h := range ev.Headers { - headerMap[h.Name] = h.Value - } - if len(headerMap) == 0 { - headerMap = ev.Request.Headers - } - - resourceType := ev.ResourceType - if resourceType == "" { - resourceType = "Other" - if ev.IsNavigationRequest { - resourceType = "Document" - } - } - - cdpFrameID := ev.FrameID - if cdpSessionID != "" { - cdpFrameID = b.cdpFrameIDForSession(cdpSessionID, ev.FrameID) - } - - // If Fetch.enable specified urlPatterns for this CDP session, only pause - // matching requests and auto-continue the rest (Chrome semantics). - // Without this, a client that scopes Fetch to a single URL would have - // every navigation request paused forever (no Fetch.continueRequest is - // ever sent for non-matching URLs), stalling Page.navigate indefinitely. - b.fetchPatternsMu.RLock() - patterns := b.fetchPatterns[cdpSessionID] - b.fetchPatternsMu.RUnlock() - if len(patterns) > 0 { - matched := false - for _, p := range patterns { - if globMatch(p, url) { - matched = true - break - } - } - if !matched { - log.Printf("[event] Browser.requestIntercepted auto-continued (no urlPattern match) requestId=%s url=%s cdpSession=%s", ev.RequestID, url, cdpSessionID) - if _, err := b.callJuggler("", "Browser.continueInterceptedRequest", map[string]interface{}{ - "requestId": ev.RequestID, - }); err != nil { - log.Printf("events: failed to auto-continue intercepted request %s: %v", ev.RequestID, err) - } - return - } - } - - log.Printf("[event] Browser.requestIntercepted → Fetch.requestPaused requestId=%s url=%s cdpSession=%s", ev.RequestID, url, cdpSessionID) - - // Emit Network.requestWillBeSent BEFORE Fetch.requestPaused. - // Puppeteer needs both events with matching requestId/networkId to process interception. - b.emitEvent("Network.requestWillBeSent", map[string]interface{}{ - "requestId": ev.RequestID, - "loaderId": ev.RequestID, - "documentURL": url, - "request": map[string]interface{}{ - "url": url, - "method": method, - "headers": headerMap, - "initialPriority": "High", - "referrerPolicy": "strict-origin-when-cross-origin", - }, - "timestamp": 0, - "wallTime": 0, - "initiator": map[string]interface{}{"type": "other"}, - "type": resourceType, - "frameId": cdpFrameID, - }, cdpSessionID) - - b.emitEvent("Fetch.requestPaused", map[string]interface{}{ - "requestId": ev.RequestID, - "networkId": ev.RequestID, - "request": map[string]interface{}{ - "url": url, - "method": method, - "headers": headerMap, - "initialPriority": "High", - "referrerPolicy": "strict-origin-when-cross-origin", - }, - "frameId": cdpFrameID, - "resourceType": resourceType, - }, cdpSessionID) - }) } // emitTabAttach emits the tab-level attachment on the browser session. diff --git a/pkg/bridge/fetch.go b/pkg/bridge/fetch.go index 11ea084..3b51502 100644 --- a/pkg/bridge/fetch.go +++ b/pkg/bridge/fetch.go @@ -10,6 +10,27 @@ import ( "github.com/VulpineOS/foxbridge/pkg/cdp" ) +// globMatch reports whether url matches a CDP urlPattern glob ('*' matches +// any sequence of characters). Used to honor Fetch.enable patterns: requests +// outside every pattern are auto-continued instead of paused. +func globMatch(pattern, url string) bool { + var sb strings.Builder + sb.WriteString("(?i)^") + for _, r := range pattern { + if r == '*' { + sb.WriteString(".*") + } else { + sb.WriteString(regexp.QuoteMeta(string(r))) + } + } + sb.WriteString("$") + re, err := regexp.Compile(sb.String()) + if err != nil { + return false + } + return re.MatchString(url) +} + func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMessage, *cdp.Error) { switch msg.Method { case "Fetch.enable": @@ -21,36 +42,43 @@ func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMe json.Unmarshal(msg.Params, ¶ms) } - // Chrome semantics: remember the urlPatterns for this CDP session so the - // Browser.requestIntercepted handler can auto-continue requests that do not - // match any pattern instead of pausing every request. - var patterns []string - for _, p := range params.Patterns { - var entry struct { - URLPattern string `json:"urlPattern"` + // Remember the requested URL patterns per CDP session. Requests that + // do NOT match any pattern are auto-continued instead of paused + // (Chrome semantics). A nil/empty pattern list keeps the legacy + // behavior: intercept everything. + b.fetchPatternsMu.Lock() + if len(params.Patterns) == 0 { + b.fetchPatterns[msg.SessionID] = nil + } else { + patterns := make([]string, 0, len(params.Patterns)) + for _, raw := range params.Patterns { + var p struct { + URLPattern string `json:"urlPattern"` + } + if err := json.Unmarshal(raw, &p); err != nil || p.URLPattern == "" { + continue + } + patterns = append(patterns, p.URLPattern) } - json.Unmarshal(p, &entry) - if entry.URLPattern != "" { - patterns = append(patterns, entry.URLPattern) + if len(patterns) == 0 { + b.fetchPatterns[msg.SessionID] = nil + } else { + b.fetchPatterns[msg.SessionID] = patterns } } - if len(patterns) == 0 { - patterns = nil // no patterns = intercept everything (current behavior) - } - b.fetchPatternsMu.Lock() - b.fetchPatterns[msg.SessionID] = patterns b.fetchPatternsMu.Unlock() jugglerParams := map[string]interface{}{ "enabled": true, } - if msg.SessionID != "" { - if info, ok := b.sessions.Get(msg.SessionID); ok { - b.setJugglerBrowserContext(jugglerParams, info.BrowserContextID) - } - } - _, err := b.callJuggler("", "Browser.setRequestInterception", jugglerParams) + // Use the PAGE-level Network.setRequestInterception: Juggler only + // activates the per-page NetworkObserver (which emits the intercepted + // request events) through this method on the page session. + // Browser.setRequestInterception merely flips a browser-context flag + // and never surfaces intercepted requests — using it stalls every + // request with no events (verified against Camoufox 135 juggler). + _, err := b.callJuggler(msg.SessionID, "Network.setRequestInterception", jugglerParams) if err != nil { return nil, &cdp.Error{Code: -32000, Message: err.Error()} } @@ -64,13 +92,8 @@ func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMe jugglerParams := map[string]interface{}{ "enabled": false, } - if msg.SessionID != "" { - if info, ok := b.sessions.Get(msg.SessionID); ok { - b.setJugglerBrowserContext(jugglerParams, info.BrowserContextID) - } - } - _, err := b.callJuggler("", "Browser.setRequestInterception", jugglerParams) + _, err := b.callJuggler(msg.SessionID, "Network.setRequestInterception", jugglerParams) if err != nil { return nil, &cdp.Error{Code: -32000, Message: err.Error()} } @@ -107,7 +130,7 @@ func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMe jugglerParams["headers"] = headers } - _, err := b.callJuggler("", "Browser.continueInterceptedRequest", jugglerParams) + _, err := b.callJuggler(msg.SessionID, "Network.resumeInterceptedRequest", jugglerParams) if err != nil { return nil, &cdp.Error{Code: -32000, Message: err.Error()} } @@ -149,7 +172,7 @@ func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMe "body": params.Body, } - _, err := b.callJuggler("", "Browser.fulfillInterceptedRequest", jugglerParams) + _, err := b.callJuggler(msg.SessionID, "Network.fulfillInterceptedRequest", jugglerParams) if err != nil { return nil, &cdp.Error{Code: -32000, Message: err.Error()} } @@ -172,7 +195,7 @@ func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMe "errorCode": errorCode, } - _, err := b.callJuggler("", "Browser.abortInterceptedRequest", jugglerParams) + _, err := b.callJuggler(msg.SessionID, "Network.abortInterceptedRequest", jugglerParams) if err != nil { return nil, &cdp.Error{Code: -32000, Message: err.Error()} } @@ -279,7 +302,7 @@ func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMe jugglerParams["headers"] = headers } - _, err := b.callJuggler("", "Browser.continueInterceptedRequest", jugglerParams) + _, err := b.callJuggler(msg.SessionID, "Network.resumeInterceptedRequest", jugglerParams) if err != nil { return nil, &cdp.Error{Code: -32000, Message: err.Error()} } @@ -377,17 +400,3 @@ func httpStatusText(code int) string { return "OK" } } - -// globMatch reports whether url matches pattern, where '*' matches any -// sequence of characters. Matching is case-insensitive and anchored to the -// full URL, mirroring Chrome's Fetch.enable urlPattern semantics. -func globMatch(pattern, url string) bool { - re := regexp.QuoteMeta(pattern) - re = strings.ReplaceAll(re, `\*`, ".*") - re = "^" + re + "$" - matched, err := regexp.MatchString("(?i)"+re, url) - if err != nil { - return false - } - return matched -} From a41f9e6ee3616eb8906155ecf988acfe803e65ef Mon Sep 17 00:00:00 2001 From: Luis Gustavo Wacker Date: Wed, 12 Aug 2026 17:45:56 -0300 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20Fetch.enable=20no-op=20=E2=80=94=20J?= =?UTF-8?q?uggler=20never=20delivers=20interception=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against Camoufox 135 (omni.ja): enabling Network.setRequestInterception pauses every request (channelIntercepted stores them in _interceptedRequests) but PageNetwork.Events.Request is never delivered to the PageHandler in the browser process — no Network.requestWillBeSent, no isIntercepted, nothing resumes the paused requests. Navigation stalls forever (Page.navigate returns navigationId, frameNavigated never arrives). Fetch.enable is now a no-op (patterns still remembered for a future Juggler fix): dialog interception is degraded, but pages load. This unblocks every CDP client that enables Fetch (e.g. Hermes dialog-bridge supervisor). --- pkg/bridge/fetch.go | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/pkg/bridge/fetch.go b/pkg/bridge/fetch.go index 3b51502..90caa0b 100644 --- a/pkg/bridge/fetch.go +++ b/pkg/bridge/fetch.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "log" "regexp" "strings" @@ -68,20 +69,15 @@ func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMe } b.fetchPatternsMu.Unlock() - jugglerParams := map[string]interface{}{ - "enabled": true, - } - - // Use the PAGE-level Network.setRequestInterception: Juggler only - // activates the per-page NetworkObserver (which emits the intercepted - // request events) through this method on the page session. - // Browser.setRequestInterception merely flips a browser-context flag - // and never surfaces intercepted requests — using it stalls every - // request with no events (verified against Camoufox 135 juggler). - _, err := b.callJuggler(msg.SessionID, "Network.setRequestInterception", jugglerParams) - if err != nil { - return nil, &cdp.Error{Code: -32000, Message: err.Error()} - } + // NOTE: we do NOT call Network.setRequestInterception here. The + // Camoufox/Juggler NetworkObserver (content process) never delivers + // PageNetwork.Events.Request to the PageHandler (browser process) — + // verified: with interception enabled, requests are paused forever, + // no Network.requestWillBeSent is ever emitted, nothing resumes them. + // Treating Fetch.enable as a no-op keeps navigation flowing (dialog + // interception is degraded but pages load). Patterns are still + // remembered so a future Juggler fix can honor them. + log.Printf("[fetch] Fetch.enable NO-OP (Juggler never delivers interception events) cdpSession=%s", msg.SessionID) return json.RawMessage(`{}`), nil case "Fetch.disable":