fix(money-safety): 4 confirmed bugs from adversarial review + Animate guard
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_<id>' 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 <noreply@anthropic.com>
This commit is contained in:
parent
20941c007c
commit
4aa7e19825
70
content.js
70
content.js
@ -257,7 +257,7 @@ function findTryAgain() {
|
|||||||
// errors constantly under load, so instead of failing we AUTO-CLICK "Try again"
|
// 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
|
// (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).
|
// 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();
|
const t0 = Date.now();
|
||||||
let retries = 0, cooldownUntil = 0;
|
let retries = 0, cooldownUntil = 0;
|
||||||
while (Date.now() - t0 < maxMs) {
|
while (Date.now() - t0 < maxMs) {
|
||||||
@ -269,10 +269,17 @@ async function waitForResultOrError(baseCount, maxMs, errBase = 0, maxRetries =
|
|||||||
const btn = findTryAgain();
|
const btn = findTryAgain();
|
||||||
if (btn) {
|
if (btn) {
|
||||||
if (retries >= maxRetries) return 'error'; // gave it maxRetries shots
|
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++;
|
retries++;
|
||||||
log('info', `Flow hiccup — clicking "Try again" (${retries}/${maxRetries}).`);
|
log('info', `Flow hiccup — clicking "Try again" (${retries}/${maxRetries}).`);
|
||||||
btn.click();
|
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);
|
await sleep(4000);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -391,11 +398,16 @@ async function executeTask(task) {
|
|||||||
const doneBase = countDoneMsgs(); // old "finished" chatter lingers — only a NEW one counts
|
const doneBase = countDoneMsgs(); // old "finished" chatter lingers — only a NEW one counts
|
||||||
const errBase = countErrMsgs(); // ditto for old error banners
|
const errBase = countErrMsgs(); // ditto for old error banners
|
||||||
|
|
||||||
// Soft-detect first: if the chat already echoes this prompt, the PREVIOUS attempt's
|
// Soft-detect first: a DURABLE marker (survives the requeue hard-reload) or a live
|
||||||
// submit registered before its reload — re-typing would double-generate. Skip to waiting.
|
// prompt-echo means Google ALREADY started the paid generation for this task —
|
||||||
const alreadySubmitted = promptEchoed(task.prompt);
|
// 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) {
|
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 {
|
} else {
|
||||||
const landed = await typeIntoEditor(input, task.prompt);
|
const landed = await typeIntoEditor(input, task.prompt);
|
||||||
if (!landed) return await requeueWithReload(task, 'prompt did not register in the editor');
|
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 sleep(600); // John's ritual: paste, wait a sec, THEN enter
|
||||||
|
|
||||||
await submitEditor(input); // trusted Enter via the DevTools Protocol — this submits
|
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.
|
// "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);
|
const stuck = () => findStopSquare() || editorText(input).length === 0 || promptEchoed(task.prompt);
|
||||||
let took = await waitFor(stuck, 6000, 300);
|
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.
|
// 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 === '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 === 'error') return await requeueWithReload(task, '"Something went wrong" persisted through Try-again');
|
||||||
if (outcome !== 'asset') {
|
if (outcome !== 'asset') {
|
||||||
@ -536,8 +553,13 @@ function findTileMenuButton(tile) {
|
|||||||
let card = tile;
|
let card = tile;
|
||||||
for (let i = 0; i < 3 && card.parentElement; i++) card = card.parentElement;
|
for (let i = 0; i < 3 && card.parentElement; i++) card = card.parentElement;
|
||||||
const btns = [...card.querySelectorAll('button, [role="button"]')].filter(visible);
|
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') || '')))
|
const lbl = b => ((b.getAttribute('aria-label') || '') + (b.getAttribute('title') || '') + (b.textContent || '')).toLowerCase();
|
||||||
|| btns[btns.length - 1] || null;
|
// 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) {
|
function findMenuItem(re) {
|
||||||
@ -592,13 +614,14 @@ async function executeHarvest(task) {
|
|||||||
try {
|
try {
|
||||||
await waitForAgentReady(30000);
|
await waitForAgentReady(30000);
|
||||||
const stored = await new Promise(r => chrome.storage.local.get({ harvested_urls_v2: [] }, d => r(d.harvested_urls_v2)));
|
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 });
|
await bg({ type: 'harvestRouting', on: true });
|
||||||
window.scrollTo(0, 0); await sleep(800);
|
window.scrollTo(0, 0); await sleep(800);
|
||||||
let idle = 0;
|
let idle = 0;
|
||||||
while (idle < 3 && !harvestAbort && got + missed < 500) {
|
while (idle < 3 && !harvestAbort && got + missed < 500) {
|
||||||
const tile = [...document.querySelectorAll('a[href*="/edit/"]')]
|
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)
|
if (!tile) { // nothing new in view — page down (grid may virtualize)
|
||||||
window.scrollBy(0, innerHeight * 0.8); await sleep(1000); idle++; continue;
|
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
|
// Stage 1/4: hover the tile until the ♡ ↻ ⋮ pill renders
|
||||||
await bg({ type: 'hoverAt', ...center(tile) });
|
await bg({ type: 'hoverAt', ...center(tile) });
|
||||||
const dots = await waitFor(() => findTileMenuButton(tile), 3000, 300);
|
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
|
// Stage 2/4: click ⋮ ("More"), wait for the menu with Download in it
|
||||||
await bg({ type: 'clickAt', ...center(dots) });
|
await bg({ type: 'clickAt', ...center(dots) });
|
||||||
const dl = await waitFor(() => findMenuItem(/^\s*download\b/i), 3000, 300);
|
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
|
// 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
|
// 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) });
|
await bg({ type: 'clickAt', ...center(dl) });
|
||||||
oneK = await waitFor(findSizeOption, 1600, 250);
|
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,
|
// 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.
|
// then VERIFY a download event actually fired before counting it.
|
||||||
@ -643,10 +666,16 @@ async function executeHarvest(task) {
|
|||||||
await sleep(500);
|
await sleep(500);
|
||||||
fired = (((await bg({ type: 'harvestStats' })) || {}).count || 0) > dlBefore;
|
fired = (((await bg({ type: 'harvestStats' })) || {}).count || 0) > dlBefore;
|
||||||
}
|
}
|
||||||
if (fired) { got++; if (got % 10 === 0) log('info', `harvest: ${got} downloaded so far…`); }
|
if (fired) {
|
||||||
else { missed++; log('info', 'harvest: clicked 1K but no download event fired.'); }
|
// ONLY a confirmed download is persisted (never re-fetched). A transient miss
|
||||||
seen.add(href);
|
// stays in missedThisPass — skipped this pass, RETRIED on the next DL run, so a
|
||||||
chrome.storage.local.set({ harvested_urls_v2: [...seen].slice(-3000) });
|
// 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 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
|
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) {
|
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 {
|
try {
|
||||||
await fetch(`${settings.serverUrl}/task-completed`, {
|
await fetch(`${settings.serverUrl}/task-completed`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user