diff --git a/ci/test.mjs b/ci/test.mjs index 0baf64863..c4c61f3b3 100644 --- a/ci/test.mjs +++ b/ci/test.mjs @@ -50,8 +50,8 @@ assert.equal(googleFormsScenario.verify.scheduledJobs.definitions[1].afterSecond assert.equal(googleFormsScenario.session_settings.scheduledRequireConsequentialConfirmation, false); assert.match(googleFormsScenario.task, /after_seconds=0/); assert.match(googleFormsScenario.task, /after_seconds=60/); -assert.match(smokeWorkflow, /- "src\/chrome\/src\/background\.js"/); -assert.match(smokeWorkflow, /- "src\/chrome\/src\/offscreen\/cloud-bridge\.js"/); +assert.match(smokeWorkflow, /^\s{2}workflow_dispatch:\s*$/m); +assert.doesNotMatch(smokeWorkflow, /^\s{2}(?:push|schedule):\s*$/m); // A job killed by its own timeout skips every cleanup path and leaks the cloud // browser session it was driving, so each workflow's timeout must cover the // serial budget of the widest pack it can run. Deriving that here means adding diff --git a/package-lock.json b/package-lock.json index 95f4898fe..87764e12c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "webbrain", - "version": "30.0.3", + "version": "30.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "webbrain", - "version": "30.0.3", + "version": "30.0.4", "license": "MIT", "devDependencies": { "playwright": "^1.48.0", diff --git a/package.json b/package.json index 90c96245f..9c87b18f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "webbrain", - "version": "30.0.3", + "version": "30.0.4", "description": "Open-source AI browser agent — chat with pages, automate tasks, multi-provider LLM support.", "private": true, "type": "module", diff --git a/src/chrome/ARCHITECTURE.md b/src/chrome/ARCHITECTURE.md index 8d2dd385a..e08586149 100644 --- a/src/chrome/ARCHITECTURE.md +++ b/src/chrome/ARCHITECTURE.md @@ -1,6 +1,6 @@ # WebBrain Chrome/Edge Extension — Architecture -> Version 30.0.3 · Manifest V3 · Service Worker background +> Version 30.0.4 · Manifest V3 · Service Worker background ## High-Level Overview diff --git a/src/chrome/manifest.json b/src/chrome/manifest.json index 8293addea..05fab9395 100644 --- a/src/chrome/manifest.json +++ b/src/chrome/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "WebBrain", - "version": "30.0.3", + "version": "30.0.4", "description": "Open-source AI browser agent — chat with pages, automate tasks, multi-provider LLM support.", "permissions": [ "sidePanel", diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 834c44e80..1565e0f87 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -251,7 +251,7 @@ function captchaCandidateMatchesGate(candidate, identity) { const candidatePath = captchaCandidateFramePath(candidate); if ( expectedPath.length !== candidatePath.length - || expectedPath.some((value, index) => value !== candidatePath[index]) + || expectedPath.some((value, index) => !Object.is(value, candidatePath[index])) ) return false; const expectedFieldIds = [identity.responseFieldId, identity.alsoResponseFieldId] @@ -2430,6 +2430,13 @@ export class Agent extends LoopDetector { if (!RICH_TEXT_TOOLBAR_GUARDED_TOOLS.has(toolName)) return null; const probe = await this._probeRichTextToolbarRetryTarget(tabId, toolName, args); if (!probe?.resolved) { + const knownRefBlock = this._richTextToolbarGuard.blockRef( + tabId, + toolName, + args, + this._lastAxScopes.get(tabId)?.documentToken || '', + ); + if (knownRefBlock) return knownRefBlock; return DISPATCH_BINDING_TOOLS.has(toolName) ? { success: false, @@ -20000,7 +20007,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (name === 'iframe_click') { let dispatched = false; try { - const urlFilter = args.urlFilter || ''; + const urlFilter = String(args.urlFilter || '').trim(); const selector = args.selector; if (!selector) { return { @@ -20010,6 +20017,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d error: 'selector is required', }; } + if (!urlFilter) { + return { + success: false, + dispatched: false, + noDispatch: true, + error: 'urlFilter is required so the iframe target and permission scope remain stable', + }; + } const hasExplicitMatchIndex = args.matchIndex !== undefined && args.matchIndex !== null; const requestedMatchIndex = hasExplicitMatchIndex ? Number(args.matchIndex) : 0; if (!Number.isInteger(requestedMatchIndex) || requestedMatchIndex < 0) { @@ -20143,6 +20158,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d let dispatched = false; try { const selector = args.selector; + const urlFilter = String(args.urlFilter || '').trim(); const text = args.text || ''; const clear = !!args.clear; if (!selector) { @@ -20153,6 +20169,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d error: 'selector is required', }; } + if (!urlFilter) { + return { + success: false, + dispatched: false, + noDispatch: true, + error: 'urlFilter is required so the iframe target and its verification scope remain stable', + }; + } let binding = dispatchBinding; let targetFrameId = binding?.frameId; // The preflight sweeps every frame; when it already reported that no @@ -20184,7 +20208,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d selector, text, clear, - urlFilter: args.urlFilter || '', + urlFilter, matchIndex: args.matchIndex, }); } diff --git a/src/chrome/src/agent/completion-invariant.js b/src/chrome/src/agent/completion-invariant.js index e19dfac18..fd4634341 100644 --- a/src/chrome/src/agent/completion-invariant.js +++ b/src/chrome/src/agent/completion-invariant.js @@ -93,14 +93,17 @@ function normalizedIframeMatchIndex(value) { function iframeFormObligation(args = {}, result = {}) { const selector = String(args?.selector || '').trim(); + const scope = normalizedIframeScope(args?.urlFilter); const finalValue = typeof result?.value === 'string' ? result.value : (typeof result?.frame?.value === 'string' ? result.frame.value : null); - if (!selector) return null; + // verify_form addresses iframe targets by urlFilter. Never create debt that + // the verification tool has no stable scope with which to discharge it. + if (!selector || !scope) return null; const matchMode = finalValue !== null || args?.clear === true ? 'exact' : 'suffix'; const rawExpectedValue = String(finalValue ?? args?.text ?? ''); return { - scope: normalizedIframeScope(args?.urlFilter), + scope, frameId: Number.isInteger(result?.frameId) ? result.frameId : null, selector, matchIndex: normalizedIframeMatchIndex(result?.matchIndex ?? args?.matchIndex), diff --git a/src/chrome/src/agent/loop-detector.js b/src/chrome/src/agent/loop-detector.js index 546662856..0fdb56b6c 100644 --- a/src/chrome/src/agent/loop-detector.js +++ b/src/chrome/src/agent/loop-detector.js @@ -313,7 +313,6 @@ export class LoopDetector { refId: typeof value?.ref_id === 'string' ? value.ref_id.trim() : '', }); const page = Number(args?.page || 1); - const hasRef = typeof args?.ref_id === 'string' && args.ref_id.trim() !== ''; const currentScopeKey = scopeKeyFor(args); const currentPageKey = `${currentScopeKey}|${page}`; const sequentialPage = previous.total > 0 @@ -321,10 +320,14 @@ export class LoopDetector { && page === previous.nextPage && currentScopeKey === previous.scopeKey && !previous.seenPages.has(currentPageKey); - const repeatedRootOrPage = previous.total > 0 && !sequentialPage; - const content = String(result?.pageContent || '').trim(); - const meaningfulLines = content ? content.split(/\r?\n/).filter(line => line.trim()).length : 0; - const suspicious = !sequentialPage && (hasRef || repeatedRootOrPage || (hasRef && meaningfulLines <= 1)); + const repeatedScopeOutOfSequence = previous.total > 0 + && currentScopeKey === previous.scopeKey + && !sequentialPage; + const repeatedExactPage = previous.seenPages.has(currentPageKey); + // A first read of a new ref-anchored subtree is legitimate drill-down + // progress. Only repeated/out-of-order reads of the same scope are loop + // evidence; the total-read cap below still bounds endless ref hopping. + const suspicious = !sequentialPage && (repeatedExactPage || repeatedScopeOutOfSequence); const state = { total: previous.total + 1, diff --git a/src/chrome/src/agent/mutation-tools.js b/src/chrome/src/agent/mutation-tools.js index 85e9d4d4c..89442c071 100644 --- a/src/chrome/src/agent/mutation-tools.js +++ b/src/chrome/src/agent/mutation-tools.js @@ -10,16 +10,15 @@ /** Tools that change page or browser state, gating auto-screenshots and * unknown-outcome normalization as well as loop detection. */ -export const STATE_CHANGE_TOOLS = new Set(['navigate', 'promote_iframe', 'new_tab', 'go_back', 'go_forward', 'click', 'click_ax', 'set_checked', 'type_text', 'type_ax', 'set_field', 'iframe_type', 'press_keys', 'scroll', 'hover', 'drag_drop', 'inject_css', 'remove_injected_css', 'patch_element', 'revert_patch', 'execute_js', 'inspect_event_listeners', 'highlight_element', 'execute_webmcp_tool']); +export const STATE_CHANGE_TOOLS = new Set(['navigate', 'promote_iframe', 'new_tab', 'go_back', 'go_forward', 'click', 'click_ax', 'set_checked', 'iframe_click', 'type_text', 'type_ax', 'set_field', 'iframe_type', 'press_keys', 'scroll', 'hover', 'drag_drop', 'inject_css', 'remove_injected_css', 'patch_element', 'revert_patch', 'execute_js', 'inspect_event_listeners', 'highlight_element', 'execute_webmcp_tool']); /** * Everything the failed-action loop counters treat as a browser mutation. - * Adds the remaining frame- and upload-scoped tools that act on the page but are not + * Adds the remaining upload- and challenge-scoped tools that act on the page but are not * part of the auto-screenshot state-change set. */ export const BROWSER_MUTATION_TOOLS = new Set([ ...STATE_CHANGE_TOOLS, - 'iframe_click', 'upload_file', 'solve_captcha', ]); diff --git a/src/chrome/src/agent/permission-gate.js b/src/chrome/src/agent/permission-gate.js index c1c81d94d..732becef2 100644 --- a/src/chrome/src/agent/permission-gate.js +++ b/src/chrome/src/agent/permission-gate.js @@ -104,6 +104,8 @@ export const UNTRUSTED_CONTENT_TOOLS = new Set([ 'download_resource_from_page', 'download_files', 'download_file', + // Upload results can echo page-controlled input labels and accept metadata. + 'upload_file', // hover returns the element's accessible name (aria-label/title/innerText). 'hover', // list_downloads returns each download's url + filename; the filename can diff --git a/src/chrome/src/agent/planner.js b/src/chrome/src/agent/planner.js index a4a576bfa..59546c9ee 100644 --- a/src/chrome/src/agent/planner.js +++ b/src/chrome/src/agent/planner.js @@ -527,9 +527,13 @@ export function normalizePlan(obj, opts = {}) { const normalizedScheduling = tool === 'schedule_task' || tool === 'schedule_resume' ? { tool, hint: sanitizeText(scheduling.hint, 300) } : null; - const risks = Array.isArray(obj.risks) - ? obj.risks.map((risk) => sanitizeText(risk, 200)).filter(Boolean).slice(0, 6) + const riskEntries = Array.isArray(obj.risks) + ? obj.risks + .map((risk, sourceIndex) => ({ sourceIndex, text: sanitizeText(risk, 200) })) + .filter(entry => entry.text) + .slice(0, 6) : []; + const risks = riskEntries.map(entry => entry.text); const localizedInput = obj.localized && typeof obj.localized === 'object' ? obj.localized : {}; const providedLocalizedSteps = Array.isArray(localizedInput.steps) ? localizedInput.steps.slice(0, 12).map((step, i) => ({ @@ -545,7 +549,7 @@ export function normalizePlan(obj, opts = {}) { || step.action, })); const providedLocalizedRisks = Array.isArray(localizedInput.risks) - ? localizedInput.risks.slice(0, 6).map((risk) => sanitizeText(risk, 200)) + ? localizedInput.risks : []; const requestedLocale = normalizePlannerLocale(opts.locale || localizedInput.locale); if (opts.requireIntent) { @@ -556,7 +560,9 @@ export function normalizePlan(obj, opts = {}) { locale: requestedLocale, summary: localizedSummary || summary, steps: localizedSteps, - risks: risks.map((risk, index) => providedLocalizedRisks[index] || risk), + risks: riskEntries.map(({ sourceIndex, text: risk }) => ( + sanitizeText(providedLocalizedRisks[sourceIndex], 200) || risk + )), }; const submissionBearingPlan = executablePlan || requestKind === 'clarify'; const requiresSubmission = submissionBearingPlan diff --git a/src/chrome/src/agent/rich-text-toolbar-probe.js b/src/chrome/src/agent/rich-text-toolbar-probe.js index d11f70205..86a1c8862 100644 --- a/src/chrome/src/agent/rich-text-toolbar-probe.js +++ b/src/chrome/src/agent/rich-text-toolbar-probe.js @@ -20,6 +20,15 @@ function withDispatchBinding(probe, frameId = probe?.frameId) { }; } +function messageOrigin(url) { + try { + const origin = new URL(String(url || '')).origin; + return origin && origin !== 'null' ? origin : ''; + } catch { + return ''; + } +} + export class RichTextToolbarProbe { constructor(agent) { this.agent = agent; @@ -70,19 +79,23 @@ export class RichTextToolbarProbe { child = parent; } if (!child || child.frameId !== 0) return null; + const opaqueFrameIds = new Set(); const exactChildRect = async edge => { const token = `wb-frame-${Date.now()}-${secureRandomBase36Token(12)}`; + const parentOriginOpaque = opaqueFrameIds.has(edge.parent.frameId); + const parentOrigin = parentOriginOpaque ? '' : messageOrigin(edge.parent.url); + const expectedChildOrigin = messageOrigin(edge.child.url); const parentResponse = chrome.tabs.sendMessage(tabId, { target: 'redaction-content', action: 'wait_for_exact_child_frame_rect', - params: { token, scrollIntoView: true }, + params: { token, expectedChildOrigin, allowOpaqueChildOrigin: parentOriginOpaque, scrollIntoView: true }, }, { frameId: edge.parent.frameId }).catch(() => null); await new Promise(resolve => setTimeout(resolve, 0)); try { await chrome.tabs.sendMessage(tabId, { target: 'redaction-content', action: 'announce_exact_child_frame', - params: { token }, + params: { token, parentOrigin }, }, { frameId: edge.child.frameId }); } catch {} return parentResponse; @@ -92,6 +105,7 @@ export class RichTextToolbarProbe { let frameOwnerMeta = null; for (const edge of edges) { const exact = await exactChildRect(edge); + if (exact?.childOriginOpaque === true) opaqueFrameIds.add(edge.child.frameId); const parentTransform = transforms.get(edge.parent.frameId); const childSnapshot = snapshotById.get(edge.child.frameId); const childWidth = Number(childSnapshot?.viewport?.width); @@ -160,8 +174,8 @@ export class RichTextToolbarProbe { } async legacyIframeTypeAllFrames(tabId, { selector, text, clear, urlFilter, matchIndex: requestedMatchIndex }) { - const matchIndex = Number.isInteger(Number(requestedMatchIndex)) && Number(requestedMatchIndex) >= 0 - ? Number(requestedMatchIndex) + const matchIndex = Number.isInteger(requestedMatchIndex) && requestedMatchIndex >= 0 + ? requestedMatchIndex : null; const counted = await chrome.scripting.executeScript({ target: { tabId, allFrames: true }, @@ -292,7 +306,7 @@ export class RichTextToolbarProbe { args: { selector, text: args?.text || '', matchIndex: args?.matchIndex }, })))).filter(Boolean); if (!probes.length) return null; - const explicitMatchIndex = Number.isInteger(Number(args?.matchIndex)) && Number(args.matchIndex) >= 0; + const explicitMatchIndex = Number.isInteger(args?.matchIndex) && args.matchIndex >= 0; const matchedElementCount = probes.reduce((sum, probe) => ( sum + (explicitMatchIndex ? 1 : Math.max(1, Number(probe.selectorMatchCount) || 1)) ), 0); diff --git a/src/chrome/src/agent/tool-call-parser.js b/src/chrome/src/agent/tool-call-parser.js index 806cb04c3..f9080ec16 100644 --- a/src/chrome/src/agent/tool-call-parser.js +++ b/src/chrome/src/agent/tool-call-parser.js @@ -117,6 +117,54 @@ function parseWholeResponseJsonArray(text, allowedNames) { return parsed; } +/** + * Quote relaxed `key:` tokens only when they occur outside JSON strings and + * after an object boundary. A regular-expression replacement corrupts string + * values such as "Keep, status: pending" before JSON.parse sees them. + */ +function quoteBareJsonKeys(body) { + const source = String(body || ''); + let output = ''; + let inString = false; + let escaped = false; + + for (let i = 0; i < source.length;) { + const char = source[i]; + if (inString) { + output += char; + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '"') inString = false; + i++; + continue; + } + if (char === '"') { + inString = true; + output += char; + i++; + continue; + } + if (/\w/.test(char)) { + let previous = i - 1; + while (previous >= 0 && /\s/.test(source[previous])) previous--; + if (previous < 0 || source[previous] === '{' || source[previous] === ',') { + let keyEnd = i + 1; + while (keyEnd < source.length && /\w/.test(source[keyEnd])) keyEnd++; + let colon = keyEnd; + while (colon < source.length && /\s/.test(source[colon])) colon++; + if (source[colon] === ':') { + output += `"${source.slice(i, keyEnd)}"${source.slice(keyEnd, colon + 1)}`; + i = colon + 1; + continue; + } + } + } + output += char; + i++; + } + return output; +} + function toFallbackToolCalls(objects) { return objects.map((obj, index) => ({ id: `fallback_call_${Date.now()}_${index}`, @@ -166,6 +214,11 @@ export function parseToolCallsFromText(text, allowedNames) { let match; while ((match = re.exec(text)) !== null) { const inner = match[1].trim(); + const wrappedArray = parseWholeResponseJsonArray(inner, allowedNames); + if (wrappedArray !== null) { + results.push(...wrappedArray); + continue; + } try { const obj = JSON.parse(inner); if (obj && obj.name && allowedNames.has(obj.name)) { @@ -180,13 +233,11 @@ export function parseToolCallsFromText(text, allowedNames) { let argsBody = callMatch[2] .replace(/<\|"\|>/g, '"') .replace(/<\|'\\?\|>/g, "'"); - argsBody = argsBody.replace(/(?<=^|,)\s*(\w+)\s*:/g, '"$1":'); + argsBody = quoteBareJsonKeys(argsBody); try { const args = JSON.parse(`{${argsBody}}`); results.push({ name: toolName, arguments: args }); - } catch { - results.push({ name: toolName, arguments: {} }); - } + } catch { /* malformed arguments must never dispatch */ } } } } @@ -231,13 +282,11 @@ export function parseToolCallsFromText(text, allowedNames) { let argsBody = match[2] .replace(/<\|"\|>/g, '"') .replace(/<\|'\\?\|>/g, "'"); - argsBody = argsBody.replace(/(?<=^|,)\s*(\w+)\s*:/g, '"$1":'); + argsBody = quoteBareJsonKeys(argsBody); try { const args = JSON.parse(`{${argsBody}}`); results.push({ name: toolName, arguments: args }); - } catch { - results.push({ name: toolName, arguments: {} }); - } + } catch { /* malformed arguments must never dispatch */ } } } diff --git a/src/chrome/src/agent/tools.js b/src/chrome/src/agent/tools.js index 3166295ed..aac5c8804 100644 --- a/src/chrome/src/agent/tools.js +++ b/src/chrome/src/agent/tools.js @@ -839,7 +839,7 @@ export const AGENT_TOOLS = [ selector: { type: 'string', description: 'CSS selector for the element to click inside the iframe.' }, matchIndex: { type: 'number', description: 'Zero-based element index from iframe_read. Omit only when the selector uniquely matches one element across the selected frames.' }, }, - required: ['selector'], + required: ['urlFilter', 'selector'], }, }, }, @@ -857,7 +857,7 @@ export const AGENT_TOOLS = [ text: { type: 'string', description: 'Text to type into the field.' }, clear: { type: 'boolean', description: 'Whether to clear the field before typing. Default false.' }, }, - required: ['selector', 'text'], + required: ['urlFilter', 'selector', 'text'], }, }, }, diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js index 3f8c89523..396785a85 100644 --- a/src/chrome/src/background.js +++ b/src/chrome/src/background.js @@ -605,12 +605,12 @@ function publicTeacherSession(session) { async function notifyTeacherState(tabId, session) { if (tabId == null) return false; try { - await chrome.tabs.sendMessage(tabId, { + const response = await chrome.tabs.sendMessage(tabId, { target: 'content', action: 'teacher_state', state: publicTeacherSession(session), }); - return true; + return response?.teacherCaptureReady === true; } catch { return false; } diff --git a/src/chrome/src/content/content.js b/src/chrome/src/content/content.js index df46aa04f..2938544ed 100644 --- a/src/chrome/src/content/content.js +++ b/src/chrome/src/content/content.js @@ -1530,6 +1530,13 @@ el = document.elementFromPoint(params.x, params.y); } + // A selector miss is a discovery failure, not a changed bound target. Do + // not consume the one-shot toolbar binding when no element was resolved; + // the caller's advised re-read/retry must still be able to bind a target. + if (params.selector && !el) { + return { success: false, dispatched: false, error: 'Element not found' }; + } + if (!_consumeDispatchBinding(params.dispatchBinding?.token, el)) { return { success: false, diff --git a/src/chrome/src/content/redaction-regions.js b/src/chrome/src/content/redaction-regions.js index 91b42a463..721642a6f 100644 --- a/src/chrome/src/content/redaction-regions.js +++ b/src/chrome/src/content/redaction-regions.js @@ -152,6 +152,7 @@ function waitForExactChildFrameRect(params) { const token = String(params?.token || ''); + const expectedChildOrigin = String(params?.expectedChildOrigin || ''); if (!token) return Promise.resolve({ found: false }); return new Promise(resolve => { // A single claim is answered after this quiet window so a second frame @@ -193,11 +194,24 @@ } return frames; }; + const hasOpaqueSandboxOrigin = frame => { + const sandboxValue = frame?.getAttribute?.('sandbox'); + return sandboxValue != null + && !String(sandboxValue).toLowerCase().split(/\s+/).includes('allow-same-origin'); + }; const onMessage = event => { if (event?.data?.__webbrainExactFrameRectToken !== token) return; const frame = reachableFrames() .find(candidate => candidate.contentWindow === event.source); if (!frame) return; + // The token is necessarily transported across the page/content-script + // boundary. Bind it to the exact child window and its origin. Sandboxed + // frames without allow-same-origin (and their descendants) have an + // opaque "null" origin, so accept that value only from the matching + // frame after the background has verified the sandbox chain. + const opaqueSandboxClaim = event.origin === 'null' + && (params?.allowOpaqueChildOrigin === true || hasOpaqueSandboxOrigin(frame)); + if (expectedChildOrigin && event.origin !== expectedChildOrigin && !opaqueSandboxClaim) return; // The agent announces this token to exactly one child frame, so a // second distinct claimant is a frame answering for a token that was // never sent to it. Resolving the first arrival would let it decide @@ -212,7 +226,9 @@ contentionTimer = setTimeout(() => resolveClaim(), CLAIM_CONTENTION_MS); return; } - resolveClaim(); + // Duplicate delivery from the same frame is not independent evidence. + // Keep the full contention window open so a different claimant still + // has a chance to make this lookup fail closed. }; const resolveClaim = () => { const frame = claimedFrame; @@ -250,6 +266,7 @@ name: frame.getAttribute?.('name') || null, role: frame.getAttribute?.('role') || null, }, + childOriginOpaque: params?.allowOpaqueChildOrigin === true || hasOpaqueSandboxOrigin(frame), }); }; window.addEventListener('message', onMessage); @@ -259,9 +276,13 @@ function announceExactChildFrame(params) { const token = String(params?.token || ''); + const parentOrigin = String(params?.parentOrigin || ''); if (!token || window.parent === window) return { announced: false }; try { - window.parent.postMessage({ __webbrainExactFrameRectToken: token }, '*'); + window.parent.postMessage( + { __webbrainExactFrameRectToken: token }, + parentOrigin || '*', + ); return { announced: true }; } catch { return { announced: false }; } } diff --git a/src/chrome/src/content/teacher-capture.js b/src/chrome/src/content/teacher-capture.js index 387b0c443..aab6bd520 100644 --- a/src/chrome/src/content/teacher-capture.js +++ b/src/chrome/src/content/teacher-capture.js @@ -238,7 +238,10 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (message?.target !== 'content') return; - if (message.action === 'teacher_state') setState(message.state); + if (message.action === 'teacher_state') { + setState(message.state); + sendResponse({ teacherCaptureReady: true }); + } if (message.action === 'flush_teacher_capture') { sendResponse({ teacherAction: active ? activeFieldAction() : null }); } diff --git a/src/chrome/src/providers/fetch-with-fallback.js b/src/chrome/src/providers/fetch-with-fallback.js index 1481591d8..5aafac292 100644 --- a/src/chrome/src/providers/fetch-with-fallback.js +++ b/src/chrome/src/providers/fetch-with-fallback.js @@ -35,6 +35,7 @@ let _storageListener = null; const TIMEOUT_FLOOR_MS = 5000; // 5s — anything lower than this is a typo const TIMEOUT_CEILING_MS = 600000; // 10 min — well past any reasonable first-byte wait const OFFSCREEN_FORM_DATA_CHUNK_BYTES = 256 * 1024; +const IDEMPOTENT_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE']); async function _ensureTimeoutInitialized() { if (_timeoutInitialized) return; @@ -82,6 +83,19 @@ async function _ensureTimeoutInitialized() { export async function fetchWithFallback(url, options = {}) { await _ensureTimeoutInitialized(); const { timeoutMs = _cachedTimeoutMs, signal: callerSignal, ...fetchOptions } = options; + const method = String(fetchOptions.method || 'GET').toUpperCase(); + const canUseOffscreenProxy = typeof chrome !== 'undefined' + && typeof chrome?.runtime?.connect === 'function'; + + // A network-level fetch rejection does not prove that the server never + // received the request (CORS/PNA can reject after it was processed). Sending + // a POST/PATCH again through the proxy can therefore duplicate a billed + // generation or upload. Route non-idempotent requests through exactly one + // transport from the outset; only idempotent methods use direct-then-proxy. + if (!IDEMPOTENT_HTTP_METHODS.has(method) && canUseOffscreenProxy) { + await ensureOffscreen(); + return _fetchViaOffscreenProxy(url, fetchOptions, timeoutMs, callerSignal); + } // Fast path: try direct fetch first, with a connection-phase timeout. const controller = new AbortController(); @@ -119,6 +133,7 @@ export async function fetchWithFallback(url, options = {}) { ); } if (directError.name === 'AbortError') throw directError; + if (!IDEMPOTENT_HTTP_METHODS.has(method)) throw directError; // Network error (Failed to fetch) — try offscreen proxy console.warn( diff --git a/src/chrome/src/providers/manager.js b/src/chrome/src/providers/manager.js index b9874529d..71905b319 100644 --- a/src/chrome/src/providers/manager.js +++ b/src/chrome/src/providers/manager.js @@ -14,7 +14,6 @@ import { ADDITIONAL_PROVIDER_DEFAULTS } from './provider-catalog.js'; // fetch ever ran, so onboarding reported "no models" for a reachable server.) import { fetchWithFallback } from './fetch-with-fallback.js'; import { - AUTO_VISION_PROVIDER_IDS, VISION_MODES, parseLlamaCppVisionSupport, parseLmStudioVisionSupport, @@ -644,7 +643,9 @@ export class ProviderManager { ...migrated.ollama, visionMode: OLLAMA_VISION_MODES.has(migrated.ollama.visionMode) ? migrated.ollama.visionMode - : (legacySupportsVision === false ? 'off' : 'auto'), + : (legacySupportsVision === true + ? 'on' + : (legacySupportsVision === false ? 'off' : 'auto')), }; delete migrated.ollama.supportsVision; } @@ -652,14 +653,13 @@ export class ProviderManager { if (!visionProviderKind(id, config)) continue; const legacySupportsVision = config.supportsVision; const hasConfiguredModel = !!String(config.model || '').trim(); - const isBuiltInAutoProvider = AUTO_VISION_PROVIDER_IDS.has(id); migrated[id] = { ...config, visionMode: VISION_MODES.has(config.visionMode) ? config.visionMode : (legacySupportsVision === false ? 'off' - : (legacySupportsVision === true && !isBuiltInAutoProvider ? 'on' : 'auto')), + : (legacySupportsVision === true ? 'on' : 'auto')), // With an empty Model field the server's loaded model can change // without a settings update, so never restore a persisted detection. visionDetection: hasConfiguredModel ? (config.visionDetection || null) : null, @@ -981,6 +981,11 @@ export class ProviderManager { this._ollamaVisionChecks.set(identity.key, pending); } const result = await pending; + if (!result.ok && this._ollamaVisionChecks.get(identity.key) === pending) { + // A transient timeout, network failure, or malformed response must not + // pin this model to text-only for the service worker's entire lifetime. + this._ollamaVisionChecks.delete(identity.key); + } const current = this.providers.get(id); const currentIdentity = this._ollamaVisionIdentity(current?.config); if (!current || current.config.visionMode !== 'auto' || currentIdentity?.key !== identity.key) { diff --git a/src/chrome/src/providers/provider-compatibility.js b/src/chrome/src/providers/provider-compatibility.js index a15223bdb..618319c58 100644 --- a/src/chrome/src/providers/provider-compatibility.js +++ b/src/chrome/src/providers/provider-compatibility.js @@ -64,6 +64,7 @@ export function normalizeOpenAICompatibleBaseUrl(value) { export function openAiCompatiblePayloadError(payload, maxLength = 500) { const error = payload?.error; if (!error) return ''; + if (typeof error === 'object' && !Array.isArray(error) && Object.keys(error).length === 0) return ''; const detail = typeof error === 'string' ? error : String(error.message || error.detail || JSON.stringify(error)); diff --git a/src/chrome/src/ui/settings.js b/src/chrome/src/ui/settings.js index f3e2dd58d..c5d570b79 100644 --- a/src/chrome/src/ui/settings.js +++ b/src/chrome/src/ui/settings.js @@ -58,7 +58,7 @@ const VISION_UI_PROVIDER_IDS = new Set(['ollama', ...AUTO_VISION_PROVIDER_IDS]); // Version shown in the subtitle. Kept here so it only needs one update per // release; the subtitle string itself is translated. -const EXT_VERSION = '30.0.3'; +const EXT_VERSION = '30.0.4'; const providersContainer = document.getElementById('providers'); const displaySettings = document.getElementById('display-settings'); diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 86ab70978..482d6b586 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -11975,6 +11975,11 @@ async function restoreStagedScreenshotAttachments(root = messagesEl, tabId = ren updateAttachmentReadCount(numericTabId, 1); try { const results = Array.from(root.querySelectorAll?.('.screenshot-result') || []); + const pendingScreenshotIdsBeforeLoad = new Set(getPendingAttachmentsForTab( + numericTabId, + { create: false }, + ).filter(attachment => attachment?.source === 'slash_screenshot') + .map(attachment => attachment.stagedAttachmentId)); const storedAttachments = await loadStagedScreenshots(chrome.storage.local, numericTabId); if (!sameTabId(renderedTabId ?? currentTabId, numericTabId)) return; const pendingStoredAttachments = storedAttachments.filter(attachment => attachment.deliveryState === 'pending'); @@ -12010,6 +12015,9 @@ async function restoreStagedScreenshotAttachments(root = messagesEl, tabId = ren const pending = getPendingAttachmentsForTab(numericTabId).filter(attachment => ( attachment?.source !== 'slash_screenshot' || restoredIds.has(attachment.stagedAttachmentId) + // A capture may finish while storage is being read. It is not part of + // this restore snapshot and must remain staged for the next message. + || !pendingScreenshotIdsBeforeLoad.has(attachment.stagedAttachmentId) )); for (const { result, attachment } of restored) { const image = result.querySelector('.screenshot-result-image'); diff --git a/src/chrome/src/ui/tab-chat-persistence.js b/src/chrome/src/ui/tab-chat-persistence.js index c73702b6f..85f03189f 100644 --- a/src/chrome/src/ui/tab-chat-persistence.js +++ b/src/chrome/src/ui/tab-chat-persistence.js @@ -120,6 +120,28 @@ export function compactTabChatForPersist(html, budget = TAB_CHAT_PERSIST_BUDGET) return fallback.slice(0, boundedBudget); } +async function tabChatKeyBelongsToOpenTab(storedKey) { + const rawTabId = String(storedKey || '').slice(TAB_CHAT_PREFIX.length); + const tabId = Number(rawTabId); + const tabs = globalThis.browser?.tabs || globalThis.chrome?.tabs; + // Without a tab API there is no proof that removal is safe. + if (!Number.isFinite(tabId) || !tabs?.get) return true; + try { + await tabs.get(tabId); + return true; + } catch (error) { + // Only the browser's explicit "tab does not exist" errors prove closure. + // A transient API/context failure leaves ownership unknown and must retain + // the other tab's chat. + const message = String(error?.message || error || '').toLowerCase(); + return !( + /no tab with id\b/.test(message) + || /invalid tab id\b/.test(message) + || /no such tab\b/.test(message) + ); + } +} + export async function persistTabChatToSession(storageArea, key, html, warn = console.warn) { const source = String(html || ''); const initialValue = source.length > TAB_CHAT_PERSIST_BUDGET @@ -141,6 +163,39 @@ export async function persistTabChatToSession(storageArea, key, html, warn = con retryError = error; } + try { + // Tab-close cleanup can be interrupted by a service-worker shutdown. + // Reclaim only chats whose tab is provably gone; never delete another + // open tab's history merely to make the current write fit. + const stored = await storageArea.get(null); + const candidates = Object.entries(stored || {}) + .filter(([storedKey, value]) => ( + storedKey !== key + && storedKey.startsWith(TAB_CHAT_PREFIX) + && typeof value === 'string' + )) + .sort((a, b) => b[1].length - a[1].length); + const evictedKeys = []; + for (const [storedKey] of candidates) { + if (await tabChatKeyBelongsToOpenTab(storedKey)) continue; + await storageArea.remove(storedKey); + evictedKeys.push(storedKey); + try { + await storageArea.set({ [key]: retryValue }); + return { + ok: true, + degraded: true, + recoveredFromQuota: true, + evictedKeys, + }; + } catch (error) { + retryError = error; + } + } + } catch (error) { + retryError = error; + } + try { warn( '[WebBrain] persistTabChat: session storage write failed after compacting the stored copy; chat may not survive a panel reopen:', diff --git a/src/firefox/ARCHITECTURE.md b/src/firefox/ARCHITECTURE.md index 225fc1443..d1bdb22e6 100644 --- a/src/firefox/ARCHITECTURE.md +++ b/src/firefox/ARCHITECTURE.md @@ -1,6 +1,6 @@ # WebBrain Firefox Extension — Architecture -> Version 30.0.3 · Manifest V2 · Background Page +> Version 30.0.4 · Manifest V2 · Background Page ## How Firefox Differs from Chrome diff --git a/src/firefox/manifest.json b/src/firefox/manifest.json index 847c22b1f..16382fe17 100644 --- a/src/firefox/manifest.json +++ b/src/firefox/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "WebBrain", - "version": "30.0.3", + "version": "30.0.4", "description": "Open-source AI browser agent — chat with pages, automate tasks, multi-provider LLM support.", "permissions": [ "activeTab", diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 3cb03213e..2b2550824 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -242,7 +242,7 @@ function captchaCandidateMatchesGate(candidate, identity) { const candidatePath = captchaCandidateFramePath(candidate); if ( expectedPath.length !== candidatePath.length - || expectedPath.some((value, index) => value !== candidatePath[index]) + || expectedPath.some((value, index) => !Object.is(value, candidatePath[index])) ) return false; const expectedFieldIds = [identity.responseFieldId, identity.alsoResponseFieldId] @@ -2324,6 +2324,13 @@ export class Agent extends LoopDetector { if (!RICH_TEXT_TOOLBAR_GUARDED_TOOLS.has(toolName)) return null; const probe = await this._probeRichTextToolbarRetryTarget(tabId, toolName, args); if (!probe?.resolved) { + const knownRefBlock = this._richTextToolbarGuard.blockRef( + tabId, + toolName, + args, + this._lastAxScopes.get(tabId)?.documentToken || '', + ); + if (knownRefBlock) return knownRefBlock; return DISPATCH_BINDING_TOOLS.has(toolName) ? { success: false, @@ -17918,7 +17925,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (name === 'iframe_click') { let dispatched = false; try { - const urlFilter = args.urlFilter || ''; + const urlFilter = String(args.urlFilter || '').trim(); const selector = args.selector; if (!selector) { return { @@ -17928,6 +17935,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d error: 'selector is required', }; } + if (!urlFilter) { + return { + success: false, + dispatched: false, + noDispatch: true, + error: 'urlFilter is required so the iframe target and permission scope remain stable', + }; + } const hasExplicitMatchIndex = args.matchIndex !== undefined && args.matchIndex !== null; const requestedMatchIndex = hasExplicitMatchIndex ? Number(args.matchIndex) : 0; if (!Number.isInteger(requestedMatchIndex) || requestedMatchIndex < 0) { @@ -18051,6 +18066,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d let dispatched = false; try { const selector = args.selector; + const urlFilter = String(args.urlFilter || '').trim(); const text = args.text || ''; const clear = !!args.clear; if (!selector) { @@ -18061,6 +18077,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d error: 'selector is required', }; } + if (!urlFilter) { + return { + success: false, + dispatched: false, + noDispatch: true, + error: 'urlFilter is required so the iframe target and its verification scope remain stable', + }; + } let binding = dispatchBinding; let targetFrameId = binding?.frameId; // The preflight sweeps every frame; when it already reported that no @@ -18092,7 +18116,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d selector, text, clear, - urlFilter: args.urlFilter || '', + urlFilter, matchIndex: args.matchIndex, }); } diff --git a/src/firefox/src/agent/completion-invariant.js b/src/firefox/src/agent/completion-invariant.js index e19dfac18..fd4634341 100644 --- a/src/firefox/src/agent/completion-invariant.js +++ b/src/firefox/src/agent/completion-invariant.js @@ -93,14 +93,17 @@ function normalizedIframeMatchIndex(value) { function iframeFormObligation(args = {}, result = {}) { const selector = String(args?.selector || '').trim(); + const scope = normalizedIframeScope(args?.urlFilter); const finalValue = typeof result?.value === 'string' ? result.value : (typeof result?.frame?.value === 'string' ? result.frame.value : null); - if (!selector) return null; + // verify_form addresses iframe targets by urlFilter. Never create debt that + // the verification tool has no stable scope with which to discharge it. + if (!selector || !scope) return null; const matchMode = finalValue !== null || args?.clear === true ? 'exact' : 'suffix'; const rawExpectedValue = String(finalValue ?? args?.text ?? ''); return { - scope: normalizedIframeScope(args?.urlFilter), + scope, frameId: Number.isInteger(result?.frameId) ? result.frameId : null, selector, matchIndex: normalizedIframeMatchIndex(result?.matchIndex ?? args?.matchIndex), diff --git a/src/firefox/src/agent/loop-detector.js b/src/firefox/src/agent/loop-detector.js index 546662856..0fdb56b6c 100644 --- a/src/firefox/src/agent/loop-detector.js +++ b/src/firefox/src/agent/loop-detector.js @@ -313,7 +313,6 @@ export class LoopDetector { refId: typeof value?.ref_id === 'string' ? value.ref_id.trim() : '', }); const page = Number(args?.page || 1); - const hasRef = typeof args?.ref_id === 'string' && args.ref_id.trim() !== ''; const currentScopeKey = scopeKeyFor(args); const currentPageKey = `${currentScopeKey}|${page}`; const sequentialPage = previous.total > 0 @@ -321,10 +320,14 @@ export class LoopDetector { && page === previous.nextPage && currentScopeKey === previous.scopeKey && !previous.seenPages.has(currentPageKey); - const repeatedRootOrPage = previous.total > 0 && !sequentialPage; - const content = String(result?.pageContent || '').trim(); - const meaningfulLines = content ? content.split(/\r?\n/).filter(line => line.trim()).length : 0; - const suspicious = !sequentialPage && (hasRef || repeatedRootOrPage || (hasRef && meaningfulLines <= 1)); + const repeatedScopeOutOfSequence = previous.total > 0 + && currentScopeKey === previous.scopeKey + && !sequentialPage; + const repeatedExactPage = previous.seenPages.has(currentPageKey); + // A first read of a new ref-anchored subtree is legitimate drill-down + // progress. Only repeated/out-of-order reads of the same scope are loop + // evidence; the total-read cap below still bounds endless ref hopping. + const suspicious = !sequentialPage && (repeatedExactPage || repeatedScopeOutOfSequence); const state = { total: previous.total + 1, diff --git a/src/firefox/src/agent/mutation-tools.js b/src/firefox/src/agent/mutation-tools.js index 599621788..603aaa132 100644 --- a/src/firefox/src/agent/mutation-tools.js +++ b/src/firefox/src/agent/mutation-tools.js @@ -10,16 +10,15 @@ /** Tools that change page or browser state, gating auto-screenshots and * unknown-outcome normalization as well as loop detection. */ -export const STATE_CHANGE_TOOLS = new Set(['navigate', 'promote_iframe', 'new_tab', 'go_back', 'go_forward', 'click', 'click_ax', 'set_checked', 'type_text', 'type_ax', 'set_field', 'iframe_type', 'press_keys', 'scroll', 'hover', 'drag_drop', 'execute_js']); +export const STATE_CHANGE_TOOLS = new Set(['navigate', 'promote_iframe', 'new_tab', 'go_back', 'go_forward', 'click', 'click_ax', 'set_checked', 'iframe_click', 'type_text', 'type_ax', 'set_field', 'iframe_type', 'press_keys', 'scroll', 'hover', 'drag_drop', 'execute_js']); /** * Everything the failed-action loop counters treat as a browser mutation. - * Adds the remaining frame- and upload-scoped tools that act on the page but are not + * Adds the remaining upload- and challenge-scoped tools that act on the page but are not * part of the auto-screenshot state-change set. */ export const BROWSER_MUTATION_TOOLS = new Set([ ...STATE_CHANGE_TOOLS, - 'iframe_click', 'upload_file', 'solve_captcha', ]); diff --git a/src/firefox/src/agent/planner.js b/src/firefox/src/agent/planner.js index a4a576bfa..59546c9ee 100644 --- a/src/firefox/src/agent/planner.js +++ b/src/firefox/src/agent/planner.js @@ -527,9 +527,13 @@ export function normalizePlan(obj, opts = {}) { const normalizedScheduling = tool === 'schedule_task' || tool === 'schedule_resume' ? { tool, hint: sanitizeText(scheduling.hint, 300) } : null; - const risks = Array.isArray(obj.risks) - ? obj.risks.map((risk) => sanitizeText(risk, 200)).filter(Boolean).slice(0, 6) + const riskEntries = Array.isArray(obj.risks) + ? obj.risks + .map((risk, sourceIndex) => ({ sourceIndex, text: sanitizeText(risk, 200) })) + .filter(entry => entry.text) + .slice(0, 6) : []; + const risks = riskEntries.map(entry => entry.text); const localizedInput = obj.localized && typeof obj.localized === 'object' ? obj.localized : {}; const providedLocalizedSteps = Array.isArray(localizedInput.steps) ? localizedInput.steps.slice(0, 12).map((step, i) => ({ @@ -545,7 +549,7 @@ export function normalizePlan(obj, opts = {}) { || step.action, })); const providedLocalizedRisks = Array.isArray(localizedInput.risks) - ? localizedInput.risks.slice(0, 6).map((risk) => sanitizeText(risk, 200)) + ? localizedInput.risks : []; const requestedLocale = normalizePlannerLocale(opts.locale || localizedInput.locale); if (opts.requireIntent) { @@ -556,7 +560,9 @@ export function normalizePlan(obj, opts = {}) { locale: requestedLocale, summary: localizedSummary || summary, steps: localizedSteps, - risks: risks.map((risk, index) => providedLocalizedRisks[index] || risk), + risks: riskEntries.map(({ sourceIndex, text: risk }) => ( + sanitizeText(providedLocalizedRisks[sourceIndex], 200) || risk + )), }; const submissionBearingPlan = executablePlan || requestKind === 'clarify'; const requiresSubmission = submissionBearingPlan diff --git a/src/firefox/src/agent/rich-text-toolbar-probe.js b/src/firefox/src/agent/rich-text-toolbar-probe.js index d50d0e029..87ba42a2c 100644 --- a/src/firefox/src/agent/rich-text-toolbar-probe.js +++ b/src/firefox/src/agent/rich-text-toolbar-probe.js @@ -19,6 +19,15 @@ function withDispatchBinding(probe, frameId = probe?.frameId) { }; } +function messageOrigin(url) { + try { + const origin = new URL(String(url || '')).origin; + return origin && origin !== 'null' ? origin : ''; + } catch { + return ''; + } +} + export class RichTextToolbarProbe { constructor(agent) { this.agent = agent; @@ -69,19 +78,23 @@ export class RichTextToolbarProbe { child = parent; } if (!child || child.frameId !== 0) return null; + const opaqueFrameIds = new Set(); const exactChildRect = async edge => { const token = `wb-frame-${Date.now()}-${secureRandomBase36Token(12)}`; + const parentOriginOpaque = opaqueFrameIds.has(edge.parent.frameId); + const parentOrigin = parentOriginOpaque ? '' : messageOrigin(edge.parent.url); + const expectedChildOrigin = messageOrigin(edge.child.url); const parentResponse = browser.tabs.sendMessage(tabId, { target: 'redaction-content', action: 'wait_for_exact_child_frame_rect', - params: { token, scrollIntoView: true }, + params: { token, expectedChildOrigin, allowOpaqueChildOrigin: parentOriginOpaque, scrollIntoView: true }, }, { frameId: edge.parent.frameId }).catch(() => null); await new Promise(resolve => setTimeout(resolve, 0)); try { await browser.tabs.sendMessage(tabId, { target: 'redaction-content', action: 'announce_exact_child_frame', - params: { token }, + params: { token, parentOrigin }, }, { frameId: edge.child.frameId }); } catch {} return parentResponse; @@ -91,6 +104,7 @@ export class RichTextToolbarProbe { let frameOwnerMeta = null; for (const edge of edges) { const exact = await exactChildRect(edge); + if (exact?.childOriginOpaque === true) opaqueFrameIds.add(edge.child.frameId); const parentTransform = transforms.get(edge.parent.frameId); const childSnapshot = snapshotById.get(edge.child.frameId); const childWidth = Number(childSnapshot?.viewport?.width); @@ -159,8 +173,8 @@ export class RichTextToolbarProbe { } async legacyIframeTypeAllFrames(tabId, { selector, text, clear, urlFilter, matchIndex: requestedMatchIndex }) { - const matchIndex = Number.isInteger(Number(requestedMatchIndex)) && Number(requestedMatchIndex) >= 0 - ? Number(requestedMatchIndex) + const matchIndex = Number.isInteger(requestedMatchIndex) && requestedMatchIndex >= 0 + ? requestedMatchIndex : null; let navigationFrames = []; try { navigationFrames = await browser.webNavigation.getAllFrames({ tabId }); } catch {} @@ -302,7 +316,7 @@ export class RichTextToolbarProbe { args: { selector, text: args?.text || '', matchIndex: args?.matchIndex }, })))).filter(Boolean); if (!probes.length) return null; - const explicitMatchIndex = Number.isInteger(Number(args?.matchIndex)) && Number(args.matchIndex) >= 0; + const explicitMatchIndex = Number.isInteger(args?.matchIndex) && args.matchIndex >= 0; const matchedElementCount = probes.reduce((sum, probe) => ( sum + (explicitMatchIndex ? 1 : Math.max(1, Number(probe.selectorMatchCount) || 1)) ), 0); diff --git a/src/firefox/src/agent/tool-call-parser.js b/src/firefox/src/agent/tool-call-parser.js index 806cb04c3..f9080ec16 100644 --- a/src/firefox/src/agent/tool-call-parser.js +++ b/src/firefox/src/agent/tool-call-parser.js @@ -117,6 +117,54 @@ function parseWholeResponseJsonArray(text, allowedNames) { return parsed; } +/** + * Quote relaxed `key:` tokens only when they occur outside JSON strings and + * after an object boundary. A regular-expression replacement corrupts string + * values such as "Keep, status: pending" before JSON.parse sees them. + */ +function quoteBareJsonKeys(body) { + const source = String(body || ''); + let output = ''; + let inString = false; + let escaped = false; + + for (let i = 0; i < source.length;) { + const char = source[i]; + if (inString) { + output += char; + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '"') inString = false; + i++; + continue; + } + if (char === '"') { + inString = true; + output += char; + i++; + continue; + } + if (/\w/.test(char)) { + let previous = i - 1; + while (previous >= 0 && /\s/.test(source[previous])) previous--; + if (previous < 0 || source[previous] === '{' || source[previous] === ',') { + let keyEnd = i + 1; + while (keyEnd < source.length && /\w/.test(source[keyEnd])) keyEnd++; + let colon = keyEnd; + while (colon < source.length && /\s/.test(source[colon])) colon++; + if (source[colon] === ':') { + output += `"${source.slice(i, keyEnd)}"${source.slice(keyEnd, colon + 1)}`; + i = colon + 1; + continue; + } + } + } + output += char; + i++; + } + return output; +} + function toFallbackToolCalls(objects) { return objects.map((obj, index) => ({ id: `fallback_call_${Date.now()}_${index}`, @@ -166,6 +214,11 @@ export function parseToolCallsFromText(text, allowedNames) { let match; while ((match = re.exec(text)) !== null) { const inner = match[1].trim(); + const wrappedArray = parseWholeResponseJsonArray(inner, allowedNames); + if (wrappedArray !== null) { + results.push(...wrappedArray); + continue; + } try { const obj = JSON.parse(inner); if (obj && obj.name && allowedNames.has(obj.name)) { @@ -180,13 +233,11 @@ export function parseToolCallsFromText(text, allowedNames) { let argsBody = callMatch[2] .replace(/<\|"\|>/g, '"') .replace(/<\|'\\?\|>/g, "'"); - argsBody = argsBody.replace(/(?<=^|,)\s*(\w+)\s*:/g, '"$1":'); + argsBody = quoteBareJsonKeys(argsBody); try { const args = JSON.parse(`{${argsBody}}`); results.push({ name: toolName, arguments: args }); - } catch { - results.push({ name: toolName, arguments: {} }); - } + } catch { /* malformed arguments must never dispatch */ } } } } @@ -231,13 +282,11 @@ export function parseToolCallsFromText(text, allowedNames) { let argsBody = match[2] .replace(/<\|"\|>/g, '"') .replace(/<\|'\\?\|>/g, "'"); - argsBody = argsBody.replace(/(?<=^|,)\s*(\w+)\s*:/g, '"$1":'); + argsBody = quoteBareJsonKeys(argsBody); try { const args = JSON.parse(`{${argsBody}}`); results.push({ name: toolName, arguments: args }); - } catch { - results.push({ name: toolName, arguments: {} }); - } + } catch { /* malformed arguments must never dispatch */ } } } diff --git a/src/firefox/src/agent/tools.js b/src/firefox/src/agent/tools.js index ae163e927..60a18161b 100644 --- a/src/firefox/src/agent/tools.js +++ b/src/firefox/src/agent/tools.js @@ -686,7 +686,7 @@ export const AGENT_TOOLS = [ selector: { type: 'string', description: 'CSS selector for the element to click inside the iframe.' }, matchIndex: { type: 'number', description: 'Zero-based element index from iframe_read. Omit only when the selector uniquely matches one element across the selected frames.' }, }, - required: ['selector'], + required: ['urlFilter', 'selector'], }, }, }, @@ -704,7 +704,7 @@ export const AGENT_TOOLS = [ text: { type: 'string', description: 'Text to type into the field.' }, clear: { type: 'boolean', description: 'Whether to clear the field before typing.' }, }, - required: ['selector', 'text'], + required: ['urlFilter', 'selector', 'text'], }, }, }, diff --git a/src/firefox/src/background.js b/src/firefox/src/background.js index 3c3ce398c..ed7228eaa 100644 --- a/src/firefox/src/background.js +++ b/src/firefox/src/background.js @@ -567,12 +567,12 @@ function publicTeacherSession(session) { async function notifyTeacherState(tabId, session) { if (tabId == null) return false; try { - await browser.tabs.sendMessage(tabId, { + const response = await browser.tabs.sendMessage(tabId, { target: 'content', action: 'teacher_state', state: publicTeacherSession(session), }); - return true; + return response?.teacherCaptureReady === true; } catch { return false; } diff --git a/src/firefox/src/content/content.js b/src/firefox/src/content/content.js index 27a41842b..f0f2068e4 100644 --- a/src/firefox/src/content/content.js +++ b/src/firefox/src/content/content.js @@ -1837,6 +1837,13 @@ el = document.elementFromPoint(params.x, params.y); } + // A selector miss is a discovery failure, not a changed bound target. Do + // not consume the one-shot toolbar binding when no element was resolved; + // the caller's advised re-read/retry must still be able to bind a target. + if (params.selector && !el) { + return { success: false, dispatched: false, error: 'Element not found' }; + } + if (!_consumeDispatchBinding(params.dispatchBinding?.token, el)) { return { success: false, diff --git a/src/firefox/src/content/redaction-regions.js b/src/firefox/src/content/redaction-regions.js index 91b42a463..721642a6f 100644 --- a/src/firefox/src/content/redaction-regions.js +++ b/src/firefox/src/content/redaction-regions.js @@ -152,6 +152,7 @@ function waitForExactChildFrameRect(params) { const token = String(params?.token || ''); + const expectedChildOrigin = String(params?.expectedChildOrigin || ''); if (!token) return Promise.resolve({ found: false }); return new Promise(resolve => { // A single claim is answered after this quiet window so a second frame @@ -193,11 +194,24 @@ } return frames; }; + const hasOpaqueSandboxOrigin = frame => { + const sandboxValue = frame?.getAttribute?.('sandbox'); + return sandboxValue != null + && !String(sandboxValue).toLowerCase().split(/\s+/).includes('allow-same-origin'); + }; const onMessage = event => { if (event?.data?.__webbrainExactFrameRectToken !== token) return; const frame = reachableFrames() .find(candidate => candidate.contentWindow === event.source); if (!frame) return; + // The token is necessarily transported across the page/content-script + // boundary. Bind it to the exact child window and its origin. Sandboxed + // frames without allow-same-origin (and their descendants) have an + // opaque "null" origin, so accept that value only from the matching + // frame after the background has verified the sandbox chain. + const opaqueSandboxClaim = event.origin === 'null' + && (params?.allowOpaqueChildOrigin === true || hasOpaqueSandboxOrigin(frame)); + if (expectedChildOrigin && event.origin !== expectedChildOrigin && !opaqueSandboxClaim) return; // The agent announces this token to exactly one child frame, so a // second distinct claimant is a frame answering for a token that was // never sent to it. Resolving the first arrival would let it decide @@ -212,7 +226,9 @@ contentionTimer = setTimeout(() => resolveClaim(), CLAIM_CONTENTION_MS); return; } - resolveClaim(); + // Duplicate delivery from the same frame is not independent evidence. + // Keep the full contention window open so a different claimant still + // has a chance to make this lookup fail closed. }; const resolveClaim = () => { const frame = claimedFrame; @@ -250,6 +266,7 @@ name: frame.getAttribute?.('name') || null, role: frame.getAttribute?.('role') || null, }, + childOriginOpaque: params?.allowOpaqueChildOrigin === true || hasOpaqueSandboxOrigin(frame), }); }; window.addEventListener('message', onMessage); @@ -259,9 +276,13 @@ function announceExactChildFrame(params) { const token = String(params?.token || ''); + const parentOrigin = String(params?.parentOrigin || ''); if (!token || window.parent === window) return { announced: false }; try { - window.parent.postMessage({ __webbrainExactFrameRectToken: token }, '*'); + window.parent.postMessage( + { __webbrainExactFrameRectToken: token }, + parentOrigin || '*', + ); return { announced: true }; } catch { return { announced: false }; } } diff --git a/src/firefox/src/content/teacher-capture.js b/src/firefox/src/content/teacher-capture.js index 387b0c443..aab6bd520 100644 --- a/src/firefox/src/content/teacher-capture.js +++ b/src/firefox/src/content/teacher-capture.js @@ -238,7 +238,10 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (message?.target !== 'content') return; - if (message.action === 'teacher_state') setState(message.state); + if (message.action === 'teacher_state') { + setState(message.state); + sendResponse({ teacherCaptureReady: true }); + } if (message.action === 'flush_teacher_capture') { sendResponse({ teacherAction: active ? activeFieldAction() : null }); } diff --git a/src/firefox/src/providers/manager.js b/src/firefox/src/providers/manager.js index e581675b6..bf3c04842 100644 --- a/src/firefox/src/providers/manager.js +++ b/src/firefox/src/providers/manager.js @@ -8,7 +8,6 @@ import { AwsBedrockProvider } from './aws-bedrock.js'; import { ADDITIONAL_PROVIDER_DEFAULTS } from './provider-catalog.js'; import { fetchWithTimeout } from './fetch-timeout.js'; import { - AUTO_VISION_PROVIDER_IDS, VISION_MODES, parseLlamaCppVisionSupport, parseLmStudioVisionSupport, @@ -623,7 +622,9 @@ export class ProviderManager { ...migrated.ollama, visionMode: OLLAMA_VISION_MODES.has(migrated.ollama.visionMode) ? migrated.ollama.visionMode - : (legacySupportsVision === false ? 'off' : 'auto'), + : (legacySupportsVision === true + ? 'on' + : (legacySupportsVision === false ? 'off' : 'auto')), }; delete migrated.ollama.supportsVision; } @@ -631,14 +632,13 @@ export class ProviderManager { if (!visionProviderKind(id, config)) continue; const legacySupportsVision = config.supportsVision; const hasConfiguredModel = !!String(config.model || '').trim(); - const isBuiltInAutoProvider = AUTO_VISION_PROVIDER_IDS.has(id); migrated[id] = { ...config, visionMode: VISION_MODES.has(config.visionMode) ? config.visionMode : (legacySupportsVision === false ? 'off' - : (legacySupportsVision === true && !isBuiltInAutoProvider ? 'on' : 'auto')), + : (legacySupportsVision === true ? 'on' : 'auto')), visionDetection: hasConfiguredModel ? (config.visionDetection || null) : null, }; delete migrated[id].supportsVision; @@ -954,6 +954,11 @@ export class ProviderManager { this._ollamaVisionChecks.set(identity.key, pending); } const result = await pending; + if (!result.ok && this._ollamaVisionChecks.get(identity.key) === pending) { + // A transient timeout, network failure, or malformed response must not + // pin this model to text-only for the background page's entire lifetime. + this._ollamaVisionChecks.delete(identity.key); + } const current = this.providers.get(id); const currentIdentity = this._ollamaVisionIdentity(current?.config); if (!current || current.config.visionMode !== 'auto' || currentIdentity?.key !== identity.key) { diff --git a/src/firefox/src/providers/provider-compatibility.js b/src/firefox/src/providers/provider-compatibility.js index a15223bdb..618319c58 100644 --- a/src/firefox/src/providers/provider-compatibility.js +++ b/src/firefox/src/providers/provider-compatibility.js @@ -64,6 +64,7 @@ export function normalizeOpenAICompatibleBaseUrl(value) { export function openAiCompatiblePayloadError(payload, maxLength = 500) { const error = payload?.error; if (!error) return ''; + if (typeof error === 'object' && !Array.isArray(error) && Object.keys(error).length === 0) return ''; const detail = typeof error === 'string' ? error : String(error.message || error.detail || JSON.stringify(error)); diff --git a/src/firefox/src/ui/settings.js b/src/firefox/src/ui/settings.js index 65678576f..5a24679fc 100644 --- a/src/firefox/src/ui/settings.js +++ b/src/firefox/src/ui/settings.js @@ -58,7 +58,7 @@ const VISION_UI_PROVIDER_IDS = new Set(['ollama', ...AUTO_VISION_PROVIDER_IDS]); // Version shown in the subtitle. Kept here so it only needs one update per // release; the subtitle string itself is translated. -const EXT_VERSION = '30.0.3'; +const EXT_VERSION = '30.0.4'; const providersContainer = document.getElementById('providers'); const displaySettings = document.getElementById('display-settings'); diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index fc6300268..25e9da5ec 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -11530,6 +11530,11 @@ async function restoreStagedScreenshotAttachments(root = messagesEl, tabId = ren updateAttachmentReadCount(numericTabId, 1); try { const results = Array.from(root.querySelectorAll?.('.screenshot-result') || []); + const pendingScreenshotIdsBeforeLoad = new Set(getPendingAttachmentsForTab( + numericTabId, + { create: false }, + ).filter(attachment => attachment?.source === 'slash_screenshot') + .map(attachment => attachment.stagedAttachmentId)); const storedAttachments = await loadStagedScreenshots(browser.storage.local, numericTabId); if (!sameTabId(renderedTabId ?? currentTabId, numericTabId)) return; const pendingStoredAttachments = storedAttachments.filter(attachment => attachment.deliveryState === 'pending'); @@ -11565,6 +11570,9 @@ async function restoreStagedScreenshotAttachments(root = messagesEl, tabId = ren const pending = getPendingAttachmentsForTab(numericTabId).filter(attachment => ( attachment?.source !== 'slash_screenshot' || restoredIds.has(attachment.stagedAttachmentId) + // A capture may finish while storage is being read. It is not part of + // this restore snapshot and must remain staged for the next message. + || !pendingScreenshotIdsBeforeLoad.has(attachment.stagedAttachmentId) )); for (const { result, attachment } of restored) { const image = result.querySelector('.screenshot-result-image'); diff --git a/src/firefox/src/ui/tab-chat-persistence.js b/src/firefox/src/ui/tab-chat-persistence.js index 09c234347..890179c66 100644 --- a/src/firefox/src/ui/tab-chat-persistence.js +++ b/src/firefox/src/ui/tab-chat-persistence.js @@ -120,6 +120,28 @@ export function compactTabChatForPersist(html, budget = TAB_CHAT_PERSIST_BUDGET) return fallback.slice(0, boundedBudget); } +async function tabChatKeyBelongsToOpenTab(storedKey) { + const rawTabId = String(storedKey || '').slice(TAB_CHAT_PREFIX.length); + const tabId = Number(rawTabId); + const tabs = globalThis.browser?.tabs || globalThis.chrome?.tabs; + // Without a tab API there is no proof that removal is safe. + if (!Number.isFinite(tabId) || !tabs?.get) return true; + try { + await tabs.get(tabId); + return true; + } catch (error) { + // Only the browser's explicit "tab does not exist" errors prove closure. + // A transient API/context failure leaves ownership unknown and must retain + // the other tab's chat. + const message = String(error?.message || error || '').toLowerCase(); + return !( + /no tab with id\b/.test(message) + || /invalid tab id\b/.test(message) + || /no such tab\b/.test(message) + ); + } +} + export async function persistTabChatToSession(storageArea, key, html, warn = console.warn) { const source = String(html || ''); const initialValue = source.length > TAB_CHAT_PERSIST_BUDGET @@ -141,6 +163,39 @@ export async function persistTabChatToSession(storageArea, key, html, warn = con retryError = error; } + try { + // Tab-close cleanup can be interrupted by a background-page shutdown. + // Reclaim only chats whose tab is provably gone; never delete another + // open tab's history merely to make the current write fit. + const stored = await storageArea.get(null); + const candidates = Object.entries(stored || {}) + .filter(([storedKey, value]) => ( + storedKey !== key + && storedKey.startsWith(TAB_CHAT_PREFIX) + && typeof value === 'string' + )) + .sort((a, b) => b[1].length - a[1].length); + const evictedKeys = []; + for (const [storedKey] of candidates) { + if (await tabChatKeyBelongsToOpenTab(storedKey)) continue; + await storageArea.remove(storedKey); + evictedKeys.push(storedKey); + try { + await storageArea.set({ [key]: retryValue }); + return { + ok: true, + degraded: true, + recoveredFromQuota: true, + evictedKeys, + }; + } catch (error) { + retryError = error; + } + } + } catch (error) { + retryError = error; + } + try { warn( '[WebBrain] persistTabChat: session storage write failed after compacting the stored copy; chat may not survive a panel reopen:', diff --git a/test/run.js b/test/run.js index 1a458af44..ce2950035 100644 --- a/test/run.js +++ b/test/run.js @@ -1533,6 +1533,210 @@ test('redaction collectors filter offscreen fields and report incomplete region } }); +test('exact iframe geometry claims stay source-bound, allow opaque sandbox origins, and keep their contention window', () => { + for (const browserName of ['chrome', 'firefox']) { + const source = fs.readFileSync(path.join(ROOT, `src/${browserName}/src/content/redaction-regions.js`), 'utf8'); + const sourceBinding = source.indexOf('.find(candidate => candidate.contentWindow === event.source)'); + const originBinding = source.indexOf('event.origin !== expectedChildOrigin', sourceBinding); + assert.ok(sourceBinding >= 0 && originBinding > sourceBinding, + `${browserName}: frame claims must bind the exact source before applying origin exceptions`); + assert.match(source, /event\.origin === 'null'[\s\S]*allowOpaqueChildOrigin === true \|\| hasOpaqueSandboxOrigin\(frame\)/, + `${browserName}: opaque origins should be limited to sandboxed frames without allow-same-origin`); + assert.match(source, /event\.origin !== expectedChildOrigin && !opaqueSandboxClaim/, + `${browserName}: frame claims must verify the child origin`); + assert.match(source, /parentOrigin \|\| '\*'/, + `${browserName}: child announcements must target a known parent origin when available`); + assert.match(source, /if \(!claimant\) \{[\s\S]*contentionTimer = setTimeout[\s\S]*return;[\s\S]*Duplicate delivery from the same frame/, + `${browserName}: duplicate same-source claims must not resolve before contention expires`); + } +}); + +test('exact iframe geometry accepts null origins only for opaque sandboxed source frames', async () => { + for (const browserName of ['chrome', 'firefox']) { + const source = fs.readFileSync(path.join(ROOT, `src/${browserName}/src/content/redaction-regions.js`), 'utf8'); + const claim = async ({ eventOrigin, sandboxValue, allowOpaqueChildOrigin = false }) => { + let runtimeListener = null; + let windowMessageListener = null; + const timers = []; + const setTestTimeout = (callback, delay) => { + const timer = { callback, delay, active: true }; + timers.push(timer); + return timer; + }; + const clearTestTimeout = timer => { + if (timer) timer.active = false; + }; + const runTimer = delay => { + const timer = timers.find(candidate => candidate.active && candidate.delay === delay); + assert.ok(timer, `${browserName}: expected a ${delay}ms claim timer`); + timer.active = false; + timer.callback(); + }; + const childWindow = {}; + const frame = { + contentWindow: childWindow, + isConnected: true, + tagName: 'IFRAME', + id: 'sandbox-child', + offsetWidth: 120, + offsetHeight: 80, + clientWidth: 120, + clientHeight: 80, + clientLeft: 0, + clientTop: 0, + getAttribute(name) { + if (name === 'sandbox') return sandboxValue; + return null; + }, + getBoundingClientRect() { + return { left: 10, top: 20, right: 130, bottom: 100, width: 120, height: 80 }; + }, + }; + const runtime = { onMessage: { addListener(listener) { runtimeListener = listener; } } }; + const context = { + chrome: { runtime }, + browser: browserName === 'firefox' ? { runtime } : undefined, + window: { + innerWidth: 800, + innerHeight: 600, + scrollX: 0, + scrollY: 0, + pageXOffset: 0, + pageYOffset: 0, + addEventListener(type, listener) { + if (type === 'message') windowMessageListener = listener; + }, + removeEventListener(type, listener) { + if (type === 'message' && windowMessageListener === listener) windowMessageListener = null; + }, + }, + document: { + querySelectorAll(selector) { + if (selector === 'iframe, frame') return [frame]; + return []; + }, + }, + setTimeout: setTestTimeout, + clearTimeout: clearTestTimeout, + }; + vm.runInNewContext(source, context); + assert.equal(typeof runtimeListener, 'function', `${browserName}: collector listener should register`); + const resultPromise = new Promise(resolve => { + const pending = runtimeListener({ + target: 'redaction-content', + action: 'wait_for_exact_child_frame_rect', + params: { token: 'opaque-token', expectedChildOrigin: 'https://child.test', allowOpaqueChildOrigin }, + }, null, resolve); + assert.equal(pending, true, `${browserName}: exact-frame request should stay asynchronous`); + }); + assert.equal(typeof windowMessageListener, 'function', `${browserName}: message listener should register`); + windowMessageListener({ + data: { __webbrainExactFrameRectToken: 'opaque-token' }, + origin: eventOrigin, + source: childWindow, + }); + const accepted = timers.some(timer => timer.active && timer.delay === 30); + runTimer(accepted ? 30 : 750); + return resultPromise; + }; + + assert.equal( + (await claim({ eventOrigin: 'null', sandboxValue: 'allow-scripts' })).found, + true, + `${browserName}: an opaque sandbox frame should resolve its exact geometry`, + ); + assert.equal( + (await claim({ eventOrigin: 'null', sandboxValue: null })).found, + false, + `${browserName}: an unsandboxed frame should not bypass its expected origin`, + ); + assert.equal( + (await claim({ eventOrigin: 'null', sandboxValue: 'allow-scripts allow-same-origin' })).found, + false, + `${browserName}: allow-same-origin frames should not use the opaque-origin exception`, + ); + const inheritedOpaque = await claim({ + eventOrigin: 'null', + sandboxValue: null, + allowOpaqueChildOrigin: true, + }); + assert.equal(inheritedOpaque.found, true, + `${browserName}: a child of an opaque sandbox frame should inherit the origin exception`); + assert.equal(inheritedOpaque.childOriginOpaque, true, + `${browserName}: inherited opacity should propagate to deeper descendants`); + assert.equal( + (await claim({ eventOrigin: 'https://child.test', sandboxValue: null })).found, + true, + `${browserName}: ordinary exact-origin claims should remain valid`, + ); + } +}); + +test('rich-text frame geometry uses wildcard delivery only for known opaque sandbox parents', async () => { + const originalChrome = globalThis.chrome; + const originalBrowser = globalThis.browser; + try { + for (const build of ['chrome', 'firefox']) { + const announcements = []; + const waits = []; + const runtime = { + tabs: { + async sendMessage(_tabId, message, options) { + const frameId = options?.frameId; + if (message.action === 'get_redaction_regions') { + return { viewport: { width: 100, height: 100, scrollX: 0, scrollY: 0 } }; + } + if (message.action === 'wait_for_exact_child_frame_rect') { + waits.push({ frameId, ...message.params }); + return { + found: true, + childOriginOpaque: frameId === 0 || message.params.allowOpaqueChildOrigin === true, + outerRect: { x: 10, y: 10, w: 100, h: 100, pageX: 10, pageY: 10 }, + contentRect: { x: 10, y: 10, w: 100, h: 100 }, + ownerMeta: { tag: 'iframe' }, + }; + } + if (message.action === 'announce_exact_child_frame') { + announcements.push({ frameId, ...message.params }); + return { announced: true }; + } + throw new Error(`Unexpected action: ${message.action}`); + }, + }, + }; + globalThis.chrome = build === 'chrome' + ? { ...runtime, scripting: { executeScript: async () => [] } } + : originalChrome; + globalThis.browser = build === 'firefox' + ? { ...runtime, tabs: { ...runtime.tabs, executeScript: async () => [] } } + : originalBrowser; + const { RichTextToolbarProbe } = await import(pathToFileURL( + path.join(ROOT, `src/${build}/src/agent/rich-text-toolbar-probe.js`), + ).href); + const geometry = await new RichTextToolbarProbe({}).frameGeometryToTop(77, [ + { frameId: 0, parentFrameId: -1, url: 'https://top.test/' }, + { frameId: 1, parentFrameId: 0, url: 'https://middle.test/sandboxed' }, + { frameId: 2, parentFrameId: 1, url: 'https://child.test/editor' }, + ], 2, { x: 5, y: 6, w: 20, h: 10 }); + + assert.ok(geometry?.annotationRect, `${build}: nested geometry should resolve`); + assert.equal(announcements[0]?.parentOrigin, 'https://top.test', + `${build}: ordinary parents should retain exact-origin delivery`); + assert.equal(announcements[1]?.parentOrigin, '', + `${build}: opaque sandbox parents should request wildcard delivery`); + assert.equal(waits[0]?.allowOpaqueChildOrigin, false, + `${build}: the top-level receiver should not enable inherited opacity`); + assert.equal(waits[1]?.allowOpaqueChildOrigin, true, + `${build}: an opaque receiver should accept and propagate null child origins`); + } + } finally { + if (originalChrome === undefined) delete globalThis.chrome; + else globalThis.chrome = originalChrome; + if (originalBrowser === undefined) delete globalThis.browser; + else globalThis.browser = originalBrowser; + } +}); + test('redaction collectors expose a per-frame overflow sentinel', () => { const visibleRect = { left: 10, top: 10, right: 110, bottom: 30, width: 100, height: 20 }; for (const browserName of ['chrome', 'firefox']) { @@ -1739,6 +1943,16 @@ test('ref-id action tools are state changes in both browser agents', () => { } }); +test('iframe mutations require a stable frame scope in both browser tool schemas', () => { + for (const [label, getTools] of [['chrome', getToolsForModeCh], ['firefox', getToolsForModeFx]]) { + for (const name of ['iframe_click', 'iframe_type']) { + const tool = getTools('act').find(item => item.function.name === name); + assert.ok(tool, `${label}: ${name} tool is missing`); + assert.equal(tool.function.parameters.required.includes('urlFilter'), true, `${label}: ${name} does not require urlFilter`); + } + } +}); + test('set_checked is exposed and permission-gated as a click in both browser agents', () => { for (const [label, getTools, capabilityFn, Capabilities] of [ ['chrome', getToolsForModeCh, capabilityForCh, CapabilityCh], @@ -8355,7 +8569,7 @@ test('Enter SPA route changes reset dead-scroll state and defer queued ref reuse } }); -test('accessibility-tree ref enumeration nudges at three and stops at six', () => { +test('accessibility-tree allows bounded unique ref drill-downs and still stops enumeration', () => { const d = new ConfiguredLoopDetector(); const tab = 21; const root = d._checkAccessibilityReadLoop(tab, 'get_accessibility_tree', { filter: 'visible' }, { @@ -8366,10 +8580,16 @@ test('accessibility-tree ref enumeration nudges at three and stops at six', () = assert.equal(root.kind, 'none'); assert.equal(d._checkAccessibilityReadLoop(tab, 'get_accessibility_tree', { ref_id: 'ref_1' }, { pageContent: 'form [ref_1]\n textbox "Subject" [ref_2]' }).kind, 'none'); assert.equal(d._checkAccessibilityReadLoop(tab, 'get_accessibility_tree', { ref_id: 'ref_2' }, { pageContent: 'textbox "Subject" [ref_2]' }).kind, 'none'); - assert.equal(d._checkAccessibilityReadLoop(tab, 'get_accessibility_tree', { ref_id: 'ref_3' }, { pageContent: 'generic [ref_3]' }).kind, 'nudge'); - assert.equal(d._checkAccessibilityReadLoop(tab, 'get_accessibility_tree', { ref_id: 'ref_4' }, { pageContent: 'generic [ref_4]' }).kind, 'none'); - assert.equal(d._checkAccessibilityReadLoop(tab, 'get_accessibility_tree', { ref_id: 'ref_5' }, { pageContent: 'generic [ref_5]' }).kind, 'none'); - assert.equal(d._checkAccessibilityReadLoop(tab, 'get_accessibility_tree', { ref_id: 'ref_6' }, { pageContent: 'generic [ref_6]' }).kind, 'stop'); + for (let ref = 3; ref <= 10; ref++) { + assert.equal( + d._checkAccessibilityReadLoop(tab, 'get_accessibility_tree', { ref_id: `ref_${ref}` }, { pageContent: `generic [ref_${ref}]` }).kind, + 'none', + ); + } + assert.equal( + d._checkAccessibilityReadLoop(tab, 'get_accessibility_tree', { ref_id: 'ref_11' }, { pageContent: 'generic [ref_11]' }).kind, + 'stop', + ); }); test('accessibility-tree nextPage pagination past the read cap is not suspicious and other tools reset it', () => { @@ -10207,12 +10427,14 @@ test('loop detection classifies mutating tools from each build tool list, not a for (const name of ['read_page', 'extract_data', 'get_accessibility_tree', 'fetch_url', 'find_text', 'done']) { assert.equal(agent._isBrowserMutationTool(name), false, `${label}: ${name} must not count as a mutation`); } - // iframe_type now needs state-change screenshots for its toolbar safety - // preflight as well as its ordinary post-action evidence. - assert.equal(stateChangeTools.has('iframe_type'), true, `${label}: iframe_type must trigger state-change screenshots`); - // The remaining frame/upload tools act on the page but are not + // Iframe mutations need state-change screenshots for post-action evidence; + // iframe_type also uses them for its toolbar safety preflight. + for (const name of ['iframe_click', 'iframe_type']) { + assert.equal(stateChangeTools.has(name), true, `${label}: ${name} must trigger state-change screenshots`); + } + // The remaining upload/challenge tools act on the page but are not // auto-screenshot state changes, so the two sets must not be collapsed. - for (const name of ['iframe_click', 'upload_file', 'solve_captcha']) { + for (const name of ['upload_file', 'solve_captcha']) { assert.equal(stateChangeTools.has(name), false, `${label}: ${name} must stay out of STATE_CHANGE_TOOLS`); assert.equal(mutationTools.has(name), true, `${label}: ${name} must be a browser mutation`); } @@ -16786,6 +17008,14 @@ test('completion invariant state machine enforces post-action observation with C assert.equal(iframeFormState.iframeFormVerificationDebt, false, `${label}: matching iframe verify_form did not clear form debt`); assert.equal(invariant.completionDoneBlock(iframeFormState, 'done', { outcome: 'success' }), null); + const unscopedIframeState = invariant.recordCompletionToolResult( + invariant.createCompletionInvariantState(`${label}-unscoped-iframe-form`), + 'iframe_type', + { selector: '#field', text: 'value' }, + { success: true, dispatched: true, verified: true, frameId: 7, value: 'value' }, + ); + assert.equal(unscopedIframeState.iframeFormVerificationDebt, false, `${label}: an unscoped iframe edit created debt that verify_form cannot address`); + let multiIframeState = invariant.createCompletionInvariantState(`${label}-multi-iframe-form`); multiIframeState = invariant.recordCompletionToolResult( multiIframeState, @@ -16937,22 +17167,26 @@ test('pre-dispatch action failures opt out without weakening ambiguous iframe fa 'chrome', CompletionInvariantCh, 'iframe_type', - { selector: '::invalid' }, - await chromeAgent.executeTool(6401, 'iframe_type', { selector: '::invalid', text: 'x' }), + { urlFilter: 'example.test', selector: '::invalid' }, + await chromeAgent.executeTool(6401, 'iframe_type', { urlFilter: 'example.test', selector: '::invalid', text: 'x' }), ); + const unscopedChromeIframe = await chromeAgent.executeTool(6401, 'iframe_type', { selector: '#field', text: 'x' }); + assertNoDebt('chrome', CompletionInvariantCh, 'iframe_type', { selector: '#field', text: 'x' }, unscopedChromeIframe); + assert.match(unscopedChromeIframe.error, /urlFilter is required/); + chromeIframeTypeResponse = { success: false, dispatched: true, error: 'event handler threw after target resolution', }; - const ambiguousChromeIframe = await chromeAgent.executeTool(6401, 'iframe_type', { selector: '#field', text: 'x' }); + const ambiguousChromeIframe = await chromeAgent.executeTool(6401, 'iframe_type', { urlFilter: 'example.test', selector: '#field', text: 'x' }); assert.equal(ambiguousChromeIframe.dispatched, true, 'chrome: ambiguous iframe failure lost its dispatch marker'); assert.equal( CompletionInvariantCh.recordCompletionToolResult( CompletionInvariantCh.createCompletionInvariantState('chrome-ambiguous-iframe'), 'iframe_type', - { selector: '#field', text: 'x' }, + { urlFilter: 'example.test', selector: '#field', text: 'x' }, ambiguousChromeIframe, ).verificationDebt, true, @@ -17016,22 +17250,26 @@ test('pre-dispatch action failures opt out without weakening ambiguous iframe fa 'firefox', CompletionInvariantFx, 'iframe_type', - { selector: '::invalid' }, - await firefoxAgent.executeTool(6402, 'iframe_type', { selector: '::invalid', text: 'x' }), + { urlFilter: 'example.test', selector: '::invalid' }, + await firefoxAgent.executeTool(6402, 'iframe_type', { urlFilter: 'example.test', selector: '::invalid', text: 'x' }), ); + const unscopedFirefoxIframe = await firefoxAgent.executeTool(6402, 'iframe_type', { selector: '#field', text: 'x' }); + assertNoDebt('firefox', CompletionInvariantFx, 'iframe_type', { selector: '#field', text: 'x' }, unscopedFirefoxIframe); + assert.match(unscopedFirefoxIframe.error, /urlFilter is required/); + firefoxIframeTypeResponse = { success: false, dispatched: true, error: 'event handler threw after target resolution', }; - const ambiguousFirefoxIframe = await firefoxAgent.executeTool(6402, 'iframe_type', { selector: '#field', text: 'x' }); + const ambiguousFirefoxIframe = await firefoxAgent.executeTool(6402, 'iframe_type', { urlFilter: 'example.test', selector: '#field', text: 'x' }); assert.equal(ambiguousFirefoxIframe.dispatched, true, 'firefox: ambiguous iframe failure lost its dispatch marker'); assert.equal( CompletionInvariantFx.recordCompletionToolResult( CompletionInvariantFx.createCompletionInvariantState('firefox-ambiguous-iframe'), 'iframe_type', - { selector: '#field', text: 'x' }, + { urlFilter: 'example.test', selector: '#field', text: 'x' }, ambiguousFirefoxIframe, ).verificationDebt, true, @@ -23311,7 +23549,7 @@ test('chrome offscreen helper recreates an evicted document after ready cache is } }); -test('chrome fetch fallback clears offscreen proxy timeout after success', async () => { +test('chrome non-idempotent fetch uses one offscreen transport and clears its timeout', async () => { const previousChrome = globalThis.chrome; const previousFetch = globalThis.fetch; const previousSetTimeout = globalThis.setTimeout; @@ -23327,7 +23565,9 @@ test('chrome fetch fallback clears offscreen proxy timeout after success', async globalThis.clearTimeout = (handle) => { if (handle) handle.cleared = true; }; + let directAttempts = 0; globalThis.fetch = async () => { + directAttempts += 1; throw new TypeError('Failed to fetch'); }; globalThis.chrome = { @@ -23369,7 +23609,8 @@ test('chrome fetch fallback clears offscreen proxy timeout after success', async assert.equal(res.status, 200, 'chrome: fallback should synthesize the proxied response'); assert.equal(await res.text(), '{"ok":true}', 'chrome: fallback response body should survive proxy conversion'); - assert.equal(timers.length, 2, 'chrome: direct fetch and offscreen proxy should each install one timeout'); + assert.equal(directAttempts, 0, 'chrome: a POST must not be replayed after a direct network failure'); + assert.equal(timers.length, 1, 'chrome: the single offscreen transport should install one timeout'); assert.equal(timers.every((timer) => timer.cleared), true, 'chrome: offscreen proxy timeout should be cleared after success'); } finally { globalThis.setTimeout = previousSetTimeout; @@ -25633,6 +25874,67 @@ test('tab-chat persistence never evicts other chats when shared quota remains ex } }); +test('tab-chat quota recovery removes only a provably closed tab chat', async () => { + const originalChrome = globalThis.chrome; + const originalBrowser = globalThis.browser; + try { + for (const [label, persistence, runtimeKey] of [ + ['chrome', TabChatPersistenceCh, 'chrome'], + ['firefox', TabChatPersistenceFx, 'browser'], + ]) { + delete globalThis.chrome; + delete globalThis.browser; + globalThis[runtimeKey] = { + tabs: { + async get(tabId) { + if (tabId === 41) throw new Error('No tab with id: 41'); + if (tabId === 40) throw new Error('Tabs API temporarily unavailable'); + return { id: tabId }; + }, + }, + }; + const quota = 1200 * 1024; + const values = { unrelatedSessionState: 'keep' }; + const unknownKey = persistence.TAB_CHAT_PREFIX + '40'; + const staleKey = persistence.TAB_CHAT_PREFIX + '41'; + const openKey = persistence.TAB_CHAT_PREFIX + '42'; + const currentKey = persistence.TAB_CHAT_PREFIX + '43'; + values[unknownKey] = 'u'.repeat(750 * 1024); + values[staleKey] = 's'.repeat(700 * 1024); + values[openKey] = 'o'.repeat(300 * 1024); + const storageBytes = next => Object.entries(next) + .reduce((total, [storedKey, value]) => total + storedKey.length + String(value).length, 0); + const storageArea = { + async get() { return { ...values }; }, + async set(patch) { + const next = { ...values, ...patch }; + if (storageBytes(next) > quota) throw new Error('QUOTA_BYTES exceeded'); + Object.assign(values, patch); + }, + async remove(key) { delete values[key]; }, + }; + + const result = await persistence.persistTabChatToSession( + storageArea, + currentKey, + `
${'current '.repeat(80 * 1024)}
`, + ); + assert.equal(result.ok, true, `${label}: stale closed-tab data should unblock persistence`); + assert.deepEqual(result.evictedKeys, [staleKey], `${label}: only the closed tab should be reclaimed`); + assert.equal(values[staleKey], undefined, `${label}: closed tab chat was retained`); + assert.equal(values[unknownKey]?.length > 0, true, `${label}: a tab with unknown liveness was evicted`); + assert.equal(values[openKey]?.length > 0, true, `${label}: open tab chat was evicted`); + assert.equal(typeof values[currentKey], 'string', `${label}: current compact snapshot was not stored`); + assert.equal(values.unrelatedSessionState, 'keep', `${label}: unrelated session state changed`); + } + } finally { + if (originalChrome === undefined) delete globalThis.chrome; + else globalThis.chrome = originalChrome; + if (originalBrowser === undefined) delete globalThis.browser; + else globalThis.browser = originalBrowser; + } +}); + test('tab-chat handoff coordinator orders a returning-panel read behind the outgoing snapshot', async () => { for (const [label, persistence] of [ ['chrome', TabChatPersistenceCh], @@ -40036,6 +40338,8 @@ test('inspect_event_listeners walks from open shadow roots through their hosts', }); test('Chrome Dev mutation and state-change classifications cover the new toolkit', () => { + assert.equal(UNTRUSTED_CONTENT_TOOLS_CH.has('upload_file'), true, 'chrome: upload results must remain inside the untrusted boundary'); + assert.equal(UNTRUSTED_CONTENT_TOOLS.has('upload_file'), true, 'firefox: upload results must remain inside the untrusted boundary'); for (const name of ['inject_css', 'remove_injected_css', 'patch_element', 'revert_patch', 'inspect_event_listeners', 'highlight_element']) { assert.equal(capabilityForCh(name, {}), CapabilityCh.DEV_PATCH, `${name} should require the Dev patch capability`); assert.equal(AgentCh.STATE_CHANGE_TOOLS.has(name), true, `${name} should trigger state-change screenshots`); @@ -40054,6 +40358,38 @@ test('Chrome Dev mutation and state-change classifications cover the new toolkit assert.equal(UNTRUSTED_CONTENT_TOOLS_CH.has('highlight_element'), true); }); +test('toolbar retry guards fail closed without consuming unresolved targets', () => { + for (const build of ['chrome', 'firefox']) { + const content = fs.readFileSync(path.join(ROOT, `src/${build}/src/content/content.js`), 'utf8'); + const clickStart = content.indexOf('function clickElement('); + const selectorMiss = content.indexOf('if (params.selector && !el)', clickStart); + const consumeBinding = content.indexOf('_consumeDispatchBinding(params.dispatchBinding?.token, el)', clickStart); + assert.ok(clickStart >= 0 && selectorMiss > clickStart && selectorMiss < consumeBinding, + `${build}: selector misses must return before consuming a dispatch binding`); + + const agent = fs.readFileSync(path.join(ROOT, `src/${build}/src/agent/agent.js`), 'utf8'); + const toolBlockStart = agent.indexOf('async _richTextToolbarToolBlock('); + const retryProbe = agent.indexOf('this._probeRichTextToolbarRetryTarget(', toolBlockStart); + const unresolvedBranch = agent.indexOf('if (!probe?.resolved)', retryProbe); + const refBlock = agent.indexOf('this._richTextToolbarGuard.blockRef(', unresolvedBranch); + const unresolvedFallback = agent.indexOf('return DISPATCH_BINDING_TOOLS.has(toolName)', refBlock); + assert.ok( + toolBlockStart >= 0 + && retryProbe > toolBlockStart + && unresolvedBranch > retryProbe + && refBlock > unresolvedBranch + && refBlock < unresolvedFallback, + `${build}: unresolved probes must enforce known blocked refs before the generic fallback`, + ); + + const probe = fs.readFileSync(path.join(ROOT, `src/${build}/src/agent/rich-text-toolbar-probe.js`), 'utf8'); + assert.match(probe, /const explicitMatchIndex = Number\.isInteger\(args\?\.matchIndex\) && args\.matchIndex >= 0/, + `${build}: null matchIndex must not select iframe match zero`); + assert.match(probe, /const matchIndex = Number\.isInteger\(requestedMatchIndex\) && requestedMatchIndex >= 0/, + `${build}: the legacy iframe fallback must also treat null matchIndex as omitted`); + } +}); + test('HLS implicit-IV derivation does not 32-bit-truncate the media sequence', () => { // RFC 8216 §5.2 implicit IV = media-sequence number as a 128-bit big-endian // integer. A `BigInt(seq | 0)` truncates to signed 32-bit, yielding an @@ -40358,7 +40694,7 @@ test('llama.cpp, LM Studio, and LocalAI migrate to tri-state vision alongside Ol const migratedOff = manager._migrateStoredProviderConfigs({ [id]: { model: 'fixed', supportsVision: false } })[id]; assert.equal(migratedOff.visionMode, 'off'); const migratedAuto = manager._migrateStoredProviderConfigs({ [id]: { model: 'fixed', supportsVision: true } })[id]; - assert.equal(migratedAuto.visionMode, 'auto'); + assert.equal(migratedAuto.visionMode, 'on'); const migratedBlank = manager._migrateStoredProviderConfigs({ [id]: { model: '', visionMode: 'auto', visionDetection: { supportsVision: true } }, })[id]; @@ -41009,7 +41345,7 @@ test('Ollama vision detection fails closed and ignores stale responses', async ( assert.equal(mgr.getActive().supportsVision, false, `${label}: metadata failure should fail closed`); assert.equal(mgr.getActive().config.visionDetection, null, `${label}: metadata failure must not persist text-only detection`); await mgr.prepareActiveProviderCapabilities(); - assert.equal(fetchCalls, 2, `${label}: a failed key should still be checked only once per worker lifetime`); + assert.equal(fetchCalls, 3, `${label}: a failed key should be evicted so a later turn can retry`); await mgr.updateProvider('ollama', { model: 'malformed-model' }); const malformed = await mgr.prepareActiveProviderCapabilities(); @@ -41020,7 +41356,7 @@ test('Ollama vision detection fails closed and ignores stale responses', async ( const timedOut = await mgr.prepareActiveProviderCapabilities(); assert.equal(timedOut.ok, false, `${label}: a metadata timeout should fail closed`); assert.equal(mgr.getActive().supportsVision, false, `${label}: a timed-out turn must remain text-only`); - assert.equal(fetchCalls, 4, `${label}: each distinct model should receive one metadata request`); + assert.equal(fetchCalls, 5, `${label}: distinct models and the retried failed key should each issue metadata requests`); const managerSource = fs.readFileSync(path.join(ROOT, `src/${label}/src/providers/manager.js`), 'utf8'); assert.match(managerSource, /OLLAMA_VISION_METADATA_TIMEOUT_MS\s*=\s*3000/, @@ -42096,8 +42432,8 @@ test('ProviderManager load ignores unsupported stored provider configs', async ( globalThis[runtimeKey] = makeRuntime(legacyVisionOnStorage); const legacyVisionOnManager = new PM(); await legacyVisionOnManager.load(); - assert.equal(legacyVisionOnManager.providers.get('ollama')?.config.visionMode, 'auto', `${label}: legacy vision=true should migrate to auto detection`); - assert.equal(legacyVisionOnManager.providers.get('ollama')?.supportsVision, false, `${label}: auto mode should fail closed before metadata detection`); + assert.equal(legacyVisionOnManager.providers.get('ollama')?.config.visionMode, 'on', `${label}: legacy vision=true should remain an explicit override`); + assert.equal(legacyVisionOnManager.providers.get('ollama')?.supportsVision, true, `${label}: migrated on mode should preserve working vision`); const legacyVisionMissingStorage = { webbrainDeviceGuid: validGuid, @@ -47093,7 +47429,7 @@ test('approved submit confirmation is one-time and skips generic click always gr } }); -test('approved iframe submit keeps host gate fail-closed without urlFilter', async () => { +test('iframe submit without urlFilter fails before confirmation or dispatch', async () => { for (const AgentClass of [AgentCh, AgentFx]) { const agent = new AgentClass({ getVisionProvider: async () => null }); const tabId = 5115; @@ -47142,12 +47478,14 @@ test('approved iframe submit keeps host gate fail-closed without urlFilter', asy 1, ); - assert.equal(submitPrompts, 1, `${AgentClass.name}: expected submit confirmation before iframe host gate`); + assert.equal(submitPrompts, 0, `${AgentClass.name}: invalid iframe args should not request submit confirmation`); assert.equal(executed, false, `${AgentClass.name}: iframe submit ran without a target host gate`); assert.equal(messages.length, 1, `${AgentClass.name}: expected fail-closed tool result`); const denied = JSON.parse(messages[0].content); - assert.equal(denied.denied, true, `${AgentClass.name}: fail-closed result should be denied`); - assert.match(denied.error, /urlFilter/, `${AgentClass.name}: error should tell the model to provide urlFilter`); + assert.equal(denied.success, false, `${AgentClass.name}: invalid iframe args should fail`); + assert.equal(denied.invalidArguments, true, `${AgentClass.name}: failure should identify invalid arguments`); + assert.equal(denied.noDispatch, true, `${AgentClass.name}: invalid iframe args should be marked no-dispatch`); + assert.match(denied.detail, /urlFilter/, `${AgentClass.name}: detail should identify the missing urlFilter`); } }); @@ -59061,6 +59399,11 @@ test('text tool-call parser is production code with format and allowlist coverag raw: 'call:click{text:<|"|>Save<|"|>}', expected: [{ name: 'click', args: { text: 'Save' } }], }, + { + label: 'bare-key repair does not rewrite string contents', + raw: 'call:click{text:<|"|>Keep, status: pending<|"|>}', + expected: [{ name: 'click', args: { text: 'Keep, status: pending' } }], + }, { label: 'bare JSON with string arguments', raw: '{"name":"read_page","arguments":"[]"}', @@ -59097,6 +59440,14 @@ test('text tool-call parser is production code with format and allowlist coverag { name: 'click', args: { text: 'Go' } }, ], }, + { + label: 'recognized tool wrapper accepts an atomic compact array', + raw: '[{"name":"read_page","arguments":{}},{"name":"click","arguments":{"text":"Go"}}]', + expected: [ + { name: 'read_page', args: {} }, + { name: 'click', args: { text: 'Go' } }, + ], + }, { label: 'unclosed prose brace does not swallow the following call', raw: [ @@ -59179,6 +59530,11 @@ test('text tool-call parser is production code with format and allowlist coverag [], 'oversized model text was parsed', ); + assert.deepEqual( + parser.parseToolCallsFromText('call:click{text:<|"|>unterminated}', allowed), + [], + 'malformed fallback arguments dispatched an empty-argument action', + ); // A parsed call replaces the model's prose entirely (the caller sets // result.content = null), so a call the model was describing rather than @@ -62315,7 +62671,10 @@ test('Chrome iframe_click fallback never treats the top document as an iframe ta }; const agent = new AgentCh({}); agent._probeRichTextToolbarIframeTarget = async () => null; - const result = await agent.executeTool(42, 'iframe_click', { selector: 'button[type=submit]' }); + const result = await agent.executeTool(42, 'iframe_click', { + urlFilter: 'host.example', + selector: 'button[type=submit]', + }); assert.equal(result.success, false); assert.equal(result.noDispatch, true); assert.equal(result.matchCount, 0); @@ -62948,6 +63307,15 @@ test('planner: canonical fields recover missing and partial localization without `${label}: localized risk holes shifted translations away from their canonical positions`, ); + partialLocalization.risks = ['', 'Second canonical risk']; + partialLocalization.localized.risks = ['Wrong shifted translation', 'Correct second risk']; + const blankCanonical = parse(JSON.stringify(partialLocalization), { requireIntent: true, locale: 'tr' }); + assert.deepEqual( + blankCanonical?.localized.risks, + ['Correct second risk'], + `${label}: filtering a blank canonical risk shifted the localized risk index`, + ); + const malformedClarification = parse(JSON.stringify({ request_kind: 'clarify', requires_state_change: false, @@ -70249,6 +70617,7 @@ test('attachments: slash screenshots stage for the next turn and sent bubbles re assert.match(panel, /sendToBackground\('capture_viewport_screenshot', \{ tabId \}\)[\s\S]*stageScreenshotAttachment\(tabId, res\.dataUrl, \{[\s\S]*modelRedactionReady:[\s\S]*modelDataUrl:[\s\S]*addScreenshotResultMessage\(res\.dataUrl, \{ pageUrl: tab\.url, stagedAttachment \}\)/, `${label}: /screenshot must retain the capture-bound model copy before rendering its result card`); assert.match(panel, /source: 'slash_screenshot'[\s\S]*redactionSnapshotReady: redactionSnapshotReady === true,[\s\S]*modelRedactionReady: modelRedactionReady === true,[\s\S]*modelDataUrl[\s\S]*getPendingAttachmentsForTab\(numericTabId\)\.push\(attachment\)[\s\S]*renderAttachmentPreviews\(\)/, `${label}: slash screenshot must retain capture-time privacy data and model pixels in the tab-scoped pending attachment list`); assert.match(panel, /data-screenshot-attachment-id=[\s\S]*data-staged-screenshot=[\s\S]*actualSize !== size[\s\S]*function restoreStagedScreenshotAttachments[\s\S]*screenshot-attachment-note/, `${label}: staged screenshot cards must carry a byte-checked restore envelope and clear an unrecoverable staged claim`); + assert.match(panel, /pendingScreenshotIdsBeforeLoad[\s\S]*!pendingScreenshotIdsBeforeLoad\.has\(attachment\.stagedAttachmentId\)/, `${label}: a screenshot captured during restore must remain pending`); assert.match(panel, /function rebindRestoredMessageControls\(\) \{[\s\S]*?restoreStagedScreenshotAttachments\(\);/, `${label}: restored chats must reconstruct valid staged screenshots before rebinding controls`); assert.match(panel, /function clearPendingAttachmentsForTab[\s\S]*?setScreenshotAttachmentStaged\(numericTabId, attachment, false\)[\s\S]*?pendingAttachmentsByTab\.delete/, `${label}: sending or clearing attachments must remove stale staged screenshot claims`); assert.match(panel, /userEl = addMessage\('user', agentPrompt \? submittedText : text, \{[\s\S]*attachments: attachmentsForSend,[\s\S]*attachmentState:/, `${label}: user bubble must receive the visible prompt and actual send attachment set`); @@ -71729,6 +72098,7 @@ test('teacher slash commands and value-free page capture are wired in both brows assert.match(background, /teacherRunInterlock\.record\(tabId, msg\.teacherAction\)/); assert.match(background, /teacherRunInterlock\.navigation\(tabId, url, options\)/); assert.match(background, /scheduler\.isRunning\(tabId\)/); + assert.match(background, /response\?\.teacherCaptureReady === true/, `${build}: teacher start must require the capture script's acknowledgement`); assert.match(background, /compileWorkflowFromDemonstration/); const scheduler = fs.readFileSync(path.join(ROOT, `src/${build}/src/agent/scheduler.js`), 'utf8'); @@ -71739,6 +72109,7 @@ test('teacher slash commands and value-free page capture are wired in both brows assert.match(capture, /event\.isTrusted/); assert.match(capture, /action: 'record_teacher_action'/); assert.match(capture, /action === 'flush_teacher_capture'/); + assert.match(capture, /sendResponse\(\{ teacherCaptureReady: true \}\)/, `${build}: teacher capture must acknowledge activation`); assert.match(capture, /document\.addEventListener\('submit'/); assert.match(capture, /event\.defaultPrevented/); assert.match(capture, /sendAction\(\{ \.\.\.pending\.action, submit: true \}\)/); @@ -73837,6 +74208,17 @@ test('post-solve CAPTCHA gates consume only the correlated response token and re } }); +test('CAPTCHA gate identity compares malformed frame-path segments stably', () => { + for (const build of ['chrome', 'firefox']) { + const source = fs.readFileSync(path.join(ROOT, `src/${build}/src/agent/agent.js`), 'utf8'); + assert.match( + source, + /expectedPath\.some\(\(value, index\) => !Object\.is\(value, candidatePath\[index\]\)\)/, + `${build}: NaN frame-path segments can make a persisted CAPTCHA identity permanently unmatchable`, + ); + } +}); + test('enabled CAPTCHA gate performs a read-only dialog preflight before the first mutation', async () => { for (const [build, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) { const challengeLabel = 'Complete "Security verification" now'; @@ -76592,6 +76974,29 @@ test('OpenAI-compatible chat providers reject HTTP-200 error and non-completion } }); +test('base chat completion ignores empty error metadata but preserves real errors', async () => { + for (const build of ['chrome', 'firefox']) { + const { BaseLLMProvider } = await import(pathToFileURL( + path.join(ROOT, `src/${build}/src/providers/base.js`), + ).href); + const provider = new BaseLLMProvider(); + const message = { role: 'assistant', content: 'valid completion' }; + assert.equal( + provider._chatCompletionMessage({ error: {}, choices: [{ message }] }), + message, + `${build}: empty error metadata overrode a valid completion`, + ); + assert.throws( + () => provider._chatCompletionMessage({ + error: { message: 'Quota exceeded.' }, + choices: [{ message }], + }), + /Quota exceeded/, + `${build}: a completion choice masked a real provider error`, + ); + } +}); + test('dedicated vision descriptions suppress hidden reasoning and reuse one image for a bounded retry', async () => { for (const [label, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) { const vision = { @@ -76866,7 +77271,7 @@ test('transcription runtime uses the Chrome offscreen fallback when direct fetch assert.equal(result.ok, true); assert.equal(result.text, 'fallback transcript'); - assert.equal(directAttempts, 1); + assert.equal(directAttempts, 0); assert.equal(proxiedRequest.url, 'http://127.0.0.1:1234/v1/audio/transcriptions'); assert.equal(proxiedRequest.bodyType, 'form-data-chunked'); assert.equal(bodyChunkMessages.length, 1);