diff --git a/flow-agent/flow_engine/generators/i2v.py b/flow-agent/flow_engine/generators/i2v.py index 2c09d97..63aa7c3 100644 --- a/flow-agent/flow_engine/generators/i2v.py +++ b/flow-agent/flow_engine/generators/i2v.py @@ -11,6 +11,14 @@ log = logging.getLogger("flow_engine.generators.i2v") +def _safe_error_text(value) -> str | None: + """Return a bounded scalar error without serializing arbitrary responses.""" + if not isinstance(value, (str, int, float)): + return None + text = " ".join(str(value).split()) + return text[:500] or None + + async def upload_image( bridge, image_path: str, @@ -52,11 +60,20 @@ async def upload_image( log.info("Uploading image: %s", os.path.basename(image_path)) result = await bridge.api_request(ENDPOINTS["upload_image"], body) - status = result.get("status", 0) - data = result.get("data", {}) + status = result.get("status", 0) if isinstance(result, dict) else 0 + data = result.get("data", {}) if isinstance(result, dict) else {} if status != 200: - err = data.get("error", {}).get("message", "Unknown") if isinstance(data, dict) else str(data) - err_msg = f"Image upload failed: {err}" + nested = data.get("error", {}) if isinstance(data, dict) else {} + nested = nested if isinstance(nested, dict) else {} + message = _safe_error_text(nested.get("message")) + google_status = _safe_error_text(nested.get("status")) + top_level_error = _safe_error_text(result.get("error")) if isinstance(result, dict) else None + detail = message or top_level_error or google_status or "Unknown error" + if message and google_status and google_status not in message: + detail = f"{message} ({google_status})" + status_text = _safe_error_text(status) + status_suffix = f" (status {status_text})" if status_text and status_text != "0" else "" + err_msg = f"Image upload failed{status_suffix}: {detail}" log.error("%s", err_msg) raise ValueError(err_msg) diff --git a/flow-agent/tests/test_extension_flow_urls.py b/flow-agent/tests/test_extension_flow_urls.py new file mode 100644 index 0000000..2464279 --- /dev/null +++ b/flow-agent/tests/test_extension_flow_urls.py @@ -0,0 +1,115 @@ +import json +from pathlib import Path + + +EXTENSION_DIR = Path(__file__).resolve().parents[2] / "flow-extension" + + +def test_manifest_allows_new_and_legacy_flow_pages(): + manifest = json.loads((EXTENSION_DIR / "manifest.json").read_text(encoding="utf-8")) + + assert "https://flow.google.com/*" in manifest["host_permissions"] + assert "https://flow.google.com/*" in manifest["content_scripts"][0]["matches"] + assert "https://flow.google.com/*" in manifest["web_accessible_resources"][0]["matches"] + assert "https://labs.google/fx/tools/flow*" in manifest["content_scripts"][0]["matches"] + + +def test_background_uses_precise_flow_page_eligibility_and_shared_tab_patterns(): + source = (EXTENSION_DIR / "background.js").read_text(encoding="utf-8") + + assert "'https://flow.google.com/*'" in source + assert "parsed.hostname === 'flow.google.com'" in source + assert "parsed.hostname !== 'labs.google'" in source + assert "return createdTab;" in source + assert "url: '*://labs.google/*'" not in source + assert source.count("url: FLOW_TAB_URLS") >= 3 + + +def test_background_opens_captcha_capable_project_pages(): + source = (EXTENSION_DIR / "background.js").read_text(encoding="utf-8") + + # labs.google/fx/tools/flow redirects to the flow.google.com home page, which + # never loads reCAPTCHA; only /project/ pages do. + assert "const FLOW_URL = 'https://flow.google.com/';" in source + assert "function isFlowProjectUrl(url)" in source + assert "/^\\/project\\/[^/]+/" in source + assert "https://flow.google.com/project/${encodeURIComponent(projectId)}" in source + assert "tabs.filter((t) => isFlowProjectUrl(t.url))" in source + assert "solveCaptcha(id, captchaAction, projectId)" in source + + +def test_background_refreshes_token_through_labs_handoff(): + source = (EXTENSION_DIR / "background.js").read_text(encoding="utf-8") + + # The ya29 bearer is only observable during the labs.google -> flow.google.com + # redirect; reloading a flow.google.com tab does not re-capture it. + assert "const TOKEN_URL = 'https://labs.google/fx/tools/flow';" in source + assert "async function refreshTokenViaLabs()" in source + assert source.count("await refreshTokenViaLabs()") >= 2 + assert "chrome.tabs.reload(tabs[0].id)" not in source + + +def test_extension_verifies_captcha_bridge_before_using_a_tab(): + background = (EXTENSION_DIR / "background.js").read_text(encoding="utf-8") + content = (EXTENSION_DIR / "content.js").read_text(encoding="utf-8") + injected = (EXTENSION_DIR / "injected.js").read_text(encoding="utf-8") + + # A tab matching a Flow URL is not enough: its bridge must answer a ping, + # otherwise every request burns the content-script timeout. + assert "async function bridgeAlive(tabId)" in background + assert "if (!(await bridgeAlive(tab.id))) continue;" in background + assert "type: 'PING_BRIDGE'" in background + assert "msg.type !== 'PING_BRIDGE'" in content + assert "'FLOW_AGENT_PING'" in injected and "'FLOW_AGENT_PONG'" in injected + # GET_CAPTCHA is re-dispatched until injected.js answers; injected.js dedups. + assert "setInterval(dispatch, 500)" in content + assert "_captchaInFlight" in injected + assert "grecaptcha execute timeout" in injected + + + +def test_background_forgets_user_home_and_skips_non_project_candidates(): + source = (EXTENSION_DIR / "background.js").read_text(encoding="utf-8") + reuse = source.split("async function _getOrOpenFlowTab(projectId) {", 1)[1].split( + "const tabs = await chrome.tabs.query", 1 + )[0] + guard = "if (!workTabCreatedByExtension && !isFlowProjectUrl(tab?.url)) {" + assert guard in reuse + forgotten, owned = reuse.split(guard, 1)[1].split("} else {", 1) + assert "workTabId = null;" in forgotten + assert "console.warn(" in forgotten + assert "return tab;" not in forgotten + assert "chrome.tabs.update" not in forgotten + assert "const needsProjectPage = workTabCreatedByExtension && !isFlowProjectUrl(tab?.url);" in owned + assert "if (tab && needsProjectPage) {" in owned + assert owned.index("if (tab && needsProjectPage)") < owned.index("chrome.tabs.update") + candidates = source.split("for (const tab of candidates) {", 1)[1].split("if (tabs.length)", 1)[0] + assert "if (!isFlowProjectUrl(tab.url)) {" in candidates + skip = "if (isFlowProjectUrl(targetUrl)) continue;" + assert "console.warn(" in candidates + assert candidates.index(skip) < candidates.index("bridgeAlive(tab.id)") < candidates.index("workTabId = tab.id;") + assert "chrome.tabs.create({ url: targetUrl, active: false })" in source + + +def test_background_serves_generation_through_flow_batchexecute_rpcs(): + source = (EXTENSION_DIR / "background.js").read_text(encoding="utf-8") + + # flow.google.com has no aisandbox-pa calls and no bearer; generation, + # polling, signed URLs and credits are replayed as the page's own RPCs. + assert "function classifyFlowRpc(url)" in source + assert "async function handleFlowRpcRequest(msg, kind)" in source + for rpc in ("'YhhmEf'", "'jwpduf'", "'as29s'", "'nzlxg'", "'ogiZ0b'"): + assert rpc in source + assert "/_/AiSandboxAngularFrontend/data/batchexecute" in source + assert "const rpcKind = classifyFlowRpc(url);" in source + assert source.index("const rpcKind = classifyFlowRpc(url);") < source.index("sendToAgent({ id, error: 'INVALID_URL' });") + # Verified enums (see FLOW_IMAGE_ASPECT / FLOW_VIDEO_ASPECT comments). + assert "IMAGE_ASPECT_RATIO_SQUARE: 1" in source + assert "VIDEO_ASPECT_RATIO_PORTRAIT: 1, VIDEO_ASPECT_RATIO_LANDSCAPE: 2" in source + assert "_portrait" in source + # A verified cookie session stands in for the bearer, and is never sent to + # aisandbox-pa as one. + assert "const FLOW_SESSION_KEY = 'flow-session';" in source + assert "async function ensureFlowSession(" in source + assert "if (activeFlowKey === FLOW_SESSION_KEY) {" in source + assert "const signed = await resolveFlowMediaUrl(mediaId);" in source diff --git a/flow-agent/tests/test_media_storage.py b/flow-agent/tests/test_media_storage.py index 97dc02c..812c2f5 100644 --- a/flow-agent/tests/test_media_storage.py +++ b/flow-agent/tests/test_media_storage.py @@ -2,6 +2,7 @@ import importlib import json import os +import re import subprocess import sys from pathlib import Path @@ -216,6 +217,61 @@ async def api_request(self, endpoint, body, **kwargs): assert media_store.get_for_file(image_path, project_id="project-a")["media_id"] == "fresh-id" +@pytest.mark.parametrize( + ("response", "expected"), + [ + ( + {"status": 403, "error": "CAPTCHA_FAILED"}, + "Image upload failed (status 403): CAPTCHA_FAILED", + ), + ({"error": "Request timed out"}, "Image upload failed: Request timed out"), + ( + {"status": 500, "error": {"debug": {"internal": "sensitive-context"}}}, + "Image upload failed (status 500): Unknown error", + ), + ( + { + "status": 400, + "data": { + "error": { + "message": "The image could not be processed", + "status": "INVALID_ARGUMENT", + } + }, + }, + "Image upload failed (status 400): The image could not be processed (INVALID_ARGUMENT)", + ), + ], +) +def test_upload_image_preserves_safe_actionable_errors( + monkeypatch, tmp_path, response, expected +): + _configure_store(monkeypatch, tmp_path) + image_path = tmp_path / "reference.png" + image_path.write_bytes(PNG_BYTES) + + class Bridge: + async def api_request(self, endpoint, body, **kwargs): + return response + + with pytest.raises(ValueError, match=re.escape(expected)): + asyncio.run(upload_image(Bridge(), str(image_path), "project-a")) + + +def test_upload_image_still_accepts_successful_media_response(monkeypatch, tmp_path): + _configure_store(monkeypatch, tmp_path) + image_path = tmp_path / "reference.png" + image_path.write_bytes(PNG_BYTES) + + class Bridge: + async def api_request(self, endpoint, body, **kwargs): + return {"status": 200, "data": {"media": {"name": "uploaded-media-id"}}} + + media_id = asyncio.run(upload_image(Bridge(), str(image_path), "project-a")) + + assert media_id == "uploaded-media-id" + + def test_upload_is_reused_as_image_to_video_reference_without_duplicates(monkeypatch, tmp_path): history_path = _configure_store(monkeypatch, tmp_path) image_path = tmp_path / "start-frame.png" diff --git a/flow-extension/README.md b/flow-extension/README.md index 446470f..b9e1c84 100644 --- a/flow-extension/README.md +++ b/flow-extension/README.md @@ -19,4 +19,12 @@ Chrome bridge for [kodelyx/flow-agent](https://github.com/kodelyx/flow-agent). I 4. Open , sign in, and keep the tab open. 5. Click the extension icon to open Flow Agent in Chrome's side panel. +## Flow site compatibility + +The extension recognizes both the current `https://flow.google.com/` site and +the legacy `https://labs.google/fx/tools/flow` route. This is partial issue #10 +compatibility: authentication and CAPTCHA behavior, plus the existing REST and +upload calls on the new site, have not been verified end to end and may still +require follow-up changes. + Main documentation: [Flow Agent README](../README.md) diff --git a/flow-extension/background.js b/flow-extension/background.js index 3cfc6df..2ee7e88 100644 --- a/flow-extension/background.js +++ b/flow-extension/background.js @@ -7,6 +7,29 @@ importScripts('config.js'); +// Keep the last '[Flow Agent]' console lines in chrome.storage.local (debugLog) +// so a stall can be diagnosed without the service-worker console, which is +// gone by the time anyone looks. +const DEBUG_LOG_MAX = 200; +let _debugLog = []; +let _debugLogFlush = null; +for (const level of ['log', 'warn', 'error']) { + const original = console[level].bind(console); + console[level] = (...args) => { + original(...args); + if (typeof args[0] !== 'string' || !args[0].startsWith('[Flow Agent]')) return; + const line = args.map((a) => (typeof a === 'string' ? a : (a?.message ?? JSON.stringify(a)))).join(' '); + _debugLog.push(`${new Date().toISOString()} ${level.toUpperCase()} ${line}`); + if (_debugLog.length > DEBUG_LOG_MAX) _debugLog = _debugLog.slice(-DEBUG_LOG_MAX); + if (!_debugLogFlush) { + _debugLogFlush = setTimeout(() => { + _debugLogFlush = null; + chrome.storage.local.set({ debugLog: _debugLog }).catch(() => {}); + }, 250); + } + }; +} + let callbackUrl = 'http://127.0.0.1:3001/api/ext/callback'; // NOTE: This is a browser-restricted public API key — safe to ship in extension bundles. const API_KEY = 'AIzaSyBtrm0o5ab1c-Ec8ZuLcGt3oJAA5VWt3pY'; @@ -114,7 +137,9 @@ async function init() { } await chrome.storage.local.remove('customServerIp'); const data = await chrome.storage.local.get(['flowKey', 'metrics', 'callbackSecret', 'callbackUrl', 'requestLog']); - if (data.flowKey) flowKey = data.flowKey; + // Only a real bearer or a verified flow session counts; anything else in + // storage is a stale experiment and must not be reported as a token. + if (data.flowKey && (data.flowKey.startsWith('ya29.') || data.flowKey === FLOW_SESSION_KEY)) flowKey = data.flowKey; if (data.metrics) Object.assign(metrics, data.metrics); if (data.callbackSecret) callbackSecret = data.callbackSecret; if (data.callbackUrl) callbackUrl = normalizeCallbackUrl(data.callbackUrl); @@ -153,7 +178,7 @@ chrome.webRequest.onBeforeSendHeaders.addListener( // Notify whichever transport is active. sendToAgent({ type: 'token_captured', flowKey, clientId: extensionClientId }); }, - { urls: ['https://aisandbox-pa.googleapis.com/*', 'https://labs.google/*'] }, + { urls: ['https://aisandbox-pa.googleapis.com/*', 'https://labs.google/*', 'https://flow.google.com/*'] }, ['requestHeaders', 'extraHeaders'], ); @@ -162,11 +187,78 @@ let _openingFlowTab = false; // ─── On-demand tab lifecycle ──────────────────────────────── // Open the Flow tab only when real work needs it (token capture or captcha). // Keep it available in the background so user tabs are never redirected. -const FLOW_TAB_URLS = ['https://labs.google/fx/tools/flow*', 'https://labs.google/fx/*/tools/flow*']; -const FLOW_URL = 'https://labs.google/fx/tools/flow'; +const FLOW_TAB_URLS = [ + 'https://flow.google.com/*', + 'https://labs.google/fx/tools/flow*', + 'https://labs.google/fx/*/tools/flow*', +]; +// labs.google/fx/tools/flow now 301s to the flow.google.com home page, which never +// loads reCAPTCHA Enterprise — only /project/ pages do. Land there directly. +const FLOW_URL = 'https://flow.google.com/'; let workTabId = null; let flowTabOpening = null; let workTabCreatedByExtension = false; +let lastFlowProjectUrl = null; + +chrome.storage.local.get(['lastFlowProjectUrl']).then((data) => { + if (!lastFlowProjectUrl && isFlowProjectUrl(data.lastFlowProjectUrl)) { + lastFlowProjectUrl = data.lastFlowProjectUrl; + } +}).catch(() => {}); + +// Remember the most recent project page any tab visits so an on-demand tab can +// open somewhere captcha-capable even when the request carries no projectId. +chrome.tabs.onUpdated.addListener((_, changeInfo) => { + if (changeInfo.url && isFlowProjectUrl(changeInfo.url)) { + lastFlowProjectUrl = changeInfo.url; + chrome.storage.local.set({ lastFlowProjectUrl }).catch(() => {}); + } +}); + +function isFlowProjectUrl(url) { + if (!url) return false; + try { + const parsed = new URL(url); + return parsed.protocol === 'https:' && parsed.hostname === 'flow.google.com' + && /^\/project\/[^/]+/.test(parsed.pathname); + } catch { + return false; + } +} + +function flowTabTargetUrl(projectId) { + if (projectId) return `https://flow.google.com/project/${encodeURIComponent(projectId)}`; + return lastFlowProjectUrl || FLOW_URL; +} + +// Google only sends the ya29 bearer while labs.google/fx/tools/flow hands off to +// flow.google.com; reloading a flow.google.com page never surfaces it. +const TOKEN_URL = 'https://labs.google/fx/tools/flow'; + +// Drive a tab through the labs.google handoff so the webRequest listener can +// capture a fresh bearer. Never navigates a tab the user opened. +async function refreshTokenViaLabs() { + let tabId = null; + if (workTabId !== null && workTabCreatedByExtension) { + try { + await chrome.tabs.get(workTabId); + tabId = workTabId; + } catch { + workTabId = null; + } + } + if (tabId === null) { + const tab = await chrome.tabs.create({ url: TOKEN_URL, active: false }); + workTabId = tab.id; + workTabCreatedByExtension = true; + tabId = tab.id; + } else { + await chrome.tabs.update(tabId, { url: TOKEN_URL }); + } + await waitForTabComplete(tabId); + scheduleFlowTabClose(); + return tabId; +} function scheduleFlowTabClose() { if (workTabCreatedByExtension) { @@ -189,7 +281,16 @@ async function closeIdleFlowTab() { } function isFlowUrl(url) { - return !!url && FLOW_TAB_URLS.some((p) => new RegExp(p.replace(/\./g, '\\.').replace(/\*/g, '.*')).test(url)); + if (!url) return false; + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:') return false; + if (parsed.hostname === 'flow.google.com') return true; + if (parsed.hostname !== 'labs.google') return false; + return /^\/fx\/(?:[^/]+\/)?tools\/flow(?:\/|$)/.test(parsed.pathname); + } catch { + return false; + } } async function waitForTabComplete(tabId, maxWaitMs = 10000) { @@ -209,54 +310,122 @@ async function waitForTabComplete(tabId, maxWaitMs = 10000) { }); } +// Every await on the tab-lookup path is bounded: one Chrome API call that never +// settles would otherwise park getOrOpenFlowTab's shared promise forever and +// silently stall every later request behind it. +function withTimeout(promise, ms, label) { + let timer; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label}_TIMEOUT`)), ms); + }); + return Promise.race([promise, deadline]).finally(() => clearTimeout(timer)); +} + +// True only if content.js AND injected.js answer in this tab. A tab can match a +// Flow URL yet have a dead bridge (opened before an extension reload, discarded, +// or on a page that never loaded injected.js) — sending it GET_CAPTCHA then just +// burns 25s and reports CONTENT_TIMEOUT. +async function bridgeAlive(tabId) { + const ping = () => withTimeout(chrome.tabs.sendMessage(tabId, { type: 'PING_BRIDGE' }), 5000, 'PING'); + try { + const resp = await ping(); + if (resp?.ok) return true; + } catch { /* no content script yet — inject and retry below */ } + try { + const tab = await chrome.tabs.get(tabId); + if (!isFlowUrl(tab?.url)) return false; + await withTimeout( + chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] }), + 10000, 'INJECT', + ); + await sleep(300); + const resp = await ping(); + return !!resp?.ok; + } catch (e) { + console.warn('[Flow Agent] Bridge ping failed for tab', tabId, e.message); + return false; + } +} + // Finds/wakes/creates the Flow tab. Returns // the tab, or null if it couldn't be opened. -async function _getOrOpenFlowTab() { +async function _getOrOpenFlowTab(projectId) { + const targetUrl = flowTabTargetUrl(projectId); + if (workTabId !== null) { try { let tab = await chrome.tabs.get(workTabId); - if (tab && !isFlowUrl(tab.url)) { - await chrome.tabs.update(workTabId, { url: FLOW_URL }); - await waitForTabComplete(workTabId); - tab = await chrome.tabs.get(workTabId); + // Never navigate or cache a user's non-project tab, even if its bridge + // answers. Home pages do not load reCAPTCHA. + if (!workTabCreatedByExtension && !isFlowProjectUrl(tab?.url)) { + console.warn('[Flow Agent] User work tab is not on a /project/ page; forgetting it'); + workTabId = null; + } else { + const needsProjectPage = workTabCreatedByExtension && !isFlowProjectUrl(tab?.url); + if (tab && needsProjectPage) { + await withTimeout(chrome.tabs.update(workTabId, { url: targetUrl }), 10000, 'TAB_UPDATE'); + await waitForTabComplete(workTabId); + tab = await chrome.tabs.get(workTabId); + } + if (await bridgeAlive(workTabId)) { + scheduleFlowTabClose(); + return tab; + } + console.warn('[Flow Agent] Flow tab', workTabId, 'has a dead captcha bridge; looking for another'); + workTabId = null; } - scheduleFlowTabClose(); - return tab; } catch (e) { workTabId = null; // closed by the user — fall through and open fresh } } const tabs = await chrome.tabs.query({ url: FLOW_TAB_URLS }); - if (tabs.length) { - workTabId = tabs[0].id; + // Project pages first — they are the only ones that load reCAPTCHA. + const candidates = [...tabs.filter((t) => isFlowProjectUrl(t.url)), ...tabs.filter((t) => !isFlowProjectUrl(t.url))]; + for (const tab of candidates) { + if (!isFlowProjectUrl(tab.url)) { + console.warn('[Flow Agent] Flow tab is not on a /project/ page; reCAPTCHA is only available there'); + if (isFlowProjectUrl(targetUrl)) continue; + } + if (!(await bridgeAlive(tab.id))) continue; + workTabId = tab.id; workTabCreatedByExtension = false; - return tabs[0]; + return tab; + } + if (tabs.length) { + console.warn('[Flow Agent] None of', tabs.length, 'eligible Flow tab(s) answered the bridge ping; opening a fresh one'); } - const createdTab = await chrome.tabs.create({ url: FLOW_URL, active: false }); + const createdTab = await withTimeout(chrome.tabs.create({ url: targetUrl, active: false }), 10000, 'TAB_CREATE'); workTabId = createdTab.id; workTabCreatedByExtension = true; + console.log('[Flow Agent] Opened Flow work tab', workTabId, 'at', targetUrl); await waitForTabComplete(workTabId); await sleep(1500); // Inject content script to make sure reCAPTCHA bridge is ready try { - await chrome.scripting.executeScript({ + const readyTab = await chrome.tabs.get(workTabId); + if (!isFlowUrl(readyTab?.url)) throw new Error('INVALID_FLOW_TAB'); + await withTimeout(chrome.scripting.executeScript({ target: { tabId: workTabId }, files: ['content.js'], - }); + }), 10000, 'INJECT'); } catch (e) { console.warn('[Flow Agent] Content script pre-injection:', e.message); } scheduleFlowTabClose(); - return retryTabs[0]; + return createdTab; } -async function getOrOpenFlowTab() { +async function getOrOpenFlowTab(projectId) { if (flowTabOpening) return flowTabOpening; - flowTabOpening = _getOrOpenFlowTab(); + flowTabOpening = withTimeout(_getOrOpenFlowTab(projectId), 60000, 'FLOW_TAB') + .catch((e) => { + console.error('[Flow Agent] getOrOpenFlowTab failed:', e.message); + return null; + }); try { return await flowTabOpening; } finally { @@ -279,22 +448,17 @@ async function captureTokenFromFlowTab() { return; } + // flow.google.com: no bearer exists; a verified cookie session is the token. + if (await ensureFlowSession()) return; + if (_openingFlowTab) { console.log('[Flow Agent] Flow tab already opening, skipping'); return; } _openingFlowTab = true; try { - const tab = await getOrOpenFlowTab(); - if (!tab) { - console.log('[Flow Agent] Flow tab not ready yet after open'); - return; - } - await chrome.scripting.executeScript({ - target: { tabId: tab.id }, - files: ['content.js'], - }); - console.log('[Flow Agent] Token refresh triggered on Flow tab'); + const tabId = await refreshTokenViaLabs(); + console.log('[Flow Agent] Token refresh triggered via labs.google handoff in tab', tabId); } catch (e) { console.error('[Flow Agent] Token refresh failed:', e); } finally { @@ -387,24 +551,22 @@ async function connectToAgent() { metrics, }, }); + } else if (msg.method === 'reload_extension') { + console.log('[Flow Agent] Reloading extension on agent request'); + chrome.runtime.reload(); } else if (msg.method === 'open_flow_tab') { // Python bridge asks us to open/focus a Flow tab // If token is still fresh, just send it back — no need to open/reload if (isTokenFresh()) { console.log('[Flow Agent] open_flow_tab: token fresh, sending cached token'); sendToAgent({ type: 'token_captured', flowKey, clientId: extensionClientId }); + } else if (await ensureFlowSession()) { + console.log('[Flow Agent] open_flow_tab: flow session verified'); } else { console.log('[Flow Agent] open_flow_tab: token missing/expired, opening tab'); - const tabs = await chrome.tabs.query({ - url: ['https://labs.google/fx/tools/flow*', 'https://labs.google/fx/*/tools/flow*'], - }); - if (tabs.length) { - await chrome.tabs.reload(tabs[0].id); - console.log('[Flow Agent] Refreshed existing Flow tab'); - } else { - await chrome.tabs.create({ url: 'https://labs.google/fx/tools/flow', active: true }); - console.log('[Flow Agent] Opened new Flow tab'); - } + // Reloading an existing flow.google.com tab never yields a bearer — + // only the labs.google handoff does. + await refreshTokenViaLabs(); await sleep(5000); if (flowKey && ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'token_captured', flowKey })); @@ -630,6 +792,8 @@ async function deliverOnce(entry) { ...(callbackSecret ? { Authorization: `Bearer ${callbackSecret}` } : {}), }, body: JSON.stringify({ ...entry.msg, session_id: extensionClientId }), + // A stalled delivery must not wedge flushOutbox (and every response behind it). + signal: AbortSignal.timeout(30000), }); // Any HTTP reply means the backend is reachable and has taken the response // (ok:true = matched a request, ok:false = unknown id / already handled). @@ -696,6 +860,8 @@ async function requestCaptchaFromTab(tabId, requestId, pageAction) { if (!shouldInject) throw error; // Inject content script and retry + const tab = await chrome.tabs.get(tabId); + if (!isFlowUrl(tab?.url)) throw new Error('INVALID_FLOW_TAB'); await chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'], @@ -709,9 +875,10 @@ async function requestCaptchaFromTab(tabId, requestId, pageAction) { } } -async function solveCaptcha(requestId, captchaAction) { - const tab = await getOrOpenFlowTab(); +async function solveCaptcha(requestId, captchaAction, projectId) { + const tab = await getOrOpenFlowTab(projectId); if (!tab) return { error: 'NO_FLOW_TAB' }; + console.log('[Flow Agent] Solving captcha', captchaAction, 'in tab', tab.id, tab.url); try { const resp = await Promise.race([ @@ -726,7 +893,7 @@ async function solveCaptcha(requestId, captchaAction) { async function handleSolveCaptcha(msg) { const { id, params } = msg; - const result = await solveCaptcha(id, params?.captchaAction || 'VIDEO_GENERATION'); + const result = await solveCaptcha(id, params?.captchaAction || 'VIDEO_GENERATION', params?.projectId); // Standalone captcha solve counts as captcha-consuming metrics.requestCount++; @@ -783,7 +950,7 @@ async function handleUploadVideo(msg) { const { videoBase64, projectId, videoSize } = params; try { - const tabs = await chrome.tabs.query({ url: '*://labs.google/*' }); + const tabs = await chrome.tabs.query({ url: FLOW_TAB_URLS }); if (!tabs.length) { sendToAgent({ id, error: 'NO_FLOW_TAB' }); return; @@ -846,6 +1013,14 @@ async function handleApiRequest(msg) { return; } + // flow.google.com no longer talks to aisandbox-pa; these calls are served + // by the page's own batchexecute RPCs instead (see handleFlowRpcRequest). + const rpcKind = classifyFlowRpc(url); + if (rpcKind) { + await handleFlowRpcRequest(msg, rpcKind); + return; + } + if (!url.startsWith('https://aisandbox-pa.googleapis.com/')) { sendToAgent({ id, error: 'INVALID_URL' }); return; @@ -866,7 +1041,8 @@ async function handleApiRequest(msg) { // Step 1: Solve captcha if needed let captchaToken = null; if (captchaAction) { - const captchaResult = await solveCaptcha(id, captchaAction); + const projectId = body?.clientContext?.projectId || body?.requests?.[0]?.clientContext?.projectId || null; + const captchaResult = await solveCaptcha(id, captchaAction, projectId); captchaToken = captchaResult?.token || null; if (!captchaToken) { // Cannot proceed without captcha — API will 403 @@ -899,6 +1075,17 @@ async function handleApiRequest(msg) { // Step 3: Use flowKey for auth const activeFlowKey = flowKey; + if (activeFlowKey === FLOW_SESSION_KEY) { + // Cookie session only — aisandbox-pa rejects it (XD3). Endpoints not yet + // mapped to a batchexecute RPC cannot work on flow.google.com. + const err = `NOT_SUPPORTED_ON_FLOW_GOOGLE_COM: ${_classifyApiUrl(url)}`; + sendToAgent({ id, status: 501, error: err }); + if (hasCaptcha) { metrics.failedCount++; metrics.lastError = err; } + chrome.storage.local.set({ metrics }); + updateRequestLog(logId, { status: 'failed', error: err }); + setState('idle'); + return; + } if (!activeFlowKey) { sendToAgent({ id, status: 503, error: 'NO_FLOW_KEY' }); if (hasCaptcha) { metrics.failedCount++; metrics.lastError = 'NO_FLOW_KEY'; } @@ -911,13 +1098,23 @@ async function handleApiRequest(msg) { const fetchHeaders = { ...(headers || {}) }; fetchHeaders['authorization'] = `Bearer ${activeFlowKey}`; - // Step 4: Make the API call from browser context - const response = await fetch(url, { - method: method || 'POST', - headers: fetchHeaders, - credentials: 'include', - body: method === 'GET' ? undefined : JSON.stringify(finalBody), - }); + // Step 4: Make the API call from browser context. Bound it: a stalled + // connection here otherwise leaves the agent waiting for its own timeout + // with no error ever reported. + const abort = new AbortController(); + const abortTimer = setTimeout(() => abort.abort(), 120000); + let response; + try { + response = await fetch(url, { + method: method || 'POST', + headers: fetchHeaders, + credentials: 'include', + body: method === 'GET' ? undefined : JSON.stringify(finalBody), + signal: abort.signal, + }); + } finally { + clearTimeout(abortTimer); + } let responseData; const responseText = await response.text(); @@ -971,6 +1168,9 @@ async function handleGetMediaUrl(msg) { const mediaId = params?.media_id; if (!mediaId) { sendToAgent({ id, error: 'MISSING_MEDIA_ID' }); return; } try { + // Media generated through batchexecute: signed URL comes from as29s. + const signed = await resolveFlowMediaUrl(mediaId); + if (signed) { sendToAgent({ id, status: 200, result: { url: signed } }); return; } const url = new URL('https://labs.google/fx/api/trpc/media.getMediaUrlRedirect'); url.searchParams.set('name', mediaId); const response = await fetch(url.toString(), { credentials: 'include', redirect: 'follow' }); @@ -981,6 +1181,318 @@ async function handleGetMediaUrl(msg) { } } +// ─── flow.google.com batchexecute RPCs ────────────────────── +// +// The Angular frontend on flow.google.com does not call aisandbox-pa; it +// talks to /_/AiSandboxAngularFrontend/data/batchexecute with cookie auth +// plus a per-page CSRF token (WIZ_global_data.SNlM0e). The RPCs below were +// captured from a real session on 2026-09-18 and are replayed from inside a +// Flow tab (MAIN world) so cookies, CSRF and origin all line up. Results are +// mapped back into the aisandbox-pa REST shapes the Python side still parses. +// +// YhhmEf submit text-to-video -> [null, credits, [[mediaId, ...]], [[opId, projectId, mediaId, "CAE", ...]]] +// jwpduf poll [null,null,[[opId]]] -> [null, credits, [[record]]]; record[5][8][0] = status (6 queued, 2 running, 3 done) +// as29s result ["opId"] -> record incl. signed flow-content.google/video/ URL +// nzlxg credits [] -> [credits, ...] +// ogiZ0b generate image (sync, ~25 s) -> [[[mediaId, null, sceneId, ..., [[...,"",aspect,...], null, [w,h]]]], ...] + +const FLOW_VIDEO_ASPECT = { VIDEO_ASPECT_RATIO_PORTRAIT: 1, VIDEO_ASPECT_RATIO_LANDSCAPE: 2 }; +// Verified by generating one image per enum and reading the returned [w,h]. +const FLOW_IMAGE_ASPECT = { + IMAGE_ASPECT_RATIO_SQUARE: 1, // 1024x1024 + IMAGE_ASPECT_RATIO_PORTRAIT: 2, // 768x1376 + IMAGE_ASPECT_RATIO_LANDSCAPE: 3, // 1376x768 + IMAGE_ASPECT_RATIO_3_4: 4, // 896x1200 + IMAGE_ASPECT_RATIO_4_3: 5, // 1200x896 +}; +// flowKey value reported to the agent once the cookie session has been proven +// by a real RPC. The agent only routes work to clients that reported a key; +// there is no bearer to capture on flow.google.com any more. +const FLOW_SESSION_KEY = 'flow-session'; +const FLOW_STATUS_DONE = 3; +const FLOW_STATUS_PENDING = new Set([0, 1, 2, 6]); +// opId -> signed video URL, filled in as polls complete. +const flowMediaUrls = new Map(); + +function classifyFlowRpc(url) { + if (url.includes('/v1/credits') || url.endsWith('/credits')) return 'credits'; + if (url.includes('batchAsyncGenerateVideoText')) return 't2v'; + if (url.includes('batchCheckAsyncVideoGenerationStatus')) return 'poll'; + if (url.includes('batchGenerateImages')) return 'image'; + return null; +} + +// Prove the cookie session with a credits call and tell the agent about it. +// Replaces bearer capture on flow.google.com; the marker expires like a token +// (isTokenFresh) so it is re-proven periodically. +let _ensuringSession = null; +async function ensureFlowSession(force = false) { + if (!force && flowKey === FLOW_SESSION_KEY && isTokenFresh()) return true; + if (_ensuringSession) return _ensuringSession; + _ensuringSession = (async () => { + try { + const tab = await flowTabFor(null); + if (!tab) return false; + const res = await flowRpc(tab.id, 'nzlxg', [], null); + if (!res.ok) { + console.warn('[Flow Agent] Flow session check failed:', res.error); + return false; + } + flowKey = FLOW_SESSION_KEY; + metrics.tokenCapturedAt = Date.now(); + await chrome.storage.local.set({ flowKey, metrics }); + console.log('[Flow Agent] Flow session verified (credits:', res.data?.[0], ')'); + sendToAgent({ type: 'token_captured', flowKey, clientId: extensionClientId }); + return true; + } catch (e) { + console.warn('[Flow Agent] Flow session check error:', e.message); + return false; + } finally { + _ensuringSession = null; + } + })(); + return _ensuringSession; +} + +// Old keys (abra_t2v_8s) and new ones (veo_3_1_t2v_fast) both map onto the +// new frontend's model keys; portrait is a distinct key with a suffix. +function flowVideoModelKey(requested, aspect) { + let base = 'veo_3_1_t2v_fast'; + const r = (requested || '').toLowerCase(); + if (r.includes('veo_3_1_t2v')) base = r.replace(/_portrait$/, ''); + else if (r.includes('quality')) base = 'veo_3_1_t2v'; + else if (r.includes('lite')) base = 'veo_3_1_t2v_lite'; + return aspect === 'VIDEO_ASPECT_RATIO_PORTRAIT' ? `${base}_portrait` : base; +} + +// Runs inside the Flow tab. Self-contained: executeScript serialises it. +function pageFlowRpc(rpcId, arg, sourcePath) { + return (async () => { + try { + const w = window.WIZ_global_data || {}; + if (!w.SNlM0e) return { ok: false, error: 'NO_CSRF_TOKEN' }; + const q = new URLSearchParams({ + rpcids: rpcId, + 'source-path': sourcePath, + bl: w.cfb2h || '', + 'f.sid': w.FdrFJe || '', + hl: w.GWsdKe || 'en', + _reqid: String(Math.floor(Math.random() * 900000) + 100000), + rt: 'c', + }); + const body = new URLSearchParams({ + 'f.req': JSON.stringify([[[rpcId, JSON.stringify(arg), null, 'generic']]]), + at: w.SNlM0e, + }); + const res = await fetch(`https://flow.google.com/_/AiSandboxAngularFrontend/data/batchexecute?${q}`, { + method: 'POST', + credentials: 'include', + headers: { + 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8', + 'x-same-domain': '1', + }, + body: body.toString(), + }); + const text = await res.text(); + for (const line of text.split('\n')) { + if (!line.startsWith('[[')) continue; + let frames; + try { frames = JSON.parse(line); } catch { continue; } + for (const f of frames) { + if (!Array.isArray(f) || f[0] !== 'wrb.fr' || f[1] !== rpcId) continue; + if (typeof f[2] === 'string') return { ok: true, status: res.status, data: JSON.parse(f[2]) }; + return { ok: false, status: res.status, error: `RPC_ERROR ${JSON.stringify(f.slice(3)).slice(0, 300)}` }; + } + } + return { ok: false, status: res.status, error: `NO_FRAME ${text.slice(0, 200)}` }; + } catch (e) { + return { ok: false, error: e.message }; + } + })(); +} + +async function flowRpc(tabId, rpcId, arg, projectId) { + const results = await withTimeout( + chrome.scripting.executeScript({ + target: { tabId }, + world: 'MAIN', + func: pageFlowRpc, + args: [rpcId, arg, projectId ? `/project/${projectId}` : '/'], + }), + 60000, + `FLOW_RPC_${rpcId}`, + ); + return results?.[0]?.result || { ok: false, error: 'NO_RESULT' }; +} + +function findFlowUrl(node, kind) { + if (typeof node === 'string') return node.includes(`flow-content.google/${kind}/`) ? node : null; + if (Array.isArray(node)) { + for (const v of node) { const hit = findFlowUrl(v, kind); if (hit) return hit; } + } + return null; +} + +async function flowTabFor(projectId) { + return (await getAnyFlowTab()) || (await getOrOpenFlowTab(projectId)); +} + +async function resolveFlowMediaUrl(mediaId) { + if (flowMediaUrls.has(mediaId)) return flowMediaUrls.get(mediaId); + const tab = await flowTabFor(null); + if (!tab) return null; + const res = await flowRpc(tab.id, 'as29s', [mediaId], null); + const url = res.ok ? (findFlowUrl(res.data, 'video') || findFlowUrl(res.data, 'image')) : null; + if (url) flowMediaUrls.set(mediaId, url); + return url; +} + +function flowStatusString(code) { + if (code === FLOW_STATUS_DONE) return 'MEDIA_GENERATION_STATUS_SUCCESSFUL'; + if (FLOW_STATUS_PENDING.has(code)) return 'MEDIA_GENERATION_STATUS_ACTIVE'; + return `MEDIA_GENERATION_STATUS_FAILED_${code}`; +} + +async function handleFlowRpcRequest(msg, kind) { + const { id, params } = msg; + const { url, body, captchaAction } = params; + const projectId = body?.clientContext?.projectId || body?.requests?.[0]?.clientContext?.projectId || body?.media?.[0]?.projectId || null; + const logType = _classifyApiUrl(url); + const visible = _VISIBLE_TYPES.has(logType); + if (visible) { + addRequestLog({ id, type: logType, time: new Date().toISOString(), status: 'processing', error: null, outputUrl: null, url, payloadSummary: body ? JSON.stringify(body).slice(0, 200) : null }); + } + const fail = (status, error) => { + console.warn('[Flow Agent] flow rpc', kind, 'failed:', error); + sendToAgent({ id, status, error }); + if (kind === 't2v' || kind === 'image') { metrics.failedCount++; metrics.lastError = error; chrome.storage.local.set({ metrics }); } + if (visible) updateRequestLog(id, { status: 'failed', error }); + setState('idle'); + }; + + setState('running'); + try { + const tab = await flowTabFor(projectId); + if (!tab) return fail(503, 'NO_FLOW_TAB'); + + if (kind === 'credits') { + const res = await flowRpc(tab.id, 'nzlxg', [], projectId); + if (!res.ok) return fail(res.status || 500, res.error); + const credits = Array.isArray(res.data) ? res.data[0] : null; + sendToAgent({ id, status: 200, data: { credits, userPaygateTier: 'PAYGATE_TIER_ONE', sku: 'G1_PRO' } }); + setState('idle'); + return; + } + + if (kind === 'poll') { + const media = []; + let credits; + for (const m of body?.media || []) { + const res = await flowRpc(tab.id, 'jwpduf', [null, null, [[m.name]]], projectId || m.projectId); + if (!res.ok) return fail(res.status || 500, res.error); + credits = res.data?.[1]; + const record = res.data?.[2]?.[0]; + const code = record?.[5]?.[8]?.[0]; + const videoUrl = findFlowUrl(record, 'video'); + if (videoUrl) flowMediaUrls.set(m.name, videoUrl); + const status = videoUrl ? 'MEDIA_GENERATION_STATUS_SUCCESSFUL' : flowStatusString(code); + media.push({ name: m.name, mediaMetadata: { mediaStatus: { mediaGenerationStatus: status } } }); + } + sendToAgent({ id, status: 200, data: { media, remainingCredits: credits } }); + setState('idle'); + return; + } + + if (kind === 'image') { + metrics.requestCount++; + const items = body?.requests || []; + if (items.some((r) => r?.imageInputs?.length)) { + return fail(501, 'NOT_SUPPORTED: reference images are not wired to the flow.google.com RPC yet'); + } + const uuid = () => crypto.randomUUID().toUpperCase(); + // One ogiZ0b call per requested image, in parallel; each needs its own + // reCAPTCHA token. + const results = await Promise.all(items.map(async (req, i) => { + const prompt = req?.structuredPrompt?.parts?.map((p) => p.text).join('\n') || ''; + const aspectEnum = FLOW_IMAGE_ASPECT[req?.imageAspectRatio] || FLOW_IMAGE_ASPECT.IMAGE_ASPECT_RATIO_LANDSCAPE; + const model = req?.imageModelName || 'NARWHAL'; + const seed = Number.isInteger(req?.seed) ? req.seed : Math.floor(Math.random() * 1000000); + const captchaResult = await solveCaptcha(`${id}-${i}`, captchaAction || 'IMAGE_GENERATION', projectId); + const token = captchaResult?.token; + if (!token) return { ok: false, error: `CAPTCHA_FAILED: ${captchaResult?.error || 'no token'}` }; + const ctx = [null, 22, null, null, null, projectId, null, null, null, null, [token, 1]]; + const arg = [null, [[null, null, null, seed, aspectEnum, model, null, ctx, [[[prompt]]], null, null, null, uuid(), uuid()]], 1, ctx, [uuid()]]; + console.log('[Flow Agent] ogiZ0b submit', model, req?.imageAspectRatio, 'project', projectId); + const res = await flowRpc(tab.id, 'ogiZ0b', arg, projectId); + if (!res.ok) return res; + const imageUrl = findFlowUrl(res.data, 'image'); + if (!imageUrl) return { ok: false, error: `NO_IMAGE_URL ${JSON.stringify(res.data).slice(0, 200)}` }; + const mediaId = res.data?.[0]?.[0]?.[0] || (imageUrl.match(/image\/([0-9a-f-]{36})/) || [])[1]; + flowMediaUrls.set(mediaId, imageUrl); + return { ok: true, mediaId, imageUrl }; + })); + const bad = results.find((r) => !r.ok); + if (bad) return fail(bad.status || 500, bad.error); + metrics.successCount++; + metrics.lastError = null; + chrome.storage.local.set({ metrics }); + const media = results.map((r) => ({ name: r.mediaId, image: { generatedImage: { fifeUrl: r.imageUrl } } })); + if (visible) updateRequestLog(id, { status: 'success', httpStatus: 200, outputUrl: results[0].imageUrl, responseSummary: JSON.stringify(media.map((m) => m.name)) }); + sendToAgent({ id, status: 200, data: { media } }); + setState('idle'); + return; + } + + // kind === 't2v' + metrics.requestCount++; + const media = []; + let credits; + for (const req of body?.requests || []) { + const prompt = req?.textInput?.structuredPrompt?.parts?.map((p) => p.text).join('\n') || ''; + const aspect = req?.aspectRatio || 'VIDEO_ASPECT_RATIO_LANDSCAPE'; + const modelKey = flowVideoModelKey(req?.videoModelKey, aspect); + const aspectEnum = FLOW_VIDEO_ASPECT[aspect] || FLOW_VIDEO_ASPECT.VIDEO_ASPECT_RATIO_LANDSCAPE; + + const captchaResult = await solveCaptcha(id, captchaAction || 'VIDEO_GENERATION', projectId); + const token = captchaResult?.token; + if (!token) return fail(403, `CAPTCHA_FAILED: ${captchaResult?.error || 'no token'}`); + + const uuid = () => crypto.randomUUID().toUpperCase(); + const arg = [ + [[[null, null, [[[prompt]]]], modelKey, aspectEnum, null, [null, null, null, null, uuid(), uuid()]]], + [null, 22, null, null, null, projectId, null, null, null, null, [token, 1]], + [uuid(), 1], + ]; + console.log('[Flow Agent] YhhmEf submit', modelKey, aspect, 'project', projectId); + const res = await flowRpc(tab.id, 'YhhmEf', arg, projectId); + if (!res.ok) return fail(res.status || 500, res.error); + credits = res.data?.[1]; + const opId = res.data?.[3]?.[0]?.[0] || res.data?.[2]?.[0]?.[3]?.[4]; + if (!opId) return fail(500, `NO_OP_ID ${JSON.stringify(res.data).slice(0, 200)}`); + media.push({ name: opId }); + } + metrics.successCount++; + metrics.lastError = null; + chrome.storage.local.set({ metrics }); + if (visible) updateRequestLog(id, { status: 'success', httpStatus: 200, responseSummary: JSON.stringify(media) }); + sendToAgent({ id, status: 200, data: { media, remainingCredits: credits } }); + setState('idle'); + } catch (e) { + fail(500, e.message || 'FLOW_RPC_FAILED'); + } +} + +async function getAnyFlowTab() { + try { + const tabs = await chrome.tabs.query({ url: FLOW_TAB_URLS }); + if (!tabs || !tabs.length) return null; + const projectTab = tabs.find((t) => isFlowProjectUrl(t.url)); + return projectTab || tabs[0]; + } catch { + return null; + } +} + // ─── State & Popup ────────────────────────────────────────── function setState(newState) { @@ -1091,9 +1603,7 @@ chrome.runtime.onMessage.addListener((msg, _, reply) => { } if (msg.type === 'OPEN_FLOW_TAB') { - chrome.tabs.query({ - url: ['https://labs.google/fx/tools/flow*', 'https://labs.google/fx/*/tools/flow*'], - }).then((tabs) => { + chrome.tabs.query({ url: FLOW_TAB_URLS }).then((tabs) => { if (tabs.length) { chrome.tabs.update(tabs[0].id, { active: true }); reply({ ok: true, tabId: tabs[0].id }); diff --git a/flow-extension/content.js b/flow-extension/content.js index 256fba9..61e2d83 100644 --- a/flow-extension/content.js +++ b/flow-extension/content.js @@ -12,29 +12,71 @@ globalThis.__FLOW_AGENT_CONTENT_LOADED__ = true; (document.head || document.documentElement).appendChild(s); })(); +// Bridge liveness check — answers only if injected.js is running in this page. +chrome.runtime.onMessage.addListener((msg, _, reply) => { + if (msg.type !== 'PING_BRIDGE') return; + + const requestId = `ping-${Math.random().toString(36).slice(2)}`; + // Declared before the handler: injected.js answers synchronously inside the + // first dispatch(), so the handler can run before setInterval is assigned. + let redispatch = null; + const handler = (e) => { + if (e.detail?.requestId === requestId) { + window.removeEventListener('FLOW_AGENT_PONG', handler); + clearTimeout(timer); + if (redispatch !== null) clearInterval(redispatch); + redispatch = -1; // never start the interval after an answer + reply({ ok: true, grecaptcha: !!e.detail.grecaptcha }); + } + }; + const timer = setTimeout(() => { + window.removeEventListener('FLOW_AGENT_PONG', handler); + if (redispatch !== null) clearInterval(redispatch); + redispatch = -1; + reply({ ok: false }); + }, 2500); + window.addEventListener('FLOW_AGENT_PONG', handler); + + const dispatch = () => window.dispatchEvent(new CustomEvent('FLOW_AGENT_PING', { detail: { requestId } })); + dispatch(); + if (redispatch === null) redispatch = setInterval(dispatch, 300); + + return true; +}); + chrome.runtime.onMessage.addListener((msg, _, reply) => { if (msg.type !== 'GET_CAPTCHA') return; const { requestId, pageAction } = msg; + let redispatch = null; // see PING_BRIDGE: may be answered before assignment const handler = (e) => { if (e.detail?.requestId === requestId) { window.removeEventListener('CAPTCHA_RESULT', handler); clearTimeout(timer); + if (redispatch !== null) clearInterval(redispatch); + redispatch = -1; reply({ token: e.detail.token, error: e.detail.error }); } }; const timer = setTimeout(() => { window.removeEventListener('CAPTCHA_RESULT', handler); + if (redispatch !== null) clearInterval(redispatch); + redispatch = -1; reply({ error: 'CONTENT_TIMEOUT' }); }, 25000); window.addEventListener('CAPTCHA_RESULT', handler); - window.dispatchEvent(new CustomEvent('GET_CAPTCHA', { + // injected.js is loaded asynchronously via a