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..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,119 +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) - } - - 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 4e3200a..90caa0b 100644 --- a/pkg/bridge/fetch.go +++ b/pkg/bridge/fetch.go @@ -4,10 +4,34 @@ import ( "encoding/base64" "encoding/json" "fmt" + "log" + "regexp" + "strings" "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": @@ -19,32 +43,53 @@ func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMe json.Unmarshal(msg.Params, ¶ms) } - jugglerParams := map[string]interface{}{ - "enabled": true, - } - if msg.SessionID != "" { - if info, ok := b.sessions.Get(msg.SessionID); ok { - b.setJugglerBrowserContext(jugglerParams, info.BrowserContextID) + // 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) + } + if len(patterns) == 0 { + b.fetchPatterns[msg.SessionID] = nil + } else { + b.fetchPatterns[msg.SessionID] = patterns } } - - _, err := b.callJuggler("", "Browser.setRequestInterception", jugglerParams) - if err != nil { - return nil, &cdp.Error{Code: -32000, Message: err.Error()} - } + b.fetchPatternsMu.Unlock() + + // 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": + b.fetchPatternsMu.Lock() + delete(b.fetchPatterns, msg.SessionID) + b.fetchPatternsMu.Unlock() + 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()} } @@ -81,7 +126,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()} } @@ -123,7 +168,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()} } @@ -146,7 +191,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()} } @@ -253,7 +298,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()} }