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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ci/test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/chrome/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/chrome/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
30 changes: 27 additions & 3 deletions src/chrome/src/agent/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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,
});
}
Expand Down
7 changes: 5 additions & 2 deletions src/chrome/src/agent/completion-invariant.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
13 changes: 8 additions & 5 deletions src/chrome/src/agent/loop-detector.js
Original file line number Diff line number Diff line change
Expand Up @@ -313,18 +313,21 @@ 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
&& Number.isFinite(previous.nextPage)
&& 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,
Expand Down
5 changes: 2 additions & 3 deletions src/chrome/src/agent/mutation-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
2 changes: 2 additions & 0 deletions src/chrome/src/agent/permission-gate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions src/chrome/src/agent/planner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand All @@ -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) {
Expand All @@ -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
Expand Down
24 changes: 19 additions & 5 deletions src/chrome/src/agent/rich-text-toolbar-probe.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading