Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 91 additions & 23 deletions extension/dist/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,33 @@ const WS_RECONNECT_BASE_DELAY = 2e3;
const WS_RECONNECT_MAX_DELAY = 6e4;

const attached = /* @__PURE__ */ new Set();
function isDebuggableUrl$1(url) {
if (!url) return false;
return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://");
}
async function ensureAttached(tabId) {
if (attached.has(tabId)) return;
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 chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
expression: "1",
returnByValue: true
});
return;
} catch {
attached.delete(tabId);
}
}
try {
await chrome.debugger.attach({ tabId }, "1.3");
} catch (e) {
Expand Down Expand Up @@ -89,6 +114,17 @@ function registerListeners() {
chrome.debugger.onDetach.addListener((source) => {
if (source.tabId) attached.delete(source.tabId);
});
chrome.tabs.onUpdated.addListener((tabId, info) => {
if (info.url && !isDebuggableUrl$1(info.url)) {
if (attached.has(tabId)) {
attached.delete(tabId);
try {
chrome.debugger.detach({ tabId });
} catch {
}
}
}
});
}

let ws = null;
Expand Down Expand Up @@ -161,7 +197,7 @@ function scheduleReconnect() {
}, delay);
}
const automationSessions = /* @__PURE__ */ new Map();
const WINDOW_IDLE_TIMEOUT = 3e4;
const WINDOW_IDLE_TIMEOUT = 12e4;
function getWorkspaceKey(workspace) {
return workspace?.trim() || "default";
}
Expand Down Expand Up @@ -265,17 +301,30 @@ async function handleCommand(cmd) {
};
}
}
function isWebUrl(url) {
function isDebuggableUrl(url) {
if (!url) return false;
return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://");
}
async function resolveTabId(tabId, workspace) {
if (tabId !== void 0) return tabId;
if (tabId !== void 0) {
try {
const tab = await chrome.tabs.get(tabId);
if (isDebuggableUrl(tab.url)) return tabId;
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
} catch {
console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
}
}
const windowId = await getAutomationWindow(workspace);
const tabs = await chrome.tabs.query({ windowId });
const webTab = tabs.find((t) => t.id && isWebUrl(t.url));
if (webTab?.id) return webTab.id;
if (tabs.length > 0 && tabs[0]?.id) return tabs[0].id;
const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url));
if (debuggableTab?.id) return debuggableTab.id;
const reuseTab = tabs.find((t) => t.id);
if (reuseTab?.id) {
await chrome.tabs.update(reuseTab.id, { url: "about:blank" });
await new Promise((resolve) => setTimeout(resolve, 200));
return reuseTab.id;
}
const newTab = await chrome.tabs.create({ windowId, url: "about:blank", active: true });
if (!newTab.id) throw new Error("Failed to create tab in automation window");
return newTab.id;
Expand All @@ -292,7 +341,7 @@ async function listAutomationTabs(workspace) {
}
async function listAutomationWebTabs(workspace) {
const tabs = await listAutomationTabs(workspace);
return tabs.filter((tab) => isWebUrl(tab.url));
return tabs.filter((tab) => isDebuggableUrl(tab.url));
}
async function handleExec(cmd, workspace) {
if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" };
Expand All @@ -307,28 +356,47 @@ async function handleExec(cmd, workspace) {
async function handleNavigate(cmd, workspace) {
if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" };
const tabId = await resolveTabId(cmd.tabId, workspace);
await chrome.tabs.update(tabId, { url: cmd.url });
const beforeTab = await chrome.tabs.get(tabId);
const beforeUrl = beforeTab.url ?? "";
const targetUrl = cmd.url;
await chrome.tabs.update(tabId, { url: targetUrl });
let timedOut = false;
await new Promise((resolve) => {
chrome.tabs.get(tabId).then((tab2) => {
if (tab2.status === "complete") {
let urlChanged = false;
const listener = (id, info, tab2) => {
if (id !== tabId) return;
if (info.url && info.url !== beforeUrl) {
urlChanged = true;
}
if (urlChanged && info.status === "complete") {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
return;
}
const listener = (id, info) => {
if (id === tabId && info.status === "complete") {
};
chrome.tabs.onUpdated.addListener(listener);
setTimeout(async () => {
try {
const currentTab = await chrome.tabs.get(tabId);
if (currentTab.url !== beforeUrl && currentTab.status === "complete") {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
};
chrome.tabs.onUpdated.addListener(listener);
setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}, 15e3);
});
} catch {
}
}, 100);
setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
timedOut = true;
console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`);
resolve();
}, 15e3);
});
const tab = await chrome.tabs.get(tabId);
return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } };
return {
id: cmd.id,
ok: true,
data: { title: tab.title, url: tab.url, tabId, timedOut }
};
}
async function handleTabs(cmd, workspace) {
switch (cmd.op) {
Expand Down Expand Up @@ -425,7 +493,7 @@ async function handleSessions(cmd) {
const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
workspace,
windowId: session.windowId,
tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isWebUrl(tab.url)).length,
tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length,
idleMsRemaining: Math.max(0, session.idleDeadlineAt - now)
})));
return { id: cmd.id, ok: true, data };
Expand Down
82 changes: 64 additions & 18 deletions extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ type AutomationSession = {
};

const automationSessions = new Map<string, AutomationSession>();
const WINDOW_IDLE_TIMEOUT = 30000; // 30s
const WINDOW_IDLE_TIMEOUT = 120000; // 120s — longer to survive slow pipelines

function getWorkspaceKey(workspace?: string): string {
return workspace?.trim() || 'default';
Expand Down Expand Up @@ -238,7 +238,20 @@ function isDebuggableUrl(url?: string): boolean {
* Otherwise, find or create a tab in the dedicated automation window.
*/
async function resolveTabId(tabId: number | undefined, workspace: string): Promise<number> {
if (tabId !== undefined) return tabId;
// Even when an explicit tabId is provided, validate it is still debuggable.
// This prevents issues when extensions hijack the tab URL to chrome-extension://
// or when the tab has been closed by the user.
if (tabId !== undefined) {
try {
const tab = await chrome.tabs.get(tabId);
if (isDebuggableUrl(tab.url)) return tabId;
// Tab exists but URL is not debuggable — fall through to auto-resolve
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
} catch {
// Tab was closed — fall through to auto-resolve
console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
}
}

// Get (or create) the automation window
const windowId = await getAutomationWindow(workspace);
Expand All @@ -255,6 +268,8 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
const reuseTab = tabs.find(t => t.id);
if (reuseTab?.id) {
await chrome.tabs.update(reuseTab.id, { url: 'about:blank' });
// Wait briefly for the navigation to take effect
await new Promise(resolve => setTimeout(resolve, 200));
return reuseTab.id;
}

Expand Down Expand Up @@ -294,31 +309,62 @@ async function handleExec(cmd: Command, workspace: string): Promise<Result> {
async function handleNavigate(cmd: Command, workspace: string): Promise<Result> {
if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' };
const tabId = await resolveTabId(cmd.tabId, workspace);
await chrome.tabs.update(tabId, { url: cmd.url });

// Wait for page to finish loading, checking current status first to avoid race
// Capture the current URL before navigation to detect actual URL change
const beforeTab = await chrome.tabs.get(tabId);
const beforeUrl = beforeTab.url ?? '';
const targetUrl = cmd.url;

await chrome.tabs.update(tabId, { url: targetUrl });

// Wait for: 1) URL to change from the old URL, 2) tab.status === 'complete'
// This avoids the race where 'complete' fires for the OLD URL (e.g. about:blank)
let timedOut = false;
await new Promise<void>((resolve) => {
// Check if already complete (e.g. cached pages)
chrome.tabs.get(tabId).then(tab => {
if (tab.status === 'complete') { resolve(); return; }
let urlChanged = false;

const listener = (id: number, info: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => {
if (id !== tabId) return;

// Track URL change (new URL differs from the one before navigation)
if (info.url && info.url !== beforeUrl) {
urlChanged = true;
}

const listener = (id: number, info: chrome.tabs.TabChangeInfo) => {
if (id === tabId && info.status === 'complete') {
// Only resolve when both URL has changed AND status is complete
if (urlChanged && info.status === 'complete') {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
};
chrome.tabs.onUpdated.addListener(listener);

// Also check if the tab already navigated (e.g. instant cache hit)
setTimeout(async () => {
try {
const currentTab = await chrome.tabs.get(tabId);
if (currentTab.url !== beforeUrl && currentTab.status === 'complete') {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
};
chrome.tabs.onUpdated.addListener(listener);
// Timeout fallback
setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}, 15000);
});
} catch { /* tab gone */ }
}, 100);

// Timeout fallback with warning
setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
timedOut = true;
console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`);
resolve();
}, 15000);
});

const tab = await chrome.tabs.get(tabId);
return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } };
return {
id: cmd.id,
ok: true,
data: { title: tab.title, url: tab.url, tabId, timedOut },
};
}

async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
Expand Down
43 changes: 42 additions & 1 deletion extension/src/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,40 @@

const attached = new Set<number>();

/** Check if a URL can be attached via CDP */
function isDebuggableUrl(url?: string): boolean {
if (!url) return false;
return !url.startsWith('chrome://') && !url.startsWith('chrome-extension://');
}

async function ensureAttached(tabId: number): Promise<void> {
if (attached.has(tabId)) return;
// Verify the tab URL is debuggable before attempting attach
try {
const tab = await chrome.tabs.get(tabId);
if (!isDebuggableUrl(tab.url)) {
// Invalidate cache if previously attached
attached.delete(tabId);
throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? 'unknown'}`);
}
} catch (e) {
// Re-throw our own error, catch only chrome.tabs.get failures
if (e instanceof Error && e.message.startsWith('Cannot debug tab')) throw e;
attached.delete(tabId);
throw new Error(`Tab ${tabId} no longer exists`);
}

if (attached.has(tabId)) {
// Verify the debugger is still actually attached by sending a harmless command
try {
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
expression: '1', returnByValue: true,
});
return; // Still attached and working
} catch {
// Stale cache entry — need to re-attach
attached.delete(tabId);
}
}

try {
await chrome.debugger.attach({ tabId }, '1.3');
Expand Down Expand Up @@ -122,4 +154,13 @@ export function registerListeners(): void {
chrome.debugger.onDetach.addListener((source) => {
if (source.tabId) attached.delete(source.tabId);
});
// Invalidate attached cache when tab URL changes to non-debuggable
chrome.tabs.onUpdated.addListener((tabId, info) => {
if (info.url && !isDebuggableUrl(info.url)) {
if (attached.has(tabId)) {
attached.delete(tabId);
try { chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
}
}
});
}
Loading
Loading