From dbf3153db4d38a8d441fad2e2a8eda9033b943ba Mon Sep 17 00:00:00 2001 From: LeonTing1010 Date: Sat, 9 May 2026 01:03:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(extension):=20cross-origin=20nav=20must=20u?= =?UTF-8?q?pdate=20daemon's=20lastActiveTab=20=E2=80=94=20=C2=A72C(iv)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge dogfood (2026-05-09) caught a regression: §2C(iii) opens a new bg tab for cross-origin nav, but chrome.tabs.create({active:false}) does NOT trigger chrome.tabs.onActivated, so the existing active_tab_changed listener (line ~1430) never fires. Daemon's lastActiveTab cache stays pointing at the OLD active tab → subsequent ops in the same plan (eval/extract/click) silently route to the wrong page. Symptom: a cursor.directory plan returned bodyText/title/url from the PREVIOUSLY-active Ahrefs tab (silent data corruption — reverse of the problem §2C(iii) was meant to fix). The cross-origin guard worked (new tab opened) but the post-nav routing silently broke. Fix: in the §2C(ii)/(iii) chrome.tabs.create branch, manually emit active_tab_changed via ws.send when fromDaemon=true. Daemon updates its lastActiveTab cache; subsequent ops route to the new tab via the existing fallback logic. Static guard (Rule iv): self-heal.test.mjs source-text checks: iv/1: chrome.tabs.create branch sends active_tab_changed iv/2: emit is gated on fromDaemon (popup path unaffected) CDD Phase 7 post-mortem: Why didn't existing tests catch this? Source-text tests verified the STATIC SHAPE (new tab created on cross-origin) but couldn't verify the DYNAMIC TAB ROUTING across daemon-driven multi-op plans. Real dogfood exposed the gap between "new tab opened" and "subsequent ops route to it". Rule (iv) closes the static guard at the same layer (source-text proxy for the runtime behavior). Real-world verification: after this lands + reload extension, re-run the same cross-origin batch (cursor after Ahrefs) — eval should return cursor.directory content, not Ahrefs content. Companion to PR #5 §2C(i)+(ii)+(iii). All four §2C subsections are substrate-side fixes for the same dogfood session — one contiguous self-heal patch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/background.js | 17 ++++++++ extension/test/self-heal.test.mjs | 72 +++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/extension/background.js b/extension/background.js index 8dbe8bf..b6fc893 100644 --- a/extension/background.js +++ b/extension/background.js @@ -370,6 +370,23 @@ async function handleMethod(method, params = {}, senderTabId = null, { fromDaemo // (§2C(iii)) → open new background tab, never clobber. const tab = await chrome.tabs.create({ url: params.url, active: false }) tabId = tab.id + // §2C(iv) [2026-05-09 post-merge dogfood fix] — chrome.tabs.create + // with active:false does NOT trigger chrome.tabs.onActivated, so + // the existing active_tab_changed notification (line ~1430) never + // fires. Daemon's lastActiveTab cache stays pointing at the OLD + // active tab, and subsequent ops in the same plan (eval/extract) + // silently route to the wrong page. Manually emit the + // notification here so the cache catches up. Gated on fromDaemon + // so popup path (user-driven) doesn't override daemon cache. + if (fromDaemon && ws && ws.readyState === WebSocket.OPEN) { + try { + ws.send(JSON.stringify({ + jsonrpc: '2.0', + method: 'active_tab_changed', + params: { tabId, url: params.url }, + })) + } catch { /* socket gone */ } + } } else { // Same-origin SPA-style nav: cheap tabs.update. await chrome.tabs.update(tabId, { url: params.url }) diff --git a/extension/test/self-heal.test.mjs b/extension/test/self-heal.test.mjs index fb70e7b..d342dde 100644 --- a/extension/test/self-heal.test.mjs +++ b/extension/test/self-heal.test.mjs @@ -202,6 +202,78 @@ test("origin mismatch branch opens new tab via chrome.tabs.create", () => { ); }); +// ═══════════════════════════════════════════════════════════ +// Rule (iv): cross-origin new tab must update daemon's lastActiveTab cache +// Why: 2026-05-08 dogfood post-merge — after §2C(iii) opens a new bg tab +// for cross-origin nav, subsequent ops in the same plan got routed to +// the OLD active tab (silent data corruption again, just at a different +// layer). Root cause: chrome.tabs.create({active:false}) doesn't trigger +// chrome.tabs.onActivated, so the existing `active_tab_changed` listener +// at line ~1430 never fires. Daemon's lastActiveTab cache stays stale. +// Fix: after creating the new bg tab in the §2C(iii) cross-origin branch, +// the extension must MANUALLY emit `active_tab_changed` to daemon so the +// cache points at the new tab. Subsequent ops without explicit tabId/ +// sessionId then route correctly via the lastActiveTab fallback. +// ═══════════════════════════════════════════════════════════ + +console.log("\n -- Rule (iv): cross-origin new tab notifies daemon --\n"); + +test("cross-origin/isInternal nav branch sends active_tab_changed", () => { + // The branch that calls chrome.tabs.create for cross-origin or + // isInternal must, before returning, send active_tab_changed via ws + // with the new tab's id. Otherwise daemon-side lastActiveTab cache + // keeps pointing at the previous active tab, and subsequent ops + // (eval/extract/click) silently target the wrong page. + const navStart = BG_SRC.indexOf("case 'nav':"); + const navBlock = BG_SRC.slice(navStart, navStart + 4000); + // Pattern: within or right after chrome.tabs.create call, must have + // an ws.send referencing 'active_tab_changed'. + // Looser proxy: after `chrome.tabs.create` (within ~600 chars) there + // must be `active_tab_changed` referenced. + const createMatches = [...navBlock.matchAll(/chrome\.tabs\.create/g)]; + let foundAfterCreate = false; + for (const m of createMatches) { + const after = navBlock.slice(m.index, m.index + 800); + if (/active_tab_changed/.test(after)) { + foundAfterCreate = true; + break; + } + } + assert( + foundAfterCreate, + "After `chrome.tabs.create` in nav handler, must emit `active_tab_changed` " + + "to daemon so lastActiveTab cache updates. Otherwise subsequent ops " + + "in the same plan route to the OLD active tab (silent tab routing).", + ); +}); + +test("active_tab_changed emit is gated on fromDaemon (popup path unaffected)", () => { + // The new emit must only fire when fromDaemon=true, so popup path + // (user-initiated) doesn't spuriously update daemon cache. + // Looser proxy: somewhere in the nav handler, an `if (fromDaemon ...)` + // guard exists with `active_tab_changed` in its body block (within + // ~500 chars after the if). + const navStart = BG_SRC.indexOf("case 'nav':"); + const navBlock = BG_SRC.slice(navStart, navStart + 4000); + // Find any `if (fromDaemon ...)` whose body block (next ~500 chars) + // includes active_tab_changed. + const ifFromDaemonRe = /if\s*\(\s*fromDaemon[^)]*\)\s*\{/g; + let foundGated = false; + for (const m of navBlock.matchAll(ifFromDaemonRe)) { + const body = navBlock.slice(m.index, m.index + 600); + if (/active_tab_changed/.test(body)) { + foundGated = true; + break; + } + } + assert( + foundGated, + "active_tab_changed emit in nav handler must be inside an " + + "`if (fromDaemon ...)` guard (otherwise popup-driven navs would " + + "override daemon cache).", + ); +}); + // ═══════════════════════════════════════════════════════════ console.log(`\n ${passed} passed, ${failed} failed`);