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..9c2d6e9 --- /dev/null +++ b/flow-agent/tests/test_extension_flow_urls.py @@ -0,0 +1,68 @@ +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 + 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..a7fa94f 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'; @@ -153,7 +176,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 +185,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 +279,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 +308,116 @@ 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 }); + // Move our own tab onto a project page; a user's tab is only replaced + // when it has left Flow entirely. + const needsProjectPage = workTabCreatedByExtension && !isFlowProjectUrl(tab?.url); + if (tab && (!isFlowUrl(tab.url) || needsProjectPage)) { + await withTimeout(chrome.tabs.update(workTabId, { url: targetUrl }), 10000, 'TAB_UPDATE'); await waitForTabComplete(workTabId); tab = await chrome.tabs.get(workTabId); } - scheduleFlowTabClose(); - return tab; + if (await bridgeAlive(workTabId)) { + scheduleFlowTabClose(); + return tab; + } + console.warn('[Flow Agent] Flow tab', workTabId, 'has a dead captcha bridge; looking for another'); + workTabId = null; } 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 (!(await bridgeAlive(tab.id))) continue; + if (!isFlowProjectUrl(tab.url)) { + console.warn('[Flow Agent] Flow tab is not on a /project/ page; reCAPTCHA is only available there'); + } + workTabId = tab.id; workTabCreatedByExtension = false; - return tabs[0]; + return tab; + } + if (tabs.length) { + console.warn('[Flow Agent] None of', tabs.length, '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 { @@ -285,16 +446,8 @@ async function captureTokenFromFlowTab() { } _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 { @@ -395,16 +548,9 @@ async function connectToAgent() { sendToAgent({ type: 'token_captured', flowKey, clientId: extensionClientId }); } 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 +776,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 +844,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 +859,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 +877,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 +934,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; @@ -866,7 +1017,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 @@ -911,13 +1063,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(); @@ -1091,9 +1253,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