fix(ab-connect): bind to stable tabId as the primary key + retry on mid-flight detach (0.4.8, #23)

claude-in-chrome completes the Rakuten cart→購入手続き→checkout flow that
chrome-use 1.2.3 couldn't, because it binds to the browser-level tabId (survives
renderer-process swaps) rather than a CDP target/sessionId (torn down by the
cross-origin OAuth/SSO nav). chrome-use's relay is already keyed to the stable
tabId (cb-tab-<tabId>, #17) and sends commands by {tabId} — the gap was purely
that the extension treated the session→tab map as the source of truth and only
reactively re-attached after a failed lookup.

Make the tabId the PRIMARY resolution path: derive it straight from the session
id (tabIdFromSession), ensure-attach with short retries across the swap window
(recoverSessionTab now loops), and route every send through sendCdpToTab, which
on a detached-style error drops the stale handle, re-attaches the stable tab, and
retries once. So a cross-process nav never surfaces as a hard error — there's no
'session gone' window, matching claude-in-chrome. Builds on 0.4.5/0.4.6 reattach;
makes it primary + bulletproof rather than a fallback.
This commit is contained in:
leeguooooo
2026-06-14 00:02:22 +09:00
parent 9bf79a4242
commit 4e7e80a596
4 changed files with 67 additions and 33 deletions
Binary file not shown.
Binary file not shown.
+61 -27
View File
@@ -155,23 +155,56 @@ function tabForTarget(targetId) {
return null return null
} }
// Best-effort recovery for a stale `cb-tab-<tabId>` session: the handle is gone // The STABLE Chrome tabId encoded in a `cb-tab-<tabId>` session id (#17), or
// from our maps, but if the underlying Chrome tab still exists and is eligible, // null for any other session shape (child/iframe sessions). The tabId is the
// re-attach to it and return its id so the in-flight command can be retried. // real source of truth: it survives the renderer-process swaps (cross-origin
// Returns null when the tab is genuinely gone (closed / restricted), in which // OAuth/SSO navs) that tear down the page's CDP target — which is why binding to
// case the caller surfaces the stale-session error. (issue #20.1) // it (like claude-in-chrome) rides through the hop that killed the old
// target/sessionId binding (issue #23).
function tabIdFromSession(sessionId) {
const m = /^cb-tab-(\d+)$/.exec(sessionId || '')
return m ? Number(m[1]) : null
}
// Ensure the debugger is attached to a `cb-tab-<tabId>` session's tab, re-attaching
// across the transient window of a process swap (with a couple of short retries).
// Returns the tabId on success, or null when the tab is genuinely gone
// (closed / restricted). (issues #20.1, #23)
async function recoverSessionTab(sessionId) { async function recoverSessionTab(sessionId) {
const m = /^cb-tab-(\d+)$/.exec(sessionId) const tabId = tabIdFromSession(sessionId)
if (!m) return null if (tabId == null) return null
const tabId = Number(m[1]) for (let i = 0; i < 3; i++) {
const tab = await chrome.tabs.get(tabId).catch(() => null) const tab = await chrome.tabs.get(tabId).catch(() => null)
if (!eligible(tab)) return null if (!eligible(tab)) return null
try { try {
await attachTab(tabId) await attachTab(tabId)
if (tabs.has(tabId)) return tabId
} catch { } catch {
return null // mid-swap: the tab exists but isn't attachable yet — back off and retry.
}
await new Promise((r) => setTimeout(r, 120 + i * 150))
}
return null
}
// Send a CDP command to a tab, riding a debugger detach that can happen between
// our attach check and the command itself (a renderer-process swap mid-flight).
// On a detached-style failure, drop the stale handle, re-attach the stable tab,
// and retry once — so a cross-process nav never surfaces as a hard error (#23).
async function sendCdpToTab(tabId, method, params) {
const dbg = { tabId }
try {
return await chrome.debugger.sendCommand(dbg, method, params)
} catch (e) {
const msg = String((e && e.message) || e)
if (!/detached|not attached|target.*(closed|gone)|no target|cannot access|frame.*detached/i.test(msg)) {
throw e
}
detachTab(tabId, false)
const ok = await recoverSessionTab(`cb-tab-${tabId}`)
if (!ok) throw e
return await chrome.debugger.sendCommand(dbg, method, params)
} }
return tabs.has(tabId) ? tabId : null
} }
function anyConnectedTab() { function anyConnectedTab() {
@@ -232,24 +265,26 @@ async function handleForwardCdpCommand(msg) {
// Fail loudly instead so the agent sees an actionable error, not bad data. // Fail loudly instead so the agent sees an actionable error, not bad data.
let tabId let tabId
if (sessionId) { if (sessionId) {
tabId = tabForSession(sessionId) // The stable Chrome tabId encoded in `cb-tab-<tabId>` is the source of truth
if (!tabId) { // (it survives renderer-process swaps; the CDP target/sessionId does not).
// The session's debugger handle is gone, but `cb-tab-<tabId>` encodes the // Resolve via it primarily — don't depend on a session→tab map entry that the
// STABLE Chrome tabId (#17). A cross-process navigation (e.g. an SSO // detach handler may have cleared — and ensure the debugger is attached,
// redirect to another origin), a service-worker restart, or DevTools // re-attaching across a cross-process nav before failing (issues #20.1, #23).
// briefly stealing the debugger all tear the handle down while the tab // `tabForSession` still covers child/iframe sessions that aren't `cb-tab-*`.
// itself lives on. Before failing, try to transparently re-attach to that tabId = tabIdFromSession(sessionId) ?? tabForSession(sessionId)
// same tab and retry — so `open`/`navigate`/`eval` self-heal instead of if (tabId == null) {
// dead-ending the agent (issue #20.1). attachTab re-mints the identical throw new Error(`unknown sessionId ${sessionId} for ${method}`)
// `cb-tab-<tabId>` session, so the daemon's binding stays valid. }
tabId = await recoverSessionTab(sessionId) if (!tabs.has(tabId)) {
if (!tabId) { const recovered = await recoverSessionTab(sessionId)
if (!recovered) {
throw new Error( throw new Error(
`stale sessionId ${sessionId} for ${method}: its tab is gone (closed, ` + `stale sessionId ${sessionId} for ${method}: its tab is gone (closed, ` +
`navigated across processes, or lost after an extension restart). ` + `navigated across processes, or lost after an extension restart). ` +
`Re-attach by re-opening your target URL before retrying.`, `Re-attach by re-opening your target URL before retrying.`,
) )
} }
tabId = recovered
} }
} else if (typeof params?.targetId === 'string') { } else if (typeof params?.targetId === 'string') {
tabId = tabForTarget(params.targetId) tabId = tabForTarget(params.targetId)
@@ -259,18 +294,17 @@ async function handleForwardCdpCommand(msg) {
// applies to any attached tab. // applies to any attached tab.
tabId = anyConnectedTab() tabId = anyConnectedTab()
} }
if (!tabId) throw new Error(`no attached tab for ${method}`) if (tabId == null) throw new Error(`no attached tab for ${method}`)
const dbg = { tabId }
// Re-enabling Runtime can leave a stale state; bounce it (matches upstream). // Re-enabling Runtime can leave a stale state; bounce it (matches upstream).
if (method === 'Runtime.enable') { if (method === 'Runtime.enable') {
try { try {
await chrome.debugger.sendCommand(dbg, 'Runtime.disable') await sendCdpToTab(tabId, 'Runtime.disable', undefined)
await new Promise((r) => setTimeout(r, 30)) await new Promise((r) => setTimeout(r, 30))
} catch {} } catch {}
return await chrome.debugger.sendCommand(dbg, 'Runtime.enable', params) return await sendCdpToTab(tabId, 'Runtime.enable', params)
} }
return await chrome.debugger.sendCommand(dbg, method, params) return await sendCdpToTab(tabId, method, params)
} }
// ---- attach / detach ------------------------------------------------------ // ---- attach / detach ------------------------------------------------------
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "chrome-use", "name": "chrome-use",
"version": "0.4.7", "version": "0.4.8",
"description": "Let chrome-use drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.", "description": "Let chrome-use drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
"icons": { "icons": {