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:
Binary file not shown.
Binary file not shown.
@@ -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,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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user