← Commit history
Update 5 file(s)
5 files changed
+86−19
bridge/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.1.50+0.1.51
bridge/bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "native-browser", "displayName": "Adom Browser Extension",- "version": "0.1.50",+ "version": "0.1.51", "expectedExtensionVersion": "0.1.3", "description": "Drives the user's REAL signed-in Chrome/Edge. MOST verbs require the Adom extension installed in that browser profile (CDP driving: navigate/click/read/screenshot). TWO verbs need NO extension at all and work on any machine: nbrowser_open_os_window (open a URL in the user's real profile, background+flashing - the OAuth/consent path) and nbrowser_os_windows (enumerate browser windows). Also extension-free: nbrowser_profiles, nbrowser_wake_profile, nbrowser_force_close. The bridge binds an ephemeral loopback port and the native-messaging host pumps stdio <-> bridge.", "homepage": "https://wiki.adom.inc/adom/adom-browser-extension",
bridge/package.json+1−1@@ -1,6 +1,6 @@ { "name": "adom-native-browser-bridge",- "version": "0.1.50",+ "version": "0.1.51", "private": true, "description": "adom-desktop bridge for the Adom native-browser extension (Chrome + Edge)", "main": "server.js",
bridge/server.js+78−11@@ -326,14 +326,36 @@ function discoverAdBase(fileOnly) { return 'http://127.0.0.1:47200'; } let adTokenRejected = false; // AD rotates the bridge token on self-update; a stale one 403s every call-function adRequest(base, payload, noToken) {+// ── CALLER PROVENANCE (wiki #345/#351) ─────────────────────────────────────────────────────────────+// AD hands us WHO asked (X-Adom-Caller-*). We (a) log it, (b) FORWARD it on every AD callback we make+// while acting on that thread's behalf, adding X-Adom-Caller-Delegate so AD can show the user both+// 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 currentCaller() { return callerCtx.getStore() || null; }+function callerLabel() { const c = currentCaller(); return c && c.thread ? c.thread : null; }+// Headers for an AD call. Acting FOR a thread -> echo its identity + name ourselves as the delegate.+// Acting on our OWN behalf (idle timers, startup sweeps, reapers) -> no stale identity is forwarded;+// callers of adCommand pass selfCaller:true and we send our own name instead.+function callerHeaders(self) {+ const c = self ? null : currentCaller();+ if (!c) return { 'x-adom-caller-thread': `${BRIDGE} bridge (self)`, 'x-adom-caller-container': 'local', 'x-adom-caller-delegate': BRIDGE };+ return {+ 'x-adom-caller-thread': c.thread || '',+ 'x-adom-caller-container': c.container || '',+ 'x-adom-caller-reason': c.reason || '',+ 'x-adom-caller-delegate': BRIDGE,+ };+}+function adRequest(base, payload, noToken, self) { return new Promise((resolve) => { let u; try { u = new URL('/command', base); } catch { u = new URL('http://127.0.0.1:47200/command'); } const data = Buffer.from(JSON.stringify(payload)); // X-Adom-Bridge-Token is ATTRIBUTION only (AD 1.9.84) - AD injects ADOM_BRIDGE_TOKEN into our spawn // env; sending it badges our outbound calls as "bridge" in the Activity Log. It is NOT an approval // gate (we're trusted-by-install; AD binds 127.0.0.1 only). Omitting it is fine; a stale token 403s.- const headers = { 'content-type': 'application/json', 'content-length': data.length };+ const headers = { 'content-type': 'application/json', 'content-length': data.length, ...callerHeaders(self) }; if (process.env.ADOM_BRIDGE_TOKEN && !noToken && !adTokenRejected) headers['x-adom-bridge-token'] = process.env.ADOM_BRIDGE_TOKEN; const req = http.request({ host: u.hostname, port: u.port || 80, path: u.pathname, method: 'POST', headers, timeout: 20000 }, (res) => { let b = ''; res.on('data', (c) => (b += c)); res.on('end', () => { let j; try { j = JSON.parse(b); } catch { j = b; } resolve({ status: res.statusCode, body: j }); });@@ -343,7 +365,7 @@ function adRequest(base, payload, noToken) { req.write(data); req.end(); }); }-function adCommand(app, command, args, target) {+function adCommand(app, command, args, target, self) { // app is OPTIONAL on AD 1.9.84+ (inferred) - omit it for top-level verbs like notify_user, keep it // when a caller passes one (e.g. desktop_* recording delegation). const payload = { command, args: args || {} };@@ -353,13 +375,13 @@ function adCommand(app, command, args, target) { if (target) payload.target = target; const first = discoverAdBase(); const ok2xx = (r) => r && r.status >= 200 && r.status < 300;- return adRequest(first, payload).then(async (r) => {+ return adRequest(first, payload, false, self).then(async (r) => { // A STALE BRIDGE TOKEN 403s EVERY CALL (AD rotates it when it self-updates). The token is // ATTRIBUTION-ONLY, so the correct recovery is to DROP it and retry - otherwise every AD-backed // feature (window correlation, taskbar badge, flash) dies SILENTLY. Real incident 2026-07-21: the // fusion-bridge maintainer saw nbrowser_os_windows report 0 windows while AD saw 21. if (r.status === 401 || r.status === 403) {- const retry = await adRequest(first, payload, true);+ const retry = await adRequest(first, payload, true, self); if (ok2xx(retry)) { if (!adTokenRejected) { adTokenRejected = true; console.error('[native-browser] AD rejected our bridge token (403) - it is attribution-only, so dropping it and continuing. AD likely self-updated and rotated it.'); logActivity({ verb: '_ad_link', ok: true, errorCode: 'token_dropped', internal: true }); } adDownSince = 0;@@ -379,7 +401,7 @@ function adCommand(app, command, args, target) { } // Connection-level failure -> AD may have restarted on a new port. Re-discover from the FILE and retry. const fromFile = discoverAdBase(true);- const retry = fromFile && fromFile !== first ? adRequest(fromFile, payload) : Promise.resolve(r);+ const retry = fromFile && fromFile !== first ? adRequest(fromFile, payload, false, self) : Promise.resolve(r); return retry.then((r2) => { if (ok2xx(r2)) { adBaseOverride = fromFile;@@ -472,6 +494,7 @@ const sessionHwnd = new Map(); // sessionId -> hwnd (opened windows) const spawnHwnd = new Map(); // chrome windowId -> hwnd (adopted SPAWNED windows: compose/popups) const sessionProfile = new Map(); // sessionId -> profile KEY (pin: verbs on this session route here by default) const sessionThread = new Map(); // sessionId -> thread label (from registration) — for the activity log+const sessionCaller = new Map(); // sessionId -> AD-stamped caller thread (wiki #345: who ACTUALLY owns this session) const sessionPurpose = new Map(); // sessionId -> purpose (from registration) // ── ACTIVITY LOG — a bounded, queryable "who drove what, when" trail so we can answer questions like@@ -487,6 +510,9 @@ const ACT_FILE = path.join(os.homedir(), '.adom', 'bridge-logs', 'native-browser try { fs.mkdirSync(path.dirname(ACT_FILE), { recursive: true }); } catch {} function logActivity(e) { e.ts = Date.now();+ // 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; } 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();@@ -703,6 +729,23 @@ function resolvePendingProfiles() { // avatar). If the composite can't be built yet (unresolved profile / no avatar file), we skip the // paint, nudge profile resolution, and the caller's badgeFallback retry upgrades it on a later touch. const badgeFallback = new Set(); // sids whose badge paint is deferred until the composite is buildable+// WINDOW OWNERSHIP LABEL (wiki #345 ask 3 - the payoff John asked for): stamp the OWNING AI THREAD on+// the window's taskbar identity, so hovering a taskbar button tells the user which of their ~20+// conversations owns that window. Best-effort: a failure here never blocks driving.+function labelWindowOwner(hwnd, sid) {+ if (!hwnd) return;+ const owner = (sid && sessionCaller.get(sid)) || callerLabel();+ if (!owner) return;+ const purpose = (sid && sessionPurpose.get(sid)) || '';+ adCommand('desktop', 'desktop_set_window_identity', {+ hwnd,+ appId: 'Adom.NativeBrowser',+ displayName: `Adom · ${owner}${purpose ? ' · ' + String(purpose).slice(0, 60) : ''}`,+ }).then((r) => {+ const ok = !!(r && r.status >= 200 && r.status < 300);+ logActivity({ verb: '_window_identity', sessionId: sid || null, hwnd, ops: owner, ok, internal: true });+ }).catch(() => {});+} function paintBadge(hwnd, profileKey, sid, style) { if (!hwnd) return; const hollow = style === 'hollow';@@ -1322,7 +1365,7 @@ function dispatch(verb, args) { // dead flash is debuggable straight from this trail, not just "something failed". const entries = rows.slice(-limit).map((r) => ({ agoMs: now - r.ts, verb: r.verb, sessionId: r.sessionId, thread: r.thread, profile: r.profile, ok: r.ok, errorCode: r.errorCode || undefined, error: r.error || undefined, hwnd: r.hwnd || undefined, ops: r.ops || undefined, internal: r.internal || undefined })); const bySession = {};- for (const r of rows) { if (!r.sessionId) continue; const s = bySession[r.sessionId] || (bySession[r.sessionId] = { sessionId: r.sessionId, thread: sessionThread.get(r.sessionId) || r.thread, purpose: sessionPurpose.get(r.sessionId) || null, profile: r.profile, count: 0, lastAgoMs: Infinity }); s.count++; s.lastAgoMs = Math.min(s.lastAgoMs, now - r.ts); }+ for (const r of rows) { if (!r.sessionId) continue; const s = bySession[r.sessionId] || (bySession[r.sessionId] = { sessionId: r.sessionId, thread: sessionThread.get(r.sessionId) || r.thread, ownerCaller: sessionCaller.get(r.sessionId) || null, purpose: sessionPurpose.get(r.sessionId) || null, profile: r.profile, count: 0, lastAgoMs: Infinity }); s.count++; s.lastAgoMs = Math.min(s.lastAgoMs, now - r.ts); } return resolve({ success: true, output: JSON.stringify({ ok: true, count: entries.length, totalLogged: ACTIVITY_LOG.length, windowMs: ACT_LOG_TTL_MS, activeSessions: Object.values(bySession).sort((a, b) => a.lastAgoMs - b.lastAgoMs), entries, _hint: 'Bounded activity trail (max 600 entries / ~1h, in-memory, resets on bridge restart). Filter: {sessionId, thread, profile, verb, errorsOnly:true, sinceMs, limit} - verb:"_taskbar" shows every overlay-badge/flash paint (hwnd + ops + ok), verb:"_ad_link" shows AD-link outages/recoveries. activeSessions = who drove what + how long ago (lastAgoMs); a large lastAgoMs means that session is idle (its badge should have faded).' }) }); } // nbrowser_readiness - READ-ONLY cold-start probe (SDK Cold-start tiers). Never spawns work, never@@ -1733,7 +1776,7 @@ function dispatch(verb, args) { // LAYER-1 BADGE (hollow): mark the window Adom OPENED even though it cannot drive it - provenance // matters most on an OAuth consent page. Uses applyBadge so the normal 3-min idle expiry restores // the native avatar; nothing lingers.- if (hwnd) applyBadge(a.sessionId, hwnd, kp.key, 'hollow');+ if (hwnd) { applyBadge(a.sessionId, hwnd, kp.key, 'hollow'); labelWindowOwner(hwnd, a.sessionId); } logActivity({ verb: 'nbrowser_open_os_window', sessionId: a.sessionId, thread: a.thread, profile: kp.key, ok: true, ops: `fg=${a.foreground === true}·hwndResolved=${!!hwnd}·minimized=${minimized}`, hwnd: hwnd || undefined, errorCode: hwnd ? null : 'grouped_into_existing_window' });@@ -2012,6 +2055,7 @@ function dispatch(verb, args) { } } catch (e) { return resolve({ success: false, error: 'upload staging failed: ' + ((e && e.message) || e) }); } }+ let crossThreadWarn = null; const tgt = targetConn(args); if (tgt && tgt.mismatch) { return resolve({ success: false, errorCode: 'profile_mismatch', error: `profile_mismatch: session '${tgt.sessionId}' is pinned to profile '${tgt.pin}', but you called with profile '${tgt.wantKey}'.`, _hint: `Refused so you never act on the WRONG account (personal vs corporate). Drop the profile arg (the session already routes to '${tgt.pin}'), or open a NEW registered session in '${tgt.wantKey}' if you truly meant that account.` });@@ -2052,7 +2096,19 @@ function dispatch(verb, args) { }; // PIN the session to whatever profile this window opened in, so every later verb carrying this // sessionId auto-routes back to the SAME profile (no cross-profile mistakes) even without `profile:`.- if (verb === 'nbrowser_open_window' && args && args.sessionId) { sessionProfile.set(args.sessionId, tgt.key); if (args.thread) sessionThread.set(args.sessionId, args.thread); if (args.purpose) sessionPurpose.set(args.sessionId, args.purpose); persistSessions(); }+ if (verb === 'nbrowser_open_window' && args && args.sessionId) { sessionProfile.set(args.sessionId, tgt.key); if (args.thread) sessionThread.set(args.sessionId, args.thread); if (args.purpose) sessionPurpose.set(args.sessionId, args.purpose); const _c = callerLabel(); if (_c) sessionCaller.set(args.sessionId, _c); persistSessions(); }+ // CONCURRENT-AGENT ARBITRATION (wiki #345 ask 2): ~20 AI threads share one desktop. If a DIFFERENT+ // thread drives a session someone else registered, warn and NAME the owner so they can coordinate.+ // A warning, not a refusal - the owning-thread refusal already exists for destructive cross-thread+ // ops (force_close). Self-asserted identity: never an authorization decision.+ if (args && args.sessionId && verb !== 'nbrowser_open_window') {+ const owner = sessionCaller.get(args.sessionId);+ const nowCaller = callerLabel();+ if (owner && nowCaller && owner !== nowCaller) {+ crossThreadWarn = `⚠️ CONCURRENT AGENTS: session '${args.sessionId}' was opened by AI thread "${owner}", but this call came from "${nowCaller}". You are driving another thread's window - coordinate with them or open your own window (nbrowser_open_window) instead of sharing one. Allowed, but the other thread may be mid-task.`;+ logActivity({ verb: '_cross_thread', sessionId: args.sessionId, ok: true, ops: `${nowCaller} -> ${owner}`, internal: true });+ }+ } // For open_window, snapshot the Chrome window list BEFORE forwarding so the post-open diff reliably // catches the new window (the old fire-and-forget snapshot raced the window appearing). if (verb === 'nbrowser_open_window') chromeHwnds().then((s) => { preOpenHwnds = s; forward(); }).catch(forward);@@ -2123,7 +2179,7 @@ async function onHostFrame(frame) { neu = list.map((w) => w.hwnd).find((h) => !preOpenHwnds.has(h)) || null; if (!neu) neu = matchHwnd(list, payload.bounds, payload.title); } catch {}- if (neu) { lastHwnd = neu; if (sid) { sessionHwnd.set(sid, neu); persistSessions(); } applyBadge(sid, neu, p.profileKey); if (p.args && p.args.userRequestedForeground === true && p.args.foregroundReason) announceForeground(p.args.foregroundReason, sid); break; } // applyBadge = badge (composited onto the profile avatar) + arm idle-expiry+ if (neu) { lastHwnd = neu; if (sid) { sessionHwnd.set(sid, neu); persistSessions(); } applyBadge(sid, neu, p.profileKey); labelWindowOwner(neu, sid); if (p.args && p.args.userRequestedForeground === true && p.args.foregroundReason) announceForeground(p.args.foregroundReason, sid); break; } // applyBadge = badge (composited onto the profile avatar) + arm idle-expiry await new Promise((r) => setTimeout(r, 250)); } payload = { ...payload, ...(neu ? { hwnd: neu } : {}), _taskbarHint: flashHint(),@@ -2156,6 +2212,7 @@ async function onHostFrame(frame) { if (payload.sessionId) touchBadge(payload.sessionId, p.profileKey); else touchProfileBadge(p.profileKey); }+ if (crossThreadWarn && payload && typeof payload === 'object') payload._concurrentAgents = crossThreadWarn; const sid = payload && payload.sessionId; logActivity({ verb: p.verb, sessionId: sid || null, thread: sessionThread.get(sid) || null, profile: p.profileKey || null, ok: payload && payload.ok !== false, errorCode: (payload && payload.errorCode) || null, ...(p._internal ? { internal: true } : {}) }); resolve_ok(p.resolve, payload);@@ -2194,7 +2251,17 @@ const httpSrv = http.createServer((req, res) => { try { parsed = JSON.parse(body || '{}'); } catch { return reply(400, { success: false, error: 'bad json' }); } const verb = parsed.command || parsed.verb; if (!verb) return reply(400, { success: false, error: 'missing command/verb' });- reply(200, await dispatch(verb, parsed.args));+ // CALLER PROVENANCE (AD 1.9.180+, wiki #345/#351): AD stamps WHO asked on every relayed request.+ // SELF-ASSERTED - attribution/logging/UX/arbitration only, NEVER authorization.+ const caller = {+ 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),+ };+ // Run the verb inside an ambient caller context so EVERY AD callback we make while carrying it+ // out (taskbar badge, caption, window enumeration) forwards that identity automatically. The+ // bridge is deeply async, so threading it by hand through every call site would rot immediately.+ reply(200, await withCaller(caller, () => dispatch(verb, parsed.args))); }); return; }
native-browser-bridge-manifest.json+5−5@@ -1,8 +1,8 @@ { "manifest_version": 1,- "version": "0.1.50",- "url": "https://wiki.adom.inc/download/adom/adom-browser-extension/0.1.50/native-browser-bridge-v0.1.50.zip",- "sha256": "6d7144fe1911f57cd40a0ea8102ee6e451dece5559f5b88d43d79e7ecaf474f8",- "size": 97050,- "released_at": "2026-07-24T00:00:00Z"+ "version": "0.1.51",+ "url": "https://wiki.adom.inc/download/adom/adom-browser-extension/0.1.51/native-browser-bridge-v0.1.51.zip",+ "sha256": "02fa75c3af5cd3f4b4ff7cec6fdff66749d6a35edce356670add923f734072a0",+ "size": 98922,+ "released_at": "2026-07-25T00:00:00Z" }