← Commit history

Fix 6 real-session bugs reported by the Bills thread driving Chase Connect's login. BUG 1: browser_press_key ignored modifiers entirely (keyboard.press only), so Ctrl+A typed a literal 'a' and every chord was unusable — modifiers are now held down around the press and released in reverse (aliases ctrl/cmd/command/option accepted; unknown ones rejected instead of silently dropped). BUG 2: browser_click accepted clickCount and silently ignored it — clickCount/button/delay are now passed through, so triple-click selects. BUG 3 (top priority): browser_type always APPENDED with no way to clear, which with bugs 1+2 left NO way to replace a field value (caller got 'adominc1adominc1' and fell back to 20 Backspaces) — new clear:true empties first using real select-all+Delete key events, which React-controlled inputs honour unlike setting .value. BUG 4: owner was never written to the session file, so every bridge restart erased it and list_windows reported owner:null for everything, silently disabling session_owned_by_another_thread — owner now persists and is restored on recovery. GAP 5: browser_eval only ran in the top frame, making iframe login forms reachable only via same-origin contentDocument hacks and cross-origin ones unreachable — new frame:'<name|url-substring|index>' selects the context, and a miss returns frame_not_found WITH the tab's frame list. GAP 6: new browser_maximize verb (CDP Browser.setWindowBounds, no AD verb dependency, does not foreground) replaces the window.resizeTo workaround. All documented in browser_describe.

John Lauer ·8698de38e5 ·3mo ago ·parent 93cdf18
4 files changed +107−13
adom-bridge-puppeteer-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "puppeteer",-  "version": "1.9.196",-  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.196/adom-bridge-puppeteer-v1.9.196.zip",-  "sha256": "15cf19135e3158d96a0674105f06d4359d31dfd7ed1d9d92f3016bf0376717c2",-  "size": 427130,+  "version": "1.9.197",+  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.197/adom-bridge-puppeteer-v1.9.197.zip",+  "sha256": "5694b48f35f04472e2b194eeeb70aed6d0c860197071393740ec703996eca500",+  "size": 429878,   "verbPrefixes": [     "browser_",     "desktop_recorder_",
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.196+1.9.197
src/bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "puppeteer",   "displayName": "Puppeteer (Chrome for Testing + Native)",-  "version": "1.9.196",+  "version": "1.9.197",   "description": "Drives a browser ALREADY on the machine \u2014 installed Chrome, else Microsoft Edge (every Windows PC ships with Edge) \u2014 launched with a FRESH isolated profile, so the common case has NO ~150 MB Chrome-for-Testing download and it never touches the user's real tabs/logins. Binds its local server to LOOPBACK (127.0.0.1) only, so no Windows Firewall 'allow Node through the firewall' prompt ever pops. Chrome for Testing is fetched only as a LAST RESORT if the machine has no Chromium browser at all. SPAWN-VERIFIED fallback: pup tries each detected browser (Chrome \u2192 Edge \u2192 CfT) and actually launches it, falling through to the next if one won't start (and handling browsers like Edge that relaunch onto another PID), so a corrupt/partial install never hard-fails an open. The browser that launches is cached as the default (persisted across restarts) so it isn't re-probed each time. On spawn the bridge SELF-WARMS: detects browsers + disk, and only if the box has no Chrome/Edge and no cached CfT does it background-fetch CfT so the first open is instant. browser_use pins a specific browser (chrome | edge | cft | auto); {browser:'chrome',install:true} downloads & installs REAL Google Chrome (user-scoped, no admin) on an Edge-only box. browser_readiness (READ-ONLY) reports which browser will be used + the full candidate list + the default + free disk. Every failure returns a specific errorCode + _hint (chrome_install_no_disk, chrome_download_network, chrome_install_permission, chrome_stable_not_detected, \u2026). Launches with FULL browser permissions by default for full debug (strictPermissions:true opts out). Headful automation: open/close windows+tabs, navigate, screenshot, eval JS, record. pup is a GENERIC, non-profile browser only \u2014 every session is a fresh isolated Chrome/Edge process, never the user's real signed-in browser; profile defaults to sessionId. To drive the user's REAL Chrome/Edge (their profile, cookies, SSO), use the Adom browser EXTENSION's nbrowser_* verbs \u2014 a separate bridge, not pup's job. macOS (v1.9.74+): full parity with mac-native mechanics \u2014 background-by-default is the app-level hide (windows stay drivable/screenshottable while hidden), raise/lower map to unhide+activate/hide, browser_alert_window posts a native toast whose click focuses the window, browser_use {browser:'chrome',install:true} installs Chrome from the official dmg, and taskbar identity/badging remains Windows-only.",   "homepage": "https://wiki.adom.inc/adom/adom-desktop",   "author": "Adom Inc.",
src/server.js+101−7
@@ -1010,6 +1010,10 @@ function saveSessionFile(sessionId, profileName, url) {     // biggest "pup windows don't behave / keep vanishing" driver (John, 2026-07-29). The flag is set     // by a granted raise OR by pup OBSERVING the user foreground the window themselves (taskbar click).     foreground: !!((sessions.get(sessionId) || {})._foreground),+    // v1.9.197 BUG 4 (Bills thread): owner was NEVER written here, so every bridge restart erased it+    // and browser_list_windows reported owner:null for every session — silently disabling the+    // session_owned_by_another_thread protection that owner is supposed to back.+    owner: ((sessions.get(sessionId) || {}).owner) || null,     createdAt: new Date().toISOString(),     updatedAt: new Date().toISOString(),   };@@ -1670,6 +1674,7 @@ async function recoverSessions() {       // (which reads it back out of the session) — otherwise every restart wiped the flag and the       // park below yanked a window the user was actively using to the bottom (John, 2026-07-29).       if (sf.foreground) { session._foreground = true; session._userActiveAt = Date.now(); }+      if (sf.owner) session.owner = sf.owner;   // v1.9.197 BUG 4: restore ownership across restarts       sessions.set(sessionId, session);       const be = browsers.get(profileName);       if (be) be.refCount++;@@ -3267,7 +3272,7 @@ function clearSessionBusy(sessionId) { // Verbs that are pure status/polling: they do NOT mean an agent is driving the window, and lighting // the bar for them would make every window look permanently busy (the audit loops alone poll these). const PASSIVE_VERBS = new Set([-  'browser_list_windows', 'browser_list_tabs', 'browser_readiness', 'browser_status',+  'browser_list_windows', 'browser_list_tabs', 'browser_readiness', 'browser_status', 'browser_maximize',   'browser_describe', 'browser_configure', 'browser_prewarm', 'browser_rescan', 'browser_wait', ]); @@ -7330,8 +7335,9 @@ const server = http.createServer(async (req, res) => {             V('browser_reload', 'Reload the active tab.', { sessionId: 'required str' }, 30, { sessionId: 'work' }),             V('browser_click', 'Click an element (by selector or coords).', { sessionId: 'required str', selector: 'optional str', x: 'optional int', y: 'optional int' }, 20, { sessionId: 'work', selector: 'button#go' }),             V('browser_assisted_click', 'RESPECTFUL real-cursor click for a surface pup CDP cannot drive (a NATIVE OS dialog, or a cross-origin FedCM "Sign in with Google" account chooser that rejects every in-page click). Do NOT hand-roll raise+desktop_click for these — use THIS. It: captions the reason + toasts BEFORE touching the screen; foregrounds; then WAITS, detecting if the user alt-tabbed away and holding (captioning "Adom is waiting — click back when ready") until the user is actually on the window; then runs the TIGHT real-cursor hover->click that FedCM accepts (interleaving any screenshot/eval between raise and click makes the click silently no-op); captions each step. Times out as user_not_ready clicking NOTHING if they never return. First take a desktop_screenshot_window of the session to read the target\'s WINDOW-pixel x,y.', { sessionId: 'required str', x: 'required int — WINDOW-image pixel of the target (from desktop_screenshot_window)', y: 'required int', reason: 'required str — WHY you must click (shown to the user as a caption)', label: 'required str — WHAT you are clicking (e.g. "your Google account")', maxWaitMs: 'optional int — how long to wait for the user (default 120000)' }, 130, { sessionId: 'adsk', x: 900, y: 379, reason: 'clicking your Google account to finish signing into Autodesk', label: 'your Google account' }),-            V('browser_type', 'Type text into the focused/selected field.', { sessionId: 'required str', text: 'required str', selector: 'optional str' }, 20, { sessionId: 'work', text: 'hello' }),-            V('browser_press_key', 'Press a key / chord (e.g. Enter, Ctrl+A).', { sessionId: 'required str', key: 'required str' }, 15, { sessionId: 'work', key: 'Enter' }),+            V('browser_type', 'Type text into a field. Pass clear:true to REPLACE the existing value instead of appending (this is what you want on any prefilled/React form — it selects-all then deletes with real key events, which React-controlled inputs honour, unlike setting .value).', { sessionId: 'required str', text: 'required str', selector: 'optional str', clear: 'optional bool — empty the field first (replace, not append)' }, 20, { sessionId: 'work', selector: '#user', text: 'adominc1', clear: true }),+            V('browser_press_key', 'Press a key, optionally with modifier chords held (Ctrl+A, Ctrl+V, Shift+Tab).', { sessionId: 'required str', key: 'required str', modifiers: 'optional array — any of ["Control","Shift","Alt","Meta"] (aliases ctrl/cmd/command/option accepted); held down around the key press' }, 15, { sessionId: 'work', key: 'a', modifiers: ['Control'] }),+            V('browser_maximize', 'Maximize (or restore/fullscreen/minimize) a pup window via CDP — no AD desktop verb required. Does NOT foreground it.', { sessionId: 'required str', state: 'optional "maximized"|"normal"|"minimized"|"fullscreen" (default maximized)' }, 15, { sessionId: 'work' }),             V('browser_screenshot', 'Screenshot the page (PNG; fullPage optional). NOTE: fullPage only covers DOCUMENT-level scroll — on SPAs that scroll inside a nested container it returns just the viewport + a nestedScroll hint; use browser_screenshot_element or browser_scroll for those.', { sessionId: 'required str', fullPage: 'optional bool' }, 30, { sessionId: 'work' }),             V('browser_screenshot_element', 'Screenshot ONE element, resolved by CSS selector OR visible text (pierces shadow DOM), scrolled into view first. The clean way to grab a single card/widget on a component-heavy page. Text matches the TIGHTEST text node — to capture the enclosing CARD, add closest (e.g. {text:"Download…", closest:".download-card"}); with closest, an ambiguous phrase still lands on the real widget (skips prose that has no such ancestor). Returns base64 + filePath like browser_screenshot.', { sessionId: 'required str', selector: 'CSS selector (or use text)', text: 'visible text to match (or use selector)', closest: 'optional selector — climb to the nearest ancestor matching it (the card container)', padding: 'optional int px around the element', deep: 'optional bool — pierce shadow DOM (default true)' }, 30, { sessionId: 'work', selector: '.download-card' }),             V('browser_scroll', 'Scroll the ACTUAL scrolling element (nearest ancestor whose scrollHeight>clientHeight, NOT just window — window.scrollTo no-ops on nested-scroll SPAs). Scroll by delta {dy,dx} OR to an element {toSelector|toText} (pierces shadow DOM). Returns {scrollElement, scrollTop, scrollHeight, clientHeight} so you can confirm it landed.', { sessionId: 'required str', dy: 'optional int px (down+/up-)', dx: 'optional int px', toSelector: 'optional CSS selector to scroll to', toText: 'optional visible text to scroll to', block: 'optional start|center|end (for scroll-to; default center)', behavior: 'optional auto|smooth' }, 30, { sessionId: 'work', toText: 'Download' }),@@ -7357,6 +7363,7 @@ const server = http.createServer(async (req, res) => {             // Session/tab/window management + probes (were routed but undocumented).             V('browser_status', 'List all open pup sessions + their tabs (NOT a readiness check — use browser_readiness for that).', {}, 10, {}),             V('browser_list_windows', 'List every open session/window with its tabs.', {}, 10, {}),+            V('browser_eval_frame_note', 'browser_eval accepts frame:"<name|url-substring|index>" to run inside an IFRAME (login forms usually live in one). Cross-origin frames work. Omit for the top document; a bad value returns frame_not_found WITH the list of frames in the tab.', { sessionId: 'required str', expr: 'required str', frame: 'optional str' }, 20, { sessionId: 'work', expr: 'document.querySelector("#userId").value', frame: 'logonbox' }),             V('browser_switch_window', 'Make a session the active one (for verbs that default to active).', { sessionId: 'required str' }, 10, { sessionId: 'work' }),             V('browser_list_tabs', 'List the tabs in a session.', { sessionId: 'required str' }, 10, { sessionId: 'work' }),             V('browser_switch_tab', 'Activate a tab WITHIN its session/window (idempotent — safe to call when the tab is already active). Does NOT raise a background window to the user\'s screen: the tab becomes the window\'s visible tab wherever the window is, and the taskbar auto-flash tells the user something changed. Also self-heals the window (re-park if a bug ever stranded it off-screen) and refreshes the Adom taskbar badge — good citizenship for long-lived shared windows.', { sessionId: 'required str', tabId: 'required str', updateNote: 'optional str', silent: 'optional bool' }, 10, { sessionId: 'work', tabId: 'tab-2' }),@@ -9075,6 +9082,31 @@ const server = http.createServer(async (req, res) => {         // browser_evaluate is the catalog/manifest name; browser_eval the historical one.         // Accept both, with either `expr` or `expression`.         case 'browser_evaluate':+        case 'browser_maximize': {+          // v1.9.197 GAP 6 (Bills thread): honouring "pup windows must be maximized" required+          // browser_eval + window.resizeTo(...) because desktop_set_window_state is not in every+          // desktop's verb set. This does it properly via CDP Browser.setWindowBounds (the same+          // channel park uses), so it works on any host and never depends on an AD verb being present.+          let session, resolvedId;+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          try {+            const cdp = await session.page.target().createCDPSession();+            const { windowId } = await cdp.send('Browser.getWindowForTarget');+            const want = (args.state === 'normal' || args.state === 'minimized' || args.state === 'fullscreen') ? args.state : 'maximized';+            // A window must be 'normal' before another state can be applied.+            try { await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } }); } catch (e) {}+            await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: want } });+            let bounds = null;+            try { ({ bounds } = await cdp.send('Browser.getWindowBounds', { windowId })); } catch (e) {}+            try { await cdp.detach(); } catch (e) {}+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, state: want, bounds,+              _hint: 'Window state set via CDP. This does NOT foreground the window — it stays background-parked unless you explicitly raise it (browser_raise_os_window with a foregroundReason).' }) });+          } catch (e) {+            sendJSON(res, { success: false, errorCode: 'maximize_failed', error: `browser_maximize failed: ${e.message}` });+          }+          return;+        }         case 'browser_eval': {           if (!args.expr && args.expression) args.expr = args.expression;           if (!args.expr) {@@ -9094,10 +9126,34 @@ const server = http.createServer(async (req, res) => {           // v1.8.21: ensure the shadow-DOM helpers are present so an expr can use           // $deep(sel) / $$deep(sel) / deepText(txt) without hand-rolling a shadow walk.           await ensureDeepHelpers(tab.page);+          // v1.9.197 GAP 5 (Bills thread): browser_eval only ever ran in the TOP frame, so every call+          // against a login form inside an iframe had to be hand-wrapped in+          // document.getElementById('logonbox').contentDocument — which only works because that frame+          // happened to be SAME-ORIGIN. A cross-origin login iframe (very common) was simply+          // unreachable. `frame` selects the execution context by name/id, url substring, or index;+          // puppeteer talks to each frame over its own CDP session, so cross-origin works too.+          let _evalCtx = tab.page;+          if (args.frame !== undefined && args.frame !== null && args.frame !== '') {+            const _frames = tab.page.frames();+            const _fq = String(args.frame);+            let _f = null;+            if (/^\d+$/.test(_fq)) _f = _frames[Number(_fq)] || null;+            if (!_f) _f = _frames.find(fr => { try { return fr.name() === _fq; } catch (e) { return false; } }) || null;+            if (!_f) _f = _frames.find(fr => { try { return (fr.url() || '').includes(_fq); } catch (e) { return false; } }) || null;+            if (!_f) {+              const _avail = _frames.map((fr, i) => { let nm = '', u = ''; try { nm = fr.name(); } catch (e) {} try { u = fr.url(); } catch (e) {} return `[${i}] name=${nm || '(none)'} url=${(u || '').slice(0, 70)}`; });+              sendJSON(res, { success: false, errorCode: 'frame_not_found',+                error: `No frame matched "${_fq}" in session "${resolvedId}".`,+                frames: _avail,+                _hint: `frame matches by frame NAME, then by URL substring, then by numeric index. Frames currently in this tab:\n${_avail.join('\n')}\nNote an iframe's id attribute is NOT its frame name — use its name attribute, a distinctive piece of its src URL, or the index. Omit frame to run in the top document.` });+              return;+            }+            _evalCtx = _f;+          }           let result;           try {             result = await withTimeout(-              tab.page.evaluate(new Function('return (' + args.expr + ')')),+              _evalCtx.evaluate(new Function('return (' + args.expr + ')')),               20_000, 'page.evaluate'             );           } catch (e) {@@ -9148,6 +9204,12 @@ const server = http.createServer(async (req, res) => {             // v1.9.164: show the animated cursor gliding to the target BEFORE the real click, so the             // user can see pup is driving. Default on; {showCursor:false} skips it.             const _cursor = args.showCursor !== false;+            // v1.9.197 BUG 2: build real click options so clickCount/button/delay actually apply.+            const _cc = Number(args.clickCount);+            const _clickOpts = {};+            if (Number.isFinite(_cc) && _cc >= 1) _clickOpts.clickCount = Math.min(3, Math.round(_cc));+            if (typeof args.button === 'string') _clickOpts.button = args.button;+            if (Number.isFinite(Number(args.delay))) _clickOpts.delay = Number(args.delay);             // v1.9.196: the cursor LABEL must narrate WHAT is being clicked and WHY — that is the whole             // point of showing a cursor (John: "i can't even read the indicator text you're supposed to             // put in there explaining your reasoning"). Caller may pass `intent`; otherwise pup composes@@ -9160,10 +9222,12 @@ const server = http.createServer(async (req, res) => {               let _box = null;               try { _box = await tab.page.$eval(args.selector, el => { const r = el.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); } catch (e) {}               if (_cursor && _box) { await showPupCursor(tab.page, _box.x, _box.y, { label: _intent }); await new Promise(r => setTimeout(r, 440)); }-              await withTimeout(tab.page.click(args.selector), 10_000, 'page.click');+              // v1.9.197 BUG 2: clickCount was accepted and silently ignored (a triple-click did a+              // single click and selected nothing). Honour it — 3 selects the field's text.+              await withTimeout(tab.page.click(args.selector, _clickOpts), 10_000, 'page.click');             } else {               if (_cursor) { await showPupCursor(tab.page, Number(args.x), Number(args.y), { label: _intent }); await new Promise(r => setTimeout(r, 440)); }-              await withTimeout(tab.page.mouse.click(Number(args.x), Number(args.y)), 10_000, 'mouse.click');+              await withTimeout(tab.page.mouse.click(Number(args.x), Number(args.y), _clickOpts), 10_000, 'mouse.click');             }             sendJSON(res, { success: true, output: JSON.stringify({ ok: true, clicked: args.selector || { x: Number(args.x), y: Number(args.y) }, sessionId: resolvedId, tabId, _hint: 'Click dispatched (isTrusted, CDP-level). Verify the effect with browser_eval (e.g. document.title or a state probe) — a click that hit nothing still returns ok.' }) });           } catch (e) {@@ -9225,6 +9289,19 @@ const server = http.createServer(async (req, res) => {           try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }           catch (e) { sendTabResolveError(res, e); return; }           try {+            // v1.9.197 BUG 3 (Bills thread, top priority): browser_type ALWAYS APPENDED, and with+            // modifiers broken (bug 1) and clickCount ignored (bug 2) there was NO way to replace a+            // field's value at all — the caller got "adominc1adominc1" and fell back to a bash loop of+            // 20 Backspaces. `clear:true` now empties the field first, using REAL key events+            // (select-all then Delete) rather than setting .value, because React-controlled inputs+            // ignore direct value writes — the Chase login form is exactly that case.+            if (args.clear) {+              if (args.selector) { await withTimeout(tab.page.focus(args.selector), 8_000, 'page.focus'); }+              const _selMod = process.platform === 'darwin' ? 'Meta' : 'Control';+              await tab.page.keyboard.down(_selMod);+              try { await tab.page.keyboard.press('a'); } finally { try { await tab.page.keyboard.up(_selMod); } catch (e) {} }+              await tab.page.keyboard.press('Delete');+            }             if (args.selector) await withTimeout(tab.page.type(args.selector, args.text), 15_000, 'page.type');             else await withTimeout(tab.page.keyboard.type(args.text), 15_000, 'keyboard.type');             sendJSON(res, { success: true, output: JSON.stringify({ ok: true, typed: args.text.length + ' chars', into: args.selector || 'focused element', sessionId: resolvedId, tabId, _hint: 'Typed via CDP (isTrusted). Verify with browser_eval reading the field value.' }) });@@ -9245,7 +9322,24 @@ const server = http.createServer(async (req, res) => {           try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }           catch (e) { sendTabResolveError(res, e); return; }           try {-            await withTimeout(tab.page.keyboard.press(args.key), 10_000, 'keyboard.press');+            // v1.9.197 BUG 1 (Bills thread): modifiers were parsed by NOBODY — keyboard.press(key)+            // ignores them entirely, so Ctrl+A typed a literal "a" into the field ("adominc1" ->+            // "adominc1a") and every chord (Ctrl+A/C/V) was unusable. Hold each modifier down, press,+            // then release in reverse order — the CDP-correct way to send a real chord.+            const _mods = Array.isArray(args.modifiers) ? args.modifiers.filter(m => typeof m === 'string' && m.trim()) : [];+            const _MODMAP = { control: 'Control', ctrl: 'Control', cmd: 'Meta', command: 'Meta', meta: 'Meta', alt: 'Alt', option: 'Alt', shift: 'Shift' };+            const _norm = _mods.map(m => _MODMAP[String(m).toLowerCase()] || m);+            const _bad = _norm.filter(m => !['Control', 'Meta', 'Alt', 'Shift'].includes(m));+            if (_bad.length) {+              sendJSON(res, { success: false, errorCode: 'bad_modifier', error: `Unknown modifier(s): ${_bad.join(', ')}.`, _hint: 'modifiers must be any of ["Control","Shift","Alt","Meta"] (aliases: ctrl, cmd, command, option accepted).' });+              return;+            }+            for (const m of _norm) await tab.page.keyboard.down(m);+            try {+              await withTimeout(tab.page.keyboard.press(args.key), 10_000, 'keyboard.press');+            } finally {+              for (const m of [..._norm].reverse()) { try { await tab.page.keyboard.up(m); } catch (e) {} }+            }             sendJSON(res, { success: true, output: JSON.stringify({ ok: true, pressed: args.key, sessionId: resolvedId, tabId, _hint: 'Key pressed in the PAGE (isTrusted). For OS-level chords outside the page (alt+f4, ctrl+shift+p in browser chrome), use AD\'s desktop_press_key instead.' }) });           } catch (e) {             sendJSON(res, { success: false, errorCode: 'press_key_failed', error: `browser_press_key failed: ${e.message}`, _hint: `"${args.key}" must be a valid puppeteer KeyInput name (case-sensitive: "Enter", "ArrowLeft", "F5", single chars like "a").` });