From 4aa7e19825e5f3706e7447fe8c50e5634789c962 Mon Sep 17 00:00:00 2001 From: type-two Date: Wed, 8 Jul 2026 10:04:38 +1000 Subject: [PATCH] fix(money-safety): 4 confirmed bugs from adversarial review + Animate guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review (21 agents, verify layer refuted the false positives) confirmed 4: CRITICAL — double-generation on requeue (content.js): submitEditor commits 15 credits, but if the visual stuck-signals lag under load the task requeue+reloads and re-submits (promptEchoed unreliable across reload) → up to 4×15=60 credits for one task. Fix: durable per-task 'submitted_' marker persisted BEFORE the stuck-check, checked on entry, cleared only on terminal status (centralized in reportCompletion). A rare genuine submit-failure now times out and fails (retryable) instead of paying twice. HIGH — transient harvest miss permanently blacklisted the asset: every miss path did seen.add()+persist, so a hover/menu race wrote a never-downloaded tile to storage and the seen-guard skipped it forever. Fix: transient misses go to an in-memory missedThisPass set (retried next run); ONLY confirmed downloads are persisted. MEDIUM — Try-again re-ran succeeded gens: a spurious 'Something went wrong' banner co-occurring with a lagging-but-real asset auto-clicked Try-again (15cr each, ×3). Fix: settle + re-check assets AND new completion-message before clicking; cap 3→1. MEDIUM — seen-list slice(-3000) evicted oldest done URLs on big libraries → re-download. Fix: cap raised to 20000 (and only confirmed downloads accumulate now). + belt-and-suspenders: findTileMenuButton fallback can never land on Animate/Like/♥/redo (a stray Animate click = paid image→video gen). Proven by unit test. Co-Authored-By: Claude Fable 5 --- content.js | 70 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 52 insertions(+), 18 deletions(-) diff --git a/content.js b/content.js index f535aa0..96b6f81 100644 --- a/content.js +++ b/content.js @@ -257,7 +257,7 @@ function findTryAgain() { // errors constantly under load, so instead of failing we AUTO-CLICK "Try again" // (what works by hand) up to maxRetries, with a cooldown so a lingering banner // doesn't burn all the retries at once. Returns 'asset' | 'error' | null(timeout). -async function waitForResultOrError(baseCount, maxMs, errBase = 0, maxRetries = 3) { +async function waitForResultOrError(baseCount, maxMs, errBase = 0, doneBase = 0, maxRetries = 1) { const t0 = Date.now(); let retries = 0, cooldownUntil = 0; while (Date.now() - t0 < maxMs) { @@ -269,10 +269,17 @@ async function waitForResultOrError(baseCount, maxMs, errBase = 0, maxRetries = const btn = findTryAgain(); if (btn) { if (retries >= maxRetries) return 'error'; // gave it maxRetries shots + // A "Something went wrong" banner can co-occur with a gen that ACTUALLY + // succeeded (Flow errors constantly under load). Clicking Try-again re-runs + // it for another 15 credits. So SETTLE and re-check: only retry if there is + // still no asset AND no NEW completion message. Each retry is real money — + // capped at maxRetries=1. + await sleep(2500); + if (snapshotAssets().size > baseCount || countDoneMsgs() > doneBase) return 'asset'; retries++; log('info', `Flow hiccup — clicking "Try again" (${retries}/${maxRetries}).`); btn.click(); - cooldownUntil = now + 15000; // let it re-generate before re-checking + cooldownUntil = Date.now() + 15000; // let it re-generate before re-checking await sleep(4000); continue; } @@ -391,11 +398,16 @@ async function executeTask(task) { const doneBase = countDoneMsgs(); // old "finished" chatter lingers — only a NEW one counts const errBase = countErrMsgs(); // ditto for old error banners - // Soft-detect first: if the chat already echoes this prompt, the PREVIOUS attempt's - // submit registered before its reload — re-typing would double-generate. Skip to waiting. - const alreadySubmitted = promptEchoed(task.prompt); + // Soft-detect first: a DURABLE marker (survives the requeue hard-reload) or a live + // prompt-echo means Google ALREADY started the paid generation for this task — + // re-typing would double-bill. Skip straight to waiting for the result. + const submittedKey = 'submitted_' + task.id; + const priorSubmit = await new Promise(r => chrome.storage.local.get({ [submittedKey]: false }, d => r(d[submittedKey]))); + const alreadySubmitted = priorSubmit || promptEchoed(task.prompt); if (alreadySubmitted) { - log('info', 'Prompt already echoed in the session (earlier submit registered) — not re-typing.'); + log('info', priorSubmit + ? 'Task already submitted on a prior attempt (durable marker) — waiting for the result, NOT re-generating.' + : 'Prompt already echoed in the session (earlier submit registered) — not re-typing.'); } else { const landed = await typeIntoEditor(input, task.prompt); if (!landed) return await requeueWithReload(task, 'prompt did not register in the editor'); @@ -403,6 +415,11 @@ async function executeTask(task) { await sleep(600); // John's ritual: paste, wait a sec, THEN enter await submitEditor(input); // trusted Enter via the DevTools Protocol — this submits + // CREDITS COMMITTED. Persist the marker BEFORE the stuck-check: the visual "stuck" + // signals lag under load, and a requeue+reload that trusts them to gate re-submit + // is exactly what double-billed. Conservative by design — if the submit somehow + // didn't take, the task times out and fails (retryable) rather than pays twice. + await new Promise(r => chrome.storage.local.set({ [submittedKey]: true }, r)); // "It stuck" = stop square appears, OR the box empties, OR the chat echoes the prompt. const stuck = () => findStopSquare() || editorText(input).length === 0 || promptEchoed(task.prompt); let took = await waitFor(stuck, 6000, 300); @@ -445,7 +462,7 @@ async function executeTask(task) { } // Images: the grid updates live — wait in-page for a new asset, error, or timeout. - const outcome = await waitForResultOrError(before.size, timeoutMs, errBase); + const outcome = await waitForResultOrError(before.size, timeoutMs, errBase, doneBase); if (outcome === 'ratelimit') { await handleRateLimit(task); return; } // long back-off, don't hammer if (outcome === 'error') return await requeueWithReload(task, '"Something went wrong" persisted through Try-again'); if (outcome !== 'asset') { @@ -536,8 +553,13 @@ function findTileMenuButton(tile) { let card = tile; for (let i = 0; i < 3 && card.parentElement; i++) card = card.parentElement; const btns = [...card.querySelectorAll('button, [role="button"]')].filter(visible); - return btns.find(b => /more|option|menu/i.test((b.getAttribute('aria-label') || '') + (b.getAttribute('title') || ''))) - || btns[btns.length - 1] || null; + const lbl = b => ((b.getAttribute('aria-label') || '') + (b.getAttribute('title') || '') + (b.textContent || '')).toLowerCase(); + // SAFETY: never fall back onto ♥/↻/Animate — a stray click on Animate starts a PAID + // image→video generation. Prefer the explicit more/options control; else the last + // button that is NOT one of those. + return btns.find(b => /more|option|menu/.test(lbl(b))) + || [...btns].reverse().find(b => !/animate|like|favou?rite|heart|redo|retry/.test(lbl(b))) + || null; } function findMenuItem(re) { @@ -592,13 +614,14 @@ async function executeHarvest(task) { try { await waitForAgentReady(30000); const stored = await new Promise(r => chrome.storage.local.get({ harvested_urls_v2: [] }, d => r(d.harvested_urls_v2))); - const seen = new Set(stored); + const seen = new Set(stored); // CONFIRMED downloads — persisted, never re-fetched + const missedThisPass = new Set(); // transient misses — in-memory ONLY, retried next run await bg({ type: 'harvestRouting', on: true }); window.scrollTo(0, 0); await sleep(800); let idle = 0; while (idle < 3 && !harvestAbort && got + missed < 500) { const tile = [...document.querySelectorAll('a[href*="/edit/"]')] - .find(t => visible(t) && !seen.has(t.href.split('#')[0])); + .find(t => { const k = t.href.split('#')[0]; return visible(t) && !seen.has(k) && !missedThisPass.has(k); }); if (!tile) { // nothing new in view — page down (grid may virtualize) window.scrollBy(0, innerHeight * 0.8); await sleep(1000); idle++; continue; } @@ -609,12 +632,12 @@ async function executeHarvest(task) { // Stage 1/4: hover the tile until the ♡ ↻ ⋮ pill renders await bg({ type: 'hoverAt', ...center(tile) }); const dots = await waitFor(() => findTileMenuButton(tile), 3000, 300); - if (!dots) { missed++; seen.add(href); log('info', `harvest: control pill never appeared — ${gridCensus()}`); continue; } + if (!dots) { missed++; missedThisPass.add(href); log('info', `harvest: control pill never appeared — ${gridCensus()}`); continue; } // Stage 2/4: click ⋮ ("More"), wait for the menu with Download in it await bg({ type: 'clickAt', ...center(dots) }); const dl = await waitFor(() => findMenuItem(/^\s*download\b/i), 3000, 300); - if (!dl) { missed++; seen.add(href); log('info', `harvest: ⋮ clicked but no Download item — ${gridCensus()}`); await bg({ type: 'pressEscape' }); await sleep(400); continue; } + if (!dl) { missed++; missedThisPass.add(href); log('info', `harvest: ⋮ clicked but no Download item — ${gridCensus()}`); await bg({ type: 'pressEscape' }); await sleep(400); continue; } // Stage 3/4: HOVER Download — the size flyout unfurls beside it (side depends on // where the tile sits; irrelevant, coords are re-queried fresh). Jiggle the @@ -631,7 +654,7 @@ async function executeHarvest(task) { await bg({ type: 'clickAt', ...center(dl) }); oneK = await waitFor(findSizeOption, 1600, 250); } - if (!oneK) { missed++; seen.add(href); log('info', 'harvest: no "original size" option after hover + click-expand.'); await bg({ type: 'pressEscape' }); await sleep(400); continue; } + if (!oneK) { missed++; missedThisPass.add(href); log('info', 'harvest: no "original size" option after hover + click-expand.'); await bg({ type: 'pressEscape' }); await sleep(400); continue; } // Stage 4/4: walk the pointer INTO the flyout (keeps it open), then click 1K, // then VERIFY a download event actually fired before counting it. @@ -643,10 +666,16 @@ async function executeHarvest(task) { await sleep(500); fired = (((await bg({ type: 'harvestStats' })) || {}).count || 0) > dlBefore; } - if (fired) { got++; if (got % 10 === 0) log('info', `harvest: ${got} downloaded so far…`); } - else { missed++; log('info', 'harvest: clicked 1K but no download event fired.'); } - seen.add(href); - chrome.storage.local.set({ harvested_urls_v2: [...seen].slice(-3000) }); + if (fired) { + // ONLY a confirmed download is persisted (never re-fetched). A transient miss + // stays in missedThisPass — skipped this pass, RETRIED on the next DL run, so a + // hover/menu race never permanently blacklists a never-downloaded asset. + got++; seen.add(href); + chrome.storage.local.set({ harvested_urls_v2: [...seen].slice(-20000) }); + if (got % 10 === 0) log('info', `harvest: ${got} downloaded so far…`); + } else { + missed++; missedThisPass.add(href); log('info', 'harvest: clicked 1K but no download event fired.'); + } await bg({ type: 'pressEscape' }); await sleep(300); // ensure menus are closed before the next tile await sleep(1200 + Math.random() * 1200); // pace — don't machine-gun the account } @@ -666,6 +695,11 @@ async function executeHarvest(task) { } async function reportCompletion(id, status, error, assets) { + // Terminal states clear the per-task markers; 'requeue'/'ratelimited' keep the task + // pending AND keep the submitted-marker so the reload never re-generates it. + if (status === 'success' || status === 'failed') { + chrome.storage.local.remove(['submitted_' + id, 'requeue_' + id]); + } try { await fetch(`${settings.serverUrl}/task-completed`, { method: 'POST',