Skip to content
Open
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
49 changes: 49 additions & 0 deletions extension/dist/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,41 @@ async function evaluate(tabId, expression, aggressiveRetry = false) {
throw new Error("evaluate: max retries exhausted");
}
const evaluateAsync = evaluate;
async function evaluateViaScripting(tabId, expression) {
let results;
try {
results = await chrome.scripting.executeScript({
target: { tabId },
world: "MAIN",
func: async (code) => {
try {
const value = await (0, eval)(code);
return { ok: true, value };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) };
}
},
args: [expression]
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(`--via-extension eval failed: ${msg}`);
}
if (!results || results.length === 0) {
throw new Error("--via-extension eval failed: executeScript returned no results");
}
const payload = results[0].result;
if (!payload) {
throw new Error("--via-extension eval failed: script returned undefined (return value may not be structured-cloneable)");
}
if (!payload.ok) {
const msg = payload.error ?? "Script evaluation failed";
const isCsp = /unsafe-eval|content security policy|EvalError/i.test(msg);
throw new Error(isCsp ? `${msg}
(--via-extension eval runs in the page's MAIN world and is subject to its CSP; sites that block unsafe-eval will reject this. Use plain \`browser eval\` (CDP path) on such pages.)` : msg);
}
return payload.value;
}
async function screenshot(tabId, options = {}) {
await ensureAttached(tabId);
const format = options.format ?? "png";
Expand Down Expand Up @@ -700,6 +735,7 @@ console.error = (...args) => {
forwardLog("error", args);
};
function isDaemonSocketActive(socket = ws) {
if (typeof WebSocket === "undefined") return false;
return socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING;
}
function connect() {
Expand Down Expand Up @@ -1383,6 +1419,8 @@ async function handleCommand(cmd) {
switch (cmd.action) {
case "exec":
return await handleExec(cmd, leaseKey);
case "exec-via-scripting":
return await handleExecViaScripting(cmd, leaseKey);
case "navigate":
return await handleNavigate(cmd, leaseKey);
case "tabs":
Expand Down Expand Up @@ -1613,6 +1651,17 @@ async function listAutomationWebTabs(leaseKey) {
const tabs = await listAutomationTabs(leaseKey);
return tabs.filter((tab) => isDebuggableUrl(tab.url));
}
async function handleExecViaScripting(cmd, leaseKey) {
if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" };
const cmdTabId = await resolveCommandTabId(cmd);
const tabId = await resolveTabId(cmdTabId, leaseKey);
try {
const data = await evaluateViaScripting(tabId, cmd.code);
return pageScopedResult(cmd.id, tabId, data);
} catch (err) {
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
async function handleExec(cmd, leaseKey) {
if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" };
const cmdTabId = await resolveCommandTabId(cmd);
Expand Down
1 change: 1 addition & 0 deletions extension/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in Chrome tab leases via a local daemon.",
"permissions": [
"debugger",
"scripting",
"tabs",
"cookies",
"activeTab",
Expand Down
163 changes: 162 additions & 1 deletion extension/src/background.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';

type Listener<T extends (...args: any[]) => void> = {
addListener: any;
Expand Down Expand Up @@ -216,6 +216,11 @@ function createChromeMock() {
};
}

// Keep WebSocket stubbed for the entire file so that any pending connectAttempt()
// Promises can resolve safely between describe blocks.
beforeAll(() => { vi.stubGlobal('WebSocket', MockWebSocket); });
afterAll(() => { vi.unstubAllGlobals(); });

describe('background tab isolation', () => {
beforeEach(() => {
vi.resetModules();
Expand Down Expand Up @@ -2000,3 +2005,159 @@ describe('background tab isolation', () => {
expect(chrome.tabs.create).not.toHaveBeenCalled();
});
});

describe('handleExecViaScripting path', () => {
// Keep WebSocket stubbed for the entire describe block so that any pending
// connectAttempt() Promises left over from the previous describe block can
// resolve safely (without "WebSocket is not defined") during our tests.
beforeAll(() => {
MockWebSocket.instances = [];
vi.stubGlobal('WebSocket', MockWebSocket);
});

afterAll(() => {
vi.unstubAllGlobals();
});

beforeEach(() => {
vi.resetModules();
vi.useFakeTimers();
MockWebSocket.instances = [];
vi.stubGlobal('WebSocket', MockWebSocket);
});

afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});

it('calls evaluateViaScripting and NOT evaluateAsync for exec-via-scripting action', async () => {
const { chrome } = createChromeMock();
vi.stubGlobal('chrome', chrome);

const evaluateAsync = vi.fn(async () => 'cdp-result');
const evaluateViaScripting = vi.fn(async () => 'scripting-result');
vi.doMock('./cdp', () => ({
registerListeners: vi.fn(),
registerFrameTracking: vi.fn(),
hasActiveNetworkCapture: vi.fn(() => false),
detach: vi.fn(async () => {}),
evaluateAsync,
evaluateViaScripting,
screenshot: vi.fn(),
}));

const mod = await import('./background');
mod.__test__.setSession(browserKey('default'), { windowId: 2, owned: false, preferredTabId: 2 });

const result = await mod.__test__.handleExecViaScripting({
id: 'scripting-exec',
action: 'exec-via-scripting',
session: 'default',
surface: 'browser',
code: 'document.title',
}, browserKey('default'));

expect(result.ok).toBe(true);
expect(result.data).toBe('scripting-result');
expect(evaluateViaScripting).toHaveBeenCalledOnce();
expect(evaluateAsync).not.toHaveBeenCalled();
});

it('exec action still uses evaluateAsync (CDP) and does NOT call evaluateViaScripting', async () => {
const { chrome } = createChromeMock();
vi.stubGlobal('chrome', chrome);

const evaluateAsync = vi.fn(async () => 'cdp-result');
const evaluateViaScripting = vi.fn();
vi.doMock('./cdp', () => ({
registerListeners: vi.fn(),
registerFrameTracking: vi.fn(),
hasActiveNetworkCapture: vi.fn(() => false),
detach: vi.fn(async () => {}),
evaluateAsync,
evaluateViaScripting,
screenshot: vi.fn(),
}));

const mod = await import('./background');
mod.__test__.setSession(browserKey('default'), { windowId: 2, owned: false, preferredTabId: 2 });

const result = await mod.__test__.handleExec({
id: 'cdp-exec',
action: 'exec',
session: 'default',
surface: 'browser',
code: 'document.title',
}, browserKey('default'));

expect(result.ok).toBe(true);
expect(result.data).toBe('cdp-result');
expect(evaluateAsync).toHaveBeenCalledOnce();
expect(evaluateViaScripting).not.toHaveBeenCalled();
});

it('propagates evaluateViaScripting errors as ok:false', async () => {
const { chrome } = createChromeMock();
vi.stubGlobal('chrome', chrome);

vi.doMock('./cdp', () => ({
registerListeners: vi.fn(),
registerFrameTracking: vi.fn(),
hasActiveNetworkCapture: vi.fn(() => false),
detach: vi.fn(async () => {}),
evaluateAsync: vi.fn(),
evaluateViaScripting: vi.fn(async () => { throw new Error('--via-extension eval failed: Cannot access chrome:// URL'); }),
screenshot: vi.fn(),
}));

const mod = await import('./background');
mod.__test__.setSession(browserKey('default'), { windowId: 2, owned: false, preferredTabId: 2 });

const result = await mod.__test__.handleExecViaScripting({
id: 'scripting-error',
action: 'exec-via-scripting',
session: 'default',
surface: 'browser',
code: 'document.title',
}, browserKey('default'));

expect(result.ok).toBe(false);
expect(result.error).toContain('--via-extension eval failed');
});

it('handleCommand returns ok:false for unrecognized actions (old extension fails loudly, no silent CDP fallback)', async () => {
const { chrome } = createChromeMock();
vi.stubGlobal('chrome', chrome);

const evaluateAsync = vi.fn(async () => 'cdp-result');
const evaluateViaScripting = vi.fn(async () => 'scripting-result');
vi.doMock('./cdp', () => ({
registerListeners: vi.fn(),
registerFrameTracking: vi.fn(),
hasActiveNetworkCapture: vi.fn(() => false),
detach: vi.fn(async () => {}),
evaluateAsync,
evaluateViaScripting,
screenshot: vi.fn(),
}));

const mod = await import('./background');

// An old extension that doesn't know exec-via-scripting would hit the default
// switch branch and return Unknown action — not silently fall through to CDP.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await mod.__test__.handleCommand({
id: 'unrecognized',
action: 'exec-unrecognized-future-action' as Parameters<typeof mod.__test__.handleCommand>[0]['action'],
session: 'default',
surface: 'browser',
code: 'document.title',
});

expect(result.ok).toBe(false);
expect(result.error).toMatch(/Unknown action/i);
expect(evaluateAsync).not.toHaveBeenCalled();
expect(evaluateViaScripting).not.toHaveBeenCalled();
});
});
16 changes: 16 additions & 0 deletions extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error
// ─── WebSocket connection ────────────────────────────────────────────

function isDaemonSocketActive(socket: WebSocket | null | undefined = ws): boolean {
if (typeof WebSocket === 'undefined') return false;
return socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING;
}

Expand Down Expand Up @@ -983,6 +984,8 @@ async function handleCommand(cmd: Command): Promise<Result> {
switch (cmd.action) {
case 'exec':
return await handleExec(cmd, leaseKey);
case 'exec-via-scripting':
return await handleExecViaScripting(cmd, leaseKey);
case 'navigate':
return await handleNavigate(cmd, leaseKey);
case 'tabs':
Expand Down Expand Up @@ -1274,6 +1277,18 @@ async function listAutomationWebTabs(leaseKey: string): Promise<chrome.tabs.Tab[
return tabs.filter((tab) => isDebuggableUrl(tab.url));
}

async function handleExecViaScripting(cmd: Command, leaseKey: string): Promise<Result> {
if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' };
const cmdTabId = await resolveCommandTabId(cmd);
const tabId = await resolveTabId(cmdTabId, leaseKey);
try {
const data = await executor.evaluateViaScripting(tabId, cmd.code);
return pageScopedResult(cmd.id, tabId, data);
} catch (err) {
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
}
}

async function handleExec(cmd: Command, leaseKey: string): Promise<Result> {
if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' };
const cmdTabId = await resolveCommandTabId(cmd);
Expand Down Expand Up @@ -1832,6 +1847,7 @@ async function handleBind(cmd: Command, leaseKey: string): Promise<Result> {

export const __test__ = {
handleExec,
handleExecViaScripting,
handleNavigate,
isTargetUrl,
handleTabs,
Expand Down
Loading
Loading