Compare commits

...

1 Commits

Author SHA1 Message Date
jackwener 3984e0ae5d feat(extension): smart dual-track JS executor — chrome.scripting with CDP fallback
Replace chrome.debugger (CDP) with chrome.scripting.executeScript for
JS evaluation. Zero CDP fingerprint on sites without strict CSP.

- Add extension/src/scripting.ts: tries chrome.scripting first, auto
  falls back to CDP on CSP errors (e.g. Twitter/X, Google)
- Add 'scripting' permission + host_permissions to manifest.json
- Route exec through scripting executor, keep CDP only for screenshot
- Remember CSP-blocked tabs to skip straight to CDP on subsequent calls
2026-03-21 10:53:13 +08:00
4 changed files with 185 additions and 7 deletions
+73 -4
View File
@@ -31,7 +31,7 @@ async function ensureAttached(tabId) {
} catch {
}
}
async function evaluate(tabId, expression) {
async function evaluate$1(tabId, expression) {
await ensureAttached(tabId);
const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
expression,
@@ -44,7 +44,6 @@ async function evaluate(tabId, expression) {
}
return result.result?.value;
}
const evaluateAsync = evaluate;
async function screenshot(tabId, options = {}) {
await ensureAttached(tabId);
const format = options.format ?? "png";
@@ -74,7 +73,7 @@ async function screenshot(tabId, options = {}) {
}
}
}
function detach(tabId) {
function detach$1(tabId) {
if (!attached.has(tabId)) return;
attached.delete(tabId);
try {
@@ -82,7 +81,7 @@ function detach(tabId) {
} catch {
}
}
function registerListeners() {
function registerListeners$1() {
chrome.tabs.onRemoved.addListener((tabId) => {
attached.delete(tabId);
});
@@ -91,6 +90,76 @@ function registerListeners() {
});
}
const cspBlockedTabs = /* @__PURE__ */ new Set();
async function evaluate(tabId, expression) {
if (cspBlockedTabs.has(tabId)) {
return evaluate$1(tabId, expression);
}
try {
const results = await chrome.scripting.executeScript({
target: { tabId },
world: "MAIN",
func: (expr) => {
try {
const result = (0, eval)(expr);
if (result && typeof result === "object" && typeof result.then === "function") {
return result.then(
(v) => JSON.stringify({ ok: true, v }),
(e) => JSON.stringify({ ok: false, err: e?.message || String(e) })
);
}
return JSON.stringify({ ok: true, v: result });
} catch (e) {
return JSON.stringify({ ok: false, err: e?.message || String(e) });
}
},
args: [expression]
});
if (!results || results.length === 0) {
throw new Error("executeScript returned no results");
}
const frame = results[0];
if (frame.error) {
throw new Error(frame.error.message || String(frame.error));
}
const raw = frame.result;
if (raw === null || raw === void 0) {
return evaluate$1(tabId, expression);
}
if (typeof raw === "string") {
const parsed = JSON.parse(raw);
if (!parsed.ok) {
const err = parsed.err || "";
if (err.includes("Content Security Policy") || err.includes("'unsafe-eval'")) {
cspBlockedTabs.add(tabId);
return evaluate$1(tabId, expression);
}
throw new Error(err || "Eval error");
}
return parsed.v;
}
return raw;
} catch (e) {
const msg = e?.message || String(e);
if (msg.includes("Content Security Policy") || msg.includes("'unsafe-eval'")) {
cspBlockedTabs.add(tabId);
return evaluate$1(tabId, expression);
}
throw e;
}
}
const evaluateAsync = evaluate;
function detach(tabId) {
cspBlockedTabs.delete(tabId);
detach$1(tabId);
}
function registerListeners() {
registerListeners$1();
chrome.tabs.onRemoved.addListener((tabId) => {
cspBlockedTabs.delete(tabId);
});
}
let ws = null;
let reconnectTimer = null;
let reconnectAttempts = 0;
+6 -2
View File
@@ -4,11 +4,15 @@
"version": "0.2.0",
"description": "Bridge between opencli CLI and your browser — execute commands, read cookies, manage tabs.",
"permissions": [
"debugger",
"tabs",
"cookies",
"activeTab",
"alarms"
"alarms",
"scripting",
"debugger"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "dist/background.js",
+1 -1
View File
@@ -7,7 +7,7 @@
import type { Command, Result } from './protocol';
import { DAEMON_WS_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
import * as executor from './cdp';
import * as executor from './scripting';
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
+105
View File
@@ -0,0 +1,105 @@
/**
* JS execution via chrome.scripting.executeScript (Manifest V3).
*
* Unlike cdp.ts which uses chrome.debugger (CDP), this executor leaves
* ZERO CDP fingerprint on the page. Websites cannot detect that
* automation is controlling the browser.
*
* LIMITATION: Sites with strict Content Security Policy (CSP) that block
* `unsafe-eval` (e.g., Twitter/X, Google) will reject eval(). In that
* case, we automatically fall back to CDP via cdp.ts.
*/
import * as cdp from './cdp';
/** Set of tabIds where scripting failed due to CSP, so we skip straight to CDP */
const cspBlockedTabs = new Set<number>();
export async function evaluate(tabId: number, expression: string): Promise<unknown> {
// If we already know this tab blocks eval, go straight to CDP
if (cspBlockedTabs.has(tabId)) {
return cdp.evaluate(tabId, expression);
}
try {
const results = await chrome.scripting.executeScript({
target: { tabId },
world: 'MAIN',
func: (expr: string) => {
try {
const result = (0, eval)(expr);
if (result && typeof result === 'object' && typeof result.then === 'function') {
return result.then(
(v: unknown) => JSON.stringify({ ok: true, v }),
(e: unknown) => JSON.stringify({ ok: false, err: (e as Error)?.message || String(e) }),
);
}
return JSON.stringify({ ok: true, v: result });
} catch (e) {
return JSON.stringify({ ok: false, err: (e as Error)?.message || String(e) });
}
},
args: [expression],
});
if (!results || results.length === 0) {
throw new Error('executeScript returned no results');
}
const frame = results[0];
if ((frame as any).error) {
throw new Error((frame as any).error.message || String((frame as any).error));
}
const raw = frame.result;
// MAIN world eval() returns null if Chrome can't serialize the return value
if (raw === null || raw === undefined) {
// Fall back to CDP for this execution
return cdp.evaluate(tabId, expression);
}
// Parse our JSON envelope
if (typeof raw === 'string') {
const parsed = JSON.parse(raw);
if (!parsed.ok) {
const err = parsed.err || '';
// Detect CSP errors and remember this tab
if (err.includes('Content Security Policy') || err.includes("'unsafe-eval'")) {
cspBlockedTabs.add(tabId);
return cdp.evaluate(tabId, expression);
}
throw new Error(err || 'Eval error');
}
return parsed.v;
}
return raw;
} catch (e) {
const msg = (e as Error)?.message || String(e);
// Catch CSP errors that bubble up as exceptions too
if (msg.includes('Content Security Policy') || msg.includes("'unsafe-eval'")) {
cspBlockedTabs.add(tabId);
return cdp.evaluate(tabId, expression);
}
throw e;
}
}
export const evaluateAsync = evaluate;
// Delegate to CDP for screenshot (no scripting API alternative)
export { screenshot } from './cdp';
export function detach(tabId: number): void {
cspBlockedTabs.delete(tabId);
cdp.detach(tabId);
}
export function registerListeners(): void {
cdp.registerListeners();
// Clean up CSP cache when tabs are removed
chrome.tabs.onRemoved.addListener((tabId) => {
cspBlockedTabs.delete(tabId);
});
}