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
2455 lines
90 KiB
JavaScript
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)
|
|
});
|
|
}
|