diff --git a/extension/background.js b/extension/background.js index b6e14b6..8dbe8bf 100644 --- a/extension/background.js +++ b/extension/background.js @@ -11,6 +11,23 @@ console.log('[tap] extension runtime ready') +// --- MV3 SW keep-alive (ADR 2026-05-08-failure-detection-phase-2 §2C(i)) --- +// +// MV3 unloads the SW after ~30s idle, breaking the daemon's WebSocket and +// surfacing as `peer_unreachable` to engine. classifyOpFailure routes that +// to reconnect_extension, but the root cause is fixable here: chrome.alarms +// fires even when the SW is unloaded, waking it up. Calling any chrome +// API in the listener resets the SW idle timer. +// +// periodInMinutes 0.4 = 24s, comfortably under the 30s idle window. +chrome.alarms.create('tap-keepalive', { periodInMinutes: 0.4 }) +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === 'tap-keepalive') { + // No-op API call resets the idle timer. getPlatformInfo is cheapest. + chrome.runtime.getPlatformInfo(() => {}) + } +}) + // --- State --- // --- Session Manager --- @@ -331,18 +348,30 @@ async function handleMethod(method, params = {}, senderTabId = null, { fromDaemo tabId = tab.id } else { const isInternal = current.url?.startsWith('chrome://') || current.url?.startsWith('data:') - if (isInternal && !fromDaemon) { - // Popup/content-script path: the user is actively looking at a - // chrome:// or data: tab — don't clobber it, open the target in a - // new tab instead. This is UX-preserving replacement. + // ADR 2026-05-08-failure-detection-phase-2 §2C(iii) — compute + // target vs current origin to decide tabs.update vs tabs.create. + // Cross-origin nav must NOT clobber an existing tab: the previous + // page may be in a redirect chain (e.g. CF auth) whose state + // would leak into the eval that follows. Same-origin SPA navs + // remain cheap (tabs.update). + let crossOrigin = false + try { + const target = new URL(params.url) + if (current.url) { + const currentParsed = new URL(current.url) + crossOrigin = target.origin !== currentParsed.origin + } + } catch { + // Malformed URL — treat as cross-origin (safer: open new tab) + crossOrigin = true + } + if (isInternal || crossOrigin) { + // chrome:// / data:// active tab (§2C(ii)) OR cross-origin nav + // (§2C(iii)) → open new background tab, never clobber. const tab = await chrome.tabs.create({ url: params.url, active: false }) tabId = tab.id } else { - // Daemon path (and regular-URL tabs): navigate in place via - // tabs.update. chrome://newtab/ is our own placeholder from - // session.create and can be navigated away from in place — the - // previous "create a replacement tab" branch leaked the original - // chrome://newtab/ on every session-based nav. + // 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 new file mode 100644 index 0000000..fb70e7b --- /dev/null +++ b/extension/test/self-heal.test.mjs @@ -0,0 +1,208 @@ +/** + * Constraint: extension self-heals MV3 SW idle die + always uses + * managed background tabs for daemon-driven navs. + * + * Classification: safety / what — violations cause silent peer_unreachable + * spurious failures and active-tab clobbering during daemon ops. + * + * Per ADR `2026-05-08-failure-detection-phase-2.md` §2C: + * (i) chrome.alarms keep-alive prevents MV3 SW idle (~30s timeout) from + * firing peer_unreachable to engine. + * (ii) fromDaemon exemption deleted — daemon-driven navs ALWAYS open a + * managed background tab when active is chrome://, never clobber. + * + * Adversarial framing (Phase 1a): + * "If a half-implementation made this test pass, it could (a) add the + * chrome.alarms.create call but never wire onAlarm.addListener (no-op + * timer that doesn't actually wake SW) — caught by Rule (i)/2; (b) + * delete the !fromDaemon expression but introduce a different + * bypass like `if (isInternal && something_else)` that lets daemon + * navs through — caught by Rule (ii)/2 which asserts the strict + * isInternal-only guard pattern." + * + * Run: node extension/test/self-heal.test.mjs + */ + +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; + +const BG_SRC = readFileSync( + new URL("../background.js", import.meta.url), + "utf-8", +); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(` \x1b[32m✓\x1b[0m ${name}`); + } catch (e) { + failed++; + console.log(` \x1b[31m✗\x1b[0m ${name}`); + console.log(` ${e.message}`); + } +} + +// ═══════════════════════════════════════════════════════════ +// Rule (i): MV3 SW keep-alive via chrome.alarms +// Why: MV3 SW unloads after ~30s of inactivity. Without a keep-alive, +// idle daemon connections produce spurious peer_unreachable on next +// op; classifyOpFailure routes those to reconnect_extension, but the +// root cause is fixable here, not at the engine layer. +// ═══════════════════════════════════════════════════════════ + +console.log("\n -- Rule (i): MV3 SW keep-alive --\n"); + +test("chrome.alarms.create with name 'tap-keepalive' exists", () => { + // The name string is part of the contract — onAlarm dispatch matches + // on it, and arch tests cite it directly. + assert( + /chrome\.alarms\.create\s*\(\s*["']tap-keepalive["']/.test(BG_SRC), + "background.js must call chrome.alarms.create with name 'tap-keepalive'", + ); +}); + +test("chrome.alarms.onAlarm.addListener wired to keepalive", () => { + assert( + /chrome\.alarms\.onAlarm\.addListener/.test(BG_SRC), + "background.js must register a chrome.alarms.onAlarm listener", + ); + // Listener body must reference the keepalive alarm name (otherwise it + // would be a no-op dispatcher matching nothing). + const listenerStart = BG_SRC.indexOf("chrome.alarms.onAlarm.addListener"); + const listenerBody = BG_SRC.slice(listenerStart, listenerStart + 600); + assert( + /tap-keepalive/.test(listenerBody), + "onAlarm listener body must dispatch on the 'tap-keepalive' alarm name", + ); +}); + +test("keepalive period is < 0.5 minutes (< 30s, MV3 idle window)", () => { + // Default MV3 SW idle is 30s; keepalive must fire faster. Accept any + // periodInMinutes literal < 0.5 (i.e. <= 0.4 typical, or 0.49). + const m = BG_SRC.match( + /chrome\.alarms\.create\s*\(\s*["']tap-keepalive["']\s*,\s*\{[^}]*periodInMinutes:\s*([\d.]+)/, + ); + assert(m, "chrome.alarms.create must specify periodInMinutes"); + const period = parseFloat(m[1]); + assert( + period < 0.5, + `periodInMinutes ${period} >= 0.5 — SW would idle-die between alarms (MV3 idle ~30s = 0.5min)`, + ); +}); + +// ═══════════════════════════════════════════════════════════ +// Rule (ii): chrome:// guard does NOT exempt fromDaemon +// Why: dogfood 2026-05-08 — when active tab was chrome://extensions +// (during reload), daemon-driven navs got `tab_closed: Cannot access +// a chrome:// URL`. The exemption was a UX-preserving heuristic for +// popup path that wrongly applied to daemon path. +// ═══════════════════════════════════════════════════════════ + +console.log("\n -- Rule (ii): chrome:// guard always open background tab --\n"); + +test("no `&& !fromDaemon` exemption in isInternal nav guard", () => { + // Strict text check: the exact stale pattern must be absent. + assert( + !/if\s*\(\s*isInternal\s*&&\s*!fromDaemon\s*\)/.test(BG_SRC), + "Stale exemption `if (isInternal && !fromDaemon)` must be deleted; " + + "daemon-driven navs always open managed background tab.", + ); +}); + +test("isInternal guard exists and opens new tab", () => { + // The guard may be `if (isInternal)` or `if (isInternal || )`. + // What matters: isInternal participates in a guard whose body opens + // a new background tab via chrome.tabs.create. + const idx = BG_SRC.search(/if\s*\(\s*isInternal[\s|)]/); + assert( + idx !== -1, + "Must contain `if (isInternal ...)` guard (with isInternal as first condition)", + ); + const block = BG_SRC.slice(idx, idx + 600); + assert( + /chrome\.tabs\.create/.test(block), + "isInternal-branch must call chrome.tabs.create to open a new tab", + ); +}); + +// ═══════════════════════════════════════════════════════════ +// Rule (iii): origin-mismatch nav → new background tab +// Why: 2026-05-08 dogfood — Cloudflare nav redirected through CF +// auth chain, leaving tab on dash.cloudflare.com/two-factor. Next +// nav (juejin.cn/search) called `chrome.tabs.update(tabId, { url })` +// to navigate same tab, but the eval ran on cloudflare login page — +// silent data corruption. Same applies to parallel batch calls +// sharing a tab. Fix: when daemon-driven nav target origin differs +// from current tab origin, open a new background tab instead of +// clobbering. Same-origin navs continue to use tabs.update (cheap). +// ═══════════════════════════════════════════════════════════ + +console.log("\n -- Rule (iii): origin-mismatch nav → new background tab --\n"); + +test("nav handler computes target origin", () => { + // Source-text proxy: must call new URL(...) on params.url to extract origin. + // Pattern: `new URL(params.url)` followed by `.origin` access OR variable + // assignment that's later compared to current origin. + assert( + /new URL\(params\.url\)/.test(BG_SRC), + "nav handler must construct URL(params.url) to extract target origin", + ); +}); + +test("nav handler compares target.origin vs current.origin", () => { + // Must read .origin from both target and current to compare. + // Looser pattern: at least 2 occurrences of `.origin` near nav case + // (one for target, one for current). + const navStart = BG_SRC.indexOf("case 'nav':"); + assert(navStart !== -1, "nav case handler must exist"); + // Search a 2000-char window starting from `case 'nav':`. + const navBlock = BG_SRC.slice(navStart, navStart + 2000); + const originAccesses = navBlock.match(/\.origin\b/g) || []; + assert( + originAccesses.length >= 2, + `nav handler must access .origin on both target and current to compare; ` + + `found ${originAccesses.length} .origin access(es) in 2000-char window`, + ); +}); + +test("origin mismatch branch opens new tab via chrome.tabs.create", () => { + // Two acceptable idioms: + // (a) inline: if (target.origin !== current.origin) { chrome.tabs.create(...) } + // (b) variable: const cross = a.origin !== b.origin; if (... || cross) { chrome.tabs.create(...) } + // What matters: somewhere in the nav handler there's an `.origin !== + // .origin` comparison whose result drives a chrome.tabs.create branch. + const navStart = BG_SRC.indexOf("case 'nav':"); + const navBlock = BG_SRC.slice(navStart, navStart + 3000); + // Step 1: confirm origin-vs-origin comparison appears. + assert( + /\.origin\s*!==?\s*[a-zA-Z_$.]*\.origin/.test(navBlock), + "nav handler must compare `.origin !== .origin` (cross-origin detection)", + ); + // Step 2: confirm chrome.tabs.create appears within the same nav block. + assert( + /chrome\.tabs\.create/.test(navBlock), + "nav handler must call chrome.tabs.create somewhere", + ); + // Step 3: confirm the result of the origin comparison influences a + // boolean used in the if-guard. Look for either: + // - inline: if (...origin !==...origin...) { ... chrome.tabs.create + // - variable: crossOrigin (or similar) referenced in if + assigned from origin compare + const inlinePattern = + /if\s*\([^)]*\.origin\s*!==?[^)]*\.origin[^)]*\)\s*\{[\s\S]{0,500}chrome\.tabs\.create/; + const variablePattern = + /(\w+)\s*=\s*[^;]*\.origin\s*!==?\s*[a-zA-Z_$.]*\.origin[\s\S]{0,500}if\s*\([^)]*\1[^)]*\)\s*\{[\s\S]{0,500}chrome\.tabs\.create/; + assert( + inlinePattern.test(navBlock) || variablePattern.test(navBlock), + "nav handler must use the origin comparison (inline or via boolean " + + "variable) to gate a chrome.tabs.create branch", + ); +}); + +// ═══════════════════════════════════════════════════════════ + +console.log(`\n ${passed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0);