← Commit history

v0.16.0 (insiders): First insiders-tier build (adom/nb-bridge#39): insidersManifestUrl in bridge.json, one version across runtime, dashboard and skills pkg, tools/ship.sh + tools/promote.sh. Also: canonical windowEtiquette + errorCode foreground_reason_required on every foreground-capable verb (#28); X-Adom-Verified-Origin recorded on every activity row and echoed as _origin (#36); measured progress: terminal progress block on slow verbs plus the new nbrowser_progress poll verb with active[] (#25, #29); agent_permissions declared in the skills pkg (#37); nbrowser_settings returns the verbDurations object instead of a count.

John Lauer ·25a2577940 ·2mo ago ·parent 977d348
9 files changed +407−15
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.15.3';           // keep in lockstep with package.json (pkg version)+const VERSION = '0.16.0';           // 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.5.1+0.16.0\ No newline at end of file
bridge/bridge.json+3−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "native-browser",   "displayName": "Adom Native Browser",-  "version": "0.5.1",+  "version": "0.16.0",   "expectedExtensionVersion": "0.2.8",   "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",@@ -26,6 +26,7 @@     "nbrowser_install_chrome",     "nbrowser_chrome_status",     "nbrowser_ping",+    "nbrowser_progress",     "nbrowser_activity",     "nbrowser_open_window",     "nbrowser_close_window",@@ -135,6 +136,7 @@   },   "statusVerb": "nbrowser_status",   "updateManifestUrl": "https://wiki.adom.inc/api/pages/adom/nb-bridge/files/native-browser-bridge-manifest.json",+  "insidersManifestUrl": "https://wiki.adom.inc/api/v1/pages/insiders/files/nb-bridge/version.json",   "timeouts": {     "default": 60,     "verbs": {
bridge/package.json+1−1
@@ -1,6 +1,6 @@ {   "name": "adom-native-browser-bridge",-  "version": "0.5.1",+  "version": "0.16.0",   "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+81−5
@@ -360,7 +360,7 @@ let abTokenRejected = false; // ab rotates the bridge token on self-update; a st // facts: "chip-fetcher tab 3 (via native-browser)". Self-asserted: never used for authorization. const { AsyncLocalStorage } = require('async_hooks'); const callerCtx = new AsyncLocalStorage();-function withCaller(caller, fn) { return callerCtx.run(caller && (caller.thread || caller.container) ? caller : null, fn); }+function withCaller(caller, fn) { return callerCtx.run(caller && (caller.thread || caller.container || caller.origin) ? caller : null, fn); } function currentCaller() { return callerCtx.getStore() || null; } function callerLabel() { const c = currentCaller(); return c && c.thread ? c.thread : null; } // Headers for a Bridge call. Acting FOR a thread -> echo its identity + name ourselves as the delegate.@@ -541,6 +541,7 @@ function logActivity(e) {   // WHO asked (wiki #345 ask 1): stamp the calling AI thread on every logged verb so concurrent-agent   // interference is reconstructable after the fact. Self-asserted; attribution only.   if (e.caller === undefined) { const c = (typeof currentCaller === 'function') && currentCaller(); if (c && c.thread) e.caller = c.thread; }+  if (e.origin === undefined) { const c = (typeof currentCaller === 'function') && currentCaller(); if (c && c.origin) e.origin = c.origin; }   // X-Adom-Verified-Origin (nb-bridge#36)   ACTIVITY_LOG.push(e);   const cutoff = e.ts - ACT_LOG_TTL_MS;   while (ACTIVITY_LOG.length > ACT_LOG_MAX || (ACTIVITY_LOG.length && ACTIVITY_LOG[0].ts < cutoff)) ACTIVITY_LOG.shift();@@ -687,6 +688,63 @@ function activityStop(id, verb, ok, tookMs) {   if (!ok) setTimeout(() => taskbar(rec.hwnd, { progress: { state: 'none' } }), 2500); } +// ── MEASURED PROGRESS (Bridge SDK REQUIRED rule, adom/nb-bridge#25 / #29) ──────────────────────────+// Two shapes, both from the same per-machine VERB_DUR history the taskbar bar already uses:+//   1. a terminal `progress` block on any verb whose run (or rolling average) is 3s or longer: a+//      completion receipt at percent 100 with the actuals;+//   2. `active[]` from the fast nbrowser_progress verb, one block per in-flight verb, which is the one+//      that makes a bar MOVE while a 40-second verb is still running.+// confidence is 'measured' only after three real samples; before that it is 'estimated' and a renderer+// draws it dimmed. estimatedSec is the rolling average, revised every completion. startKind: the nbe+// has no cold start of its own (the browser is already running), so it is always 'warm'; the one+// cold-ish case, waking an asleep profile, is its own verb (nbrowser_wake_profile) and is measured+// separately by name like everything else.+const PROGRESS_SLOW_MS = 3000;+function progressBlock(verb, elapsedMs, done, sid) {+  const d = VERB_DUR[verb] || { avgMs: 0, n: 0 };+  const est = d.avgMs || 0;+  const elapsedSec = Math.round(elapsedMs / 100) / 10;+  const estimatedSec = est ? Math.round(est / 100) / 10 : null;+  const measured = d.n >= 3 && est > 0;+  const openEnded = OPEN_ENDED.test(verb);+  let percent = done ? 100 : (est && !openEnded ? Math.min(95, Math.round((elapsedMs / est) * 100)) : null);+  const block = {+    phase: verb.replace(/^nbrowser_/, ''),+    stepLabel: (VERB_META[verb] && VERB_META[verb].hint ? String(VERB_META[verb].hint).split(/[.!?]/)[0].slice(0, 80) : verb),+    percent, elapsedSec, estimatedSec,+    etaSec: done ? 0 : (est && !openEnded ? Math.max(0, Math.round((est - elapsedMs) / 100) / 10) : null),+    confidence: measured ? 'measured' : 'estimated',+    startKind: 'warm',+    samples: d.n || 0,+  };+  if (sid) block.sessionId = sid;+  if (!done && /^nbrowser_(wait|wait_for|download_wait|keepalive|record_start)$/.test(verb)) block.blockedBy = null;   // open-ended by design, not on a human+  return block;+}+// Terminal frame: attach `progress` to a forwarded verb's reply when the run was slow (or usually is).+function attachProgress(verb, startedAt, r) {+  try {+    if (!r || r.success !== true || typeof r.output !== 'string') return r;+    const took = Date.now() - startedAt;+    const avg = (VERB_DUR[verb] && VERB_DUR[verb].avgMs) || 0;+    if (took < PROGRESS_SLOW_MS && avg < PROGRESS_SLOW_MS) return r;+    const j = JSON.parse(r.output);+    if (j && typeof j === 'object' && j.progress == null) { j.progress = progressBlock(verb, took, true); r.output = JSON.stringify(j); }+  } catch {}+  return r;+}+// Live poll: every in-flight forwarded verb as a progress block (what animates a bar).+function activeProgress(filterSid) {+  const out = [];+  for (const [, p] of pending) {+    if (!p.startedAt) continue;+    const sid = p.args && p.args.sessionId;+    if (filterSid && sid !== filterSid) continue;+    out.push(progressBlock(p.verb, Date.now() - p.startedAt, false, sid));+  }+  return out;+}+ // ── BADGE IDLE-EXPIRY (John, 2026-07-14): the "Adom is driving" badge should reflect ACTIVE driving. If // the AI stops sending commands to a window for a while, the badge auto-clears (so it doesn't imply the AI // is still driving when it's idle/done); it re-appears on the next command. Re-touched on every drive verb.@@ -1406,6 +1464,7 @@ const VERB_META = {   nbrowser_install_chrome: { hint: 'Install NATIVE Google Chrome for the user (Windows). Downloads Google\'s official installer + runs a QUIET user-scoped install (no admin on a normal PC). NON-BLOCKING - returns immediately; poll nbrowser_chrome_status. On a locked-down box it escalates to a notify-first UAC (fires a sticky "Approve now" toast, waits for the tap, then raises the prompt). This is step 1 of getting the AI onto the user\'s REAL browser - after Chrome is in, help them sign into Google + load the extension.', related: ['nbrowser_chrome_status', 'nbrowser_readiness', 'installing-the-extension'], pitfalls: ['non-blocking: do NOT expect Chrome to exist on return - POLL nbrowser_chrome_status until phase:"ready"', 'awaiting_uac means a prompt is on the user\'s desktop - relay it; do not re-trigger install', 'Windows only for now', 'this only INSTALLS Chrome; the user still signs into Google themselves (their credential) - guide them via setting-up-native-chrome'] },   nbrowser_chrome_status: { hint: 'READ-ONLY: is native Google Chrome installed, and how is an in-flight install going? Returns {installed, executablePath, phase, installing, awaitingUac}. phase: idle|installing|awaiting_uac|ready|failed. Poll this after nbrowser_install_chrome. Never installs or spawns.', related: ['nbrowser_install_chrome', 'nbrowser_readiness'], pitfalls: ['awaiting_uac + notifyChannel:true = a toast is on the desktop, keep polling; notifyChannel:false = relay the UAC to the user yourself', 'installed:true means Chrome.exe is present - next help the user sign into Google + load the extension'] },   nbrowser_ping: { hint: 'Round-trip liveness through to the extension.', related: ['nbrowser_status'], pitfalls: ['a pong proves the extension is reachable, NOT that any profile has a drivable window — use nbrowser_status/readiness for that'] },+  nbrowser_progress: { hint: 'FAST poll for MEASURED progress of every in-flight verb: active[] carries one block per running verb (phase, stepLabel, percent, elapsedSec, estimatedSec, etaSec, confidence measured|estimated, startKind, sessionId). Estimates come from this machine\'s own rolling per-verb history (also returned as verbDurations). Poll once a second while something slow runs; empty active[] = idle.', related: ['nbrowser_activity', 'nbrowser_status'], pitfalls: ['percent is capped at 95 until the verb actually completes - the reply of the slow verb itself carries the 100% terminal block', 'confidence is estimated (draw it dimmed) until three real samples exist for that verb on this machine', 'open-ended verbs (wait, wait_for, download_wait, keepalive, record_start) report no percent: render an indeterminate sweep, never a fake 0%'] },   nbrowser_activity: { hint: 'Bounded "who drove what, when" activity trail. Query recent verbs + a per-session summary (activeSessions, each with lastAgoMs) to debug things like "why is this profile badged" or "what is running right now". Filter with {sessionId, thread, profile, verb, errorsOnly, sinceMs, limit}.', related: ['nbrowser_events', 'nbrowser_owned', 'nbrowser_status'], pitfalls: ['the verb serves the in-memory ring (max 600 entries / ~1h, resets on restart); a persistent self-janitored JSONL survives restarts at ~/.adom/bridge-logs/native-browser-activity.jsonl for deeper forensics', 'a session with a large lastAgoMs is idle - its "Adom is driving" badge idle-expires after ~3 min'] },   nbrowser_open_window: { hint: 'Open a NEW background window YOU own and get a sessionId; do ALL your work through that sessionId. REGISTER it: pass sessionId + thread (which AI thread you are) + purpose (human-readable, why) or it is refused (session_unregistered). ' + NO_FG, related: ['nbrowser_navigate', 'nbrowser_screenshot', 'nbrowser_owned', 'nbrowser_close_window'], next: 'nbrowser_navigate', pitfalls: ['REQUIRES sessionId + thread + purpose (registration) — a bare open is refused session_unregistered', 'opens UNFOCUSED in the BACKGROUND - a bare focused:true is IGNORED; only userRequestedForeground:true raises it', 'NEVER fall back to an OS-level launch (Start-Process/msedge/chrome --new-window) - that RAISES it to the foreground; this verb is the polite path and always works in the background', 'always drive a window you opened, never the user\'s existing ones — and close everything you own at task end (nbrowser_owned → nbrowser_close_window)'] },   nbrowser_close_window: { hint: 'Close a window you opened. Pass includeSpawned:true to also close everything that window\'s pages SPAWNED (composes/popups). Clean up your own sessions when done.', related: ['nbrowser_owned', 'nbrowser_cleanup', 'nbrowser_list_windows'], pitfalls: ['⛔ only close a sessionId YOU opened - closing the user\'s window can wipe their live work and other AI threads', 'if the session spawned windows (a page opened a compose/popup), they stay open unless you pass includeSpawned:true or call nbrowser_cleanup'] },@@ -1490,6 +1549,7 @@ const VERB_META = { // always told what it got, what to do next, and what to avoid - without ever reading docs first. function decorateHint(verb, r) {   if (!r || typeof r !== 'object') return r;+  try { const c = currentCaller && currentCaller(); if (c && c.origin && r._origin == null) r._origin = c.origin; } catch {}   const m = VERB_META[verb];   if (!m) return r;   if (r._hint == null && m.hint) r._hint = m.hint;          // never clobber a more-specific hint@@ -1519,6 +1579,12 @@ function dispatch(verb, args) {     if (verb === 'nbrowser_help') {       return resolve({ success: true, output: JSON.stringify({ ok: true, bridge: BRIDGE, version: VERSION, extConnected: anyConnected(), flashMode, ...HELP }) });     }+    if (verb === 'nbrowser_progress') {+      const active = activeProgress(args && args.sessionId);+      return resolve({ success: true, output: JSON.stringify({ ok: true, active, count: active.length,+        verbDurations: VERB_DUR,+        _hint: active.length ? 'active[] is one MEASURED progress block per in-flight verb (percent monotonic, estimatedSec revised on every completion, confidence measured after 3 samples). Poll once a second while a slow verb runs; an empty active[] means idle.' : 'Nothing in flight. Slow verbs also carry a terminal progress block on their own reply (percent 100 with actuals).' }) });+    }     if (verb === 'nbrowser_activity') {       // Bounded, queryable "who drove what, when" trail — to debug things like a stray badge.       const now = Date.now();@@ -1663,6 +1729,7 @@ function dispatch(verb, args) {       };       const verbs = [         d('nbrowser_status', 'Is the extension connected? Reports ext id/version + active browser.', {}, { ok: 'bool', ext: 'str', version: 'str', engine: 'str', browser: '"chrome"|"edge"' }, 15),+        d('nbrowser_progress', 'Fast poll for measured progress of every in-flight verb (active[] blocks from this machine\'s rolling per-verb history). Empty active[] = idle.', { sessionId: 'optional str (only that session\'s verbs)' }, { ok: 'bool', active: '[{phase,stepLabel,percent,elapsedSec,estimatedSec,etaSec,confidence,startKind,samples,sessionId?}]', count: 'int', verbDurations: '{verb:{avgMs,n}}' }, 10, { example: {} }),         d('nbrowser_activity', 'Bounded "who drove what, when" activity trail (max 600 entries / ~1h, in-memory, resets on bridge restart). Returns recent verb entries + a per-session summary. Debug a stray badge or see what is running.', { sessionId: 'optional str', thread: 'optional str', profile: 'optional str', verb: 'optional str', errorsOnly: 'optional bool', sinceMs: 'optional int', limit: 'optional int (<=600)' }, { ok: 'bool', entries: '[{agoMs,verb,sessionId,thread,profile,ok,errorCode}]', activeSessions: '[{sessionId,thread,purpose,count,lastAgoMs}]' }, 15, { example: {} }),         d('nbrowser_readiness', 'READ-ONLY cold-start probe: is the native-browser extension connected + ready to drive? Never spawns work or installs. Use for "is it ready / do I have the browser extension" instead of nbrowser_status. Also flags a STALE extension (loaded build older than this bridge expects) so you fix it up-front instead of hitting "verb not implemented yet" mid-task.', {}, { ready: 'bool', state: '"ready"|"needs-extension"', installing: 'bool', extensionConnected: 'bool', profiles: '[str]', extensionVersions: '{profile:version}', expectedExtensionVersion: 'str|null', extensionStale: 'bool', staleProfiles: '[str]', chromeInstalled: 'bool', chromeInstallPhase: 'str', claudeExtension: '{installed:bool, profiles:[str]} - Claude for Chrome presence (human fallback; never drivable)' }, 15, { example: {} }),         d('nbrowser_install_chrome', 'Install NATIVE Google Chrome for the user (Windows). Downloads Google\'s official installer + runs a quiet user-scoped install (no admin on a normal PC); escalates to a notify-first UAC on a locked-down box. NON-BLOCKING - poll nbrowser_chrome_status. Step 1 of putting the AI onto the user\'s real signed-in browser.', { elevate: 'optional "auto"(default) | true (skip quiet, straight to UAC) | false (quiet only, no prompt)' }, { ok: 'bool', installing: 'bool', installed: 'bool?', alreadyInstalled: 'bool?', executablePath: 'str?', statusVerb: 'str' }, 30, { statusVerb: 'nbrowser_chrome_status', example: {} }),@@ -1916,7 +1983,7 @@ function dispatch(verb, args) {       if (!kp) return resolve({ success: false, error: `no local browser profile matches '${want}'`, _hint: 'nbrowser_profiles lists every profile on this machine (live + asleep) with emails and displayNames - pick the identity the task needs.' });       if (isBlocked(kp.key)) return resolve({ success: false, error: `profile '${kp.key}' is blocklisted`, _hint: 'The user blocked this profile from being driven.' });       if (a.foreground === true && !a.foregroundReason) {-        return resolve({ success: false, errorCode: 'foreground_unjustified', error: 'foreground:true requires foregroundReason (and you should notify_user first).', _hint: 'Background is the default and is almost always right: the window opens, minimizes, and its taskbar button FLASHES orange - the user clicks it when THEY are ready. Only pass foreground:true + foregroundReason for a flow that genuinely breaks when unfocused.' });+        return resolve({ success: false, errorCode: 'foreground_reason_required', legacyErrorCode: 'foreground_unjustified', windowEtiquette: 'background', error: 'foreground:true requires foregroundReason (and you should notify_user first).', _hint: 'Background is the default and is almost always right: the window opens, minimizes, and its taskbar button FLASHES orange - the user clicks it when THEY are ready. Only pass foreground:true + foregroundReason for a flow that genuinely breaks when unfocused.' });       }       // ── LIVE-PROFILE GUARD + protected-hwnd hardening (John, 2026-07-19 incident: an Edge open into the       // Default profile - which was hosting the user's live workspace/AI-thread - likely disrupted it).@@ -2026,7 +2093,7 @@ function dispatch(verb, args) {         // Callers must never have to regex _hint. ok/launched = did the browser launch; hwndResolved =         // can we address the window afterwards (minimize/close/foreground).         const hwndReason = hwnd ? null : 'grouped_into_existing_window';-        resolve({ success: true, output: JSON.stringify({ ok: true, launched: true, hwndResolved: !!hwnd, hwndReason, hwnd, minimized, flashed, profile: kp.key, displayName: kp.displayName, extensionInstalled: !!(extSt && extSt.installed), extensionLive: !!(extSt && extSt.live), url,+        resolve({ success: true, output: JSON.stringify({ ok: true, launched: true, windowEtiquette: a.foreground === true ? 'foreground' : 'background', hwndResolved: !!hwnd, hwndReason, hwnd, minimized, flashed, profile: kp.key, displayName: kp.displayName, extensionInstalled: !!(extSt && extSt.installed), extensionLive: !!(extSt && extSt.live), url,           _hint: hwnd             ? `Opened ${url} in the user's REAL ${kp.browser} profile '${kp.displayName}' (${kp.profileDir}) WITHOUT the extension. ${a.foreground === true ? 'Foregrounded per your foregroundReason.' : `It opened MINIMIZED (background) with an ORANGE flashing taskbar button - the user clicks it when ready (perfect for OAuth consent: they are already signed in; they just approve). TO FOREGROUND IT (only when the user asks to see it, e.g. "bring it up" / a step that needs their eyes): desktop_bring_to_front {hwnd:${hwnd}} - that restores + raises this exact window (the flashing button already points them at it, so you rarely need to). Do NOT foreground for your own convenience - it steals their focus.`} You CANNOT read/drive this window (no extension in this profile) - poll the OUTCOME out-of-band (e.g. the OAuth CLI's token poll, or ask the user). Close it afterwards via nbrowser_force_close {hwnd:${hwnd}} (two-step attestation). ${upsell}`             : `LAUNCH SUCCEEDED (ok:true, launched:true) but hwndResolved:false - ${kp.browser} almost certainly GROUPED the page into an existing window instead of creating a new one, so there is no new hwnd to address. The page IS in front of the user, which is success for a consent flow. Consequence: no minimize/flash/close by hwnd for this one - find it with nbrowser_os_windows if you need to address it. Do NOT treat this as a failure. ${upsell}` }) });@@ -2342,7 +2409,7 @@ function dispatch(verb, args) {         }         if (rejected.length) return resolve({ success: false, error: `rejected: ${rejected.join(', ')}`, settings: SETTINGS, _hint: `Valid keys: ${Object.keys(SETTINGS_DEFAULTS).join(', ')}. Enums: ${JSON.stringify(SETTINGS_ENUMS)}. Nothing was changed for the rejected keys.` });       }-      return resolve({ success: true, output: JSON.stringify({ ok: true, settings: SETTINGS, defaults: SETTINGS_DEFAULTS, enums: SETTINGS_ENUMS, changed, file: SETTINGS_FILE, verbDurations: Object.keys(VERB_DUR).length,+      return resolve({ success: true, output: JSON.stringify({ ok: true, settings: SETTINGS, defaults: SETTINGS_DEFAULTS, enums: SETTINGS_ENUMS, changed, file: SETTINGS_FILE, verbDurations: VERB_DUR,         _hint: 'overlayBadge: paint the Adom-composite avatar badge on a driven window\'s taskbar button (the "Adom is driving" tell). taskbarActivity: what the button does WHILE a verb runs - progress (a determinate bar sized by that verb\'s measured average, indeterminate for open-ended verbs), flash (orange flash when work starts), off (nothing). flashMode: auto-flash verbosity on come-look moments (quiet|chatty|off). All persisted on this desktop in ' + SETTINGS_FILE + '. The stored literal is what this verb returns.' }) });     }     if (verb === 'nbrowser_flash_mode') {@@ -2523,7 +2590,8 @@ function dispatch(verb, args) {         activityStop(id, verb, false);         resolve({ success: false, error: `verb ${verb} timed out`, _hint: 'extension did not reply in time' });       }, timeoutFor(verb));-      pending.set(id, { resolve, timer, verb, toFile: !!(args && args.toFile), profileKey: tgt.key, args, caller: currentCaller(), crossThreadWarn, startedAt: Date.now() });+      const startedAt = Date.now();+      pending.set(id, { resolve: (r) => resolve(attachProgress(verb, startedAt, r)), timer, verb, toFile: !!(args && args.toFile), profileKey: tgt.key, args, caller: currentCaller(), crossThreadWarn, startedAt });       activityStart(id, verb, args && args.sessionId);       sock.write(JSON.stringify({ id, verb, args: args || {} }) + '\n');     };@@ -2702,6 +2770,14 @@ const httpSrv = http.createServer((req, res) => {         thread: String(req.headers['x-adom-caller-thread'] || (parsed.args && parsed.args.caller && parsed.args.caller.aiThread) || '').slice(0, 120),         container: String(req.headers['x-adom-caller-container'] || (parsed.args && parsed.args.caller && parsed.args.caller.containerName) || '').slice(0, 120),         reason: String(req.headers['x-adom-caller-reason'] || '').slice(0, 200),+        // ab 2.0.96+ (adom/nb-bridge#36): the ONE origin header ab authors itself from the transport it+        // observed: relay | local | bridge. A caller cannot supply it. A MISSING header means an+        // unidentified local process POSTed to our loopback port directly, so we record it as+        // 'unattributed' rather than assuming anything. Logged on every activity row and echoed as+        // _origin on every response; no verb GATES on it yet (nb has no local-only verb today:+        // credentials never return secrets, TOTP seeds never leave the bridge).+        origin: ['relay', 'local', 'bridge'].includes(String(req.headers['x-adom-verified-origin'] || '').toLowerCase())+          ? String(req.headers['x-adom-verified-origin']).toLowerCase() : 'unattributed',       };       // Run the verb inside an ambient caller context so EVERY ab callback we make while carrying it       // out (taskbar badge, caption, window enumeration) forwards that identity automatically. The
extension/src/native.js+15−2
@@ -364,8 +364,21 @@ const FOREGROUND_GRANTED_HINT = 'Raised to the FOREGROUND (you justified it). Th // Attach the teaching warning (unjustified → stayed background) or log the justified raise. Call on every // foreground-capable verb's result. function foregroundNote(args, res, sid) {-  if (foregroundUnjustified(args)) { res._warning = (res._warning ? res._warning + ' ' : '') + FOREGROUND_JUSTIFY_HINT; res.keptBackground = true; }-  else if (userWantsForeground(args)) { _emit({ event: 'foreground_raised', sessionId: sid || null, reason: args.foregroundReason }); res._foreground = FOREGROUND_GRANTED_HINT; }+  // CANONICAL RESPONSE CONTRACT (Bridge SDK, adom/nb-bridge#28): what a caller BRANCHES on is a stable+  // non-underscore key. windowEtiquette says where the window went; errorCode says a bare foreground+  // was refused. keptBackground is kept for one migration window and is deprecated on sight.+  if (foregroundUnjustified(args)) {+    res._warning = (res._warning ? res._warning + ' ' : '') + FOREGROUND_JUSTIFY_HINT;+    res.keptBackground = true;+    res.windowEtiquette = 'background';+    res.errorCode = 'foreground_reason_required';+  } else if (userWantsForeground(args)) {+    _emit({ event: 'foreground_raised', sessionId: sid || null, reason: args.foregroundReason });+    res._foreground = FOREGROUND_GRANTED_HINT;+    res.windowEtiquette = 'foreground';+  } else {+    res.windowEtiquette = 'background';+  }   return res; } 
package.json+21−4
@@ -1,6 +1,6 @@ {   "slug": "nb-bridge",-  "version": "0.15.4",+  "version": "0.16.0",   "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.",@@ -41,7 +41,9 @@     "install": "./install.sh",     "uninstall": "./uninstall.sh"   },-  "dependencies": {},+  "dependencies": {+    "adom/adom-bridge": "^2.0.3"+  },   "tags": [     "nb",     "nbe",@@ -127,5 +129,20 @@       "label": "Install the extension",       "prompt": "install the adom native browser extension in my chrome"     }-  ]-}\ No newline at end of file+  ],+  "agent_permissions": {+    "allow_bash": [+      "nb-dashboard"+    ],+    "allow": [+      "adom-bridge nbrowser_* read-only query verbs (nbrowser_status, nbrowser_readiness, nbrowser_ping, nbrowser_activity, nbrowser_profiles, nbrowser_chrome_status, nbrowser_list_windows, nbrowser_list_tabs, nbrowser_owned, nbrowser_os_windows, nbrowser_login_state, nbrowser_credentials, nbrowser_login_form, nbrowser_state, nbrowser_describe, nbrowser_events, nbrowser_errors, nbrowser_record_status, nbrowser_record_list, nbrowser_settings with no set, nbrowser_whoami, nbrowser_screenshot, nbrowser_screenshot_full_res): they READ the state of the user's own browser windows, profiles, tabs and the bridge's activity trail, or capture a page bitmap of a window the AI itself opened; they change nothing. nbrowser_credentials lists WHICH sites have a saved login and never returns a password.",+      "nb-dashboard serve, status, health, selfcheck, stop: the local Dock Bar dashboard for nb on 127.0.0.1:8954; serve is idempotent and self-backgrounding, the others are read-only probes."+    ],+    "soft_deny": [+      "adom-bridge nbrowser_* driving verbs (nbrowser_open_window, nbrowser_open_tab, nbrowser_open_os_window, nbrowser_navigate, nbrowser_click, nbrowser_type, nbrowser_press_key, nbrowser_input_dispatch, nbrowser_select, nbrowser_set_file_input, nbrowser_set_date, nbrowser_hover, nbrowser_double_click, nbrowser_right_click, nbrowser_eval, nbrowser_evaluate, nbrowser_fetch_url, nbrowser_download, nbrowser_autologin, nbrowser_totp, nbrowser_register_totp, nbrowser_handle_dialog, nbrowser_keepalive, nbrowser_record_start, nbrowser_record_stop, nbrowser_close_window, nbrowser_close_tab, nbrowser_cleanup, nbrowser_force_close, nbrowser_clear_cookies, nbrowser_install_chrome, nbrowser_wake_profile, nbrowser_profile_block, nbrowser_profile_unblock, nbrowser_use_profile, nbrowser_pin_session, nbrowser_settings with set, nbrowser_flash_mode, nbrowser_taskbar, nbrowser_dev_reload, nbrowser_uninstall, nbrowser_add_extension_origin): they open, drive, log into, download from, record, or close windows in the user's REAL signed-in browser, or change bridge settings; keep the prompt."+    ],+    "environment": [+      "adom-bridge nbrowser_* is the Adom Native Browser bridge: the AI drives the user's own signed-in Chrome or Edge on their desktop through Adom Bridge (ab). Every nbrowser_* call the container makes is routed through ab to the desktop, where ab's own human-approval model applies (caller identity, per-call reason, Activity Log, and the bridge's own registration, background-by-default and foreground-justification gates), so the classifier approving the container call is the first of two gates. nb-dashboard is the bridge's local Dock Bar dashboard server, installed by the Adom registry."+    ]+  }+}
tools/promote.shadded+100
@@ -0,0 +1,100 @@+#!/usr/bin/env bash+# promote.sh - move an INSIDERS build of the nb bridge to the PUBLIC tier, on a human's exact words.+#+#   tools/promote.sh <version> "<the human's exact attestation words>" --by "<name>"+#+# adom/nb-bridge#39 / ab 2.0.99 release tiers: the public manifest (native-browser-bridge-manifest.json,+# the updateManifestUrl every non-insider desktop follows) moves ONLY through this script, and this+# script REFUSES without an attestation. An AI passes the human's words through verbatim or does not+# promote, never on its own judgment. The words travel IN the public manifest+# (promotion_attestation / promoted_by / promoted_at).+#+# The bytes are ALREADY on the product page: tools/ship.sh uploaded the zip as release <ver> on+# adom/nb-bridge and archived it under dist/<ver>/ with the insiders manifest. Promote re-points the+# public manifest at that same asset (sha re-checked against the served bytes) and pushes it.+set -euo pipefail+VER="${1:?usage: tools/promote.sh <version> \"<attestation words>\" --by <name>}"+ATTEST="${2:-}"+shift 2 || true+BY="${ADOM_PROMOTED_BY:-}"+while [ $# -gt 0 ]; do case "$1" in --by) BY="$2"; shift 2;; *) shift;; esac; done+REPO="$(cd "$(dirname "$0")/.." && pwd)"; cd "$REPO"+export PATH="$HOME/.local/bin:$PATH"+PUBLIC="adom/nb-bridge"+PUBLIC_MANIFEST="native-browser-bridge-manifest.json"+ZIP="native-browser-bridge-v${VER}.zip"+ARCH="dist/${VER}"++echo "== 0. attestation gate =="+A="$(printf '%s' "$ATTEST" | tr -s '[:space:]' ' ' | sed 's/^ //;s/ $//')"+if [ "${#A}" -lt 8 ] || printf '%s' "$A" | grep -qiE '^(ok|yes|promote|ship it|go|approved|test|placeholder|x+)$'; then+  echo "REFUSED: promoting to the PUBLIC tier needs the human's exact words (>= 8 chars, not a placeholder)."+  echo "         e.g. tools/promote.sh $VER \"John: $VER is good on my laptop and the conf room box, ship it to everyone\" --by \"John Lauer\""+  exit 2+fi+[ -n "$BY" ] || { echo "REFUSED: say who promoted (--by \"<name>\" or ADOM_PROMOTED_BY)."; exit 2; }+echo "   attested by: $BY"+echo "   words: \"$A\""++echo "== 1. the archived insiders build =="+[ -f "$ARCH/$ZIP" ] || { echo "MISSING $ARCH/$ZIP: ship $VER to insiders first (tools/ship.sh $VER ...)"; exit 1; }+[ -f "$ARCH/insiders-manifest.json" ] || { echo "MISSING $ARCH/insiders-manifest.json: the insiders record for $VER"; exit 1; }+SHA_LOCAL=$(python3 -c "import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$ARCH/$ZIP")+SHA_INS=$(python3 -c "import json,sys;print(json.load(open(sys.argv[1]))['sha256'])" "$ARCH/insiders-manifest.json")+[ "$SHA_LOCAL" = "$SHA_INS" ] || { echo "SHA MISMATCH: archived zip $SHA_LOCAL vs insiders manifest $SHA_INS, refusing"; exit 1; }+URL=$(python3 -c "import json,sys;print(json.load(open(sys.argv[1]))['url'])" "$ARCH/insiders-manifest.json")+REMOTE=$(curl -sL "$URL" | python3 -c "import hashlib,sys;print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())")+[ "$REMOTE" = "$SHA_LOCAL" ] || { echo "REMOTE ASSET MISMATCH: $URL serves sha $REMOTE, archive is $SHA_LOCAL, refusing to point the public tier at it"; exit 1; }+echo "   $ZIP sha256=$SHA_LOCAL matches the insiders record AND the served release asset"++echo "== 2. public manifest =="+python3 - "$VER" "$ARCH" "$A" "$BY" "$PUBLIC_MANIFEST" <<'PY'+import json, sys, datetime, os+ver, arch, words, by, pub = sys.argv[1:6]+ins = json.load(open(os.path.join(arch, 'insiders-manifest.json')))+now = datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00', 'Z')+m = json.load(open(pub))+m.update({'version': ver, 'url': ins['url'], 'sha256': ins['sha256'], 'size': ins['size'], 'released_at': now,+          'insiders_released_at': ins.get('released_at'),+          'promotion_attestation': words, 'promoted_by': by, 'promoted_at': now})+m.pop('tier', None)+for k in ('rollback_attestation', 'rolled_back_by', 'rolled_back_at'): m.pop(k, None)+json.dump(m, open(pub, 'w'), indent=2); open(pub, 'a').write('\n')+json.dump(m, open(os.path.join(arch, 'public-manifest.json'), 'w'), indent=2)+print(f"   public manifest -> {ins['url']}\n   archived {arch}/public-manifest.json")+PY++echo "== 3a. mark the insiders release-log entry promoted =="+python3 - "dist/insiders-repo/nb-bridge/README.md" "$VER" "$A" "$BY" <<'PY'+import sys, datetime, re, os+path, ver, words, by = sys.argv[1:5]+if os.path.exists(path):+    s = open(path).read()+    pat = re.compile(r"(- \*\*" + re.escape(ver) + r"\*\* insiders, [0-9-]+\.)")+    m = pat.search(s)+    if m:+        ls = s.find(f"- **{ver}**"); le = s.find("\n", ls)+        line = s[ls:le]+        if 'Promoted to public' not in line:+            line = line.replace(" Not promoted.", "") + f" Promoted to public on {datetime.date.today().isoformat()} by {by}: \"{words}\"."+            s = s[:ls] + line + s[le:]; open(path, 'w').write(s); print(f"   release log: {ver} marked promoted")+        else: print(f"   release log: {ver} already marked")+    else: print(f"   release log: no insiders entry for {ver} to mark")+PY+( cd dist/insiders-repo && adom-wiki repo push adom/insiders --files nb-bridge/README.md -m "nb-bridge ${VER}: promoted to public by ${BY}" >/dev/null 2>&1 ) || echo "   (insiders release-log push failed; the public promote continues)"++echo "== 3b. stamp the release notes on the product page =="+NOTES=$(adom-wiki release view "$PUBLIC" "$VER" --json 2>/dev/null | python3 -c "import sys,json; raw=sys.stdin.read(); i=raw.find('{'); d=json.loads(raw[i:]) if i>=0 else {}; r=d.get('data') or d; r=r.get('release') or r; print((r.get('changelog') or '').strip())" 2>/dev/null || true)+case "$NOTES" in *"Promoted to public"*) echo "   release notes already stamped";; *)+  adom-wiki release edit "$PUBLIC" "$VER" --changelog "${NOTES:+$NOTES }Promoted to public on $(date -u +%Y-%m-%d)." >/dev/null 2>&1 && echo "   release notes stamped" || echo "   (release notes stamp failed; promote continues)";; esac++echo "== 4. push the public manifest =="+adom-wiki repo push "$PUBLIC" --files "$PUBLIC_MANIFEST" \+  -m "v${VER}: promoted to PUBLIC by ${BY}: ${A}" >/dev/null 2>&1 || {+    [ "${FORCE_PUSH:-0}" = "1" ] || { echo "   PUSH FAILED (set FORCE_PUSH=1 to retry)"; exit 1; }+  }++echo "== 5. DIFF-VERIFY =="+MAN=$(python3 -c "import json;print(json.load(open('$PUBLIC_MANIFEST'))['version'])")+LIVE=$(curl -s "https://wiki.adom.inc/api/pages/adom/nb-bridge/files/$PUBLIC_MANIFEST" | python3 -c "import json,sys;print(json.load(sys.stdin).get('version'))" 2>/dev/null || echo '?')+[ "$MAN" = "$VER" ] && [ "$LIVE" = "$VER" ] && echo "PROMOTED $VER to PUBLIC (attested by $BY); live public manifest reads $LIVE" || { echo "PUBLIC MANIFEST NOT AT $VER (local $MAN, live $LIVE)"; exit 1; }
tools/ship.shadded+184
@@ -0,0 +1,184 @@+#!/usr/bin/env bash+# ship.sh - THE publish pipeline for the nb (native-browser) bridge, living IN THE REPO.+#+#   tools/ship.sh <version> "<changelog sentence>"              # -> INSIDERS tier (the default)+#   tools/promote.sh <version> "<human's words>" --by "<name>"   # -> PUBLIC tier, attested+#+# RELEASE TIERS (adom/nb-bridge#39, ab 2.0.99 insidersManifestUrl; adopted 2026-09-02 on John's word).+# The fleet convention is ONE shared private page, adom/insiders, whose repo carries ONLY+# <product>/version.json manifests plus a per-product release log; the bytes live as releases on the+# product's own page. pup-bridge did it first (tools/ship.sh there); this is the same pipeline with+# nb's file layout (bridge/ instead of src/, and the dashboard's VERSION const in lockstep).+#+#   - Every ship: the zip becomes a release on adom/nb-bridge, the INSIDERS manifest is written to+#     adom/insiders as nb-bridge/version.json (bridge-manifest shape + tier), the release log entry+#     is appended to nb-bridge/README.md there, the source text files are pushed to the public page+#     repo (source is open), and the skills pkg is published at the SAME version so the page's top+#     version row always carries a tarball (release rows + pkg rows share one namespace; a+#     release-only row on top breaks `pkg install`, adom/wiki#170). The PUBLIC manifest+#     (native-browser-bridge-manifest.json, the updateManifestUrl) is NEVER touched here.+#   - Desktops on ab >= 2.0.99 whose login can read adom/insiders follow the insiders manifest;+#     everyone else follows the public manifest and never learns the tier exists.+#   - The public manifest moves ONLY via tools/promote.sh, which REFUSES without the human's exact+#     words and records them in the manifest. One plain semver per build in both tiers.+#+# ONE VERSION FOR EVERYTHING (from 0.16.0): bridge/bridge.json, bridge/package.json,+# bridge/BRIDGE_VERSION, package.json (skills pkg) and bin/nb-dashboard's VERSION const all carry+# the same number. Before this the runtime (0.5.x) and the pkg (0.15.x) were separate streams and+# the three-way lockstep drifted twice; the ship script now owns all five.+#+# Env: FORCE_PUSH=1 pushes even when a push reports failure. DRY_RUN=1 stops after the zip.+set -euo pipefail+VER="${1:?usage: tools/ship.sh <version> \"<changelog>\"}"+LOG="${2:?changelog sentence required}"+REPO="$(cd "$(dirname "$0")/.." && pwd)"+PAGE="adom/nb-bridge"+INSIDERS_PAGE="adom/insiders"+INSIDERS_DIR="dist/insiders-repo"          # working copy shaped like the adom/insiders repo+INSIDERS_REL="nb-bridge/version.json"       # the convention: <product>/version.json+ZIP="native-browser-bridge-v${VER}.zip"+ARCH="dist/${VER}"+PUBLIC_MANIFEST="native-browser-bridge-manifest.json"+cd "$REPO"+export PATH="$HOME/.local/bin:$PATH"++echo "== 0. static gates =="+node --check bridge/server.js+node --check bin/nb-dashboard+cp extension/src/native.js /tmp/nb-native-check.mjs && node --check /tmp/nb-native-check.mjs && rm -f /tmp/nb-native-check.mjs+cp extension/src/cdp.js /tmp/nb-cdp-check.mjs && node --check /tmp/nb-cdp-check.mjs && rm -f /tmp/nb-cdp-check.mjs+grep -q '"insidersManifestUrl": "https://wiki.adom.inc/api/v1/pages/insiders/files/nb-bridge/version.json"' bridge/bridge.json \+  || { echo "bridge.json must declare insidersManifestUrl -> adom/insiders nb-bridge/version.json (#39)"; exit 1; }+if grep -q '&mdash;\|&ndash;' bin/nb-dashboard; then echo "em-dash entity in the dashboard UI (John: none, ever)"; exit 1; fi+echo "   server.js, nb-dashboard, native.js, cdp.js parse; insidersManifestUrl declared; no dash entities"++echo "== 1. version lockstep (five files) =="+printf '%s' "$VER" > bridge/BRIDGE_VERSION+python3 - "$VER" <<'PY'+import json, sys, re+v = sys.argv[1]+for p in ('bridge/bridge.json', 'bridge/package.json', 'package.json'):+    d = json.load(open(p)); d['version'] = v+    json.dump(d, open(p, 'w'), indent=2); open(p, 'a').write('\n')+    print(f"   {p} -> {v}")+p = 'bin/nb-dashboard'; s = open(p).read()+s2 = re.sub(r"^const VERSION = '[^']+';", f"const VERSION = '{v}';", s, count=1, flags=re.M)+assert s2 != s or f"const VERSION = '{v}';" in s, 'nb-dashboard VERSION const not found'+open(p, 'w').write(s2); print(f"   bin/nb-dashboard VERSION -> {v}")+PY++echo "== 1b. regenerate bridge.json verbs[] from VERB_META (never hand-edited) =="+node -e 'const fs=require("fs");const s=fs.readFileSync("bridge/server.js","utf8");+const i=s.indexOf("const VERB_META = {");let d=0,e=-1;+for(let j=s.indexOf("{",i);j<s.length;j++){const c=s[j];if(c==="{")d++;else if(c==="}"){d--;if(!d){e=j;break}}}+const k=[...s.slice(i,e+1).matchAll(/^ {2}(nbrowser_[a-z0-9_]+):\s*\{/gm)].map(m=>m[1]);+const b=JSON.parse(fs.readFileSync("bridge/bridge.json","utf8"));b.verbs=k;+fs.writeFileSync("bridge/bridge.json",JSON.stringify(b,null,2)+"\n");console.log("   verbs:",k.length)'+node tools/check-guard-coverage.mjs >/dev/null || { echo "   guard-coverage gate FAILED (see: node tools/check-guard-coverage.mjs)"; exit 1; }+echo "   guard coverage / verb drift gate: ok"++echo "== 2. build the zip (bridge files at the zip ROOT, no node_modules) =="+mkdir -p "$ARCH"+python3 - "$ARCH/$ZIP" <<'PY'+import zipfile, os, hashlib, sys+out = sys.argv[1]+members = ['bridge.json', 'package.json', 'server.js', 'BRIDGE_VERSION', 'adom-favicon.png']+with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z:+    for m in members:+        p = os.path.join('bridge', m)+        if not os.path.exists(p):+            if m == 'adom-favicon.png': continue+            raise SystemExit(f'missing {p}')+        z.write(p, m)+print(f"   {out}  sha256={hashlib.sha256(open(out,'rb').read()).hexdigest()}  size={os.path.getsize(out)}")+PY+[ "${DRY_RUN:-0}" = "1" ] && { echo "DRY_RUN: stopping after the zip"; exit 0; }++echo "== 3. release on the product page ($PAGE) =="+adom-wiki release create "$PAGE" "$VER" --title "native-browser bridge runtime v${VER}" >/dev/null 2>&1 || true+UP=$(adom-wiki release upload "$PAGE" "$VER" "$ARCH/$ZIP" --platform any --json 2>&1 | tail -c 4000)+mkdir -p "$INSIDERS_DIR/nb-bridge"+python3 - "$UP" "$VER" "$ZIP" "$ARCH" "$INSIDERS_DIR/$INSIDERS_REL" "$PUBLIC_MANIFEST" <<'PY'+import json, sys, datetime, hashlib, os+raw, ver, zipname, arch, ins_path, pub = sys.argv[1:7]+i = raw.find('{')+d = json.loads(raw[i:]) if i >= 0 else {}+a = d.get('data') or d.get('asset') or d+sha, size = a.get('sha256'), a.get('size')+url = a.get('download_url') or f"https://wiki.adom.inc/download/adom/nb-bridge/{ver}/{zipname}"+if not url.startswith('http'): url = 'https://wiki.adom.inc' + url+zp = os.path.join(arch, zipname)+local_sha = hashlib.sha256(open(zp, 'rb').read()).hexdigest(); local_size = os.path.getsize(zp)+if not (sha and size): sha, size = local_sha, local_size+assert sha == local_sha, f'server sha {sha} != local {local_sha}'+base = json.load(open(pub))+for k in ('promotion_attestation', 'promoted_by', 'promoted_at', 'rollback_attestation', 'rolled_back_by', 'rolled_back_at', 'insiders_released_at'):+    base.pop(k, None)+base.update({'name': 'native-browser', 'version': ver, 'url': url, 'sha256': sha, 'size': size, 'tier': 'insiders',+             'released_at': datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00', 'Z')})+json.dump(base, open(ins_path, 'w'), indent=2); open(ins_path, 'a').write('\n')+json.dump(base, open(os.path.join(arch, 'insiders-manifest.json'), 'w'), indent=2)+print(f"   release asset -> {url}\n   sha256={sha} size={size}\n   insiders manifest staged at {ins_path}; archived {arch}/")+PY+curl -sI "$(python3 -c "import json,sys;print(json.load(open(sys.argv[1]))['url'])" "$ARCH/insiders-manifest.json")" | head -1 | grep -q " 200" \+  || { echo "   release asset does not answer 200"; exit 1; }+echo "   download URL answers 200"++echo "== 4. insiders manifest + release log -> $INSIDERS_PAGE:nb-bridge/ =="+python3 - "$INSIDERS_DIR/nb-bridge/README.md" "$VER" "$LOG" <<'PY'+import sys, datetime, re, os, subprocess+path, ver, log = sys.argv[1:4]+today = datetime.date.today().isoformat()+entry = f"- **{ver}** insiders, {today}. {log} Not promoted.\n"+tok = open('/var/run/adom/api-key').read().strip()+cur = subprocess.run(['curl', '-s', '-H', f'Authorization: Bearer {tok}',+    'https://wiki.adom.inc/api/v1/pages/adom/insiders/files/nb-bridge/README.md'], capture_output=True, text=True).stdout+if cur.startswith('{') and 'FILE_NOT_FOUND' in cur: cur = ''+s = cur or ("# nb (Native Browser bridge) on the insiders tier\n\n"+  "**Publishing thread:** the nb-bridge thread owns this file. Every insiders publish (`tools/ship.sh` in the nb-bridge repo) appends an entry to the release log below before the publish is called done; a promote (`tools/promote.sh`, which refuses without a human's exact words) edits that version's entry with the date and the words. Newest first.\n\n"+  "| | Insiders | Public |\n|---|---|---|\n"+  "| Manifest | [nb-bridge/version.json](https://wiki.adom.inc/adom/insiders/files/nb-bridge/version.json) | [adom/nb-bridge native-browser-bridge-manifest.json](https://wiki.adom.inc/adom/nb-bridge/files/native-browser-bridge-manifest.json) |\n"+  "| Bridge zip | [adom/nb-bridge releases](https://wiki.adom.inc/adom/nb-bridge/releases) (unlisted until promoted) | same files, same URLs |\n"+  "| Skills pkg | `pkg install adom/nb-bridge` follows the newest row until the registry insiders dist-tag lands (adom/wiki#168) | same |\n\n"+  "The bridge declares `insidersManifestUrl` in `bridge/bridge.json`; a desktop on ab 2.0.99+ whose login can read this page follows the insiders manifest, everyone else follows the public one and never sees this file.\n\n## Release log\n\n")+if f"- **{ver}**" in s: print(f"   release log already has {ver}")+else:+    s = re.sub(r"(## Release log\n\n)", lambda m: m.group(1) + entry, s, count=1)+    print(f"   release log += {ver}")+os.makedirs(os.path.dirname(path), exist_ok=True); open(path, 'w').write(s)+PY+( cd "$INSIDERS_DIR" && adom-wiki repo push "$INSIDERS_PAGE" --files "$INSIDERS_REL" nb-bridge/README.md -m "nb-bridge ${VER} (insiders): ${LOG}" >/dev/null 2>&1 ) || {+  [ "${FORCE_PUSH:-0}" = "1" ] || { echo "   INSIDERS PUSH FAILED (set FORCE_PUSH=1 to retry)"; exit 1; }+}+TOK=$(cat /var/run/adom/api-key)+ANON=$(curl -s -o /dev/null -w '%{http_code}' https://wiki.adom.inc/api/v1/pages/adom/insiders/files/nb-bridge/version.json)+BEAR=$(curl -s -H "Authorization: Bearer $TOK" https://wiki.adom.inc/api/v1/pages/adom/insiders/files/nb-bridge/version.json | python3 -c 'import sys,json;print(json.load(sys.stdin).get("version"))' 2>/dev/null || echo '?')+echo "   two-curl: anonymous $ANON (want 404), bearer version $BEAR (want $VER)"+[ "$ANON" = "404" ] && [ "$BEAR" = "$VER" ] || { echo "   TIER SPLIT NOT PROVEN"; exit 1; }++echo "== 5. push the source text files to the product page (source is open; public manifest untouched) =="+adom-wiki repo push "$PAGE" \+  --files bridge/bridge.json bridge/BRIDGE_VERSION bridge/package.json bridge/server.js \+          extension/src/native.js extension/src/cdp.js extension/manifest.json \+          bin/nb-dashboard package.json dockbar.json tools/ship.sh tools/promote.sh \+  -m "v${VER} (insiders): ${LOG}" >/dev/null 2>&1 || {+    [ "${FORCE_PUSH:-0}" = "1" ] || { echo "   PUSH FAILED (set FORCE_PUSH=1 to retry)"; exit 1; }+  }++echo "== 6. skills pkg at the SAME version (keeps the top version row carrying a tarball, adom/wiki#170) =="+PUBOUT=$(adom-wiki pkg publish --org adom 2>&1); PUBRC=$?+echo "$PUBOUT" | grep -E '"published"|error' | head -2+[ $PUBRC -eq 0 ] || { echo "PKG PUBLISH FAILED (exit $PUBRC)"; echo "$PUBOUT" | tail -15; exit 1; }+curl -s https://wiki.adom.inc/api/v1/packages/nb-bridge/manifest | python3 -c "+import sys,json; d=json.load(sys.stdin); v=d.get('version'); assert v=='$VER', f'registry manifest is {v}, not $VER'; print('   registry manifest', v)"++echo "== 7. DIFF-VERIFY =="+ON_DISK=$(cat bridge/BRIDGE_VERSION)+MAN=$(python3 -c "import json,sys;print(json.load(open(sys.argv[1]))['version'])" "$INSIDERS_DIR/$INSIDERS_REL")+BJ=$(python3 -c "import json;print(json.load(open('bridge/bridge.json'))['version'])")+PUB=$(curl -s "https://wiki.adom.inc/api/pages/adom/nb-bridge/files/$PUBLIC_MANIFEST" | python3 -c "import json,sys;print(json.load(sys.stdin).get('version'))")+[ "$ON_DISK" = "$VER" ] && [ "$MAN" = "$VER" ] && [ "$BJ" = "$VER" ] \+  && echo "VERIFIED $VER on INSIDERS (BRIDGE_VERSION + bridge.json + adom/insiders nb-bridge/version.json in lockstep); PUBLIC manifest still serves $PUB" \+  || { echo "LOCKSTEP BROKEN: disk=$ON_DISK bridge.json=$BJ insiders=$MAN"; exit 1; }+echo "   Land it on an insider desktop: adom-bridge --target <box> refresh_bridges '{\"name\":\"native-browser\"}' (a desktop whose installed bridge predates the field needs the one-time bootstrap in insiders-tier-verify 3b)."+echo "   To roll $VER out to everyone, on the human's words: tools/promote.sh $VER \"<their exact words>\" --by \"<name>\""