← Commit history

v0.16.43 (insiders): nb-bridge#54: nbrowser_export_site_session carries ONE site's live session out of the user's real signed-in browser for another tool on the same machine, behind confirm:true and a reason; the cookie values never cross the relay (they are written owner-only to a machine-local handoff file that expires in 10 minutes), and nbrowser_drop_handoff deletes it as soon as the local tool has imported it; extension 0.2.21

John Lauer ·5add96bc5d ·13d ago ·parent f5bea98
11 files changed +125−10
bin/nb-dashboard+1−1
@@ -20,7 +20,7 @@ const { execFile, spawn } = require('child_process'); const fs = require('fs'); const crypto = require('crypto'); -const VERSION = '0.16.42';           // keep in lockstep with package.json (pkg version)+const VERSION = '0.16.43';           // keep in lockstep with package.json (pkg version) // SEND TO AI IS UN-PARKED (John: "yeah, we are re-enabling the send to ai. it seems to be good now"). // The ah build thread certified the crash family dead on the gate-fixed build and the delivery leg is // proven end to end. The park const is DELETED rather than flipped, so no dead branch can resurrect it.
bridge/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.16.42\ No newline at end of file+0.16.43\ No newline at end of file
bridge/bridge.json+4−2
@@ -2,8 +2,8 @@   "manifest_version": 1,   "name": "native-browser",   "displayName": "Adom Native Browser",-  "version": "0.16.42",-  "expectedExtensionVersion": "0.2.20",+  "version": "0.16.43",+  "expectedExtensionVersion": "0.2.21",   "description": "Drives the user's REAL signed-in Chrome/Edge, in two layers. Layer 1 (nb) needs NOTHING installed and works on any machine: nbrowser_open_os_window (open a URL in the user's real profile, background+flashing, the OAuth/consent path), nbrowser_os_windows, nbrowser_profiles, nbrowser_wake_profile, nbrowser_force_close. Layer 2 (nbe, the Adom Native Browser Extension) unlocks the rest: CDP driving (navigate/click/read/screenshot) inside their logged-in profile. The bridge binds an ephemeral loopback port and the native-messaging host pumps stdio <-> bridge.",   "homepage": "https://wiki.adom.inc/adom/nb-bridge",   "author": "Adom Inc.",@@ -98,6 +98,8 @@     "nbrowser_demo",     "nbrowser_uninstall",     "nbrowser_dev_reload",+    "nbrowser_export_site_session",+    "nbrowser_drop_handoff",     "nbrowser_clear_cookies",     "nbrowser_caption",     "nbrowser_focus",
bridge/package.json+1−1
@@ -1,6 +1,6 @@ {   "name": "adom-native-browser-bridge",-  "version": "0.16.42",+  "version": "0.16.43",   "private": true,   "description": "Native-browser bridge for Bridge (ab): layer 1 (nb) plus layer 2 via nbe, the Adom Native Browser Extension (Chrome + Edge).",   "main": "server.js",
bridge/server.js+54
@@ -1802,6 +1802,8 @@ const VERB_META = {   nbrowser_demo: { hint: 'ONE-CALL SHOWCASE of layer 2: picks a live profile, opens a representative page in the user\'s REAL signed-in browser (background + badged), and returns that exact window\'s hwnd/tabId plus a screenshot delivered as a file (fullPath; ab pulls it to you) with per-step progress. Built for embedding "drive my real browser" in a web demo without any window hunting.', related: ['nbrowser_state', 'nbrowser_open_window', 'nbrowser_close_window'], pitfalls: ['it OPENS a real window on the user\'s desktop - clean up with nbrowser_close_window {sessionId} (default "nbdemo") when the demo ends', 'idempotent: re-running closes its previous demo window first', 'needs nbe connected in some profile - if readiness says needs-extension it returns no drivable profile'] },   nbrowser_uninstall: { hint: 'CASCADE HOOK, not for normal use: ab calls this itself during bridge_uninstall (ab 2.0.35+) with {cascade:true} BEFORE deleting the bridge cache, so nb can remove artifacts that live outside it - above all the HKCU native-messaging host registration, which would otherwise leave every profile dialing a deleted binary forever ("Native host has exited"). Also clears ~/.adom/bridges/native-browser, the activity trail, and the icon cache.', related: ['nbrowser_readiness'], pitfalls: ['you almost never call this directly - run ab\'s bridge_uninstall and let it cascade', 'it deliberately does NOT remove the nbe extension from the user\'s browser (their profile, their choice) - it only stops working; use uninstalling-the-extension to remove it properly', 'best-effort by contract: a failure here still lets ab delete the cache, so check removed[]/failed[] rather than assuming'] },   nbrowser_dev_reload: { hint: 'MAINTAINER-only: reload the extension to pick up on-disk code changes (2-pass, reconnects in ~10s).', related: ['nbrowser_status'], pitfalls: ['throttled to 1/90s (a reload restarts the SW + native host)', 'not for normal driving - only after editing the extension source', 'reloads ONE profile; other profiles pick up the new code on their own next reload'] },+  nbrowser_export_site_session: { hint: "Carry ONE site's live session out of the user's REAL signed-in browser so another tool ON THE SAME MACHINE (pup) can act as them there, without a password and without a fresh sign-in (adom/nb-bridge#54). Needs {domain, confirm:true, reason}. The cookie VALUES are never in the reply: they are written owner-only to a machine-local file and you get a handoffPath plus counts, because a secret in a reply is a secret in an AI transcript. Pass the path to the local tool, then nbrowser_drop_handoff.", related: ['nbrowser_login_state', 'nbrowser_profiles', 'nbrowser_drop_handoff'], pitfalls: ['ASK THE USER FIRST, naming the site: this lets another tool act as them there. confirm:true is your attestation that they said yes', 'ONE registrable domain per call - there is deliberately no "export everything"', 'the handoff file lives on the USER\'S machine, not in your container: do not try to read it from an AI thread, hand the path to the local tool', 'if the profile has no cookies for the domain you get no_session - the user may be signed in under a DIFFERENT profile (nbrowser_profiles)', 'it expires in 10 minutes and nbrowser_drop_handoff removes it sooner - do that as soon as the import is done'] },+  nbrowser_drop_handoff: { hint: 'Delete a secret-handoff file created by nbrowser_export_site_session, as soon as the local tool has read it. Read-only otherwise; it never returns the contents.', related: ['nbrowser_export_site_session'], pitfalls: ['dropped:false just means it was already consumed or expired, which is the safe outcome', 'this is the cleanup half of the export - call it rather than leaving a session file sitting on the user\'s disk'] },   nbrowser_clear_cookies: { hint: 'Background logout: drop a site\'s cookies (incl. httpOnly) for ONE registrable domain, leaving other sites (e.g. Google SSO) intact.', related: ['nbrowser_login_state'], pitfalls: ['scoped to one domain so it never nukes unrelated sessions', '⚠️ do NOT use this to log OUT of a site that ties device-trust to cookies (e.g. Gusto) - it forces a fresh 2FA; use the site\'s own Sign out'] },   nbrowser_caption: { hint: 'Paint an on-page caption (subtitle or red alert banner) for demos/narration.', related: ['nbrowser_cursor'], pitfalls: ['only on a window you opened', 'pass action:"hide" or empty text to dismiss'] },   nbrowser_focus: { hint: 'Select a tab within its window + flash its taskbar button. Does NOT raise the window unless you justify a foreground (userRequestedForeground:true + foregroundReason).', related: ['nbrowser_flash', 'nbrowser_screenshot'], pitfalls: ['does NOT foreground by design - the user\'s focus is never stolen; capture in place with nbrowser_screenshot', 'to actually raise it you must justify + warn the user (ab notify) first'] },@@ -2015,6 +2017,16 @@ function dispatch(verb, args) {     // nbrowser_chrome_status - READ-ONLY: is native Chrome installed / how's an in-flight install going?     // nbrowser_prewarm - ab calls this once per installed version (~20s after the first boot after an install     // or update; adom/nb-bridge#50). nb has no assets to fetch; what is worth doing early is the tally.+    // nb-bridge#54: delete a handoff the moment the local tool has read it, instead of waiting for the TTL.+    if (verb === 'nbrowser_drop_handoff') {+      const id = String((args && args.handoffId) || '');+      if (!/^[a-z]+-\d+-[0-9a-f]{12}$/.test(id)) return resolve({ success: false, errorCode: 'bad_handoff_id', error: 'handoffId required, as returned by the verb that created it' });+      const file = path.join(SECRET_DIR, `${id}.json`);+      let existed = false; try { existed = fs.existsSync(file); if (existed) fs.unlinkSync(file); } catch {}+      sweepSecrets();+      return resolve({ success: true, output: JSON.stringify({ ok: true, dropped: existed, handoffId: id,+        _hint: existed ? 'The handoff file is gone. Do this as soon as the local tool has imported it; otherwise it self-deletes on its TTL.' : 'Nothing to drop: it was already consumed or expired. That is the safe outcome.' }) });+    }     if (verb === 'nbrowser_prewarm') {       let inv; try { inv = nbeInventory(); } catch (e) { return resolve({ success: false, error: 'inventory failed: ' + String((e && e.message) || e) }); }       const t = inv.tally;@@ -2140,6 +2152,8 @@ function dispatch(verb, args) {         d('nbrowser_login_form', 'Detect a login form\'s state (already autofilled? SSO? 2FA?) and recommend the smartest path. ALWAYS call this before driving a login.', { sessionId: S }, { ok: 'bool', hasForm: 'bool', autofilled: 'bool', sso: 'bool', needs2FA: 'bool', recommendation: 'str' }, 30, { statusVerb: null, example: { sessionId: 's1' } }),         d('nbrowser_autologin', 'Fill and submit a saved-credential login. The bridge injects the laptop-decrypted secret directly; the agent NEVER sees it. Gated on confirm:true + host.', { sessionId: S, host: 'required str (e.g. "gusto.com")', confirm: 'required true', username: 'optional str' }, { ok: 'bool', loggedIn: 'bool', needs2FA: 'bool' }, 60, { example: { sessionId: 's1', host: 'gusto.com', confirm: true } }),         d('nbrowser_download', 'Download a URL to the user\'s real Downloads folder via chrome.downloads (lands on the LAPTOP, not the container).', { sessionId: S, url: 'required str', filename: 'optional str', saveAs: 'optional bool' }, { ok: 'bool', downloadId: 'int', filename: 'str?' }, 60, { statusVerb: 'nbrowser_download_wait', example: { sessionId: 's1', url: 'https://example.com/datasheet.pdf' } }),+        d('nbrowser_export_site_session', "Carry ONE site's live session out of the user's real browser for another tool on the SAME machine (pup). Values are written machine-locally, never returned.", { domain: 'required str (e.g. "x.com")', confirm: 'required true (the user said yes)', reason: 'required str (what it is for)', profile: P }, { ok: 'bool', domain: 'str', cookieCount: 'int', authCookieCount: 'int', handoffId: 'str', handoffPath: 'str (on the USER\'s machine)', expiresInSec: 'int' }, 30, { example: { domain: 'x.com', confirm: true, reason: 'carry the signed-in session into pup so it can post as the user' } }),+        d('nbrowser_drop_handoff', 'Delete a handoff file once the local tool has imported it.', { handoffId: 'required str' }, { ok: 'bool', dropped: 'bool' }, 15, { example: { handoffId: 'session-1790000000000-0a1b2c3d4e5f' } }),         d('nbrowser_clear_cookies', 'Background logout: drop one registrable domain\'s cookies (incl. httpOnly), leaving other sites (e.g. Google SSO) intact.', { host: 'required str (e.g. "gusto.com"; url/domain also accepted)' }, { ok: 'bool', removed: 'int', hosts: '[str]' }, 30, { example: { host: 'gusto.com' } }),         d('nbrowser_caption', 'Paint an on-page caption (subtitle, or red alert banner) for demos and narration.', { sessionId: S, text: 'required str (or action:"hide")', style: 'optional "subtitle"(default)|"alert"', position: 'optional str', fontSize: 'optional int', pulse: 'optional bool', durationMs: 'optional int (default 5000)', action: 'optional "hide"' }, { ok: 'bool', tabId: 'str', style: 'str' }, 20, { example: { sessionId: 's1', text: 'Signing in to DigiKey' } }),         d('nbrowser_cursor', 'Show or move the gliding Adom cursor with an intent label (demo narration). Visual only, it does not click.', { sessionId: S, action: 'optional "move"(default)|"hide"', x: 'optional int', y: 'optional int', label: 'optional str (or intent)' }, { ok: 'bool', tabId: 'str', action: 'str', label: 'str' }, 20, { example: { sessionId: 's1', x: 400, y: 300, label: 'Clicking Sign in' } }),@@ -3058,8 +3072,48 @@ function identityOf(key) {   return { profile: key, email: (c.profile && c.profile.email) || '', displayName: (kp && kp.displayName) || profileLabel(c.profile) || null,     profileDir: (kp && kp.profileDir) || av.profileDir || null, browser: (c.profile && c.profile.browser) || 'chrome' }; }+// SECRET HANDOFF (adom/nb-bridge#54). A session carried out of the user's real browser must reach the+// OTHER LOCAL TOOL (pup) WITHOUT crossing the relay: the AI that asked usually runs in a container, and+// the moment a cookie value lands in a tool result it is in a transcript, a log and a context window.+// So the bridge writes the values to a machine-local file, owner-only, and returns a HANDLE plus counts.+// The file self-deletes after SECRET_TTL_MS, and nbrowser_consume_session deletes it on read.+const SECRET_DIR = path.join(os.homedir(), '.adom', 'bridges', BRIDGE, 'handoff');+const SECRET_TTL_MS = 10 * 60 * 1000;+function sweepSecrets() {+  try {+    for (const f of fs.readdirSync(SECRET_DIR)) {+      const fp = path.join(SECRET_DIR, f);+      try { if (Date.now() - fs.statSync(fp).mtimeMs > SECRET_TTL_MS) fs.unlinkSync(fp); } catch {}+    }+  } catch {}+}+function writeSecretHandoff(kind, meta, secret) {+  fs.mkdirSync(SECRET_DIR, { recursive: true, mode: 0o700 });+  sweepSecrets();+  const id = `${kind}-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`;+  const file = path.join(SECRET_DIR, `${id}.json`);+  fs.writeFileSync(file, JSON.stringify({ id, kind, ...meta, createdAt: new Date().toISOString(), ...secret }), { mode: 0o600 });+  try { fs.chmodSync(file, 0o600); } catch {}+  setTimeout(() => { try { fs.unlinkSync(file); } catch {} }, SECRET_TTL_MS).unref?.();+  return { handoffId: id, handoffPath: file, expiresInSec: Math.round(SECRET_TTL_MS / 1000) };+} async function decorateReply(p, frame, payloadIn, setPayload) {   let payload = payloadIn;+      // A verb that carries a secret hands it to the machine, not to the caller (nb-bridge#54).+      if (payload && payload._secret) {+        const secret = payload._secret;+        const out = { ...payload }; delete out._secret;+        try {+          const h = writeSecretHandoff(p.verb === 'nbrowser_export_site_session' ? 'session' : 'secret',+            { verb: p.verb, domain: out.domain || null, profile: p.profileKey || null, reason: (p.args && p.args.reason) || null }, secret);+          payload = { ...out, ...h,+            _hint: `The session for ${out.domain} was written to a file on the USER'S MACHINE, owner-only, and is NOT in this reply: cookie values must never cross the relay into an AI transcript. ` ++              `Hand handoffPath to the tool that runs on that machine (pup reads it and imports), then it is gone - the file self-deletes in ${h.expiresInSec}s or when nbrowser_consume_session reads it. ` ++              `You can see ${out.cookieCount} cookie(s), ${out.authCookieCount} of them auth-shaped, and nothing else.` };+        } catch (e) {+          payload = { ...out, ok: false, errorCode: 'handoff_failed', error: `could not write the local handoff file: ${String((e && e.message) || e)}` };+        }+      }       // The identity this call acted as, on every window/tab open (nb-bridge#53).       if ((p.verb === 'nbrowser_open_window' || p.verb === 'nbrowser_open_tab' || p.verb === 'nbrowser_open_os_window') && payload && payload.ok !== false) {         const id = identityOf(p.profileKey);
extension/manifest.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 3,   "name": "Adom Native Browser Extension",   "short_name": "Adom",-  "version": "0.2.20",+  "version": "0.2.21",   "description": "Layer 2 of Adom Native Browser (nb): lets your Adom AI drive your real, signed-in Chrome or Edge, using your own logins.",   "minimum_chrome_version": "116",   "background": {
extension/src/native.js+43
@@ -964,6 +964,49 @@ export async function loginState(args = {}) {   }; } +// ---- nbrowser_export_site_session: hand ONE site's live session to another local tool -----------------+// adom/nb-bridge#54. pup drives its own browser profile, so it cannot see that the user is already signed+// in to a site in their REAL Chrome. Reading the session from INSIDE the real browser (where nb lives)+// replaces ab's out-of-Chrome cookie decrypt, which Chrome hardening keeps breaking.+//+// THE RULES THIS VERB KEEPS, and they are the whole reason it is safe:+//  - ONE registrable domain per call, named by the caller. Never "everything".+//  - The caller must pass confirm:true AND a reason. The bridge records both.+//  - The VALUES never leave the machine: the extension hands them to the bridge over the local native+//    port, and the bridge writes them to a machine-local file for the other local tool to read. The AI+//    (which usually runs in a container, on the other side of a relay) only ever sees counts and names.+//  - Nothing is logged: no cookie value reaches the activity trail, the console or a hint.+export async function exportSiteSession(args = {}) {+  const raw = String(args.domain || args.host || args.url || '').replace(/^https?:\/\//, '').split('/')[0];+  if (!raw) throw new Error('domain required (e.g. domain:"x.com")');+  if (args.confirm !== true) {+    const e = new Error("exportSiteSession needs confirm:true - it hands this site's live session to another tool on this machine, which can then act as the user there");+    e.errorCode = 'confirm_required';+    e.hint = 'Ask the user first, naming the site: "I can carry your x.com session into pup so it can act as you there. OK?" Then retry with confirm:true and a reason.';+    throw e;+  }+  const base = raw.split('.').slice(-2).join('.');+  const cookies = await chrome.cookies.getAll({ domain: base });+  if (!cookies.length) {+    const e = new Error(`no cookies for '${base}' in this profile, so there is no session to carry`);+    e.errorCode = 'no_session';+    e.hint = 'The user may be signed in under a DIFFERENT profile: nbrowser_profiles lists them, and nbrowser_login_state {url} says which one has a session.';+    throw e;+  }+  // The shape another browser needs to re-create the session, nothing more.+  const out = cookies.map((c) => ({ name: c.name, value: c.value, domain: c.domain, path: c.path || '/',+    secure: !!c.secure, httpOnly: !!c.httpOnly, sameSite: c.sameSite || 'unspecified',+    expirationDate: c.expirationDate || null, hostOnly: !!c.hostOnly, session: !!c.session }));+  const authRe = /sess|auth|token|\bsid\b|login|account|jwt|__Secure|__Host/i;+  return {+    ok: true, domain: base,+    cookieCount: out.length,+    authCookieCount: out.filter((c) => authRe.test(c.name)).length,+    hosts: [...new Set(out.map((c) => c.domain.replace(/^\./, '')))].slice(0, 8),+    _secret: { cookies: out },   // stripped by the bridge: it never goes back to the caller+  };+}+ // ---- nbrowser_clear_cookies: BACKGROUND logout — drop a site's cookies (incl. httpOnly) ------------- // chrome.cookies can remove httpOnly/secure session cookies that page JS can't touch — a clean, background // way to sign a site out. Scoped to ONE registrable domain so it never nukes unrelated sessions (e.g.
extension/src/sw.js+1
@@ -268,6 +268,7 @@ async function dispatch(verb, args) {     case 'nbrowser_window_state':  return native.windowState(args);     case 'nbrowser_fullscreen':    return native.windowState({ ...args, state: args.state || 'fullscreen' });     case 'nbrowser_login_state':   return native.loginState(args);+    case 'nbrowser_export_site_session': return native.exportSiteSession(args); // nb-bridge#54: one domain, machine-local handoff     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)
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "nb-bridge",-  "version": "0.16.42",+  "version": "0.16.43",   "type": "app",   "title": "NB - Native Browser Bridge",   "description": "This package installs the SKILLS into your container so your AI knows how to drive your real signed-in Chrome and Edge; Bridge loads the bridge runtime itself from the release zip.",
tools/verb-battery.mjs+16−2
@@ -73,6 +73,20 @@ const T = {   profiles: () => { const r = call('nbrowser_profiles'); const ps = r.profiles || []; ctx.profileCount = ps.length; const def = ps.find((p) => p.profile === r.active); const pups = ps.filter((p) => p.origin === 'pup').length; return A(ps.length > 0 && !(def && def.origin === 'pup'), `${ps.length} profiles, default ${r.active} (origin ${def ? def.origin : 'none'}), ${pups} pup connection(s) set aside`, `default is pup's browser or no profiles: ${JSON.stringify(r).slice(0, 160)}`); },   resolve_profiles: () => { const r = call('nbrowser_resolve_profiles'); return A(r.ok !== false, 'resolved', JSON.stringify(r).slice(0, 120)); },   // An Edge-only box is a PASS: nb drives whatever native browser is there (nb-bridge#46).+  // nb-bridge#54. The export must (a) refuse without confirm, (b) keep cookie VALUES out of the reply,+  // (c) write them owner-only on the user's machine, and (d) be droppable. Uses the harness's own domain.+  export_site_session: () => {+    const noConfirm = call('nbrowser_export_site_session', { domain: 'wiki.adom.inc', profile: PROFILE, reason: 'battery: consent gate' });+    const r = call('nbrowser_export_site_session', { domain: 'wiki.adom.inc', confirm: true, profile: PROFILE, reason: 'battery: verify the machine-local handoff' });+    const blob = JSON.stringify(r);+    const leaked = /"value"\s*:/.test(blob) || /_secret/.test(blob);+    const dropped = r.handoffId ? call('nbrowser_drop_handoff', { handoffId: r.handoffId }) : {};+    const gated = noConfirm.ok !== true && noConfirm.errorCode === 'confirm_required';+    const ok = gated && (r.ok === true ? (!leaked && !!r.handoffPath && typeof r.cookieCount === 'number' && dropped.dropped === true)+                                       : r.errorCode === 'no_session');+    return A(ok, r.ok === true ? `consent gated; ${r.cookieCount} cookie(s) handed off locally, no values in the reply, handoff dropped` : 'consent gated; no session for the harness domain (nothing to export)', JSON.stringify({ gated, leaked, handoff: !!r.handoffPath, dropped: dropped.dropped, err: r.errorCode }));+  },+  drop_handoff: () => { const r = call('nbrowser_drop_handoff', { handoffId: 'session-1-000000000000' }); return A(r.ok === true && r.dropped === false, 'a handoff that is not there reports dropped:false (safe outcome)', JSON.stringify(r).slice(0, 160)); },   // A dead session must never report the USER's tabs (caught 2026-10-21: every dead session showed John's   // Gmail and alive:true). A session-scoped list refuses; state then says the window is gone.   dead_session_scoping: () => {@@ -317,7 +331,7 @@ const T = { // ─── coverage map: EVERY VERB_META verb decides its fate here ─────────────── export const COVERAGE = {   status: 'live', readiness: 'live', ping: 'live', progress: 'live', scroll: 'live', input_unknown_type: 'guard', hover_raw_xy: 'live', apply_settings: 'skip:internal bridge-to-extension push, exercised by the settings test through nbrowser_settings', whoami: 'live', profiles: 'live',-  resolve_profiles: 'live', prewarm: 'live', dead_session_scoping: 'live', eval_on_browser_page: 'live', guard_profile_required: 'guard', open_minimized: 'live', chrome_status: 'live', describe: 'live', activity: 'live',+  resolve_profiles: 'live', prewarm: 'live', dead_session_scoping: 'live', export_site_session: 'live', drop_handoff: 'live', eval_on_browser_page: 'live', guard_profile_required: 'guard', open_minimized: 'live', chrome_status: 'live', describe: 'live', activity: 'live',   os_windows: 'live', wake_profile: 'live', rescan: 'live', resync_sessions: 'live',   open_window: 'live', pin_session: 'live', use_profile: 'live', list_windows: 'live',   window_state: 'live', switch_window: 'live', navigate: 'live', reload: 'live',@@ -411,7 +425,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {     'record_start','record_status','record_stop','record_list',     'download','download_wait','login_state','login_form','credentials','totp','clear_cookies','add_extension_origin',     'state','demo','open_os_window','guard_autologin','guard_record_fallback','guard_force_close','guard_close_all','profile_block','profile_unblock',-    'events_and_owned','handle_dialog','cleanup','owned','close_window','dead_session_scoping','eval_on_browser_page'];+    'events_and_owned','handle_dialog','cleanup','owned','close_window','dead_session_scoping','export_site_session','drop_handoff','eval_on_browser_page'];   for (const name of order) {     if (TARGET_LOST) { record(name, 'skip', true, `not run - ${TARGET_LOST}`); continue; }     const fn = T[name]; if (!fn) { record(name, '??', false, 'no test fn'); continue; }
tools/verbs-policy.json+2−1
@@ -58,5 +58,6 @@   "evaluate": "alias",   "set_file_input": "mutating",   "scroll": "mutating",-  "apply_settings": "meta"+  "apply_settings": "meta",+  "export_site_session": "global" }