Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -789,7 +789,7 @@ These arrive in the response body as `data.error.details[].reason`. The worker a
| `Requested entity was not found` | Uploaded `media_id` expired (~1h TTL) | Auto-recover via `_recover_entity_not_found` — re-uploads from `image_url`, re-queues PENDING |
| `Internal error encountered` | Flow backend transient 500 | Exponential backoff retry: `2^retry * 10s`, capped 300s |
| `reCAPTCHA failed` / `captcha` | Extension couldn't solve CAPTCHA | Retry up to 10× without incrementing `retry_count` (processor.py:454-464) |
| `PUBLIC_ERROR_UNUSUAL_ACTIVITY` (403, message `reCAPTCHA evaluation failed`) | Google flagged the session as bot-like — usually rapid bursts of submits, VPN/shared IP, or stale auth cookies | NOT auto-recoverable. Pause submits, clear cookies for `google.com` + `labs.google` in Chrome, sign back in at `labs.google/fx/tools/flow`, then resubmit with ≥1s gap and ≤5 concurrent. See `/fk-doctor` for full playbook. |
| `PUBLIC_ERROR_UNUSUAL_ACTIVITY` (403, message `reCAPTCHA evaluation failed`) | Google flagged the session as bot-like — usually rapid bursts of submits, VPN/shared IP, or stale auth cookies | NOT auto-recoverable. Pause submits, clear cookies for `google.com` + `labs.google` in Chrome, sign back in at `flow.google.com`, then resubmit with ≥1s gap and ≤5 concurrent. See `/fk-doctor` for full playbook. |

### HTTP Status Codes

Expand Down
27 changes: 26 additions & 1 deletion agent/services/flow_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ def set_extension(self, ws):
"connected_at": time.time(),
"flow_key": None,
"token_captured_at": None,
"extension_version": None,
"flow_url_supported": None,
"unavailable_until": 0,
}
# A new unauthenticated profile must not displace an already
Expand Down Expand Up @@ -185,13 +187,25 @@ def ws_stats(self) -> dict:
uptime = None
if self._ws_connected_at and self.connected:
uptime = int(time.time() - self._ws_connected_at)
versions = sorted({
str(session["extension_version"])
for session in self._extensions.values()
if session.get("extension_version")
})
flow_url_support = [
session.get("flow_url_supported")
for session in self._extensions.values()
if session.get("flow_url_supported") is not None
]
return {
"connected": self.connected,
"active_connections": len(self._extensions),
"authenticated_connections": sum(
1 for session in self._extensions.values()
if session.get("flow_key")
),
"extension_versions": versions,
"flow_url_supported": all(flow_url_support) if flow_url_support else None,
"connects": self._ws_connect_count,
"disconnects": self._ws_disconnect_count,
"uptime_s": uptime,
Expand All @@ -212,7 +226,18 @@ async def handle_message(self, data: dict, websocket=None):
return

if data.get("type") == "extension_ready":
logger.info("Extension ready, flowKey=%s", "yes" if data.get("flowKeyPresent") else "no")
source_ws = websocket or self._extension_ws
version = data.get("extensionVersion")
flow_supported = data.get("flowUrlSupported")
if source_ws is not None and source_ws in self._extensions:
self._extensions[source_ws]["extension_version"] = version
self._extensions[source_ws]["flow_url_supported"] = flow_supported
logger.info(
"Extension ready, flowKey=%s version=%s flow.google.com=%s",
"yes" if data.get("flowKeyPresent") else "no",
version or "unknown",
"yes" if flow_supported is True else "no" if flow_supported is False else "unknown",
)
asyncio.create_task(self._sync_tier())
return

Expand Down
70 changes: 35 additions & 35 deletions dashboard/src/i18n/translations.ts

Large diffs are not rendered by default.

65 changes: 43 additions & 22 deletions extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === 'reconnect') connectToAgent();
if (alarm.name === 'keepAlive') keepAlive();
if (alarm.name === 'token-refresh') {
await captureTokenFromFlowTab();
// Passive maintenance must never create browser tabs. If the user has no
// Flow tab open, wait for an explicit action or an actual RPC to open one.
await captureTokenFromFlowTab({ createIfMissing: false });
}
});

Expand Down Expand Up @@ -151,25 +153,29 @@ chrome.webRequest.onBeforeSendHeaders.addListener(

let _openingFlowTab = false;

async function captureTokenFromFlowTab() {
const tabs = await chrome.tabs.query({ url: flowUrls });
async function captureTokenFromFlowTab({ createIfMissing = false } = {}) {
let tabs = await chrome.tabs.query({ url: flowUrls });
if (!tabs.length) {
if (!createIfMissing) {
console.log('[FlowAgent] No Flow tab found — passive refresh skipped');
return { skipped: 'NO_FLOW_TAB' };
}
if (_openingFlowTab) {
console.log('[FlowAgent] Flow tab already opening, skipping');
return;
}
_openingFlowTab = true;
try {
console.log('[FlowAgent] No Flow tab found — opening one in background');
await chrome.tabs.create({ url: FLOW_TAB_URL, active: false });
console.log('[FlowAgent] No Flow tab found — opening one for explicit refresh');
const opened = await chrome.tabs.create({ url: FLOW_TAB_URL, active: false });
await sleep(3000);
const retryTabs = await chrome.tabs.query({ url: flowUrls });
if (!retryTabs.length) {
const target = opened?.id ? await chrome.tabs.get(opened.id).catch(() => null) : null;
if (!target) {
console.log('[FlowAgent] Flow tab not ready yet after open');
return;
}
await chrome.scripting.executeScript({
target: { tabId: retryTabs[0].id },
target: { tabId: target.id },
files: ['content.js'],
});
console.log('[FlowAgent] Token refresh triggered on newly opened Flow tab');
Expand Down Expand Up @@ -218,6 +224,8 @@ function connectToAgent() {
ws.send(JSON.stringify({
type: 'extension_ready',
flowKeyPresent: !!flowKey,
extensionVersion: chrome.runtime.getManifest().version,
flowUrlSupported: chrome.runtime.getManifest().host_permissions?.includes('https://flow.google.com/*') === true,
tokenAge: flowKey && metrics.tokenCapturedAt ? Date.now() - metrics.tokenCapturedAt : null,
}));
if (flowKey) {
Expand Down Expand Up @@ -359,16 +367,19 @@ function captchaFromTab(tabId, requestId, captchaAction) {
async function solveCaptcha(requestId, captchaAction) {
let tabs = await chrome.tabs.query({ url: flowUrls });

// No Flow tab at all — spawn one and let it settle.
// No Flow tab at all — spawn one and let it settle. Keep the exact tab id:
// a redirected or stale tab must not make us select some older candidate.
if (!tabs.length) {
let opened;
try {
await chrome.tabs.create({ url: FLOW_TAB_URL, active: false });
opened = await chrome.tabs.create({ url: FLOW_TAB_URL, active: false });
await sleep(3000);
tabs = await chrome.tabs.query({ url: flowUrls });
} catch (e) {
return { error: e.message || 'NO_FLOW_TAB' };
}
if (!tabs.length) return { error: 'NO_FLOW_TAB' };
const target = opened?.id ? await chrome.tabs.get(opened.id).catch(() => null) : null;
if (!target) return { error: 'NO_FLOW_TAB' };
tabs = [target];
}

// Try each Flow tab in turn. A tab that answers "no grecaptcha" is a tab
Expand Down Expand Up @@ -401,16 +412,25 @@ async function solveCaptcha(requestId, captchaAction) {
}
}

// Every candidate failed — last-ditch, spawn a fresh tab and try it once.
// Every candidate failed — last-ditch, spawn a fresh temporary tab and
// target THAT exact tab. Previously we re-queried all Flow tabs and picked
// fresh[0], which could select the same stale tab again while leaking the
// newly-created one on every retry.
let recoveryTab = null;
try {
await chrome.tabs.create({ url: FLOW_TAB_URL, active: false });
recoveryTab = await chrome.tabs.create({ url: FLOW_TAB_URL, active: false });
await sleep(3000);
const fresh = await chrome.tabs.query({ url: flowUrls });
const target = fresh.find((t) => !t.discarded) || fresh[0];
if (!target) return { error: 'NO_FLOW_TAB' };
const target = await chrome.tabs.get(recoveryTab.id);
if (!target || target.discarded) return { error: 'NO_FLOW_TAB' };
return await captchaFromTab(target.id, requestId, captchaAction);
} catch (e) {
return { error: e?.message || errors[0] || 'NO_FLOW_TAB' };
} finally {
// A recovery tab is disposable: there were already Flow tabs available
// for the signed RPC. Do not let CAPTCHA retries accumulate root tabs.
if (recoveryTab?.id) {
try { await chrome.tabs.remove(recoveryTab.id); } catch { /* already gone */ }
}
}
}

Expand Down Expand Up @@ -447,12 +467,13 @@ async function runBatchRpc(cmd) {
let candidate = tabs.find((t) => !t.discarded) || tabs[0];
if (!candidate) {
// No Flow tab — open one and give the app a moment to boot, otherwise
// WIZ_global_data is not on the page yet and `at` comes back empty.
// WIZ_global_data is not on the page yet and `at` comes back empty. Keep
// the exact created tab id so redirects/stale tabs cannot hijack recovery.
let opened;
try {
await chrome.tabs.create({ url: FLOW_TAB_URL, active: false });
opened = await chrome.tabs.create({ url: FLOW_TAB_URL, active: false });
await sleep(5000);
const fresh = await chrome.tabs.query({ url: flowUrls });
candidate = fresh.find((t) => !t.discarded) || fresh[0];
candidate = opened?.id ? await chrome.tabs.get(opened.id).catch(() => null) : null;
} catch (e) {
return { error: e?.message || 'NO_FLOW_TAB' };
}
Expand Down Expand Up @@ -804,7 +825,7 @@ chrome.runtime.onMessage.addListener((msg, _, reply) => {
}

if (msg.type === 'REFRESH_TOKEN') {
captureTokenFromFlowTab()
captureTokenFromFlowTab({ createIfMissing: true })
.then(() => reply({ ok: true }))
.catch((e) => reply({ error: e.message }));
return true;
Expand Down
2 changes: 1 addition & 1 deletion extension/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Flow Kit",
"version": "0.3.0",
"version": "0.3.1",
"description": "Local agent bridge for Google Flow \u2014 runs batchexecute RPCs inside a signed-in flow.google.com tab, mints reCAPTCHA, proxies API calls",
"permissions": [
"storage",
Expand Down
2 changes: 1 addition & 1 deletion setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ echo " 1. Load Chrome extension:"
echo " chrome://extensions → Developer mode → Load unpacked → extension/"
echo ""
echo " 2. Open Google Flow:"
echo " https://labs.google/fx/tools/flow (sign in)"
echo " https://flow.google.com/ (sign in)"
echo ""
echo " 3. Start the agent:"
echo " source venv/bin/activate"
Expand Down
4 changes: 2 additions & 2 deletions skills/fk-doctor.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ python3 -c "from agent.config import USE_BATCH_RPC, FLOW_PROJECT_ID; \
| `Requested entity was not found` | Uploaded `media_id` expired (~1h TTL on uploads) | `_recover_entity_not_found` re-uploads from `image_url`, re-queues PENDING | If auto-recovery fails: manually `POST /api/upload-image`, patch `media_id` |
| `Internal error encountered` | Flow backend transient 500 | Exponential backoff: `2^retry * 10s`, capped 300s | None — wait, or retry manually after a minute |
| `reCAPTCHA failed` / (contains `captcha`) | Extension couldn't solve reCAPTCHA | Retry ≤10× without consuming `retry_count` (processor.py:454-464) | Ensure a Google Flow tab is open and focused; reload extension |
| `PUBLIC_ERROR_UNUSUAL_ACTIVITY` (403, message `reCAPTCHA evaluation failed`) | Google flagged the session as bot-like — usually triggered by rapid bursts of submits (e.g. many GENERATE_VIDEO in <1 minute), shared/VPN IP, or stale auth cookies | NOT auto-handled — Google blocks even fresh requests until the trust signal recovers | (1) **Stop the worker / pipeline** so submits pause. (2) Open Chrome → `chrome://settings/cookies` (or the extension's Chrome profile) → search `google.com` and `labs.google` → **remove all cookies for both**. (3) Reload `https://labs.google/fx/tools/flow` and sign back in (re-solve any reCAPTCHA puzzles manually). (4) Slow down submission cadence (≥1s gap between submits, ≤5 concurrent). If still blocked, switch to a different network or wait 1–6 h |
| `PUBLIC_ERROR_UNUSUAL_ACTIVITY` (403, message `reCAPTCHA evaluation failed`) | Google flagged the session as bot-like — usually triggered by rapid bursts of submits (e.g. many GENERATE_VIDEO in <1 minute), shared/VPN IP, or stale auth cookies | NOT auto-handled — Google blocks even fresh requests until the trust signal recovers | (1) **Stop the worker / pipeline** so submits pause. (2) Open Chrome → `chrome://settings/cookies` (or the extension's Chrome profile) → search `google.com` and `labs.google` → **remove all cookies for both**. (3) Reload `https://flow.google.com` and sign back in (re-solve any reCAPTCHA puzzles manually). (4) Slow down submission cadence (≥1s gap between submits, ≤5 concurrent). If still blocked, switch to a different network or wait 1–6 h |

### B. HTTP status codes

Expand Down Expand Up @@ -194,7 +194,7 @@ When the user describes a symptom in plain language, map it here first.
| Extension shows "No token" | Expected on the batch path — there is no bearer any more. Only act on it with `USE_BATCH_RPC=0` |
| `CAPTCHA_FAILED: NO_FLOW_TAB` | Open `https://flow.google.com/` — and check the extension is v0.3.0+, older builds only matched the dead labs.google URL and could not see the tab that was right there |
| 403 `MODEL_ACCESS_DENIED` | Tier mismatch — `GET /api/flow/credits`, downgrade model in `models.json` via `/fk-change-model` |
| 403 `PUBLIC_ERROR_UNUSUAL_ACTIVITY` / `reCAPTCHA evaluation failed` | Google flagged the session as bot-like (rapid bursts, VPN/shared IP, stale cookies). **Pause submits**, then in Chrome: `chrome://settings/cookies` → remove cookies for `google.com` and `labs.google` → reload `labs.google/fx/tools/flow` → sign in & solve any captcha → resubmit with ≥1s gap and ≤5 concurrent. Switch network or wait 1–6 h if still blocked |
| 403 `PUBLIC_ERROR_UNUSUAL_ACTIVITY` / `reCAPTCHA evaluation failed` | Google flagged the session as bot-like (rapid bursts, VPN/shared IP, stale cookies). **Pause submits**, then in Chrome: `chrome://settings/cookies` → remove cookies for `google.com` and `labs.google` → reload `flow.google.com` → sign in & solve any captcha → resubmit with ≥1s gap and ≤5 concurrent. Switch network or wait 1–6 h if still blocked |
| Scene images inconsistent across scenes | Check all refs have UUID `media_id` — run `/fk-fix-uuids` |
| `media_id` starts with `CAMS...` | Run `/fk-fix-uuids` to extract UUID from URL |
| Upscale fails on every scene | On the batch path upscale is unported (`UNSUPPORTED_ON_BATCH_API`) — no upsampler rpc has been captured. On the legacy path it needs `PAYGATE_TIER_TWO` |
Expand Down
20 changes: 19 additions & 1 deletion tests/extension_mv3_bootstrap.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const source = fs.readFileSync(
const lifecycleListeners = { alarm: [], installed: [], startup: [] };
const sockets = [];
let storageReads = 0;
let tabCreates = 0;

class FakeWebSocket {
static CONNECTING = 0;
Expand Down Expand Up @@ -40,6 +41,12 @@ const chrome = {
action: { setBadgeBackgroundColor() {}, setBadgeText() {} },
alarms: { clear() {}, create() {}, onAlarm: event(lifecycleListeners.alarm) },
runtime: {
getManifest() {
return {
version: '0.3.1',
host_permissions: ['https://flow.google.com/*'],
};
},
onInstalled: event(lifecycleListeners.installed),
onMessage: event(),
onStartup: event(lifecycleListeners.startup),
Expand All @@ -60,8 +67,14 @@ const chrome = {
},
},
tabs: {
create: async () => ({}),
create: async () => {
tabCreates += 1;
return { id: 123, discarded: false };
},
get: async (id) => ({ id, discarded: false }),
query: async () => [],
reload: async () => {},
remove: async () => {},
sendMessage: async () => {},
update: async () => {},
},
Expand Down Expand Up @@ -100,8 +113,13 @@ setImmediate(async () => {
socket.readyState = FakeWebSocket.OPEN;
socket.onopen();

await lifecycleListeners.alarm[0]({ name: 'token-refresh' });
assert.equal(tabCreates, 0, 'passive token refresh must never create a Flow tab');

assert.equal(socket.messages[0].type, 'extension_ready');
assert.equal(socket.messages[0].flowKeyPresent, true);
assert.equal(socket.messages[0].extensionVersion, '0.3.1');
assert.equal(socket.messages[0].flowUrlSupported, true);
assert.ok(socket.messages[0].tokenAge > 0);
assert.deepEqual(socket.messages[1], {
type: 'token_captured',
Expand Down