- Rebased onto upstream/main (v0.24.0, full Rust native) - Renamed package to agent-browser-stealth, version 0.24.0-fork.1 - Preserved fork-specific: abs alias, extensions/tab-group-cdp, .husky hooks - Removed upstream-only: docs/, packages/dashboard, examples/, benchmarks/ - Simplified pnpm workspace to root-only - Added [[bin]] section to keep binary name as "agent-browser" Track 1 of native-stealth migration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
426 lines
13 KiB
JavaScript
426 lines
13 KiB
JavaScript
(() => {
|
|
const REQUEST_TYPE = 'AB_TAB_GROUP_REQUEST';
|
|
const RESPONSE_TYPE = 'AB_TAB_GROUP_RESPONSE';
|
|
|
|
const CONTENT_EVENT_TYPE = 'AB_CONTENT_EVENT';
|
|
const CONTENT_EXECUTE_ACTION = 'AB_CONTENT_EXECUTE_ACTION';
|
|
const CONTENT_GET_DOM_STATE = 'AB_CONTENT_GET_DOM_STATE';
|
|
const CONTENT_PING = 'AB_CONTENT_PING';
|
|
const PAGE_BRIDGE_EVENT = 'AB_PAGE_BRIDGE_EVENT';
|
|
const STORAGE_OPTIONS_KEY = 'abExtensionOptionsV1';
|
|
|
|
const mutationState = {
|
|
total: 0,
|
|
recent: [],
|
|
observerReady: false,
|
|
};
|
|
|
|
function pushMutationSummary(entry) {
|
|
mutationState.total += 1;
|
|
mutationState.recent.push({
|
|
...entry,
|
|
timestamp: Date.now(),
|
|
});
|
|
if (mutationState.recent.length > 40) {
|
|
mutationState.recent.splice(0, mutationState.recent.length - 40);
|
|
}
|
|
}
|
|
|
|
function serializeValue(value, depth = 0) {
|
|
if (value === null || typeof value === 'undefined') return value;
|
|
if (typeof value === 'string') return value.slice(0, 300);
|
|
if (typeof value === 'number' || typeof value === 'boolean') return value;
|
|
if (value instanceof Error) return `${value.name}: ${value.message}`;
|
|
if (depth > 2) return '[depth-limit]';
|
|
|
|
if (Array.isArray(value)) {
|
|
return value.slice(0, 10).map((item) => serializeValue(item, depth + 1));
|
|
}
|
|
|
|
if (typeof value === 'object') {
|
|
const out = {};
|
|
for (const [key, entry] of Object.entries(value).slice(0, 15)) {
|
|
out[key] = serializeValue(entry, depth + 1);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
return String(value).slice(0, 300);
|
|
}
|
|
|
|
function sendRuntimeEvent(kind, payload) {
|
|
try {
|
|
chrome.runtime.sendMessage({
|
|
type: CONTENT_EVENT_TYPE,
|
|
kind,
|
|
payload: serializeValue(payload),
|
|
url: window.location.href,
|
|
title: document.title,
|
|
timestamp: Date.now(),
|
|
});
|
|
} catch {
|
|
// Ignore runtime channel errors.
|
|
}
|
|
}
|
|
|
|
function getPageBridgeEnabled() {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
chrome.storage.local.get([STORAGE_OPTIONS_KEY], (result) => {
|
|
if (chrome.runtime.lastError) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
const rawOptions = result?.[STORAGE_OPTIONS_KEY];
|
|
resolve(Boolean(rawOptions && typeof rawOptions === 'object' && rawOptions.pageBridgeEnabled === true));
|
|
});
|
|
} catch {
|
|
resolve(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function installPageBridge() {
|
|
// Receives events emitted by the injected page-world hook script.
|
|
const bridgeListener = (event) => {
|
|
if (event.source !== window) return;
|
|
const data = event.data;
|
|
if (!data || data.type !== PAGE_BRIDGE_EVENT) return;
|
|
sendRuntimeEvent(data.kind || 'page-event', data.payload || {});
|
|
};
|
|
|
|
window.addEventListener('message', bridgeListener);
|
|
|
|
const parent = document.documentElement || document.head || document.body;
|
|
if (!parent) return;
|
|
|
|
if (!(await getPageBridgeEnabled())) {
|
|
sendRuntimeEvent('lifecycle', {
|
|
event: 'bridge-disabled-default',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Use external extension script instead of inline text to reduce CSP conflicts.
|
|
const script = document.createElement('script');
|
|
script.src = chrome.runtime.getURL('page-bridge.js');
|
|
script.async = false;
|
|
script.dataset.abBridgeEvent = PAGE_BRIDGE_EVENT;
|
|
script.onload = () => script.remove();
|
|
script.onerror = () => {
|
|
sendRuntimeEvent('lifecycle', {
|
|
event: 'bridge-load-failed',
|
|
host: window.location.hostname,
|
|
});
|
|
script.remove();
|
|
};
|
|
parent.appendChild(script);
|
|
}
|
|
|
|
function ensureMutationObserver() {
|
|
if (mutationState.observerReady) return;
|
|
if (!document.documentElement) return;
|
|
|
|
const observer = new MutationObserver((records) => {
|
|
const summary = {
|
|
records: records.length,
|
|
addedNodes: 0,
|
|
removedNodes: 0,
|
|
};
|
|
|
|
for (const record of records.slice(0, 40)) {
|
|
summary.addedNodes += record.addedNodes?.length || 0;
|
|
summary.removedNodes += record.removedNodes?.length || 0;
|
|
}
|
|
|
|
pushMutationSummary(summary);
|
|
});
|
|
|
|
observer.observe(document.documentElement, {
|
|
childList: true,
|
|
subtree: true,
|
|
attributes: true,
|
|
attributeFilter: ['class', 'style', 'hidden', 'disabled', 'aria-hidden'],
|
|
});
|
|
|
|
mutationState.observerReady = true;
|
|
}
|
|
|
|
function toSimpleNode(element) {
|
|
if (!element || typeof element !== 'object') return null;
|
|
const node = {
|
|
tag: element.tagName?.toLowerCase() || 'unknown',
|
|
id: element.id || undefined,
|
|
className: typeof element.className === 'string' ? element.className.slice(0, 120) : '',
|
|
role: element.getAttribute?.('role') || undefined,
|
|
name:
|
|
element.getAttribute?.('aria-label') ||
|
|
element.getAttribute?.('name') ||
|
|
element.getAttribute?.('placeholder') ||
|
|
'',
|
|
text: (element.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 160),
|
|
disabled: element.disabled === true,
|
|
hidden: element.hidden === true,
|
|
};
|
|
|
|
return node;
|
|
}
|
|
|
|
function collectInteractiveElements(root, limit = 80) {
|
|
const selector = [
|
|
'a[href]',
|
|
'button',
|
|
'input',
|
|
'select',
|
|
'textarea',
|
|
'summary',
|
|
'[role="button"]',
|
|
'[role="link"]',
|
|
'[tabindex]'
|
|
].join(',');
|
|
|
|
const out = [];
|
|
const nodes = root.querySelectorAll(selector);
|
|
for (const element of nodes) {
|
|
if (out.length >= limit) break;
|
|
out.push(toSimpleNode(element));
|
|
}
|
|
return out.filter(Boolean);
|
|
}
|
|
|
|
function collectDomState(options = {}) {
|
|
const selector = typeof options.selector === 'string' ? options.selector.trim() : '';
|
|
const root = selector ? document.querySelector(selector) : document.body || document.documentElement;
|
|
|
|
if (!root) {
|
|
return {
|
|
ok: false,
|
|
error: selector ? `selector-not-found: ${selector}` : 'root-not-found',
|
|
};
|
|
}
|
|
|
|
const textPreview = (root.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 1000);
|
|
const interactiveOnly = options.interactiveOnly === true;
|
|
const interactiveElements = collectInteractiveElements(root, options.maxNodes || 80);
|
|
|
|
const dom = {
|
|
href: window.location.href,
|
|
title: document.title,
|
|
readyState: document.readyState,
|
|
selector: selector || null,
|
|
rootTag: root.tagName?.toLowerCase() || 'unknown',
|
|
textPreview,
|
|
interactiveCount: interactiveElements.length,
|
|
interactiveElements,
|
|
mutation: {
|
|
total: mutationState.total,
|
|
recent: mutationState.recent.slice(-10),
|
|
},
|
|
capturedAt: Date.now(),
|
|
};
|
|
|
|
if (interactiveOnly) {
|
|
dom.textPreview = '';
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
state: dom,
|
|
};
|
|
}
|
|
|
|
function queryElement(selector) {
|
|
if (typeof selector !== 'string' || selector.trim().length === 0) {
|
|
throw new Error('selector is required');
|
|
}
|
|
const element = document.querySelector(selector);
|
|
if (!element) {
|
|
throw new Error(`Element not found: ${selector}`);
|
|
}
|
|
return element;
|
|
}
|
|
|
|
function focusElement(element) {
|
|
if (typeof element.focus === 'function') {
|
|
element.focus({ preventScroll: false });
|
|
}
|
|
}
|
|
|
|
function dispatchInputEvents(element) {
|
|
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
|
|
async function executeAction(command, args = {}) {
|
|
switch (command) {
|
|
case 'click': {
|
|
const element = queryElement(args.selector);
|
|
focusElement(element);
|
|
element.click();
|
|
return { ok: true, action: command, selector: args.selector };
|
|
}
|
|
case 'fill': {
|
|
const element = queryElement(args.selector);
|
|
if (!('value' in element)) {
|
|
throw new Error(`Element is not fillable: ${args.selector}`);
|
|
}
|
|
focusElement(element);
|
|
element.value = typeof args.value === 'string' ? args.value : String(args.value || '');
|
|
dispatchInputEvents(element);
|
|
return { ok: true, action: command, selector: args.selector, valueLength: element.value.length };
|
|
}
|
|
case 'press': {
|
|
const key = typeof args.key === 'string' && args.key.trim().length > 0 ? args.key.trim() : 'Enter';
|
|
let target;
|
|
if (typeof args.selector === 'string' && args.selector.trim().length > 0) {
|
|
target = queryElement(args.selector);
|
|
focusElement(target);
|
|
} else {
|
|
target = document.activeElement || document.body;
|
|
}
|
|
|
|
const down = new KeyboardEvent('keydown', { key, bubbles: true });
|
|
const up = new KeyboardEvent('keyup', { key, bubbles: true });
|
|
target.dispatchEvent(down);
|
|
target.dispatchEvent(up);
|
|
return { ok: true, action: command, key };
|
|
}
|
|
case 'eval': {
|
|
if (typeof args.expression !== 'string' || args.expression.trim().length === 0) {
|
|
throw new Error('expression is required');
|
|
}
|
|
const fn = new Function(`return (${args.expression});`);
|
|
const result = fn();
|
|
return { ok: true, action: command, result: serializeValue(result) };
|
|
}
|
|
case 'snapshot': {
|
|
return {
|
|
ok: true,
|
|
action: command,
|
|
...collectDomState({
|
|
selector: args.selector,
|
|
interactiveOnly: args.interactiveOnly === true,
|
|
maxNodes: args.maxNodes,
|
|
}),
|
|
};
|
|
}
|
|
default:
|
|
throw new Error(`Unknown content action: ${command}`);
|
|
}
|
|
}
|
|
|
|
ensureMutationObserver();
|
|
installPageBridge();
|
|
|
|
window.addEventListener('message', (event) => {
|
|
if (event.source !== window) {
|
|
return;
|
|
}
|
|
|
|
const data = event.data;
|
|
if (!data || data.type !== REQUEST_TYPE) {
|
|
return;
|
|
}
|
|
|
|
const request = {
|
|
type: REQUEST_TYPE,
|
|
nonce: data.nonce,
|
|
session: data.session,
|
|
groupTitle: data.groupTitle,
|
|
pluginId: data.pluginId,
|
|
allowedDomains: Array.isArray(data.allowedDomains) ? data.allowedDomains : undefined,
|
|
};
|
|
|
|
try {
|
|
chrome.runtime.sendMessage(request, (response) => {
|
|
const lastError = chrome.runtime.lastError;
|
|
if (lastError) {
|
|
window.postMessage(
|
|
{
|
|
type: RESPONSE_TYPE,
|
|
nonce: request.nonce,
|
|
ok: false,
|
|
error: lastError.message,
|
|
},
|
|
'*'
|
|
);
|
|
return;
|
|
}
|
|
|
|
const payload = response && typeof response === 'object' ? response : { ok: false };
|
|
|
|
window.postMessage(
|
|
{
|
|
type: RESPONSE_TYPE,
|
|
nonce: request.nonce,
|
|
ok: payload.ok === true,
|
|
extensionId:
|
|
typeof payload.extensionId === 'string' && payload.extensionId.length > 0
|
|
? payload.extensionId
|
|
: chrome.runtime.id,
|
|
groupId: typeof payload.groupId === 'number' ? payload.groupId : undefined,
|
|
windowId: typeof payload.windowId === 'number' ? payload.windowId : undefined,
|
|
color: typeof payload.color === 'string' ? payload.color : undefined,
|
|
collapsed: payload.collapsed === true,
|
|
policy:
|
|
payload.policy && typeof payload.policy === 'object'
|
|
? {
|
|
enforced: payload.policy.enforced === true,
|
|
blocked: payload.policy.blocked === true,
|
|
reason:
|
|
typeof payload.policy.reason === 'string' ? payload.policy.reason : undefined,
|
|
}
|
|
: undefined,
|
|
riskHints: Array.isArray(payload.riskHints) ? payload.riskHints : undefined,
|
|
error: typeof payload.error === 'string' ? payload.error : undefined,
|
|
},
|
|
'*'
|
|
);
|
|
});
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
window.postMessage(
|
|
{
|
|
type: RESPONSE_TYPE,
|
|
nonce: request.nonce,
|
|
ok: false,
|
|
error: errorMessage,
|
|
},
|
|
'*'
|
|
);
|
|
}
|
|
});
|
|
|
|
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
|
if (!message || typeof message !== 'object') return;
|
|
|
|
if (message.type === CONTENT_PING) {
|
|
sendResponse({
|
|
ok: true,
|
|
href: window.location.href,
|
|
title: document.title,
|
|
readyState: document.readyState,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (message.type === CONTENT_GET_DOM_STATE) {
|
|
sendResponse(collectDomState(message.options || {}));
|
|
return;
|
|
}
|
|
|
|
if (message.type === CONTENT_EXECUTE_ACTION) {
|
|
executeAction(message.command, message.args || {})
|
|
.then((result) => sendResponse(result))
|
|
.catch((error) => {
|
|
sendResponse({
|
|
ok: false,
|
|
action: message.command,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
});
|
|
return true;
|
|
}
|
|
});
|
|
})();
|