Files
jackwener__opencli/extension/src/cdp.ts
jakevin 141ec95c01 feat(browser): bind current tab to bound workspace (#1196)
* feat(browser): bind current tab to bound workspace

* docs(browser): document bound session idle semantics

* test(extension): cover bind-current owned-overwrite refusal

Adds regression for the second guard in handleBindCurrent that refuses
binding when the bound:* workspace already has an owned automation
window. Previously only the non-bound prefix path was tested.

* refactor(browser): rename bind command

* fix(browser): bind only current window tabs

* fix(browser): fail unbind when detach command fails
2026-04-27 17:35:37 +08:00

555 lines
19 KiB
TypeScript

/**
* CDP execution via chrome.debugger API.
*
* chrome.debugger only needs the "debugger" permission — no host_permissions.
* It can attach to any http/https tab. Avoid chrome:// and chrome-extension://
* tabs (resolveTabId in background.ts filters them).
*/
const attached = new Set<number>();
const tabFrameContexts = new Map<number, Map<string, number>>();
// Large cap so agents stop hitting silent JSON.parse failures on real API bodies.
// See src/browser/cdp.ts CDP_RESPONSE_BODY_CAPTURE_LIMIT for the matching constant
// on the direct-CDP path. Keep in sync.
const CDP_RESPONSE_BODY_CAPTURE_LIMIT = 8 * 1024 * 1024;
const CDP_REQUEST_BODY_CAPTURE_LIMIT = 1 * 1024 * 1024;
type NetworkCaptureEntry = {
kind: 'cdp';
url: string;
method: string;
requestHeaders?: Record<string, string>;
requestBodyKind?: string;
requestBodyPreview?: string;
requestBodyFullSize?: number;
requestBodyTruncated?: boolean;
responseStatus?: number;
responseContentType?: string;
responseHeaders?: Record<string, string>;
responsePreview?: string;
responseBodyFullSize?: number;
responseBodyTruncated?: boolean;
timestamp: number;
};
type NetworkCaptureState = {
patterns: string[];
entries: NetworkCaptureEntry[];
requestToIndex: Map<string, number>;
};
const networkCaptures = new Map<number, NetworkCaptureState>();
/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */
function isDebuggableUrl(url?: string): boolean {
if (!url) return true; // empty/undefined = tab still loading, allow it
return url.startsWith('http://') || url.startsWith('https://') || url === 'about:blank' || url.startsWith('data:');
}
export async function ensureAttached(tabId: number, aggressiveRetry: boolean = false): Promise<void> {
// Verify the tab URL is debuggable before attempting attach
try {
const tab = await chrome.tabs.get(tabId);
if (!isDebuggableUrl(tab.url)) {
// Invalidate cache if previously attached
attached.delete(tabId);
throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? 'unknown'}`);
}
} catch (e) {
// Re-throw our own error, catch only chrome.tabs.get failures
if (e instanceof Error && e.message.startsWith('Cannot debug tab')) throw e;
attached.delete(tabId);
throw new Error(`Tab ${tabId} no longer exists`);
}
if (attached.has(tabId)) {
// Verify the debugger is still actually attached by sending a harmless command
try {
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
expression: '1', returnByValue: true,
});
return; // Still attached and working
} catch {
// Stale cache entry — need to re-attach
attached.delete(tabId);
}
}
// Retry attach up to 3 times — other extensions (1Password, Playwright MCP Bridge)
// can temporarily interfere with chrome.debugger. A short delay usually resolves it.
// Normal commands: 2 retries, 500ms delay (fast fail for non-browser use)
// Browser commands: 5 retries, 1500ms delay (aggressive, tolerates extension interference)
const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2;
const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500;
let lastError = '';
for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) {
try {
// Force detach first to clear any stale state from other extensions
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
await chrome.debugger.attach({ tabId }, '1.3');
lastError = '';
break; // Success
} catch (e: unknown) {
lastError = e instanceof Error ? e.message : String(e);
if (attempt < MAX_ATTACH_RETRIES) {
console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`);
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
// Re-verify tab URL before retrying (it may have changed)
try {
const tab = await chrome.tabs.get(tabId);
if (!isDebuggableUrl(tab.url)) {
lastError = `Tab URL changed to ${tab.url} during retry`;
break; // Don't retry if URL became un-debuggable
}
} catch {
// Tab is gone — don't fail early here.
// Later retry layers can re-resolve a fresh automation tab/window.
lastError = `Tab ${tabId} no longer exists`;
// Don't break; fall through to retry
}
}
}
}
if (lastError) {
// Log detailed diagnostics for debugging extension conflicts
let finalUrl = 'unknown';
let finalWindowId = 'unknown';
try {
const tab = await chrome.tabs.get(tabId);
finalUrl = tab.url ?? 'undefined';
finalWindowId = String(tab.windowId);
} catch { /* tab gone */ }
console.warn(`[opencli] attach failed for tab ${tabId}: url=${finalUrl}, windowId=${finalWindowId}, error=${lastError}`);
const hint = lastError.includes('chrome-extension://')
? '. Tip: another Chrome extension may be interfering — try disabling other extensions'
: '';
throw new Error(`attach failed: ${lastError}${hint}`);
}
attached.add(tabId);
try {
await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable');
} catch {
// Some pages may not need explicit enable
}
}
export async function evaluate(tabId: number, expression: string, aggressiveRetry: boolean = false): Promise<unknown> {
// Retry the entire evaluate (attach + command).
// Normal: 2 retries. Browser: 3 retries (tolerates extension interference).
const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2;
for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) {
try {
await ensureAttached(tabId, aggressiveRetry);
const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true,
}) as {
result?: { type: string; value?: unknown; description?: string; subtype?: string };
exceptionDetails?: { exception?: { description?: string }; text?: string };
};
if (result.exceptionDetails) {
const errMsg = result.exceptionDetails.exception?.description
|| result.exceptionDetails.text
|| 'Eval error';
throw new Error(errMsg);
}
return result.result?.value;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
// Only retry on attach/debugger errors, not on JS eval errors
const isNavigateError = msg.includes('Inspected target navigated') || msg.includes('Target closed');
const isAttachError = isNavigateError || msg.includes('attach failed') || msg.includes('Debugger is not attached')
|| msg.includes('chrome-extension://');
if (isAttachError && attempt < MAX_EVAL_RETRIES) {
attached.delete(tabId); // Force re-attach on next attempt
// SPA navigations recover quickly; debugger detach needs longer
const retryMs = isNavigateError ? 200 : 500;
await new Promise(resolve => setTimeout(resolve, retryMs));
continue;
}
throw e;
}
}
throw new Error('evaluate: max retries exhausted');
}
export const evaluateAsync = evaluate;
/**
* Capture a screenshot via CDP Page.captureScreenshot.
* Returns base64-encoded image data.
*/
export async function screenshot(
tabId: number,
options: { format?: 'png' | 'jpeg'; quality?: number; fullPage?: boolean } = {},
): Promise<string> {
await ensureAttached(tabId);
const format = options.format ?? 'png';
// For full-page screenshots, get the full page dimensions first
if (options.fullPage) {
// Get full page metrics
const metrics = await chrome.debugger.sendCommand({ tabId }, 'Page.getLayoutMetrics') as {
contentSize?: { width: number; height: number };
cssContentSize?: { width: number; height: number };
};
const size = metrics.cssContentSize || metrics.contentSize;
if (size) {
// Set device metrics to full page size
await chrome.debugger.sendCommand({ tabId }, 'Emulation.setDeviceMetricsOverride', {
mobile: false,
width: Math.ceil(size.width),
height: Math.ceil(size.height),
deviceScaleFactor: 1,
});
}
}
try {
const params: Record<string, unknown> = { format };
if (format === 'jpeg' && options.quality !== undefined) {
params.quality = Math.max(0, Math.min(100, options.quality));
}
const result = await chrome.debugger.sendCommand({ tabId }, 'Page.captureScreenshot', params) as {
data: string; // base64-encoded
};
return result.data;
} finally {
// Reset device metrics if we changed them for full-page
if (options.fullPage) {
await chrome.debugger.sendCommand({ tabId }, 'Emulation.clearDeviceMetricsOverride').catch(() => {});
}
}
}
/**
* Set local file paths on a file input element via CDP DOM.setFileInputFiles.
* This bypasses the need to send large base64 payloads through the message channel —
* Chrome reads the files directly from the local filesystem.
*
* @param tabId - Target tab ID
* @param files - Array of absolute local file paths
* @param selector - CSS selector to find the file input (optional, defaults to first file input)
*/
export async function setFileInputFiles(
tabId: number,
files: string[],
selector?: string,
): Promise<void> {
await ensureAttached(tabId);
// Enable DOM domain (required for DOM.querySelector and DOM.setFileInputFiles)
await chrome.debugger.sendCommand({ tabId }, 'DOM.enable');
// Get the document root
const doc = await chrome.debugger.sendCommand({ tabId }, 'DOM.getDocument') as {
root: { nodeId: number };
};
// Find the file input element
const query = selector || 'input[type="file"]';
const result = await chrome.debugger.sendCommand({ tabId }, 'DOM.querySelector', {
nodeId: doc.root.nodeId,
selector: query,
}) as { nodeId: number };
if (!result.nodeId) {
throw new Error(`No element found matching selector: ${query}`);
}
// Set files directly via CDP — Chrome reads from local filesystem
await chrome.debugger.sendCommand({ tabId }, 'DOM.setFileInputFiles', {
files,
nodeId: result.nodeId,
});
}
export async function insertText(
tabId: number,
text: string,
): Promise<void> {
await ensureAttached(tabId);
await chrome.debugger.sendCommand({ tabId }, 'Input.insertText', { text });
}
export function registerFrameTracking(): void {
chrome.debugger.onEvent.addListener((source, method, params: any) => {
const tabId = source.tabId;
if (!tabId) return;
if (method === 'Runtime.executionContextCreated') {
const context = params.context;
if (!context?.auxData?.frameId || context.auxData.isDefault !== true) return;
const frameId = context.auxData.frameId as string;
if (!tabFrameContexts.has(tabId)) {
tabFrameContexts.set(tabId, new Map());
}
tabFrameContexts.get(tabId)!.set(frameId, context.id);
}
if (method === 'Runtime.executionContextDestroyed') {
const ctxId = params.executionContextId;
const contexts = tabFrameContexts.get(tabId);
if (contexts) {
for (const [fid, cid] of contexts) {
if (cid === ctxId) { contexts.delete(fid); break; }
}
}
}
if (method === 'Runtime.executionContextsCleared') {
tabFrameContexts.delete(tabId);
}
});
chrome.tabs.onRemoved.addListener((tabId) => {
tabFrameContexts.delete(tabId);
});
}
export async function getFrameTree(tabId: number): Promise<any> {
await ensureAttached(tabId);
return chrome.debugger.sendCommand({ tabId }, 'Page.getFrameTree');
}
export async function evaluateInFrame(
tabId: number,
expression: string,
frameId: string,
aggressiveRetry: boolean = false,
): Promise<unknown> {
await ensureAttached(tabId, aggressiveRetry);
await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable').catch(() => {});
const contexts = tabFrameContexts.get(tabId);
const contextId = contexts?.get(frameId);
if (contextId === undefined) {
throw new Error(`No execution context found for frame ${frameId}. The frame may not be loaded yet.`);
}
const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
expression,
contextId,
returnByValue: true,
awaitPromise: true,
}) as {
result?: { type: string; value?: unknown; description?: string; subtype?: string };
exceptionDetails?: { exception?: { description?: string }; text?: string };
};
if (result.exceptionDetails) {
const errMsg = result.exceptionDetails.exception?.description
|| result.exceptionDetails.text
|| 'Eval error';
throw new Error(errMsg);
}
return result.result?.value;
}
function normalizeCapturePatterns(pattern?: string): string[] {
return String(pattern || '')
.split('|')
.map((part) => part.trim())
.filter(Boolean);
}
function shouldCaptureUrl(url: string | undefined, patterns: string[]): boolean {
if (!url) return false;
if (!patterns.length) return true;
return patterns.some((pattern) => url.includes(pattern));
}
function normalizeHeaders(headers: unknown): Record<string, string> {
if (!headers || typeof headers !== 'object') return {};
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
out[String(key)] = String(value);
}
return out;
}
function getOrCreateNetworkCaptureEntry(tabId: number, requestId: string, fallback?: {
url?: string;
method?: string;
requestHeaders?: Record<string, string>;
}): NetworkCaptureEntry | null {
const state = networkCaptures.get(tabId);
if (!state) return null;
const existingIndex = state.requestToIndex.get(requestId);
if (existingIndex !== undefined) {
return state.entries[existingIndex] || null;
}
const url = fallback?.url || '';
if (!shouldCaptureUrl(url, state.patterns)) return null;
const entry: NetworkCaptureEntry = {
kind: 'cdp',
url,
method: fallback?.method || 'GET',
requestHeaders: fallback?.requestHeaders || {},
timestamp: Date.now(),
};
state.entries.push(entry);
state.requestToIndex.set(requestId, state.entries.length - 1);
return entry;
}
export async function startNetworkCapture(
tabId: number,
pattern?: string,
): Promise<void> {
await ensureAttached(tabId);
await chrome.debugger.sendCommand({ tabId }, 'Network.enable');
networkCaptures.set(tabId, {
patterns: normalizeCapturePatterns(pattern),
entries: [],
requestToIndex: new Map(),
});
}
export async function readNetworkCapture(tabId: number): Promise<NetworkCaptureEntry[]> {
const state = networkCaptures.get(tabId);
if (!state) return [];
const entries = state.entries.slice();
state.entries = [];
state.requestToIndex.clear();
return entries;
}
export function hasActiveNetworkCapture(tabId: number): boolean {
return networkCaptures.has(tabId);
}
export async function detach(tabId: number): Promise<void> {
if (!attached.has(tabId)) return;
attached.delete(tabId);
networkCaptures.delete(tabId);
tabFrameContexts.delete(tabId);
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
}
export function registerListeners(): void {
chrome.tabs.onRemoved.addListener((tabId) => {
attached.delete(tabId);
networkCaptures.delete(tabId);
tabFrameContexts.delete(tabId);
});
chrome.debugger.onDetach.addListener((source) => {
if (source.tabId) {
attached.delete(source.tabId);
networkCaptures.delete(source.tabId);
tabFrameContexts.delete(source.tabId);
}
});
// Invalidate attached cache when tab URL changes to non-debuggable
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
if (info.url && !isDebuggableUrl(info.url)) {
await detach(tabId);
}
});
chrome.debugger.onEvent.addListener(async (source, method, params) => {
const tabId = source.tabId;
if (!tabId) return;
const state = networkCaptures.get(tabId);
if (!state) return;
const eventParams = params as Record<string, any> | undefined;
if (method === 'Network.requestWillBeSent') {
const requestId = String(eventParams?.requestId || '');
const request = eventParams?.request as {
url?: string;
method?: string;
headers?: Record<string, unknown>;
postData?: string;
hasPostData?: boolean;
} | undefined;
const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, {
url: request?.url,
method: request?.method,
requestHeaders: normalizeHeaders(request?.headers),
});
if (!entry) return;
entry.requestBodyKind = request?.hasPostData ? 'string' : 'empty';
{
const raw = String(request?.postData || '');
const fullSize = raw.length;
const truncated = fullSize > CDP_REQUEST_BODY_CAPTURE_LIMIT;
entry.requestBodyPreview = truncated ? raw.slice(0, CDP_REQUEST_BODY_CAPTURE_LIMIT) : raw;
entry.requestBodyFullSize = fullSize;
entry.requestBodyTruncated = truncated;
}
try {
const postData = await chrome.debugger.sendCommand({ tabId }, 'Network.getRequestPostData', { requestId }) as { postData?: string };
if (postData?.postData) {
const raw = postData.postData;
const fullSize = raw.length;
const truncated = fullSize > CDP_REQUEST_BODY_CAPTURE_LIMIT;
entry.requestBodyKind = 'string';
entry.requestBodyPreview = truncated ? raw.slice(0, CDP_REQUEST_BODY_CAPTURE_LIMIT) : raw;
entry.requestBodyFullSize = fullSize;
entry.requestBodyTruncated = truncated;
}
} catch {
// Optional; some requests do not expose postData.
}
return;
}
if (method === 'Network.responseReceived') {
const requestId = String(eventParams?.requestId || '');
const response = eventParams?.response as {
url?: string;
mimeType?: string;
status?: number;
headers?: Record<string, unknown>;
} | undefined;
const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, {
url: response?.url,
});
if (!entry) return;
entry.responseStatus = response?.status;
entry.responseContentType = response?.mimeType || '';
entry.responseHeaders = normalizeHeaders(response?.headers);
return;
}
if (method === 'Network.loadingFinished') {
const requestId = String(eventParams?.requestId || '');
const stateEntryIndex = state.requestToIndex.get(requestId);
if (stateEntryIndex === undefined) return;
const entry = state.entries[stateEntryIndex];
if (!entry) return;
try {
const body = await chrome.debugger.sendCommand({ tabId }, 'Network.getResponseBody', { requestId }) as {
body?: string;
base64Encoded?: boolean;
};
if (typeof body?.body === 'string') {
const fullSize = body.body.length;
const truncated = fullSize > CDP_RESPONSE_BODY_CAPTURE_LIMIT;
const stored = truncated ? body.body.slice(0, CDP_RESPONSE_BODY_CAPTURE_LIMIT) : body.body;
entry.responsePreview = body.base64Encoded ? `base64:${stored}` : stored;
entry.responseBodyFullSize = fullSize;
entry.responseBodyTruncated = truncated;
}
} catch {
// Optional; bodies are unavailable for some requests (e.g. uploads).
}
}
});
}