Files
wehub-resource-sync 9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

2455 lines
90 KiB
JavaScript

const DAEMON_PORT = 19825;
const DAEMON_HOST = "localhost";
const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`;
const attached = /* @__PURE__ */ new Set();
const tabFrameContexts = /* @__PURE__ */ new Map();
const frameTargets = /* @__PURE__ */ new Map();
const frameTargetKeys = /* @__PURE__ */ new Map();
let frameTargetCleanupRegistered = false;
const CDP_RESPONSE_BODY_CAPTURE_LIMIT = 8 * 1024 * 1024;
const CDP_REQUEST_BODY_CAPTURE_LIMIT = 1 * 1024 * 1024;
const networkCaptures = /* @__PURE__ */ new Map();
const CDP_COMMAND_TIMEOUT_MS = 6e4;
const CDP_PROBE_TIMEOUT_MS = 2e3;
async function sendDebuggerCommand(target, method, params, timeoutMs = CDP_COMMAND_TIMEOUT_MS) {
let timer;
const commandPromise = params === void 0 ? chrome.debugger.sendCommand(target, method) : chrome.debugger.sendCommand(target, method, params);
commandPromise.catch(() => {
});
try {
return await Promise.race([
commandPromise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(
`CDP command ${method} timed out after ${Math.round(timeoutMs / 1e3)}s — the page may be blocked by a native dialog (alert/confirm/print)`
)), timeoutMs);
})
]);
} finally {
if (timer !== void 0) clearTimeout(timer);
}
}
function isDebuggableUrl$1(url) {
if (!url) return true;
return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:");
}
async function ensureAttached(tabId, aggressiveRetry = false) {
try {
const tab = await chrome.tabs.get(tabId);
if (!isDebuggableUrl$1(tab.url)) {
attached.delete(tabId);
throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`);
}
} catch (e) {
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)) {
try {
await sendDebuggerCommand({ tabId }, "Runtime.evaluate", {
expression: "1",
returnByValue: true
}, CDP_PROBE_TIMEOUT_MS);
return;
} catch {
attached.delete(tabId);
}
}
const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2;
const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500;
let lastError = "";
const preservedNetworkCapture = networkCaptures.get(tabId);
for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) {
try {
try {
await chrome.debugger.detach({ tabId });
} catch {
}
await chrome.debugger.attach({ tabId }, "1.3");
lastError = "";
break;
} catch (e) {
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));
try {
const tab = await chrome.tabs.get(tabId);
if (!isDebuggableUrl$1(tab.url)) {
lastError = `Tab URL changed to ${tab.url} during retry`;
break;
}
} catch {
lastError = `Tab ${tabId} no longer exists`;
}
}
}
}
if (lastError) {
let finalUrl = "unknown";
let finalWindowId = "unknown";
try {
const tab = await chrome.tabs.get(tabId);
finalUrl = tab.url ?? "undefined";
finalWindowId = String(tab.windowId);
} catch {
}
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 sendDebuggerCommand({ tabId }, "Runtime.enable");
} catch {
}
if (preservedNetworkCapture) {
try {
await sendDebuggerCommand({ tabId }, "Network.enable");
networkCaptures.set(tabId, preservedNetworkCapture);
} catch {
}
}
}
async function evaluate(tabId, expression, aggressiveRetry = false, timeoutMs = CDP_COMMAND_TIMEOUT_MS) {
try {
await ensureAttached(tabId, aggressiveRetry);
const result = await sendDebuggerCommand({ tabId }, "Runtime.evaluate", {
expression,
returnByValue: true,
awaitPromise: true
}, timeoutMs);
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);
if (msg.includes("Detached") || msg.includes("Debugger is not attached") || msg.includes("Target closed")) {
attached.delete(tabId);
}
throw e;
}
}
const evaluateAsync = evaluate;
async function screenshot(tabId, options = {}) {
await ensureAttached(tabId);
const format = options.format ?? "png";
const fullPage = options.fullPage === true;
const overrideWidth = options.width && options.width > 0 ? Math.ceil(options.width) : void 0;
const overrideHeight = !fullPage && options.height && options.height > 0 ? Math.ceil(options.height) : void 0;
const needsOverride = fullPage || overrideWidth !== void 0 || overrideHeight !== void 0;
if (needsOverride) {
if (overrideWidth !== void 0 && fullPage) {
await sendDebuggerCommand({ tabId }, "Emulation.setDeviceMetricsOverride", {
mobile: false,
width: overrideWidth,
height: 0,
deviceScaleFactor: 1
});
}
let finalWidth = overrideWidth ?? 0;
let finalHeight = overrideHeight ?? 0;
if (fullPage) {
const metrics = await sendDebuggerCommand({ tabId }, "Page.getLayoutMetrics");
const size = metrics.cssContentSize || metrics.contentSize;
if (size) {
if (finalWidth === 0) finalWidth = Math.ceil(size.width);
finalHeight = Math.ceil(size.height);
}
}
await sendDebuggerCommand({ tabId }, "Emulation.setDeviceMetricsOverride", {
mobile: false,
width: finalWidth,
height: finalHeight,
deviceScaleFactor: 1
});
}
try {
const params = { format };
if (format === "jpeg" && options.quality !== void 0) {
params.quality = Math.max(0, Math.min(100, options.quality));
}
const result = await sendDebuggerCommand({ tabId }, "Page.captureScreenshot", params);
return result.data;
} finally {
if (needsOverride) {
await sendDebuggerCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => {
});
}
}
}
async function setFileInputFiles(tabId, files, selector) {
await ensureAttached(tabId);
await sendDebuggerCommand({ tabId }, "DOM.enable");
await sendDebuggerCommand({ tabId }, "Page.enable");
const query = selector || 'input[type="file"]';
const found = await sendDebuggerCommand({ tabId }, "Runtime.evaluate", {
expression: `!!document.querySelector(${JSON.stringify(query)})`,
returnByValue: true
});
if (!found.result?.value) {
throw new Error(`No element found matching selector: ${query}`);
}
await sendDebuggerCommand({ tabId }, "Page.setInterceptFileChooserDialog", { enabled: true });
try {
const backendNodeId = await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error("Page.fileChooserOpened not received within 5s — the input may not have opened a file chooser"));
}, 5e3);
const listener = (source, method, params) => {
if (source.tabId !== tabId || method !== "Page.fileChooserOpened") return;
cleanup();
const backend = params?.backendNodeId;
if (typeof backend === "number") resolve(backend);
else reject(new Error("Page.fileChooserOpened carried no backendNodeId"));
};
const cleanup = () => {
clearTimeout(timer);
chrome.debugger.onEvent.removeListener(listener);
};
chrome.debugger.onEvent.addListener(listener);
void sendDebuggerCommand({ tabId }, "Runtime.evaluate", {
expression: `document.querySelector(${JSON.stringify(query)}).click()`
}).catch((err) => {
cleanup();
reject(err instanceof Error ? err : new Error(String(err)));
});
});
await sendDebuggerCommand({ tabId }, "DOM.setFileInputFiles", {
files,
backendNodeId
});
} finally {
await sendDebuggerCommand({ tabId }, "Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => {
});
}
}
function matchesDownloadPattern(item, pattern) {
if (!pattern) return true;
const haystack = [
item.filename,
item.url,
item.finalUrl,
item.mime
].filter(Boolean).join("\n").toLowerCase();
return haystack.includes(pattern.toLowerCase());
}
function downloadResult(item, startedAt) {
return {
downloaded: item.state === "complete",
id: item.id,
filename: item.filename,
url: item.url,
finalUrl: item.finalUrl,
mime: item.mime,
totalBytes: item.totalBytes,
state: item.state,
danger: item.danger,
error: item.error,
elapsedMs: Date.now() - startedAt
};
}
async function waitForDownload(pattern = "", timeoutMs = 3e4) {
const startedAt = Date.now();
const timeout = Math.max(1, timeoutMs);
return await new Promise((resolve) => {
let done = false;
const inProgressIds = /* @__PURE__ */ new Set();
const finish = (result) => {
if (done) return;
done = true;
clearTimeout(timer);
chrome.downloads.onCreated.removeListener(onCreated);
chrome.downloads.onChanged.removeListener(onChanged);
resolve(result);
};
const inspectById = async (id) => {
const items = await chrome.downloads.search({ id });
const item = items[0];
if (!item || !matchesDownloadPattern(item, pattern)) return;
inProgressIds.add(id);
if (item.state === "complete" || item.state === "interrupted") finish(downloadResult(item, startedAt));
};
const onCreated = (item) => {
if (!matchesDownloadPattern(item, pattern)) return;
inProgressIds.add(item.id);
if (item.state === "complete" || item.state === "interrupted") finish(downloadResult(item, startedAt));
};
const onChanged = (delta) => {
if (!delta.id) return;
if (!inProgressIds.has(delta.id) && !delta.filename && !delta.url) return;
if (delta.filename?.current || delta.url?.current) {
void inspectById(delta.id);
return;
}
if (delta.state?.current === "complete" || delta.state?.current === "interrupted") {
void inspectById(delta.id);
}
};
const timer = setTimeout(() => {
finish({
downloaded: false,
state: "interrupted",
error: `No download matched "${pattern || "*"}" within ${timeout}ms`,
elapsedMs: Date.now() - startedAt
});
}, timeout);
chrome.downloads.onCreated.addListener(onCreated);
chrome.downloads.onChanged.addListener(onChanged);
void chrome.downloads.search({
limit: 50,
orderBy: ["-startTime"],
startedAfter: new Date(startedAt - Math.max(timeout, 1e3)).toISOString()
}).then((recent) => {
if (done) return;
const completed = recent.find((item) => item.state === "complete" && matchesDownloadPattern(item, pattern));
if (completed) {
finish(downloadResult(completed, startedAt));
return;
}
for (const item of recent) {
if (item.state === "in_progress" && matchesDownloadPattern(item, pattern)) inProgressIds.add(item.id);
}
}).catch((err) => {
finish({
downloaded: false,
state: "interrupted",
error: err instanceof Error ? err.message : String(err),
elapsedMs: Date.now() - startedAt
});
});
});
}
function frameTargetKey(tabId, frameId) {
return `${tabId}:${frameId}`;
}
function registerFrameTargetCleanup() {
if (frameTargetCleanupRegistered) return;
frameTargetCleanupRegistered = true;
chrome.debugger.onEvent.addListener((_source, method, params) => {
if (method === "Target.detachedFromTarget") {
const targetId = String(params?.targetId || "");
clearFrameTarget(targetId);
}
});
}
function clearFrameTarget(targetId) {
if (!targetId) return;
const key = frameTargetKeys.get(targetId);
if (key) frameTargets.delete(key);
frameTargetKeys.delete(targetId);
}
async function ensureFrameTarget(tabId, frameId, aggressiveRetry = false, targetUrl) {
registerFrameTargetCleanup();
await ensureAttached(tabId, aggressiveRetry);
const key = frameTargetKey(tabId, frameId);
const existing = frameTargets.get(key);
if (existing) return existing;
await sendDebuggerCommand({ tabId }, "Target.setDiscoverTargets", { discover: true }).catch(() => {
});
await sendDebuggerCommand({ tabId }, "Target.setAutoAttach", {
autoAttach: true,
waitForDebuggerOnStart: false,
flatten: true,
filter: [{ type: "iframe", exclude: false }]
}).catch(() => {
});
const targetId = await resolveFrameTargetId(tabId, frameId, targetUrl);
try {
await chrome.debugger.attach({ targetId }, "1.3");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (!message.includes("Another debugger is already attached")) throw err;
}
frameTargets.set(key, targetId);
frameTargetKeys.set(targetId, key);
return targetId;
}
async function resolveFrameTargetId(tabId, frameId, targetUrl) {
const result = await sendDebuggerCommand({ tabId }, "Target.getTargets").catch(() => null);
const targets = result?.targetInfos ?? [];
const frameTarget = targets.find((candidate) => {
const candidateId = candidate.targetId || candidate.id;
return candidate.type === "iframe" && (candidateId === frameId || !!targetUrl && candidate.url === targetUrl);
});
const targetId = frameTarget?.targetId || frameTarget?.id;
if (targetId) return targetId;
const candidates = targets.filter((target) => target.type === "iframe").map((target) => `${target.targetId || target.id || "?"} ${target.url || ""}`).join("; ");
throw new Error(`No iframe target found for frame ${frameId}${targetUrl ? ` (${targetUrl})` : ""}. Candidates: ${candidates || "none"}`);
}
async function sendCommandInFrameTarget(tabId, frameId, method, params = {}, aggressiveRetry = false, timeoutMs = CDP_COMMAND_TIMEOUT_MS, targetUrl) {
const targetId = await ensureFrameTarget(tabId, frameId, aggressiveRetry, targetUrl);
const target = { targetId };
return sendDebuggerCommand(target, method, params, timeoutMs);
}
async function insertText(tabId, text) {
await ensureAttached(tabId);
await sendDebuggerCommand({ tabId }, "Input.insertText", { text });
}
function registerFrameTracking() {
registerFrameTargetCleanup();
chrome.debugger.onEvent.addListener((source, method, params) => {
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;
if (!tabFrameContexts.has(tabId)) {
tabFrameContexts.set(tabId, /* @__PURE__ */ 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);
});
}
async function getFrameTree(tabId) {
await ensureAttached(tabId);
return sendDebuggerCommand({ tabId }, "Page.getFrameTree");
}
async function evaluateInFrame(tabId, expression, frameId, aggressiveRetry = false, timeoutMs = CDP_COMMAND_TIMEOUT_MS) {
await ensureAttached(tabId, aggressiveRetry);
await sendDebuggerCommand({ tabId }, "Runtime.enable").catch(() => {
});
const contexts = tabFrameContexts.get(tabId);
const contextId = contexts?.get(frameId);
if (contextId !== void 0) {
try {
const result2 = await sendDebuggerCommand({ tabId }, "Runtime.evaluate", {
expression,
contextId,
returnByValue: true,
awaitPromise: true
}, timeoutMs);
if (result2.exceptionDetails) {
const errMsg = result2.exceptionDetails.exception?.description || result2.exceptionDetails.text || "Eval error";
throw new Error(errMsg);
}
return result2.result?.value;
} catch (err) {
const msg = String(err?.message || err);
if (!/Cannot find context|context with specified id|Execution context was destroyed/i.test(msg)) {
throw err;
}
contexts?.delete(frameId);
}
}
await sendCommandInFrameTarget(tabId, frameId, "Runtime.enable", {}, aggressiveRetry, timeoutMs).catch(() => void 0);
const result = await sendCommandInFrameTarget(tabId, frameId, "Runtime.evaluate", {
expression,
returnByValue: true,
awaitPromise: true
}, aggressiveRetry, timeoutMs);
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) {
return String(pattern || "").split("|").map((part) => part.trim()).filter(Boolean);
}
function shouldCaptureUrl(url, patterns) {
if (!url) return false;
if (!patterns.length) return true;
return patterns.some((pattern) => url.includes(pattern));
}
function normalizeHeaders(headers) {
if (!headers || typeof headers !== "object") return {};
const out = {};
for (const [key, value] of Object.entries(headers)) {
out[String(key)] = String(value);
}
return out;
}
function getOrCreateNetworkCaptureEntry(tabId, requestId, fallback) {
const state = networkCaptures.get(tabId);
if (!state) return null;
const existingIndex = state.requestToIndex.get(requestId);
if (existingIndex !== void 0) {
return state.entries[existingIndex] || null;
}
const url = fallback?.url || "";
if (!shouldCaptureUrl(url, state.patterns)) return null;
const entry = {
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;
}
async function startNetworkCapture(tabId, pattern) {
await ensureAttached(tabId);
await sendDebuggerCommand({ tabId }, "Network.enable");
networkCaptures.set(tabId, {
patterns: normalizeCapturePatterns(pattern),
entries: [],
requestToIndex: /* @__PURE__ */ new Map()
});
}
async function readNetworkCapture(tabId) {
const state = networkCaptures.get(tabId);
if (!state) return [];
const entries = state.entries.slice();
state.entries = [];
state.requestToIndex.clear();
return entries;
}
function hasActiveNetworkCapture(tabId) {
return networkCaptures.has(tabId);
}
function clearFrameTargetsForTab(tabId) {
for (const [key, targetId] of [...frameTargets.entries()]) {
if (!key.startsWith(`${tabId}:`)) continue;
frameTargets.delete(key);
frameTargetKeys.delete(targetId);
chrome.debugger.detach({ targetId }).catch(() => {
});
}
}
async function detach(tabId) {
clearFrameTargetsForTab(tabId);
if (!attached.has(tabId)) return;
attached.delete(tabId);
networkCaptures.delete(tabId);
tabFrameContexts.delete(tabId);
try {
await chrome.debugger.detach({ tabId });
} catch {
}
}
function registerListeners() {
chrome.tabs.onRemoved.addListener((tabId) => {
attached.delete(tabId);
networkCaptures.delete(tabId);
tabFrameContexts.delete(tabId);
clearFrameTargetsForTab(tabId);
});
chrome.debugger.onDetach.addListener((source) => {
if (source.tabId) {
attached.delete(source.tabId);
networkCaptures.delete(source.tabId);
tabFrameContexts.delete(source.tabId);
clearFrameTargetsForTab(source.tabId);
return;
}
if (source.targetId) clearFrameTarget(source.targetId);
});
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
if (info.url && !isDebuggableUrl$1(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;
if (method === "Network.requestWillBeSent") {
const requestId = String(eventParams?.requestId || "");
const request = eventParams?.request;
const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, {
url: request?.url,
method: request?.method,
requestHeaders: normalizeHeaders(request?.headers)
});
if (!entry) return;
if (!eventParams?.redirectResponse) {
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 sendDebuggerCommand({ tabId }, "Network.getRequestPostData", { requestId });
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 {
}
}
return;
}
if (method === "Network.responseReceived") {
const requestId = String(eventParams?.requestId || "");
const response = eventParams?.response;
const stateEntryIndex = state.requestToIndex.get(requestId);
if (stateEntryIndex === void 0) return;
const entry = state.entries[stateEntryIndex];
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 === void 0) return;
const entry = state.entries[stateEntryIndex];
if (!entry) return;
try {
const body = await sendDebuggerCommand({ tabId }, "Network.getResponseBody", { requestId });
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 {
}
}
});
}
const targetToTab = /* @__PURE__ */ new Map();
const tabToTarget = /* @__PURE__ */ new Map();
async function resolveTargetId(tabId) {
const cached = tabToTarget.get(tabId);
if (cached) return cached;
await refreshMappings();
const result = tabToTarget.get(tabId);
if (!result) throw new Error(`No targetId for tab ${tabId} — page may have been closed`);
return result;
}
async function resolveTabId$1(targetId) {
const cached = targetToTab.get(targetId);
if (cached !== void 0) return cached;
await refreshMappings();
const result = targetToTab.get(targetId);
if (result === void 0) throw new Error(`Page not found: ${targetId} — stale page identity`);
return result;
}
function evictTab(tabId) {
const targetId = tabToTarget.get(tabId);
if (targetId) targetToTab.delete(targetId);
tabToTarget.delete(tabId);
}
async function refreshMappings() {
const targets = await chrome.debugger.getTargets();
targetToTab.clear();
tabToTarget.clear();
for (const t of targets) {
if (t.type === "page" && t.tabId !== void 0) {
targetToTab.set(t.id, t.tabId);
tabToTarget.set(t.tabId, t.id);
}
}
}
const JOURNAL_KEY = "opencli_command_journal_v1";
const JOURNAL_MAX_ENTRIES = 64;
const JOURNAL_RESULT_MAX_BYTES = 64 * 1024;
let cache = null;
let writeQueue = Promise.resolve();
const inFlight = /* @__PURE__ */ new Map();
async function load() {
if (cache) return cache;
try {
const stored = await chrome.storage.session.get(JOURNAL_KEY);
cache = stored?.[JOURNAL_KEY] ?? {};
} catch {
cache = {};
}
return cache;
}
function persist() {
const snapshot = cache;
if (!snapshot) return;
writeQueue = writeQueue.then(async () => {
try {
await chrome.storage.session.set({ [JOURNAL_KEY]: snapshot });
} catch {
}
});
}
function trim(journal) {
const ids = Object.keys(journal);
if (ids.length <= JOURNAL_MAX_ENTRIES) return;
ids.sort((a, b) => journal[a].ts - journal[b].ts);
for (const id of ids.slice(0, ids.length - JOURNAL_MAX_ENTRIES)) delete journal[id];
}
function resultByteLength(result) {
try {
return JSON.stringify(result).length;
} catch {
return Number.POSITIVE_INFINITY;
}
}
const UNKNOWN_OUTCOME_HINT = "Inspect the browser/session state before retrying. Do not blindly re-run write commands such as navigate, click, type, or eval.";
async function executeWithJournal(cmd, execute) {
const id = cmd.id;
if (!id) return execute(cmd);
const running = inFlight.get(id);
if (running) return running;
const run = (async () => {
const journal = await load();
const entry = journal[id];
if (entry?.status === "done") {
if (entry.result) return entry.result;
return {
id,
ok: false,
errorCode: "result_evicted",
error: "Command already executed, but its result was too large to record for replay.",
errorHint: UNKNOWN_OUTCOME_HINT
};
}
if (entry?.status === "started") {
return {
id,
ok: false,
errorCode: "command_lost",
error: "Command was interrupted mid-execution (extension or browser restarted); it may or may not have applied.",
errorHint: UNKNOWN_OUTCOME_HINT
};
}
journal[id] = { status: "started", ts: Date.now() };
trim(journal);
persist();
let result;
try {
result = await execute(cmd);
} catch (err) {
result = { id, ok: false, error: err instanceof Error ? err.message : String(err) };
}
journal[id] = resultByteLength(result) <= JOURNAL_RESULT_MAX_BYTES ? { status: "done", ts: Date.now(), result } : { status: "done", ts: Date.now() };
persist();
return result;
})();
inFlight.set(id, run);
try {
return await run;
} finally {
inFlight.delete(id);
}
}
let ws = null;
let reconnectTimer = null;
let reconnectAttempts = 0;
const CONTEXT_ID_KEY = "opencli_context_id_v1";
let currentContextId = "default";
let contextIdPromise = null;
let connectInFlight = null;
let workerReady = Promise.resolve();
let workerRecovered = true;
async function getCurrentContextId() {
if (contextIdPromise) return contextIdPromise;
contextIdPromise = (async () => {
try {
const local = chrome.storage?.local;
if (!local) return currentContextId;
const raw = await local.get(CONTEXT_ID_KEY);
const existing = raw[CONTEXT_ID_KEY];
if (typeof existing === "string" && existing.trim()) {
currentContextId = existing.trim();
return currentContextId;
}
const generated = generateContextId();
await local.set({ [CONTEXT_ID_KEY]: generated });
currentContextId = generated;
return currentContextId;
} catch {
return currentContextId;
}
})();
return contextIdPromise;
}
function generateContextId() {
const alphabet = "23456789abcdefghjkmnpqrstuvwxyz";
const maxUnbiasedByte = Math.floor(256 / alphabet.length) * alphabet.length;
let id = "";
while (id.length < 8) {
const bytes = new Uint8Array(8);
try {
crypto.getRandomValues(bytes);
} catch {
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
}
for (const byte of bytes) {
if (byte >= maxUnbiasedByte) continue;
id += alphabet[byte % alphabet.length];
if (id.length === 8) break;
}
}
return id;
}
const _origLog = console.log.bind(console);
const _origWarn = console.warn.bind(console);
const _origError = console.error.bind(console);
function forwardLog(level, args) {
try {
const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
safeSend(ws, { type: "log", level, msg, ts: Date.now() });
} catch {
}
}
function safeSend(socket, payload) {
if (!socket || socket.readyState !== WebSocket.OPEN) return false;
try {
socket.send(JSON.stringify(payload));
return true;
} catch {
return false;
}
}
console.log = (...args) => {
_origLog(...args);
forwardLog("info", args);
};
console.warn = (...args) => {
_origWarn(...args);
forwardLog("warn", args);
};
console.error = (...args) => {
_origError(...args);
forwardLog("error", args);
};
function isDaemonSocketActive(socket = ws) {
return socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING;
}
function connect() {
if (isDaemonSocketActive()) return Promise.resolve();
if (connectInFlight) return connectInFlight;
const attempt = workerRecovered ? connectAttempt() : workerReady.then(() => connectAttempt());
connectInFlight = attempt.finally(() => {
connectInFlight = null;
});
return connectInFlight;
}
async function connectAttempt() {
if (isDaemonSocketActive()) return;
try {
const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) });
if (!res.ok) {
scheduleReconnect();
return;
}
reconnectAttempts = 0;
} catch {
scheduleReconnect();
return;
}
if (isDaemonSocketActive()) return;
let thisWs;
try {
const contextId = await getCurrentContextId();
if (isDaemonSocketActive()) return;
thisWs = new WebSocket(DAEMON_WS_URL);
ws = thisWs;
currentContextId = contextId;
} catch {
scheduleReconnect();
return;
}
thisWs.onopen = () => {
if (ws !== thisWs) return;
console.log("[opencli] Connected to daemon");
reconnectAttempts = 0;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
safeSend(thisWs, {
type: "hello",
contextId: currentContextId,
version: chrome.runtime.getManifest().version,
compatRange: ">=1.7.0"
});
startWsKeepalive(thisWs);
};
thisWs.onmessage = async (event) => {
if (ws !== thisWs) return;
try {
const command = JSON.parse(event.data);
const result = await executeWithJournal(command, handleCommand);
const target = ws && ws.readyState === WebSocket.OPEN ? ws : thisWs;
safeSend(target, result);
} catch (err) {
console.error("[opencli] Message handling error:", err);
}
};
thisWs.onclose = () => {
stopWsKeepalive(thisWs);
if (ws !== thisWs) return;
console.log("[opencli] Disconnected from daemon");
ws = null;
scheduleReconnect();
};
thisWs.onerror = () => {
thisWs.close();
};
}
const WS_KEEPALIVE_INTERVAL_MS = 2e4;
let wsKeepaliveTimer = null;
let wsKeepaliveSocket = null;
function startWsKeepalive(socket) {
if (wsKeepaliveTimer) clearInterval(wsKeepaliveTimer);
wsKeepaliveSocket = socket;
wsKeepaliveTimer = setInterval(() => {
if (socket !== ws || socket.readyState !== WebSocket.OPEN) {
stopWsKeepalive(socket);
return;
}
safeSend(socket, { type: "ping", ts: Date.now() });
}, WS_KEEPALIVE_INTERVAL_MS);
}
function stopWsKeepalive(socket) {
if (wsKeepaliveSocket !== socket) return;
if (wsKeepaliveTimer) clearInterval(wsKeepaliveTimer);
wsKeepaliveTimer = null;
wsKeepaliveSocket = null;
}
const RECONNECT_BASE_DELAY_MS = 1e3;
const RECONNECT_MAX_DELAY_MS = 15e3;
function nextReconnectDelayMs() {
const exp = Math.min(RECONNECT_MAX_DELAY_MS, RECONNECT_BASE_DELAY_MS * 2 ** Math.min(reconnectAttempts, 6));
return exp + Math.floor(Math.random() * 500);
}
function scheduleReconnect() {
if (reconnectTimer) return;
const delay = nextReconnectDelayMs();
reconnectAttempts++;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
void connect();
}, delay);
}
const automationSessions = /* @__PURE__ */ new Map();
const IDLE_TIMEOUT_DEFAULT = 3e4;
const IDLE_TIMEOUT_INTERACTIVE = 6e5;
const IDLE_TIMEOUT_NONE = -1;
const REGISTRY_KEY = "opencli_target_lease_registry_v2";
const LEASE_IDLE_ALARM_PREFIX = "opencli:lease-idle:";
const CONTAINER_TAB_GROUP_TITLE = {
interactive: "OpenCLI Browser",
// Retained for registry/type compatibility. Adapter automation no longer
// creates or discovers a visible tab group.
automation: "OpenCLI Adapter"
};
const OWNED_TAB_GROUP_COLOR = "orange";
let leaseMutationQueue = Promise.resolve();
const ownedContainers = {
interactive: { windowId: null, groupId: null, promise: null, groupPromise: null },
automation: { windowId: null, groupId: null, promise: null, groupPromise: null }
};
const interactiveGroupLedger = /* @__PURE__ */ new Set();
class CommandFailure extends Error {
constructor(code, message, hint) {
super(message);
this.code = code;
this.hint = hint;
this.name = "CommandFailure";
}
}
const sessionOverrides = /* @__PURE__ */ new Map();
function setSessionOverride(key, patch) {
sessionOverrides.set(key, { ...sessionOverrides.get(key), ...patch });
}
const activeCommandCounts = /* @__PURE__ */ new Map();
const LEASE_KEY_SEPARATOR = "\0";
function getLeaseKey(session, surface) {
return `${surface}${LEASE_KEY_SEPARATOR}${encodeURIComponent(session)}`;
}
function getSessionName(session) {
const raw = session?.trim();
if (!raw) throw new CommandFailure(
"session_required",
"Browser session is required.",
"Pass a browser session name, e.g. opencli browser <session> <command>."
);
return raw;
}
function getCommandSurface(cmd) {
return cmd.surface === "adapter" ? "adapter" : "browser";
}
function getSurfaceFromKey(key) {
return key.split(LEASE_KEY_SEPARATOR, 1)[0] === "adapter" ? "adapter" : "browser";
}
function getSessionFromKey(key) {
const idx = key.indexOf(LEASE_KEY_SEPARATOR);
if (idx === -1) return key;
try {
return decodeURIComponent(key.slice(idx + 1));
} catch {
return key.slice(idx + 1);
}
}
function getIdleTimeout(key) {
const session = automationSessions.get(key);
if (session?.kind === "bound") return IDLE_TIMEOUT_NONE;
const overrides = sessionOverrides.get(key);
const adapterPersistent = getSurfaceFromKey(key) === "adapter" && (session?.lifecycle === "persistent" || overrides?.lifecycle === "persistent");
if (adapterPersistent) return IDLE_TIMEOUT_NONE;
if (overrides?.idleTimeoutMs !== void 0) return overrides.idleTimeoutMs;
return getSurfaceFromKey(key) === "browser" ? IDLE_TIMEOUT_INTERACTIVE : IDLE_TIMEOUT_DEFAULT;
}
function getLeaseLifecycle(key, kind) {
if (kind === "bound") return "pinned";
const override = sessionOverrides.get(key)?.lifecycle;
if (override) return override;
return getSurfaceFromKey(key) === "browser" ? "persistent" : "ephemeral";
}
function getOwnedWindowRole(key) {
return getSurfaceFromKey(key) === "browser" ? "interactive" : "automation";
}
function getWindowRole(key, ownership) {
return ownership === "borrowed" ? "borrowed-user" : getOwnedWindowRole(key);
}
function getWindowMode(key) {
return sessionOverrides.get(key)?.windowMode ?? (getOwnedWindowRole(key) === "interactive" ? "foreground" : "background");
}
function makeAlarmName(leaseKey) {
return `${LEASE_IDLE_ALARM_PREFIX}${encodeURIComponent(leaseKey)}`;
}
function leaseKeyFromAlarmName(name) {
if (!name.startsWith(LEASE_IDLE_ALARM_PREFIX)) return null;
try {
return decodeURIComponent(name.slice(LEASE_IDLE_ALARM_PREFIX.length));
} catch {
return null;
}
}
function withLeaseMutation(fn) {
const run = leaseMutationQueue.then(fn, fn);
leaseMutationQueue = run.then(() => void 0, () => void 0);
return run;
}
function makeSession(key, session) {
const ownership = session.owned ? "owned" : "borrowed";
return {
...session,
contextId: currentContextId,
ownership,
lifecycle: getLeaseLifecycle(key, session.kind),
windowRole: getWindowRole(key, ownership)
};
}
function emptyRegistry() {
return {
version: 2,
contextId: currentContextId,
ownedContainers: {
interactive: {
windowId: ownedContainers.interactive.windowId,
groupIds: [...interactiveGroupLedger]
},
automation: { windowId: ownedContainers.automation.windowId }
},
leases: {}
};
}
async function readRegistry() {
try {
const session = chrome.storage?.session;
if (!session) return emptyRegistry();
const raw = await session.get(REGISTRY_KEY);
const stored = raw[REGISTRY_KEY];
if (!stored || stored.version !== 2 || typeof stored.leases !== "object") return emptyRegistry();
const storedContainers = stored.ownedContainers && typeof stored.ownedContainers === "object" ? stored.ownedContainers : emptyRegistry().ownedContainers;
return {
version: 2,
contextId: currentContextId,
ownedContainers: {
interactive: {
windowId: typeof storedContainers.interactive?.windowId === "number" ? storedContainers.interactive.windowId : null,
groupIds: Array.isArray(storedContainers.interactive?.groupIds) ? storedContainers.interactive.groupIds.filter((id) => typeof id === "number") : []
},
automation: {
windowId: typeof storedContainers.automation?.windowId === "number" ? storedContainers.automation.windowId : null
}
},
leases: stored.leases
};
} catch {
return emptyRegistry();
}
}
async function writeRegistry(registry) {
try {
await chrome.storage?.session?.set({ [REGISTRY_KEY]: registry });
} catch {
}
}
async function persistRuntimeState() {
const leases = {};
for (const [leaseKey, session] of automationSessions.entries()) {
leases[leaseKey] = {
session: session.session,
surface: session.surface,
kind: session.kind,
windowId: session.windowId,
owned: session.owned,
preferredTabId: session.preferredTabId,
contextId: session.contextId,
ownership: session.ownership,
lifecycle: session.lifecycle,
windowRole: session.windowRole,
idleDeadlineAt: session.idleDeadlineAt,
updatedAt: Date.now()
};
}
await writeRegistry({
version: 2,
contextId: currentContextId,
ownedContainers: {
interactive: {
windowId: ownedContainers.interactive.windowId,
groupIds: [...interactiveGroupLedger]
},
automation: { windowId: ownedContainers.automation.windowId }
},
leases
});
}
function scheduleIdleAlarm(leaseKey, timeout) {
const alarmName = makeAlarmName(leaseKey);
try {
if (timeout > 0) {
chrome.alarms?.create?.(alarmName, { when: Date.now() + timeout });
} else {
chrome.alarms?.clear?.(alarmName);
}
} catch {
}
}
async function safeDetach(tabId) {
try {
const detach$1 = detach;
if (typeof detach$1 === "function") await detach$1(tabId);
} catch {
}
}
async function removeLeaseSession(leaseKey) {
const existing = automationSessions.get(leaseKey);
if (existing?.idleTimer) clearTimeout(existing.idleTimer);
automationSessions.delete(leaseKey);
sessionOverrides.delete(leaseKey);
scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE);
await persistRuntimeState();
}
function resetWindowIdleTimer(leaseKey, remainingMs) {
const session = automationSessions.get(leaseKey);
if (!session) return;
if (session.idleTimer) clearTimeout(session.idleTimer);
const timeout = getIdleTimeout(leaseKey);
if (timeout <= 0) {
scheduleIdleAlarm(leaseKey, timeout);
session.idleTimer = null;
session.idleDeadlineAt = 0;
void persistRuntimeState();
return;
}
const interval = remainingMs === void 0 ? timeout : Math.max(0, Math.min(remainingMs, timeout));
scheduleIdleAlarm(leaseKey, interval);
session.idleDeadlineAt = Date.now() + interval;
void persistRuntimeState();
session.idleTimer = setTimeout(async () => {
if ((activeCommandCounts.get(leaseKey) ?? 0) > 0) {
return;
}
await releaseLease(leaseKey, "idle timeout");
}, interval);
}
function getOwnedContainerGroupTitles(role) {
return role === "automation" ? [] : [CONTAINER_TAB_GROUP_TITLE.interactive];
}
async function focusOwnedWindowIfRequested(windowId, mode) {
if (mode !== "foreground") return;
const updateWindow = chrome.windows.update;
if (typeof updateWindow === "function") await updateWindow(windowId, { focused: true }).catch(() => {
});
}
async function toOwnedContainerGroupCandidate(group) {
try {
const chromeWindow = await chrome.windows.get(group.windowId);
const reusableTabId = await findReusableOwnedContainerTab(group.windowId, group.id);
return {
id: group.id,
windowId: group.windowId,
title: group.title,
focused: !!chromeWindow.focused,
hasReusableTab: reusableTabId !== void 0
};
} catch {
return null;
}
}
function selectOwnedContainerGroupCandidate(candidates) {
if (candidates.length === 0) return null;
return [...candidates].sort((a, b) => {
if (a.focused !== b.focused) return a.focused ? -1 : 1;
if (a.hasReusableTab !== b.hasReusableTab) return a.hasReusableTab ? -1 : 1;
if (a.windowId !== b.windowId) return a.windowId - b.windowId;
return a.id - b.id;
})[0];
}
async function collectOwnedGroupCandidates(role) {
if (role === "automation") return [];
const container = ownedContainers[role];
const groupsById = /* @__PURE__ */ new Map();
if (container.groupId !== null) {
try {
const group = await chrome.tabGroups.get(container.groupId);
groupsById.set(group.id, group);
} catch {
container.groupId = null;
}
}
let ledgerPruned = false;
for (const groupId of [...interactiveGroupLedger]) {
if (groupsById.has(groupId)) continue;
try {
const group = await chrome.tabGroups.get(groupId);
groupsById.set(group.id, group);
} catch {
interactiveGroupLedger.delete(groupId);
ledgerPruned = true;
}
}
if (ledgerPruned) await persistRuntimeState();
for (const title of getOwnedContainerGroupTitles(role)) {
const groups = await chrome.tabGroups.query({ title });
for (const group of groups) groupsById.set(group.id, group);
}
for (const [leaseKey, session] of automationSessions.entries()) {
if (!session.owned || getOwnedWindowRole(leaseKey) !== role || session.preferredTabId === null) continue;
try {
const tab = await chrome.tabs.get(session.preferredTabId);
const groupId = tab.groupId;
if (typeof groupId !== "number" || groupId === chrome.tabGroups.TAB_GROUP_ID_NONE) continue;
const group = await chrome.tabGroups.get(groupId);
groupsById.set(group.id, group);
} catch {
}
}
const ownedPreferredTabIds = /* @__PURE__ */ new Set();
for (const [leaseKey, session] of automationSessions.entries()) {
if (!session.owned || getOwnedWindowRole(leaseKey) !== role || session.preferredTabId === null) continue;
ownedPreferredTabIds.add(session.preferredTabId);
}
if (ownedPreferredTabIds.size > 0) {
try {
const allGroups = await chrome.tabGroups.query({});
for (const group of allGroups) {
if (group.title) continue;
if (groupsById.has(group.id)) continue;
const tabsInGroup = await chrome.tabs.query({ groupId: group.id });
if (tabsInGroup.some((tab) => tab.id !== void 0 && ownedPreferredTabIds.has(tab.id))) {
groupsById.set(group.id, group);
}
}
} catch {
}
}
const candidates = await Promise.all([...groupsById.values()].map(toOwnedContainerGroupCandidate));
return candidates.filter((candidate) => candidate !== null);
}
function updateOwnedSessionWindowForTabs(role, tabIds, windowId) {
const moved = new Set(tabIds);
for (const [leaseKey, session] of automationSessions.entries()) {
if (!session.owned || getOwnedWindowRole(leaseKey) !== role) continue;
if (session.preferredTabId !== null && moved.has(session.preferredTabId)) {
session.windowId = windowId;
}
}
}
async function ensureTabsInWindow(tabIds, windowId) {
const movedIds = [];
for (const tabId of tabIds) {
try {
const tab = await chrome.tabs.get(tabId);
if (tab.windowId !== windowId) {
await chrome.tabs.move(tabId, { windowId, index: -1 });
movedIds.push(tabId);
}
} catch {
}
}
return movedIds;
}
async function ensureCanonicalGroupTitle(role, group) {
const canonicalTitle = CONTAINER_TAB_GROUP_TITLE[role];
if (group.title === canonicalTitle) return group;
const updated = await chrome.tabGroups.update(group.id, {
title: canonicalTitle,
color: OWNED_TAB_GROUP_COLOR
});
return { id: updated.id, windowId: updated.windowId, title: updated.title };
}
async function convergeOwnedGroupDuplicates(role, canonical, candidates) {
for (const duplicate of candidates) {
if (duplicate.id === canonical.id) continue;
const tabs = await chrome.tabs.query({ groupId: duplicate.id });
const tabIds = tabs.map((tab) => tab.id).filter((id) => id !== void 0);
if (tabIds.length === 0) continue;
await ensureTabsInWindow(tabIds, canonical.windowId);
await chrome.tabs.group({ groupId: canonical.id, tabIds });
updateOwnedSessionWindowForTabs(role, tabIds, canonical.windowId);
}
return canonical;
}
async function attachTabsToOwnedGroup(role, group, ids) {
if (ids.length === 0) return group;
await ensureTabsInWindow(ids, group.windowId);
const tabs = await Promise.all(ids.map((id) => chrome.tabs.get(id).catch(() => null)));
const missing = tabs.filter((tab) => tab !== null && tab.id !== void 0 && tab.groupId !== group.id).map((tab) => tab.id);
if (missing.length > 0) await chrome.tabs.group({ groupId: group.id, tabIds: missing });
updateOwnedSessionWindowForTabs(role, ids, group.windowId);
return group;
}
async function createOwnedGroup(role, windowId, ids) {
if (ids.length === 0) throw new Error(`Cannot create ${role} tab group without tabs`);
await ensureTabsInWindow(ids, windowId);
const groupId = await chrome.tabs.group({ tabIds: ids, createProperties: { windowId } });
ownedContainers[role].groupId = groupId;
ownedContainers[role].windowId = windowId;
if (role === "interactive") interactiveGroupLedger.add(groupId);
await persistRuntimeState();
const group = await chrome.tabGroups.update(groupId, {
color: OWNED_TAB_GROUP_COLOR,
title: CONTAINER_TAB_GROUP_TITLE[role],
collapsed: false
});
updateOwnedSessionWindowForTabs(role, ids, group.windowId);
return { id: group.id, windowId: group.windowId, title: group.title };
}
async function ensureOwnedContainerGroup(role, fallbackWindowId, tabIds) {
if (role === "automation") return null;
const ids = [...new Set(tabIds.filter((id) => id !== void 0))];
const container = ownedContainers[role];
const previousGroupPromise = container.groupPromise ?? Promise.resolve(null);
const nextGroupPromise = previousGroupPromise.catch(() => null).then(() => ensureOwnedContainerGroupUnlocked(role, fallbackWindowId, ids));
const trackedGroupPromise = nextGroupPromise.finally(() => {
if (container.groupPromise === trackedGroupPromise) container.groupPromise = null;
});
container.groupPromise = trackedGroupPromise;
return trackedGroupPromise;
}
async function ensureOwnedContainerGroupUnlocked(role, fallbackWindowId, ids) {
try {
const candidates = await collectOwnedGroupCandidates(role);
const selected = selectOwnedContainerGroupCandidate(candidates);
let canonical = selected ? { id: selected.id, windowId: selected.windowId, title: selected.title } : null;
if (canonical) {
canonical = await convergeOwnedGroupDuplicates(role, canonical, candidates);
canonical = await ensureCanonicalGroupTitle(role, canonical);
canonical = await attachTabsToOwnedGroup(role, canonical, ids);
} else if (fallbackWindowId !== null && ids.length > 0) {
canonical = await createOwnedGroup(role, fallbackWindowId, ids);
}
if (canonical) {
ownedContainers[role].windowId = canonical.windowId;
ownedContainers[role].groupId = canonical.id;
if (!interactiveGroupLedger.has(canonical.id)) {
interactiveGroupLedger.add(canonical.id);
await persistRuntimeState();
}
} else {
ownedContainers[role].groupId = null;
if (fallbackWindowId === null) ownedContainers[role].windowId = null;
}
return canonical;
} catch (err) {
console.warn(`[opencli] Failed to ensure ${role} tab group: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
}
async function ensureOwnedContainerWindow(role, initialUrl, mode = "background") {
const container = ownedContainers[role];
if (container.promise) return container.promise;
container.promise = ensureOwnedContainerWindowUnlocked(role, initialUrl, mode).finally(() => {
container.promise = null;
});
return container.promise;
}
async function ensureOwnedContainerWindowUnlocked(role, initialUrl, mode = "background") {
const container = ownedContainers[role];
if (container.windowId !== null) {
try {
await chrome.windows.get(container.windowId);
const group2 = await ensureOwnedContainerGroup(role, container.windowId, []);
if (group2) {
await focusOwnedWindowIfRequested(group2.windowId, mode);
const initialTabId3 = await findReusableOwnedContainerTab(group2.windowId, group2.id);
return {
windowId: group2.windowId,
initialTabId: initialTabId3
};
}
await focusOwnedWindowIfRequested(container.windowId, mode);
const initialTabId2 = await findReusableOwnedContainerTab(container.windowId, null);
const createdGroup = await ensureOwnedContainerGroup(role, container.windowId, [initialTabId2]);
if (createdGroup) {
return {
windowId: createdGroup.windowId,
initialTabId: initialTabId2
};
}
return {
windowId: container.windowId,
initialTabId: initialTabId2
};
} catch {
container.windowId = null;
container.groupId = null;
}
}
const existingGroup = await ensureOwnedContainerGroup(role, null, []);
if (existingGroup) {
await focusOwnedWindowIfRequested(existingGroup.windowId, mode);
const initialTabId2 = await findReusableOwnedContainerTab(existingGroup.windowId, existingGroup.id);
await persistRuntimeState();
return {
windowId: existingGroup.windowId,
initialTabId: initialTabId2
};
}
const startUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE;
const win = await chrome.windows.create({
url: startUrl,
focused: mode === "foreground",
width: 1280,
height: 900,
type: "normal"
});
container.windowId = win.id;
await persistRuntimeState();
console.log(`[opencli] Created owned ${role} window ${container.windowId} (start=${startUrl})`);
const tabs = await chrome.tabs.query({ windowId: win.id });
const initialTabId = tabs[0]?.id;
if (initialTabId) {
await new Promise((resolve) => {
const timeout = setTimeout(resolve, 500);
const listener = (tabId, info) => {
if (tabId === initialTabId && info.status === "complete") {
chrome.tabs.onUpdated.removeListener(listener);
clearTimeout(timeout);
resolve();
}
};
if (tabs[0].status === "complete") {
clearTimeout(timeout);
resolve();
} else {
chrome.tabs.onUpdated.addListener(listener);
}
});
}
const group = await ensureOwnedContainerGroup(role, container.windowId, [initialTabId]);
await persistRuntimeState();
return { windowId: group?.windowId ?? container.windowId, initialTabId };
}
async function findReusableOwnedContainerTab(windowId, ownedGroupId) {
try {
const tabs = await chrome.tabs.query({ windowId });
const reusable = tabs.find(
(tab) => tab.id !== void 0 && initialTabIsAvailable(tab.id) && isDebuggableUrl(tab.url) && (ownedGroupId === void 0 || ownedGroupId !== null && tab.groupId === ownedGroupId || !isSafeNavigationUrl(tab.url ?? ""))
);
return reusable?.id;
} catch {
return void 0;
}
}
function initialTabIsAvailable(tabId) {
if (tabId === void 0) return false;
for (const session of automationSessions.values()) {
if (session.owned && session.preferredTabId === tabId) return false;
}
return true;
}
async function createOwnedTabLease(leaseKey, initialUrl) {
return withLeaseMutation(() => createOwnedTabLeaseUnlocked(leaseKey, initialUrl));
}
async function createOwnedTabLeaseUnlocked(leaseKey, initialUrl) {
const targetUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE;
const role = getOwnedWindowRole(leaseKey);
const { windowId, initialTabId } = await ensureOwnedContainerWindow(role, targetUrl, getWindowMode(leaseKey));
let tab;
if (initialTabIsAvailable(initialTabId)) {
tab = await chrome.tabs.get(initialTabId);
if (!isTargetUrl(tab.url, targetUrl)) {
tab = await chrome.tabs.update(initialTabId, { url: targetUrl });
await new Promise((resolve) => setTimeout(resolve, 300));
tab = await chrome.tabs.get(initialTabId);
}
} else {
tab = await chrome.tabs.create({ windowId, url: targetUrl, active: true });
}
const tabId = tab.id;
if (!tabId) throw new Error("Failed to create tab lease in automation container");
const group = await ensureOwnedContainerGroup(role, windowId, [tabId]);
const sessionWindowId = group?.windowId ?? tab.windowId;
if (tab.windowId !== sessionWindowId) tab = await chrome.tabs.get(tabId);
setLeaseSession(leaseKey, {
session: getSessionFromKey(leaseKey),
surface: getSurfaceFromKey(leaseKey),
kind: "owned",
windowId: sessionWindowId,
owned: true,
preferredTabId: tabId
});
resetWindowIdleTimer(leaseKey);
return { tabId, tab };
}
async function getAutomationWindow(leaseKey, initialUrl) {
const existing = automationSessions.get(leaseKey);
if (existing) {
if (!existing.owned) {
throw new CommandFailure(
"bound_window_operation_blocked",
`Session "${existing.session}" is bound to a user tab and does not own an OpenCLI tab lease.`,
"Use page commands on the bound tab, or unbind the session first."
);
}
try {
const tabId = existing.preferredTabId;
if (tabId !== null) {
const tab = await chrome.tabs.get(tabId);
if (isDebuggableUrl(tab.url)) return tab.windowId;
}
await chrome.windows.get(existing.windowId);
return existing.windowId;
} catch {
await removeLeaseSession(leaseKey);
}
}
const role = getOwnedWindowRole(leaseKey);
return (await ensureOwnedContainerWindow(role, initialUrl, getWindowMode(leaseKey))).windowId;
}
chrome.windows.onRemoved.addListener(async (windowId) => {
await workerReady;
for (const container of Object.values(ownedContainers)) {
if (container.windowId === windowId) {
container.windowId = null;
container.groupId = null;
}
}
for (const [leaseKey, session] of automationSessions.entries()) {
if (session.windowId === windowId) {
console.log(`[opencli] ${session.surface} container closed (session=${session.session})`);
if (session.idleTimer) clearTimeout(session.idleTimer);
automationSessions.delete(leaseKey);
sessionOverrides.delete(leaseKey);
scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE);
}
}
await persistRuntimeState();
});
chrome.tabs.onRemoved.addListener(async (tabId) => {
await workerReady;
evictTab(tabId);
for (const [leaseKey, session] of automationSessions.entries()) {
if (session.preferredTabId === tabId) {
if (session.idleTimer) clearTimeout(session.idleTimer);
automationSessions.delete(leaseKey);
sessionOverrides.delete(leaseKey);
scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE);
console.log(`[opencli] Session ${session.session} detached from tab ${tabId} (tab closed)`);
}
}
await persistRuntimeState();
});
let initialized = false;
function initialize() {
if (initialized) return;
initialized = true;
chrome.alarms.create("keepalive", { periodInMinutes: 0.5 });
registerListeners();
try {
const registerFrameTracking$1 = registerFrameTracking;
registerFrameTracking$1?.();
} catch {
}
try {
void chrome.storage?.local?.remove?.(REGISTRY_KEY)?.catch?.(() => {
});
} catch {
}
workerRecovered = false;
workerReady = (async () => {
await getCurrentContextId();
await reconcileTargetLeaseRegistry();
})().catch((err) => {
console.warn(`[opencli] Startup recovery failed: ${err instanceof Error ? err.message : String(err)}`);
}).finally(() => {
workerRecovered = true;
});
void workerReady.then(() => connect());
console.log("[opencli] OpenCLI extension initialized");
}
chrome.runtime.onInstalled.addListener(() => {
initialize();
});
chrome.runtime.onStartup.addListener(() => {
initialize();
});
initialize();
chrome.alarms.onAlarm.addListener(async (alarm) => {
await workerReady;
if (alarm.name === "keepalive") void connect();
const leaseKey = leaseKeyFromAlarmName(alarm.name);
if (!leaseKey) return;
if ((activeCommandCounts.get(leaseKey) ?? 0) > 0) {
resetWindowIdleTimer(leaseKey);
return;
}
await releaseLease(leaseKey, "idle alarm");
});
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.type === "getStatus") {
void (async () => {
const contextId = await getCurrentContextId();
const connected = ws?.readyState === WebSocket.OPEN;
const extensionVersion = chrome.runtime.getManifest().version;
const daemonVersion = connected ? await fetchDaemonVersion() : null;
sendResponse({
connected,
reconnecting: reconnectTimer !== null,
contextId,
extensionVersion,
daemonVersion
});
})();
return true;
}
return false;
});
async function fetchDaemonVersion() {
try {
const res = await fetch(`http://${DAEMON_HOST}:${DAEMON_PORT}/status`, {
method: "GET",
headers: { "X-OpenCLI": "1" },
signal: AbortSignal.timeout(1500)
});
if (!res.ok) return null;
const body = await res.json();
return typeof body.daemonVersion === "string" ? body.daemonVersion : null;
} catch {
return null;
}
}
async function handleCommand(cmd) {
const session = getSessionName(cmd.session);
const surface = getCommandSurface(cmd);
const leaseKey = getLeaseKey(session, surface);
if (cmd.windowMode === "foreground" || cmd.windowMode === "background") {
setSessionOverride(leaseKey, { windowMode: cmd.windowMode });
}
if (surface === "adapter" && (cmd.siteSession === "persistent" || cmd.siteSession === "ephemeral")) {
setSessionOverride(leaseKey, { lifecycle: cmd.siteSession });
}
if (cmd.idleTimeout != null && cmd.idleTimeout > 0) {
setSessionOverride(leaseKey, { idleTimeoutMs: cmd.idleTimeout * 1e3 });
}
resetWindowIdleTimer(leaseKey);
activeCommandCounts.set(leaseKey, (activeCommandCounts.get(leaseKey) ?? 0) + 1);
try {
switch (cmd.action) {
case "exec":
return await handleExec(cmd, leaseKey);
case "navigate":
return await handleNavigate(cmd, leaseKey);
case "tabs":
return await handleTabs(cmd, leaseKey);
case "cookies":
return await handleCookies(cmd);
case "screenshot":
return await handleScreenshot(cmd, leaseKey);
case "close-window":
return await handleCloseWindow(cmd, leaseKey);
case "cdp":
return await handleCdp(cmd, leaseKey);
case "set-file-input":
return await handleSetFileInput(cmd, leaseKey);
case "insert-text":
return await handleInsertText(cmd, leaseKey);
case "bind":
return await handleBind(cmd, leaseKey);
case "network-capture-start":
return await handleNetworkCaptureStart(cmd, leaseKey);
case "network-capture-read":
return await handleNetworkCaptureRead(cmd, leaseKey);
case "wait-download":
return await handleWaitDownload(cmd);
case "frames":
return await handleFrames(cmd, leaseKey);
default:
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
}
} catch (err) {
return errorResult(cmd.id, err);
} finally {
const remaining = (activeCommandCounts.get(leaseKey) ?? 1) - 1;
if (remaining <= 0) activeCommandCounts.delete(leaseKey);
else activeCommandCounts.set(leaseKey, remaining);
resetWindowIdleTimer(leaseKey);
}
}
const BLANK_PAGE = "about:blank";
function isDebuggableUrl(url) {
if (!url) return true;
return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:");
}
function isSafeNavigationUrl(url) {
return url.startsWith("http://") || url.startsWith("https://");
}
function normalizeUrlForComparison(url) {
if (!url) return "";
try {
const parsed = new URL(url);
if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") {
parsed.port = "";
}
const pathname = parsed.pathname === "/" ? "" : parsed.pathname;
return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`;
} catch {
return url;
}
}
function isTargetUrl(currentUrl, targetUrl) {
return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl);
}
function getUrlOrigin(url) {
if (!url) return null;
try {
return new URL(url).origin;
} catch {
return null;
}
}
function enumerateCrossOriginFrames(tree) {
const frames = [];
function collect(node, accessibleOrigin) {
for (const child of node.childFrames || []) {
const frame = child.frame;
const frameUrl = frame.url || frame.unreachableUrl || "";
const frameOrigin = getUrlOrigin(frameUrl);
if (accessibleOrigin && frameOrigin && frameOrigin === accessibleOrigin) {
collect(child, frameOrigin);
continue;
}
frames.push({
index: frames.length,
frameId: frame.id,
url: frameUrl,
name: frame.name || ""
});
}
}
const rootFrame = tree?.frameTree?.frame;
const rootUrl = rootFrame?.url || rootFrame?.unreachableUrl || "";
collect(tree.frameTree, getUrlOrigin(rootUrl));
return frames;
}
function setLeaseSession(leaseKey, session) {
const existing = automationSessions.get(leaseKey);
if (existing?.idleTimer) clearTimeout(existing.idleTimer);
const timeout = getIdleTimeout(leaseKey);
automationSessions.set(leaseKey, {
...makeSession(leaseKey, session),
idleTimer: null,
idleDeadlineAt: timeout <= 0 ? 0 : Date.now() + timeout
});
void persistRuntimeState();
}
async function resolveCommandTabId(cmd) {
if (cmd.page) return resolveTabId$1(cmd.page);
return void 0;
}
async function resolveTab(tabId, leaseKey, initialUrl) {
const existingSession = automationSessions.get(leaseKey);
if (tabId !== void 0) {
try {
const tab = await chrome.tabs.get(tabId);
const session = existingSession;
const matchesSession = session ? session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId : false;
if (isDebuggableUrl(tab.url) && matchesSession) return { tabId, tab };
if (session && !session.owned) {
throw new CommandFailure(
matchesSession ? "bound_tab_not_debuggable" : "bound_tab_mismatch",
matchesSession ? `Bound tab for session "${session.session}" is not debuggable (${tab.url ?? "unknown URL"}).` : `Target tab is not the tab bound to session "${session.session}".`,
'Run "opencli browser bind" again on a debuggable http(s) tab.'
);
}
if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) {
console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`);
try {
await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 });
const moved = await chrome.tabs.get(tabId);
if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) {
return { tabId, tab: moved };
}
} catch (moveErr) {
console.warn(`[opencli] Failed to move tab back: ${moveErr}`);
}
} else if (!isDebuggableUrl(tab.url)) {
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
}
} catch (err) {
if (err instanceof CommandFailure) throw err;
if (existingSession && !existingSession.owned) {
automationSessions.delete(leaseKey);
throw new CommandFailure(
"bound_tab_gone",
`Bound tab for session "${existingSession.session}" no longer exists.`,
'Run "opencli browser bind" again, then retry the command.'
);
}
console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
}
}
const existingPreferredTabId = existingSession?.preferredTabId ?? null;
if (existingSession && existingPreferredTabId !== null) {
const session = existingSession;
try {
const preferredTab = await chrome.tabs.get(existingPreferredTabId);
if (isDebuggableUrl(preferredTab.url)) return { tabId: preferredTab.id, tab: preferredTab };
if (!session.owned) {
throw new CommandFailure(
"bound_tab_not_debuggable",
`Bound tab for session "${session.session}" is not debuggable (${preferredTab.url ?? "unknown URL"}).`,
'Switch the tab to an http(s) page or run "opencli browser bind" on another tab.'
);
}
} catch (err) {
if (err instanceof CommandFailure) throw err;
await removeLeaseSession(leaseKey);
if (!session.owned) {
throw new CommandFailure(
"bound_tab_gone",
`Bound tab for session "${session.session}" no longer exists.`,
'Run "opencli browser bind" again, then retry the command.'
);
}
return createOwnedTabLease(leaseKey, initialUrl);
}
}
if (!existingSession || existingSession.owned && existingSession.preferredTabId === null) {
return createOwnedTabLease(leaseKey, initialUrl);
}
const windowId = await getAutomationWindow(leaseKey, initialUrl);
const role = getOwnedWindowRole(leaseKey);
const group = existingSession?.owned ? await ensureOwnedContainerGroup(role, windowId, []) : null;
const scopedWindowId = group?.windowId ?? windowId;
const reusableTabId = await findReusableOwnedContainerTab(scopedWindowId, existingSession?.owned ? group?.id ?? null : void 0);
if (reusableTabId !== void 0) return { tabId: reusableTabId, tab: await chrome.tabs.get(reusableTabId) };
const tabs = await chrome.tabs.query({ windowId: scopedWindowId });
const reuseTab = existingSession?.owned ? void 0 : tabs.find((t) => t.id);
if (reuseTab?.id) {
await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE });
await new Promise((resolve) => setTimeout(resolve, 300));
try {
const updated = await chrome.tabs.get(reuseTab.id);
if (isDebuggableUrl(updated.url)) return { tabId: reuseTab.id, tab: updated };
console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`);
} catch {
}
}
const newTab = await chrome.tabs.create({ windowId: scopedWindowId, url: BLANK_PAGE, active: true });
if (!newTab.id) throw new Error("Failed to create tab in automation container");
await ensureOwnedContainerGroup(role, scopedWindowId, [newTab.id]);
return { tabId: newTab.id, tab: await chrome.tabs.get(newTab.id) };
}
async function pageScopedResult(id, tabId, data) {
const page = await resolveTargetId(tabId);
return { id, ok: true, data, page };
}
async function resolveTabId(tabId, leaseKey, initialUrl) {
const resolved = await resolveTab(tabId, leaseKey, initialUrl);
return resolved.tabId;
}
async function listAutomationTabs(leaseKey) {
const session = automationSessions.get(leaseKey);
if (!session) return [];
if (session.preferredTabId !== null) {
try {
return [await chrome.tabs.get(session.preferredTabId)];
} catch {
automationSessions.delete(leaseKey);
return [];
}
}
try {
return await chrome.tabs.query({ windowId: session.windowId });
} catch {
automationSessions.delete(leaseKey);
return [];
}
}
async function listAutomationWebTabs(leaseKey) {
const tabs = await listAutomationTabs(leaseKey);
return tabs.filter((tab) => isDebuggableUrl(tab.url));
}
function commandCdpTimeoutMs(cmd) {
if (typeof cmd.deadlineAt === "number" && cmd.deadlineAt > 0) {
return Math.max(1e4, cmd.deadlineAt - Date.now() - 5e3);
}
if (typeof cmd.timeout === "number" && cmd.timeout > 0) {
return Math.max(1e4, cmd.timeout * 1e3 - 5e3);
}
return void 0;
}
function classifyExtensionError(message) {
if (/Inspected target navigated|Target closed/.test(message)) return "target_navigated";
if (/Detached while handling command/.test(message)) return "detached_mid_command";
if (/CDP command .* timed out/.test(message)) return "cdp_timeout";
if (/attach failed|Debugger is not attached/.test(message)) return "attach_failed";
if (/No tab with id|no longer exists|No window with id/.test(message)) return "tab_gone";
return void 0;
}
function errorResult(id, err) {
const message = err instanceof Error ? err.message : String(err);
if (err instanceof CommandFailure) {
return { id, ok: false, error: message, errorCode: err.code, ...err.hint ? { errorHint: err.hint } : {} };
}
const errorCode = classifyExtensionError(message);
return { id, ok: false, error: message, ...errorCode ? { errorCode } : {} };
}
async function handleExec(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 aggressive = getSurfaceFromKey(leaseKey) === "browser";
if (cmd.frameIndex != null) {
const tree = await getFrameTree(tabId);
const frames = enumerateCrossOriginFrames(tree);
if (cmd.frameIndex < 0 || cmd.frameIndex >= frames.length) {
return { id: cmd.id, ok: false, error: `Frame index ${cmd.frameIndex} out of range (${frames.length} cross-origin frames available)` };
}
const data2 = await evaluateInFrame(tabId, cmd.code, frames[cmd.frameIndex].frameId, aggressive, commandCdpTimeoutMs(cmd));
return pageScopedResult(cmd.id, tabId, data2);
}
const data = await evaluateAsync(tabId, cmd.code, aggressive, commandCdpTimeoutMs(cmd));
return pageScopedResult(cmd.id, tabId, data);
} catch (err) {
return errorResult(cmd.id, err);
}
}
async function handleFrames(cmd, leaseKey) {
const cmdTabId = await resolveCommandTabId(cmd);
const tabId = await resolveTabId(cmdTabId, leaseKey);
try {
const tree = await getFrameTree(tabId);
return { id: cmd.id, ok: true, data: enumerateCrossOriginFrames(tree) };
} catch (err) {
return errorResult(cmd.id, err);
}
}
async function handleNavigate(cmd, leaseKey) {
if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" };
if (!isSafeNavigationUrl(cmd.url)) {
return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" };
}
const cmdTabId = await resolveCommandTabId(cmd);
const resolved = await resolveTab(cmdTabId, leaseKey, cmd.url);
const tabId = resolved.tabId;
const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId);
const beforeNormalized = normalizeUrlForComparison(beforeTab.url);
const targetUrl = cmd.url;
if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) {
return pageScopedResult(cmd.id, tabId, { title: beforeTab.title, url: beforeTab.url, timedOut: false });
}
if (!hasActiveNetworkCapture(tabId)) {
await detach(tabId);
}
await chrome.tabs.update(tabId, { url: targetUrl });
let timedOut = false;
await new Promise((resolve) => {
let settled = false;
let checkTimer = null;
let timeoutTimer = null;
const finish = () => {
if (settled) return;
settled = true;
chrome.tabs.onUpdated.removeListener(listener);
if (checkTimer) clearTimeout(checkTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
resolve();
};
const isNavigationDone = (url) => {
return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized;
};
const listener = (id, info, tab2) => {
if (id !== tabId) return;
if (info.status === "complete" && isNavigationDone(tab2.url ?? info.url)) {
finish();
}
};
chrome.tabs.onUpdated.addListener(listener);
checkTimer = setTimeout(async () => {
try {
const currentTab = await chrome.tabs.get(tabId);
if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) {
finish();
}
} catch {
}
}, 100);
timeoutTimer = setTimeout(() => {
timedOut = true;
console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`);
finish();
}, 15e3);
});
let tab = await chrome.tabs.get(tabId);
const postNavigationSession = automationSessions.get(leaseKey);
if (postNavigationSession && tab.windowId !== postNavigationSession.windowId) {
console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${postNavigationSession.windowId}`);
try {
await chrome.tabs.move(tabId, { windowId: postNavigationSession.windowId, index: -1 });
tab = await chrome.tabs.get(tabId);
} catch (moveErr) {
console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`);
}
}
return pageScopedResult(cmd.id, tabId, { title: tab.title, url: tab.url, timedOut });
}
async function handleTabs(cmd, leaseKey) {
const session = automationSessions.get(leaseKey);
if (session && !session.owned && cmd.op !== "list") {
return {
id: cmd.id,
ok: false,
errorCode: "bound_tab_mutation_blocked",
error: `Session "${session.session}" is bound to a user tab; tab new/select/close requires an owned OpenCLI session.`,
errorHint: "Unbind the session first, or use a different session for owned OpenCLI tabs."
};
}
switch (cmd.op) {
case "list": {
const tabs = await listAutomationWebTabs(leaseKey);
const data = await Promise.all(tabs.map(async (t, i) => {
let page;
try {
page = t.id ? await resolveTargetId(t.id) : void 0;
} catch {
}
return { index: i, page, url: t.url, title: t.title, active: t.active };
}));
return { id: cmd.id, ok: true, data };
}
case "new": {
if (cmd.url && !isSafeNavigationUrl(cmd.url)) {
return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" };
}
if (!automationSessions.has(leaseKey)) {
const created = await createOwnedTabLease(leaseKey, cmd.url);
return pageScopedResult(cmd.id, created.tabId, { url: created.tab?.url });
}
const windowId = await getAutomationWindow(leaseKey);
let tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true });
const tabId = tab.id;
if (!tabId) return { id: cmd.id, ok: false, error: "Failed to create tab" };
const group = await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), windowId, [tabId]);
const sessionWindowId = group?.windowId ?? tab.windowId;
if (tab.windowId !== sessionWindowId) tab = await chrome.tabs.get(tabId);
setLeaseSession(leaseKey, {
session: getSessionFromKey(leaseKey),
surface: getSurfaceFromKey(leaseKey),
kind: "owned",
windowId: sessionWindowId,
owned: true,
preferredTabId: tabId
});
resetWindowIdleTimer(leaseKey);
return pageScopedResult(cmd.id, tabId, { url: tab.url });
}
case "close": {
if (cmd.index !== void 0) {
const tabs = await listAutomationWebTabs(leaseKey);
const target = tabs[cmd.index];
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
const closedPage2 = await resolveTargetId(target.id).catch(() => void 0);
const currentSession2 = automationSessions.get(leaseKey);
if (currentSession2?.preferredTabId === target.id) {
await releaseLease(leaseKey, "tab close");
} else {
await safeDetach(target.id);
await chrome.tabs.remove(target.id);
}
return { id: cmd.id, ok: true, data: { closed: closedPage2 } };
}
const cmdTabId = await resolveCommandTabId(cmd);
const tabId = await resolveTabId(cmdTabId, leaseKey);
const closedPage = await resolveTargetId(tabId).catch(() => void 0);
const currentSession = automationSessions.get(leaseKey);
if (currentSession?.preferredTabId === tabId) {
await releaseLease(leaseKey, "tab close");
} else {
await safeDetach(tabId);
await chrome.tabs.remove(tabId);
}
return { id: cmd.id, ok: true, data: { closed: closedPage } };
}
case "select": {
if (cmd.index === void 0 && cmd.page === void 0)
return { id: cmd.id, ok: false, error: "Missing index or page" };
const cmdTabId = await resolveCommandTabId(cmd);
if (cmdTabId !== void 0) {
const session2 = automationSessions.get(leaseKey);
let tab;
try {
tab = await chrome.tabs.get(cmdTabId);
} catch {
return { id: cmd.id, ok: false, error: `Page no longer exists` };
}
if (!session2 || tab.windowId !== session2.windowId) {
return { id: cmd.id, ok: false, error: `Page is not in the automation container` };
}
await chrome.tabs.update(cmdTabId, { active: true });
return pageScopedResult(cmd.id, cmdTabId, { selected: true });
}
const tabs = await listAutomationWebTabs(leaseKey);
const target = tabs[cmd.index];
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
await chrome.tabs.update(target.id, { active: true });
return pageScopedResult(cmd.id, target.id, { selected: true });
}
default:
return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` };
}
}
async function handleCookies(cmd) {
if (!cmd.domain && !cmd.url) {
return { id: cmd.id, ok: false, error: "Cookie scope required: provide domain or url to avoid dumping all cookies" };
}
const details = {};
if (cmd.domain) details.domain = cmd.domain;
if (cmd.url) details.url = cmd.url;
const cookies = await chrome.cookies.getAll(details);
const data = cookies.map((c) => ({
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
secure: c.secure,
httpOnly: c.httpOnly,
expirationDate: c.expirationDate
}));
return { id: cmd.id, ok: true, data };
}
async function handleScreenshot(cmd, leaseKey) {
const cmdTabId = await resolveCommandTabId(cmd);
const tabId = await resolveTabId(cmdTabId, leaseKey);
try {
const data = await screenshot(tabId, {
format: cmd.format,
quality: cmd.quality,
fullPage: cmd.fullPage,
width: cmd.width,
height: cmd.height
});
return pageScopedResult(cmd.id, tabId, data);
} catch (err) {
return errorResult(cmd.id, err);
}
}
const CDP_ALLOWLIST = /* @__PURE__ */ new Set([
// Agent DOM context
"Accessibility.enable",
"Accessibility.getFullAXTree",
"DOM.enable",
"DOM.getDocument",
"DOM.getBoxModel",
"DOM.getContentQuads",
"DOM.focus",
"DOM.querySelector",
"DOM.querySelectorAll",
"DOM.scrollIntoViewIfNeeded",
"DOMSnapshot.captureSnapshot",
// Native input events
"Input.dispatchMouseEvent",
"Input.dispatchKeyEvent",
"Input.insertText",
// Page metrics & screenshots
"Page.getLayoutMetrics",
"Page.captureScreenshot",
"Page.getFrameTree",
"Page.handleJavaScriptDialog",
// Runtime.enable needed for CDP attach setup (Runtime.evaluate goes through 'exec' action)
"Runtime.enable",
// Emulation (used by screenshot full-page)
"Emulation.setDeviceMetricsOverride",
"Emulation.clearDeviceMetricsOverride"
]);
async function handleCdp(cmd, leaseKey) {
if (!cmd.cdpMethod) return { id: cmd.id, ok: false, error: "Missing cdpMethod" };
if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) {
return { id: cmd.id, ok: false, error: `CDP method not permitted: ${cmd.cdpMethod}` };
}
const cmdTabId = await resolveCommandTabId(cmd);
const tabId = await resolveTabId(cmdTabId, leaseKey);
try {
const aggressive = getSurfaceFromKey(leaseKey) === "browser";
await ensureAttached(tabId, aggressive);
const params = cmd.cdpParams ?? {};
const routeFrameId = typeof params.frameId === "string" && params.sessionId === "target" ? params.frameId : void 0;
const routeTargetUrl = typeof params.targetUrl === "string" ? params.targetUrl : void 0;
const data = routeFrameId ? await sendCommandInFrameTarget(tabId, routeFrameId, cmd.cdpMethod, stripOpenCliFrameRoutingParams(params, true), aggressive, commandCdpTimeoutMs(cmd) ?? 3e4, routeTargetUrl) : await sendDebuggerCommand(
{ tabId },
cmd.cdpMethod,
stripOpenCliFrameRoutingParams(params, false),
commandCdpTimeoutMs(cmd)
);
return pageScopedResult(cmd.id, tabId, data);
} catch (err) {
return errorResult(cmd.id, err);
}
}
function stripOpenCliFrameRoutingParams(params, stripFrameId) {
const { sessionId, frameId, targetUrl, ...rest } = params;
if (!stripFrameId && frameId !== void 0) return { ...rest, frameId };
return rest;
}
async function handleCloseWindow(cmd, leaseKey) {
const sessionName = automationSessions.get(leaseKey)?.session ?? getSessionFromKey(leaseKey);
await releaseLease(leaseKey, "explicit close");
return { id: cmd.id, ok: true, data: { closed: true, session: sessionName } };
}
async function handleSetFileInput(cmd, leaseKey) {
if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) {
return { id: cmd.id, ok: false, error: "Missing or empty files array" };
}
const cmdTabId = await resolveCommandTabId(cmd);
const tabId = await resolveTabId(cmdTabId, leaseKey);
try {
await setFileInputFiles(tabId, cmd.files, cmd.selector);
return pageScopedResult(cmd.id, tabId, { count: cmd.files.length });
} catch (err) {
return errorResult(cmd.id, err);
}
}
async function handleInsertText(cmd, leaseKey) {
if (typeof cmd.text !== "string") {
return { id: cmd.id, ok: false, error: "Missing text payload" };
}
const cmdTabId = await resolveCommandTabId(cmd);
const tabId = await resolveTabId(cmdTabId, leaseKey);
try {
await insertText(tabId, cmd.text);
return pageScopedResult(cmd.id, tabId, { inserted: true });
} catch (err) {
return errorResult(cmd.id, err);
}
}
async function handleNetworkCaptureStart(cmd, leaseKey) {
const cmdTabId = await resolveCommandTabId(cmd);
const tabId = await resolveTabId(cmdTabId, leaseKey);
try {
await startNetworkCapture(tabId, cmd.pattern);
return pageScopedResult(cmd.id, tabId, { started: true });
} catch (err) {
return errorResult(cmd.id, err);
}
}
async function handleNetworkCaptureRead(cmd, leaseKey) {
const cmdTabId = await resolveCommandTabId(cmd);
const tabId = await resolveTabId(cmdTabId, leaseKey);
try {
const data = await readNetworkCapture(tabId);
return pageScopedResult(cmd.id, tabId, data);
} catch (err) {
return errorResult(cmd.id, err);
}
}
async function handleWaitDownload(cmd) {
try {
const data = await waitForDownload(cmd.pattern ?? "", cmd.timeoutMs ?? 3e4);
return { id: cmd.id, ok: true, data };
} catch (err) {
return errorResult(cmd.id, err);
}
}
async function releaseLease(leaseKey, reason = "released") {
const session = automationSessions.get(leaseKey);
if (!session) {
sessionOverrides.delete(leaseKey);
scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE);
await persistRuntimeState();
return;
}
if (session.idleTimer) clearTimeout(session.idleTimer);
scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE);
if (session.owned) {
const tabId = session.preferredTabId;
if (tabId !== null) {
const hasOtherOwnedLease = [...automationSessions.entries()].some(
([otherLease, otherSession]) => otherLease !== leaseKey && otherSession.owned && otherSession.windowId === session.windowId && otherSession.preferredTabId !== null
);
await safeDetach(tabId);
evictTab(tabId);
if (hasOtherOwnedLease) {
await chrome.tabs.remove(tabId).catch(() => {
});
console.log(`[opencli] Released owned tab lease ${tabId} (session=${session.session}, surface=${session.surface}, ${reason})`);
} else {
try {
const tab = await chrome.tabs.update(tabId, { url: BLANK_PAGE, active: true });
const group = await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), session.windowId, [tab.id ?? tabId]);
if (group) session.windowId = group.windowId;
console.log(`[opencli] Released owned tab lease ${tabId} as reusable placeholder (session=${session.session}, surface=${session.surface}, ${reason})`);
} catch {
await chrome.tabs.remove(tabId).catch(() => {
});
console.log(`[opencli] Released owned tab lease ${tabId} (session=${session.session}, surface=${session.surface}, ${reason})`);
}
}
} else {
console.log(`[opencli] Released legacy owned window lease ${session.windowId} without closing container (session=${session.session}, surface=${session.surface}, ${reason})`);
}
} else if (session.preferredTabId !== null) {
await safeDetach(session.preferredTabId);
console.log(`[opencli] Detached borrowed tab lease ${session.preferredTabId} (session=${session.session}, surface=${session.surface}, ${reason})`);
}
automationSessions.delete(leaseKey);
sessionOverrides.delete(leaseKey);
await persistRuntimeState();
}
async function reconcileTargetLeaseRegistry() {
const registry = await readRegistry();
interactiveGroupLedger.clear();
for (const id of registry.ownedContainers.interactive.groupIds) interactiveGroupLedger.add(id);
for (const role of Object.keys(ownedContainers)) {
ownedContainers[role].windowId = registry.ownedContainers[role]?.windowId ?? null;
const windowId = ownedContainers[role].windowId;
if (windowId !== null) {
try {
await chrome.windows.get(windowId);
} catch {
ownedContainers[role].windowId = null;
}
}
}
automationSessions.clear();
for (const [leaseKey, stored] of Object.entries(registry.leases)) {
const tabId = stored.preferredTabId;
if (tabId === null) continue;
try {
const tab = await chrome.tabs.get(tabId);
if (!isDebuggableUrl(tab.url)) continue;
if (stored.lifecycle === "ephemeral" || stored.lifecycle === "persistent" || stored.lifecycle === "pinned") {
setSessionOverride(leaseKey, { lifecycle: stored.lifecycle });
}
const session = makeSession(leaseKey, {
session: typeof stored.session === "string" ? stored.session : getSessionFromKey(leaseKey),
surface: stored.surface === "adapter" ? "adapter" : getSurfaceFromKey(leaseKey),
kind: stored.kind === "bound" || stored.owned === false ? "bound" : "owned",
windowId: tab.windowId,
owned: stored.owned,
preferredTabId: tabId
});
const timeout = getIdleTimeout(leaseKey);
automationSessions.set(leaseKey, {
...session,
idleTimer: null,
idleDeadlineAt: stored.idleDeadlineAt
});
if (session.owned) {
const role = getOwnedWindowRole(leaseKey);
if (ownedContainers[role].windowId === null) ownedContainers[role].windowId = tab.windowId;
const group = await ensureOwnedContainerGroup(role, tab.windowId, [tabId]);
if (group) {
const current = automationSessions.get(leaseKey);
if (current) current.windowId = group.windowId;
}
}
const remaining = stored.idleDeadlineAt > 0 ? stored.idleDeadlineAt - Date.now() : timeout;
if (timeout > 0) {
if (remaining <= 0) {
await releaseLease(leaseKey, "reconciled idle expiry");
} else {
resetWindowIdleTimer(leaseKey, remaining);
}
}
} catch {
}
}
try {
await ensureOwnedContainerGroup("interactive", null, []);
} catch (err) {
console.warn(`[opencli] Startup interactive group convergence failed: ${err instanceof Error ? err.message : String(err)}`);
}
await persistRuntimeState();
}
async function handleBind(cmd, leaseKey) {
const existing = automationSessions.get(leaseKey);
if (existing?.owned) {
await releaseLease(leaseKey, "rebind");
}
const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true });
const boundTab = activeTabs.find((tab) => isDebuggableUrl(tab.url)) ?? fallbackTabs.find((tab) => isDebuggableUrl(tab.url));
if (!boundTab?.id) {
return {
id: cmd.id,
ok: false,
errorCode: "bound_tab_not_found",
error: "No debuggable tab found in the current window",
errorHint: "Focus the target Chrome tab/window, then retry bind."
};
}
const current = automationSessions.get(leaseKey);
if (current && !current.owned && current.preferredTabId !== null && current.preferredTabId !== boundTab.id) {
await detach(current.preferredTabId).catch(() => {
});
}
setLeaseSession(leaseKey, {
session: getSessionFromKey(leaseKey),
surface: getSurfaceFromKey(leaseKey),
kind: "bound",
windowId: boundTab.windowId,
owned: false,
preferredTabId: boundTab.id
});
resetWindowIdleTimer(leaseKey);
console.log(`[opencli] Session ${getSessionFromKey(leaseKey)} explicitly bound to tab ${boundTab.id} (${boundTab.url})`);
return pageScopedResult(cmd.id, boundTab.id, {
url: boundTab.url,
title: boundTab.title,
session: getSessionFromKey(leaseKey)
});
}