← Commit history

session-tracking fix: explicit sessionId resolves STRICTLY off the session->window map (never the active-tab heuristic that hard-blocked); gone windows -> marked closed; add nbrowser_resync_sessions (lightweight rebuild, no dev_reload)

John Lauer ·5ad17c52f2 ·4mo ago ·parent 6c51e84
7 files changed +108−43
bridge/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.0.38\ No newline at end of file+0.0.39\ No newline at end of file
bridge/bridge.json+3−2
@@ -2,8 +2,8 @@   "manifest_version": 1,   "name": "native-browser",   "displayName": "Adom Native Browser (Chrome + Edge)",-  "version": "0.0.38",-  "expectedExtensionVersion": "0.0.20",+  "version": "0.0.39",+  "expectedExtensionVersion": "0.0.21",   "description": "Drives the user's REAL signed-in Chrome/Edge via an installed extension (native-messaging transport). Mirrors the puppeteer bridge's verb surface as nbrowser_* but runs inside the actual logged-in profile, so cookies/SSO/saved-logins/captcha human-trust all work. The bridge binds an ephemeral loopback port (PORTS.md compliant) and writes a discovery file; the native-messaging host pumps stdio <-> this bridge; the extension never touches a TCP port.",   "homepage": "https://wiki.adom.inc/adom/adom-browser-extension",   "author": "Adom Inc.",@@ -39,6 +39,7 @@     "nbrowser_profile_block",     "nbrowser_profile_unblock",     "nbrowser_rescan",+    "nbrowser_resync_sessions",     "nbrowser_open_tab",     "nbrowser_close_tab",     "nbrowser_switch_tab",
bridge/package.json+1−1
@@ -1,6 +1,6 @@ {   "name": "adom-native-browser-bridge",-  "version": "0.0.38",+  "version": "0.0.39",   "private": true,   "description": "adom-desktop bridge for the Adom native-browser extension (Chrome + Edge)",   "main": "server.js",
bridge/server.js+4−2
@@ -732,7 +732,8 @@ const VERB_META = {   nbrowser_whoami: { hint: 'Ask ONE profile to (re)resolve + report its Google email (and re-announce it to the bridge). For all profiles at once use nbrowser_resolve_profiles.', related: ['nbrowser_resolve_profiles', 'nbrowser_profiles'], pitfalls: ['signedIn:false = this browser profile has no Google account, so no email'] },   nbrowser_profile_block: { hint: 'Blocklist a profile so it is NEVER driven (user safety whitelist; persists).', related: ['nbrowser_profile_unblock', 'nbrowser_profiles'], pitfalls: ['respect the blocklist - it is the user telling you hands-off'] },   nbrowser_profile_unblock: { hint: 'Remove a profile from the blocklist.', related: ['nbrowser_profile_block'], pitfalls: [] },-  nbrowser_rescan: { hint: 'Reconcile the session map against live windows (drop closed).', related: ['nbrowser_list_windows'], pitfalls: [] },+  nbrowser_rescan: { hint: 'Reconcile the session map against live windows (drop/mark-closed the gone ones).', related: ['nbrowser_list_windows', 'nbrowser_resync_sessions'], pitfalls: [] },+  nbrowser_resync_sessions: { hint: 'LIGHTWEIGHT rebuild of session tracking WITHOUT a dev_reload. Re-reads the persisted session→window map, merges it into memory, and re-validates every session against live windows. Use it the moment a session verb fails with session_untracked (e.g. after the user interacted with Chrome, or another tool moved/closed windows via SetForegroundWindow/WM_CLOSE) - it un-sticks tracking in ONE call. Returns the drivable sessions + which are closed.', related: ['nbrowser_open_window', 'nbrowser_profiles', 'nbrowser_rescan'], pitfalls: ['a session whose window was genuinely closed cannot be revived - open a fresh one with nbrowser_open_window', 'you do NOT need this in the normal path; sessions survive SW sleep on their own now - reach for it only on a session_untracked error'] },   nbrowser_open_tab: { hint: 'Open a background tab in one of YOUR session windows.', related: ['nbrowser_close_tab', 'nbrowser_list_tabs'], pitfalls: ['opens active:false (background); open tabs in a window you created, not the user\'s'] },   nbrowser_close_tab: { hint: 'Close a tab you opened.', related: ['nbrowser_list_tabs'], pitfalls: ['⛔ only close a tab YOU opened - closing the user\'s tab destroys their live page/session'] },   nbrowser_switch_tab: { hint: 'Make a tab active within its window.', related: ['nbrowser_list_tabs'], pitfalls: [] },@@ -885,7 +886,8 @@ function dispatch(verb, args) {         d('nbrowser_whoami', 'Ask one profile to (re)resolve + report its Google email and re-announce it.', { profile: P }, { ok: 'bool', email: 'str', signedIn: 'bool', browser: 'str' }, 15, { example: {} }),         d('nbrowser_profile_block', 'Blocklist a profile so it is NEVER driven (persists).', { profile: 'required str' }, { ok: 'bool', blocklist: '[str]' }, 15, { example: { profile: 'edge:[email protected]' } }),         d('nbrowser_profile_unblock', 'Remove a profile from the blocklist.', { profile: 'required str' }, { ok: 'bool', blocklist: '[str]' }, 15, { example: { profile: 'edge:[email protected]' } }),-        d('nbrowser_rescan', 'Reconcile the session map against live windows (drop closed).', {}, { ok: 'bool', sessions: '[...]' }, 20, { example: {} }),+        d('nbrowser_rescan', 'Reconcile the session map against live windows (drop/mark-closed the gone ones).', {}, { ok: 'bool', sessions: '[...]' }, 20, { example: {} }),+        d('nbrowser_resync_sessions', 'Lightweight rebuild of session tracking (no dev_reload): re-read persisted map + re-validate vs live windows. Use on a session_untracked error.', {}, { ok: 'bool', tracked: '[{sessionId,windowId}]', trackedCount: 'int', closed: '[{sessionId,byUser,external}]' }, 15, { example: {} }),         d('nbrowser_open_tab', 'Open a tab in the BACKGROUND (active:false) in a window.', { sessionId: S, url: 'required str', focused: 'optional bool (default false)' }, { ok: 'bool', sessionId: 'str', tabId: 'str', url: 'str', title: 'str' }, 30, { example: { sessionId: 'work', url: 'https://example.com' } }),         d('nbrowser_close_tab', 'Close a tab.', { sessionId: S, tabId: 'required str' }, { ok: 'bool', tabId: 'str' }, 20, { example: { sessionId: 'work', tabId: '123' } }),         d('nbrowser_switch_tab', 'Make a tab active within its window.', { sessionId: S, tabId: 'required str' }, { ok: 'bool', tabId: 'str' }, 15, { example: { sessionId: 'work', tabId: '123' } }),
extension/src/native.js+94−33
@@ -20,15 +20,29 @@ function windowExists(winId) { async function ensureLoaded() {   if (loaded) return;   loaded = true;+  await reconcile();+}+// Re-read the persisted map, MERGE it into memory (never lose an in-memory session that hasn't been+// persisted yet), and reconcile against LIVE windows. A session whose window no longer exists is MOVED+// to _closedSessions - NOT silently dropped - so a later verb reports "closed" and NEVER falls back to+// the user's active tab. Safe to call anytime; nbrowser_resync_sessions calls it. This is what makes+// tracking survive SW sleep + external window closes (SetForegroundWindow / WM_CLOSE from another tool).+async function reconcile() {   try {     const got = await chrome.storage.local.get(SKEY);     const obj = got[SKEY];     if (obj) {-      seq = obj.seq || 0;-      for (const [sid, winId] of obj.sessions || []) sessionWindows.set(sid, winId);-      for (const [sid, winId] of [...sessionWindows]) if (!(await windowExists(winId))) sessionWindows.delete(sid);+      seq = Math.max(seq, obj.seq || 0);+      for (const [sid, winId] of obj.sessions || []) if (!sessionWindows.has(sid)) sessionWindows.set(sid, winId);     }   } catch {}+  for (const [sid, winId] of [...sessionWindows]) {+    if (!(await windowExists(winId))) {+      sessionWindows.delete(sid);+      if (!_closedSessions.has(sid)) _closedSessions.set(sid, { windowId: winId, byUser: null, ts: Date.now(), external: true });+    }+  }+  await persist(); }  function tabView(t) {@@ -123,36 +137,68 @@ async function assertOwned(tabId, verb) {   } } -// Resolve which tab a verb targets: explicit tabId > active tab of the session.-// opts.requireOwned (set for every MUTATING verb) additionally (a) rejects bare calls instead of-// falling through to the user's active tab, and (b) rejects targets outside agent-owned windows.+// Resolve which tab a verb targets. Keyed off the REAL session→window map, never an "active tab"+// heuristic when a sessionId/tabId is given:+//   explicit tabId   → that tab (ownership re-checked for mutating verbs)+//   explicit sessionId → the active tab of THAT SESSION'S OWN window; if the session isn't tracked+//                        (window closed/re-created externally, or the map was lost while the SW slept)+//                        it THROWS a clear session_untracked/closed error - it NEVER acts on whatever+//                        tab the user happens to be in. (That fallback is the bug this fixes.)+//   bare (neither)   → mutating verbs are refused; only a read-only bare call may use the active tab. async function resolveTabId(args, opts = {}) {   await ensureLoaded();   const verb = opts.verb || 'this verb';-  if (opts.requireOwned) requireTarget(args, verb);-  let tabId;+   if (args && args.tabId != null) {-    tabId = Number(args.tabId);-  } else {-    if (args && args.sessionId != null && !sessionWindows.has(args.sessionId)) {-      const c = _closedSessions.get(args.sessionId);-      if (c) throw new Error(`session '${args.sessionId}' was closed ${c.byUser ? 'BY THE USER' : 'by the agent'} — open a new window with nbrowser_open_window`);+    const tabId = Number(args.tabId);+    await assertDriveableWindow(tabId, args);+    if (opts.requireOwned) await assertOwned(tabId, verb);+    return tabId;+  }++  if (args && args.sessionId != null) {+    const tabId = await tabForSession(args.sessionId, verb); // reconciles; throws if untracked - NEVER active-tab+    await assertDriveableWindow(tabId, args);+    return tabId; // owned by construction: the session→window map is written ONLY by openWindow+  }++  // Bare call - no sessionId, no tabId.+  if (opts.requireOwned) requireTarget(args, verb); // mutating → refuse; never touch the user's tab+  const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); // read-only bare only+  if (!tab) throw new Error('no target tab found');+  await assertDriveableWindow(tab.id, args);+  return tab.id;+}++// Resolve the tab for an EXPLICIT session strictly from the session→window map. Reconciles first (so a+// map lost from memory but present in storage is recovered), and if the session's window is truly gone+// it records the close and throws a clear error - it NEVER falls back to the user's active tab.+async function tabForSession(sid, verb) {+  if (!sessionWindows.has(sid)) await reconcile();+  if (sessionWindows.has(sid)) {+    const windowId = sessionWindows.get(sid);+    if (await windowExists(windowId)) {+      let [tab] = await chrome.tabs.query({ active: true, windowId });+      if (!tab) [tab] = await chrome.tabs.query({ windowId }); // OUR window, any tab+      if (tab) return tab.id;     }-    let windowId;-    if (args && args.sessionId != null && sessionWindows.has(args.sessionId)) windowId = sessionWindows.get(args.sessionId);-    const q = windowId != null ? { active: true, windowId } : { active: true, lastFocusedWindow: true };-    const [tab] = await chrome.tabs.query(q);-    if (!tab) throw new Error('no target tab found');-    tabId = tab.id;+    sessionWindows.delete(sid);+    if (!_closedSessions.has(sid)) _closedSessions.set(sid, { windowId, byUser: null, ts: Date.now(), external: true });+    await persist();   }-  await assertDriveableWindow(tabId, args); // ⛔ PWA guard on EVERY resolution path-  if (opts.requireOwned) await assertOwned(tabId, verb);-  return tabId;+  const c = _closedSessions.get(sid);+  throw new GuardError('session_untracked',+    c+      ? `session '${sid}' is closed (${c.byUser === true ? 'by the user' : c.byUser === false ? 'by the agent' : 'its window was closed externally'}) — open a fresh window with nbrowser_open_window. NOT retrying into your active tab.`+      : `session '${sid}' isn't tracked (its window was likely closed/re-created outside the extension). Run nbrowser_resync_sessions to rebuild tracking, then nbrowser_open_window for a fresh window. NOT falling back to your active tab.`,+    OPEN_WINDOW_HINT); } -function resolveWindowId(args) {-  if (args && args.sessionId != null && sessionWindows.has(args.sessionId)) return sessionWindows.get(args.sessionId);-  return null;+// Reconcile-aware window resolve for window-level verbs (open_tab/close_window/switch_window/…).+async function resolveWindowId(args) {+  if (!(args && args.sessionId != null)) return null;+  if (!sessionWindows.has(args.sessionId)) await reconcile();+  return sessionWindows.has(args.sessionId) ? sessionWindows.get(args.sessionId) : null; }  function waitForLoad(tabId, timeoutMs = 30000) {@@ -186,7 +232,7 @@ export async function openWindow(args = {}) {  export async function closeWindow(args = {}) {   await ensureLoaded();-  const winId = resolveWindowId(args);+  const winId = await resolveWindowId(args);   if (winId == null) {     const c = _closedSessions.get(args.sessionId);     throw new Error(c ? `session '${args.sessionId}' was already closed ${c.byUser ? 'by the USER' : 'by the agent'}` : `unknown sessionId '${args.sessionId}'`);@@ -200,7 +246,7 @@ export async function closeWindow(args = {}) {  export async function switchWindow(args = {}) {   await ensureLoaded();-  const winId = resolveWindowId(args);+  const winId = await resolveWindowId(args);   if (winId == null) throw new Error('unknown sessionId');   // BACKGROUND by default — switching which window the agent targets must NEVER raise it into the   // user's face. Every verb already targets a window by its sessionId, so this is a no-op for@@ -215,7 +261,7 @@ export async function switchWindow(args = {}) { // intercepts browser-chrome shortcuts before the page ever sees the CDP key event). export async function windowState(args = {}) {   await ensureLoaded();-  const winId = resolveWindowId(args);+  const winId = await resolveWindowId(args);   if (winId == null) throw new Error('unknown sessionId');   // Move/resize path — any of left/top/width/height present. chrome.windows requires the   // window be in 'normal' state before bounds take, so force it first.@@ -255,16 +301,31 @@ export async function listWindows() {  // reconcile the session map against live windows (drop closed ones) export async function rescan() {-  await ensureLoaded();-  for (const [sid, winId] of [...sessionWindows]) if (!(await windowExists(winId))) sessionWindows.delete(sid);-  await persist();+  await reconcile();   return listWindows(); } +// nbrowser_resync_sessions — LIGHTWEIGHT rebuild of session tracking WITHOUT a full dev_reload. Re-reads+// the persisted map, merges it into memory, and re-validates every session against live windows (gone+// windows → marked closed). Use it when session verbs start failing with session_untracked after the+// user interacted with Chrome or another tool moved/closed windows - it un-sticks tracking in one call.+export async function resyncSessions() {+  loaded = false;              // force a fresh read from storage+  await ensureLoaded();+  const tracked = [...sessionWindows].map(([sessionId, windowId]) => ({ sessionId, windowId }));+  const closed = [...(_closedSessions.entries())].map(([sessionId, c]) => ({ sessionId, byUser: c.byUser, external: !!c.external }));+  return {+    ok: true, tracked, trackedCount: tracked.length, closed,+    _hint: tracked.length+      ? `Session tracking rebuilt: ${tracked.length} live agent window(s) are drivable by their sessionId. Closed/untracked sessions are listed under "closed" - open fresh windows for those with nbrowser_open_window.`+      : 'No live agent windows are tracked. Open one with nbrowser_open_window (it auto-pins its sessionId).',+  };+}+ // ---- tabs ------------------------------------------------------------------ export async function openTab(args = {}) {   await ensureLoaded();-  const windowId = resolveWindowId(args);+  const windowId = await resolveWindowId(args);   if (windowId == null) {     throw new GuardError('no_target',       "\u26d4 no_target: nbrowser_open_tab without the sessionId of a window YOU opened would drop a tab into the USER'S OWN window \u2014 refused.",@@ -291,7 +352,7 @@ export async function switchTab(args = {}) {  export async function listTabs(args = {}) {   await ensureLoaded();-  const windowId = resolveWindowId(args);+  const windowId = await resolveWindowId(args);   const tabs = await chrome.tabs.query(windowId != null ? { windowId } : {});   const active = tabs.find((t) => t.active);   return { ok: true, count: tabs.length, activeTabId: active ? String(active.id) : null, tabs: tabs.map(tabView) };
extension/src/sw.js+1
@@ -192,6 +192,7 @@ async function dispatch(verb, args) {     case 'nbrowser_login_state':   return native.loginState(args);     case 'nbrowser_clear_cookies': return native.clearCookies(args); // BACKGROUND logout — drops a site's cookies (incl. httpOnly)     case 'nbrowser_rescan':        return native.rescan();+    case 'nbrowser_resync_sessions': return native.resyncSessions(); // lightweight session-tracking rebuild (no dev_reload)      // --- demo aids -------------------------------------------------------     case 'nbrowser_caption':       return native.caption(args);
native-browser-bridge-manifest.json+4−4
@@ -1,8 +1,8 @@ {   "manifest_version": 1,-  "version": "0.0.38",-  "url": "https://wiki.adom.inc/download/adom/adom-browser-extension/0.0.38/native-browser-bridge-v0.0.38.bin",-  "sha256": "861630936453a2e34e79eae9c37065e64b8b7d872521401e602942ed7efbdf08",-  "size": 44233,+  "version": "0.0.39",+  "url": "https://wiki.adom.inc/download/adom/adom-browser-extension/0.0.39/native-browser-bridge-v0.0.39.bin",+  "sha256": "56d4a9e8ebcc8c20f941b67760bb040c4b9b1f7b7cb67bb39d96e260e752fc55",+  "size": 44645,   "released_at": "2026-07-06T00:00:00Z" }\ No newline at end of file