Skip to content

Commit 7eee841

Browse files
authored
Merge pull request #515 from alectimison-maker/fix/firefox-duplicate-submit-guard
fix(firefox): block rapid duplicate submits
2 parents 4834191 + 358439f commit 7eee841

7 files changed

Lines changed: 358 additions & 40 deletions

File tree

src/chrome/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,7 @@ System prompt has a new "MODALS & DIALOGS" section that describes the intended f
420420

421421
### Duplicate-submit guard (v3.6.5+)
422422

423-
Before any `click` whose resolved text matches `^(create|save|submit|add|post|publish|send|confirm|place order|pay|checkout|update|finish|done)\b` the agent checks a per-tab+URL 45-second window. Duplicate clicks in that window are blocked unless `_allowResubmit` is set. Prevents the "clicked Create three times → three products created" failure mode.
423+
Before any submit-like text `click`, the agent checks a per-tab+URL 45-second window. Duplicate clicks in that window are blocked unless `_allowResubmit` is set; an acknowledged retry re-arms the window, so a further rapid duplicate needs its own acknowledgement. The browser-free guard is byte-identical in Chrome and Firefox, preventing the "clicked Create three times → three products created" failure mode in both builds.
424424

425425
### API shortcut observer (v18.0.0)
426426

src/chrome/src/agent/agent.js

Lines changed: 11 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { LoopDetector } from './loop-detector.js';
44
import { parseToolCallsFromText } from './tool-call-parser.js';
55
import { IMAGE_BUDGET, estimateImageTokens, fitImageDimensions } from './image-budget.js';
66
import { BROWSER_MUTATION_TOOLS, STATE_CHANGE_TOOLS as SHARED_STATE_CHANGE_TOOLS } from './mutation-tools.js';
7+
import { guardRecentSubmitClick } from './submit-click-guard.js';
78
import { isCredentialField, CREDENTIAL_NOTE_STRICT, STRICT_SECRET_SYSTEM_NOTE } from './credential-fields.js';
89
import { detectProgressAction, formatLedgerRow, formatLedgerSummary, isBlockedLedgerDowngrade, isTerminalLedgerStatus, isValidLedgerStatus, ledgerDoneBlock, ledgerRowKey, normalizeLedgerStatus, progressCounts, selectLedgerRows, unresolvedLedgerRows, upsertLedgerItems } from './progress-ledger.js';
910
import { buildGithubStargazerProgressItems } from './observers/github-stargazers.js';
@@ -15824,41 +15825,16 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1582415825

1582515826
await cdpClient.attach(tabId);
1582615827

15827-
// ── Duplicate submit-click guard ────────────────────────────────
15828-
// The model often mistakes the modal-open link and the in-modal
15829-
// submit button on Stripe-style UIs (both labeled "Create product",
15830-
// "Add product", etc.) — clicking twice creates duplicate records.
15831-
// Track clicks whose text matches a submit-like pattern and block
15832-
// a second one on the same tab+URL within a short window, UNLESS
15833-
// the URL has changed (real navigation) or the model explicitly
15834-
// acknowledges the duplicate via args._allowResubmit = true.
15835-
if (args.text && !args._allowResubmit) {
15836-
const rawText = String(args.text).trim();
15837-
const submitLikeRE = /^(create|save|submit|add|post|publish|send|confirm|sign up|sign in|log in|register|place order|pay|checkout|update|apply|finish|done)\b/i;
15838-
if (submitLikeRE.test(rawText)) {
15839-
let curUrl = '';
15840-
try { const t = await chrome.tabs.get(tabId); curUrl = t?.url || ''; } catch (e) {}
15841-
const buf = this._recentSubmitClicks.get(tabId) || [];
15842-
const key = `${rawText.toLowerCase()}|${curUrl}`;
15843-
const now = Date.now();
15844-
// Keep entries from the last 45 seconds
15845-
const fresh = buf.filter(e => now - e.ts < 45000);
15846-
const match = fresh.find(e => e.key === key);
15847-
if (match) {
15848-
return {
15849-
success: false,
15850-
dispatched: false,
15851-
blockedDuplicateSubmit: true,
15852-
error: `Blocked: you already clicked "${rawText}" on this page ${Math.round((now - match.ts) / 1000)}s ago and the URL has not changed since. Stripe-style UIs often reuse the same label for the modal-OPEN button and the SUBMIT button inside the modal — a second click typically creates a duplicate record. Before clicking "${rawText}" again, verify: (a) that all required fields are actually filled by reading the form/page, (b) that this click is intended as a FIRST submit and not a retry. If the previous click did nothing because a field was empty, fill the field first. If you genuinely need to retry, pass _allowResubmit: true in the args.`,
15853-
previousClickUrl: match.url,
15854-
currentUrl: curUrl,
15855-
secondsSincePrevious: Math.round((now - match.ts) / 1000),
15856-
};
15857-
}
15858-
fresh.push({ key, ts: now, url: curUrl, text: rawText });
15859-
this._recentSubmitClicks.set(tabId, fresh);
15860-
}
15861-
}
15828+
const duplicateSubmit = await guardRecentSubmitClick(
15829+
this._recentSubmitClicks,
15830+
tabId,
15831+
args,
15832+
async () => {
15833+
const tab = await chrome.tabs.get(tabId);
15834+
return tab?.url || '';
15835+
},
15836+
);
15837+
if (duplicateSubmit) return duplicateSubmit;
1586215838

1586315839
// ── Global SELECT guard ─────────────────────────────────────────
1586415840
// Inject a capture-phase mousedown+click listener that prevents
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Browser-free duplicate-submit guard. This file is mirrored in the Firefox
2+
// tree; keep both copies byte-identical.
3+
4+
export const SUBMIT_CLICK_WINDOW_MS = 45_000;
5+
6+
const SUBMIT_LIKE_CLICK_RE = /^(create|save|submit|add|post|publish|send|confirm|sign up|sign in|log in|register|place order|pay|checkout|update|apply|finish|done)\b/i;
7+
8+
/**
9+
* Record the first submit-like text click and reject a rapid duplicate on the
10+
* same tab and URL. Returns the tool result to emit when blocked, otherwise
11+
* null. The URL callback is lazy so ordinary clicks do not perform a tab
12+
* lookup. An `_allowResubmit` retry is allowed through but re-recorded, so a
13+
* further rapid duplicate needs its own acknowledgement.
14+
*/
15+
export async function guardRecentSubmitClick(
16+
recentSubmitClicks,
17+
tabId,
18+
args,
19+
getCurrentUrl,
20+
now = Date.now,
21+
) {
22+
if (!args?.text) return null;
23+
24+
const rawText = String(args.text).trim();
25+
if (!SUBMIT_LIKE_CLICK_RE.test(rawText)) return null;
26+
27+
let currentUrl = '';
28+
try {
29+
currentUrl = await getCurrentUrl() || '';
30+
} catch { /* preserve empty-URL fallback */ }
31+
32+
const entries = recentSubmitClicks.get(tabId) || [];
33+
const key = `${rawText.toLowerCase()}|${currentUrl}`;
34+
const timestamp = now();
35+
const fresh = entries.filter(entry => timestamp - entry.ts < SUBMIT_CLICK_WINDOW_MS);
36+
const match = fresh.find(entry => entry.key === key);
37+
38+
if (match && args._allowResubmit) {
39+
match.ts = timestamp;
40+
match.url = currentUrl;
41+
match.text = rawText;
42+
recentSubmitClicks.set(tabId, fresh);
43+
return null;
44+
}
45+
46+
if (match) {
47+
return {
48+
success: false,
49+
dispatched: false,
50+
blockedDuplicateSubmit: true,
51+
error: `Blocked: you already clicked "${rawText}" on this page ${Math.round((timestamp - match.ts) / 1000)}s ago and the URL has not changed since. Stripe-style UIs often reuse the same label for the modal-OPEN button and the SUBMIT button inside the modal — a second click typically creates a duplicate record. Before clicking "${rawText}" again, verify: (a) that all required fields are actually filled by reading the form/page, (b) that this click is intended as a FIRST submit and not a retry. If the previous click did nothing because a field was empty, fill the field first. If you genuinely need to retry, pass _allowResubmit: true in the args.`,
52+
previousClickUrl: match.url,
53+
currentUrl,
54+
secondsSincePrevious: Math.round((timestamp - match.ts) / 1000),
55+
};
56+
}
57+
58+
fresh.push({ key, ts: timestamp, url: currentUrl, text: rawText });
59+
recentSubmitClicks.set(tabId, fresh);
60+
return null;
61+
}

src/firefox/ARCHITECTURE.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ Firefox uses Manifest V2 (background page, not service worker) and has **no acce
1010
- **No pixel-perfect / full-page screenshots** — uses `browser.tabs.captureVisibleTab()` instead of CDP `Page.captureScreenshot`.
1111
- **No shadow DOM piercing** — content script can read open shadow roots via `element.shadowRoot`, but cannot pierce closed roots.
1212
- **No offscreen document** — no HTTP fetch proxy for localhost LLM servers with Private Network Access / CORS issues. User must ensure their local LLM server sends permissive CORS headers.
13-
- **No duplicate-submit guard** — the per-tab submit-throttle (Chrome v3.6.5+) is still Chrome-only. Firefox's agent loop does not block rapid duplicate Create/Submit clicks. `blockedDone` and the ambiguous-click candidate payload were ported to Firefox in v4.0.1 (see "Overlay defenses" below).
1413
- **Some Chrome-only tools/features remain absent** — no CDP full-page screenshot, CDP upload automation, tab recording, offscreen fetch proxy, Chrome-only `shadow_dom_query`, or closed-shadow-root traversal.
1514

1615
Everything else — the agent loop, LLM providers, site adapters, Ask/Act/Dev mode routing, Plan before Act, loop detection, API shortcut observer, trace recorder, scheduler, context management — is architecturally identical to Chrome unless noted below.
@@ -173,10 +172,9 @@ Firefox's AX tools use synthetic events only — there is no trusted-event path.
173172

174173
### What was intentionally skipped in the Firefox port
175174

176-
These Chrome v3.6.x features depend on CDP or agent-level state and were not ported — they can be re-evaluated later:
175+
These Chrome v3.6.x features depend on CDP and were not ported — they can be re-evaluated later:
177176

178177
- **CDP-enriched `click_ax` frontmost resolution** — when `click_ax` lands on a node that overlaps many candidates, Chrome re-queries via CDP to pick the frontmost hit. Firefox relies on the initial ref_id resolution plus the v4.0.1 occlusion hit-test (see below).
179-
- **Duplicate-submit guard** — Chrome's agent.js tracks recent submit tool calls and blocks duplicates within a short window. Not in Firefox's agent loop.
180178
- **Offscreen fetch fallback** — Chrome falls through to an offscreen-document proxy when direct fetch fails (common for localhost LLM servers and private-network destinations). Firefox has no offscreen API; local servers must handle CORS themselves.
181179

182180
### Overlay defenses (v4.0.1+)
@@ -191,6 +189,16 @@ Brought to Chrome parity in v4.0.1 — same three layers, synthetic-event-safe s
191189

192190
System prompt has a new "MODALS & DIALOGS" section describing the intended flow and the "dialog still open" failure pattern.
193191

192+
### Duplicate-submit guard
193+
194+
Submit-like text clicks use the same browser-free guard as Chrome. The first
195+
click is recorded by tab, normalized label, and current URL; another matching
196+
click within 45 seconds is blocked unless `_allowResubmit` explicitly
197+
acknowledges the retry. An acknowledged retry re-arms the window, so a further
198+
rapid duplicate needs its own acknowledgement. Navigation, expired entries,
199+
ordinary labels, and validation-rejected submits remain eligible for a fresh
200+
click.
201+
194202
---
195203

196204
## Skills and Dynamic Skill Tools
@@ -547,7 +555,6 @@ when the tab conversation already has `/allow-api`.
547555
| No full-page screenshot | Only visible viewport | Scroll + multiple captures |
548556
| No shadow-root piercing (closed) | Can't read closed shadow roots | Dev-mode `execute_js` with manual traversal |
549557
| No arbitrary-path/CDP upload | Cannot attach an arbitrary local path silently | Use a prior `downloadId` re-fetch or WebBrain's user file picker |
550-
| No duplicate-submit guard | Agent may submit twice if LLM loops | Rely on site-level idempotence / user watches |
551558
| No ambiguous-click CDP enrichment | Overlapping hit-target ambiguity resolved by ref_id only | Prompting / adapter guidance |
552559
| MV2 background page | Less efficient than MV3 service worker | `persistent: false` helps |
553560

src/firefox/src/agent/agent.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { LoopDetector } from './loop-detector.js';
44
import { parseToolCallsFromText } from './tool-call-parser.js';
55
import { IMAGE_BUDGET, estimateImageTokens, fitImageDimensions } from './image-budget.js';
66
import { BROWSER_MUTATION_TOOLS, STATE_CHANGE_TOOLS as SHARED_STATE_CHANGE_TOOLS } from './mutation-tools.js';
7+
import { guardRecentSubmitClick } from './submit-click-guard.js';
78
import { isCredentialField, CREDENTIAL_NOTE_STRICT, STRICT_SECRET_SYSTEM_NOTE } from './credential-fields.js';
89
import { detectProgressAction, formatLedgerRow, formatLedgerSummary, isBlockedLedgerDowngrade, isTerminalLedgerStatus, isValidLedgerStatus, ledgerDoneBlock, ledgerRowKey, normalizeLedgerStatus, progressCounts, selectLedgerRows, unresolvedLedgerRows, upsertLedgerItems } from './progress-ledger.js';
910
import { buildGithubStargazerProgressItems } from './observers/github-stargazers.js';
@@ -13820,6 +13821,19 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1382013821
}
1382113822
} catch { /* tab lookup failures are non-fatal — fall through */ }
1382213823

13824+
if (name === 'click') {
13825+
const duplicateSubmit = await guardRecentSubmitClick(
13826+
this._recentSubmitClicks,
13827+
tabId,
13828+
args,
13829+
async () => {
13830+
const tab = await browser.tabs.get(tabId);
13831+
return tab?.url || '';
13832+
},
13833+
);
13834+
if (duplicateSubmit) return duplicateSubmit;
13835+
}
13836+
1382313837
const axScope = this._lastAxScopes.get(tabId);
1382413838
const contentArgs = (name === 'click_ax' || name === 'set_checked') && axScope?.documentToken
1382513839
? {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Browser-free duplicate-submit guard. This file is mirrored in the Firefox
2+
// tree; keep both copies byte-identical.
3+
4+
export const SUBMIT_CLICK_WINDOW_MS = 45_000;
5+
6+
const SUBMIT_LIKE_CLICK_RE = /^(create|save|submit|add|post|publish|send|confirm|sign up|sign in|log in|register|place order|pay|checkout|update|apply|finish|done)\b/i;
7+
8+
/**
9+
* Record the first submit-like text click and reject a rapid duplicate on the
10+
* same tab and URL. Returns the tool result to emit when blocked, otherwise
11+
* null. The URL callback is lazy so ordinary clicks do not perform a tab
12+
* lookup. An `_allowResubmit` retry is allowed through but re-recorded, so a
13+
* further rapid duplicate needs its own acknowledgement.
14+
*/
15+
export async function guardRecentSubmitClick(
16+
recentSubmitClicks,
17+
tabId,
18+
args,
19+
getCurrentUrl,
20+
now = Date.now,
21+
) {
22+
if (!args?.text) return null;
23+
24+
const rawText = String(args.text).trim();
25+
if (!SUBMIT_LIKE_CLICK_RE.test(rawText)) return null;
26+
27+
let currentUrl = '';
28+
try {
29+
currentUrl = await getCurrentUrl() || '';
30+
} catch { /* preserve empty-URL fallback */ }
31+
32+
const entries = recentSubmitClicks.get(tabId) || [];
33+
const key = `${rawText.toLowerCase()}|${currentUrl}`;
34+
const timestamp = now();
35+
const fresh = entries.filter(entry => timestamp - entry.ts < SUBMIT_CLICK_WINDOW_MS);
36+
const match = fresh.find(entry => entry.key === key);
37+
38+
if (match && args._allowResubmit) {
39+
match.ts = timestamp;
40+
match.url = currentUrl;
41+
match.text = rawText;
42+
recentSubmitClicks.set(tabId, fresh);
43+
return null;
44+
}
45+
46+
if (match) {
47+
return {
48+
success: false,
49+
dispatched: false,
50+
blockedDuplicateSubmit: true,
51+
error: `Blocked: you already clicked "${rawText}" on this page ${Math.round((timestamp - match.ts) / 1000)}s ago and the URL has not changed since. Stripe-style UIs often reuse the same label for the modal-OPEN button and the SUBMIT button inside the modal — a second click typically creates a duplicate record. Before clicking "${rawText}" again, verify: (a) that all required fields are actually filled by reading the form/page, (b) that this click is intended as a FIRST submit and not a retry. If the previous click did nothing because a field was empty, fill the field first. If you genuinely need to retry, pass _allowResubmit: true in the args.`,
52+
previousClickUrl: match.url,
53+
currentUrl,
54+
secondsSincePrevious: Math.round((timestamp - match.ts) / 1000),
55+
};
56+
}
57+
58+
fresh.push({ key, ts: timestamp, url: currentUrl, text: rawText });
59+
recentSubmitClicks.set(tabId, fresh);
60+
return null;
61+
}

0 commit comments

Comments
 (0)