← Commit history

site-recipe set: driving-sites index + driving-gusto (never cookie-wipe to log out); nbrowser_login_form + clear_cookies; v0.3.8/0.0.12

John Lauer ·74f61be384 ·4mo ago ·parent 9cbbe6f
9 files changed +194−5
extension/manifest.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 3,   "name": "Adom for Chrome & Edge",   "short_name": "Adom",-  "version": "0.0.10",+  "version": "0.0.12",   "description": "Drive your real, logged-in Chrome/Edge from the Adom ecosystem \u2014 the native-browser counterpart to pup.",   "minimum_chrome_version": "116",   "background": {
extension/src/cdp.js+43
@@ -480,6 +480,49 @@ export async function autologin(tabId, args) {   } finally { bumpIdle(tabId); } } +// ---- nbrowser_login_form: detect a login form's state + recommend the SMARTEST path -----------------+// The #1 rule of login automation: if the browser already AUTOFILLED the form, just SUBMIT it (fully+// background, no autofill popup, no SSO). This inspects the current page and tells the AI which path to take.+function loginFormFn() {+  function vis(el) { var r = el.getBoundingClientRect(), s = getComputedStyle(el); return r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && s.display !== 'none' && parseFloat(s.opacity || '1') > 0.01; }+  function txt(b) { return ((b.innerText || b.value || b.getAttribute('aria-label') || '') + '').replace(/\s+/g, ' ').trim(); }+  function sel(el) { if (!el) return null; if (el.id) return '#' + el.id; if (el.name) return (el.tagName.toLowerCase() + '[name="' + el.name + '"]'); return el.tagName.toLowerCase(); }+  var pw = [].slice.call(document.querySelectorAll('input[type=password]')).filter(vis)[0];+  var user = [].slice.call(document.querySelectorAll('input[type=email],input[autocomplete=username],input[name*="user" i],input[name*="email" i],input[id*="email" i],input[id*="user" i],input[type=text]')).filter(vis)[0];+  var btns = [].slice.call(document.querySelectorAll('button,input[type=submit],[role=button],a[href]')).filter(vis);+  var submit = btns.find(function (b) { return /^(sign in|log ?in|continue|submit|next|verify)$/i.test(txt(b)); })+    || btns.find(function (b) { return /sign in|log ?in|continue|submit/i.test(txt(b)) && !/google|microsoft|apple|sso|with/i.test(txt(b)); })+    || (pw && pw.form && pw.form.querySelector('button[type=submit],input[type=submit],button'));+  var sso = btns.filter(function (b) { return /(sign in|continue|log ?in) with (google|microsoft|apple|github|okta|saml|sso)|use (sso|single sign)/i.test(txt(b)); }).map(txt).slice(0, 5);+  var twofa = /(verification code|two[- ]factor|2[- ]step|authenticator app|enter the (\d|six)[- ]?digit|security code|one[- ]time)/i.test((document.body && document.body.innerText || '').toLowerCase());+  return {+    hasPasswordField: !!pw,+    passwordFilled: !!(pw && pw.value && pw.value.length > 0),+    usernameField: !!user,+    usernameFilled: !!(user && user.value && user.value.length > 0),+    usernameValue: user ? (user.value || '').slice(0, 60) : '', // email/username is NOT a secret — lets the AI confirm the right account+    submitText: submit ? txt(submit) : '',+    submitSelector: sel(submit),+    ssoOptions: sso,+    twoFactorPrompt: twofa,+    url: location.href,+  };+}+export async function detectLoginForm(tabId) {+  await attach(tabId);+  const f = await evaluate(tabId, `(${loginFormFn.toString()})()`);+  bumpIdle(tabId);+  if (!f || f.__error) return { ok: false, error: (f && f.__error) || 'eval failed' };+  let recommendation, action;+  if (f.twoFactorPrompt) { recommendation = '2FA gate — needs a human verification code (authenticator/SMS). Surface it to the user; cannot be automated.'; action = '2fa'; }+  else if (f.hasPasswordField && f.passwordFilled) { recommendation = `✅ FORM IS AUTOFILLED${f.usernameValue ? ' for ' + f.usernameValue : ''} — the browser pre-filled the credentials. JUST SUBMIT IT (fully background, no popup/SSO): nbrowser_click {text:"${f.submitText || 'Sign in'}"}${f.submitSelector ? ' or {selector:"' + f.submitSelector + '"}' : ''}.`; action = 'submit'; }+  else if (f.ssoOptions.length) { recommendation = `Form not autofilled, but SSO is available (${f.ssoOptions.join(', ')}). Prefer SSO — clicking it uses the existing identity-provider session (background, no password): nbrowser_click {text:"${f.ssoOptions[0]}"}.`; action = 'sso'; }+  else if (f.hasPasswordField) { recommendation = 'Password form present but NOT autofilled. Fill the email (background) if split, then summon the browser autofill popup (~1s foreground: click the password field → desktop_screenshot_window owned popup → click the saved-login row). See the driving-the-desktop skill.'; action = 'autofill-popup'; }+  else if (f.usernameField) { recommendation = 'Email/username step (no password field yet). Type the email (background) + click Continue, then re-check nbrowser_login_form on the password step.'; action = 'username-step'; }+  else { recommendation = 'No login form detected on this page.'; action = 'none'; }+  return { ok: true, action, recommendation, ...f, _hint: 'ALWAYS call this before driving a login. If passwordFilled=true, the browser already autofilled it — just submit (background); never reach for the autofill popup or SSO when the form is already filled.' };+}+ // ---- nbrowser_errors: buffered console/page errors per tab ------------------ const errorBuffers = new Map(); // tabId -> [{type,text,ts}] chrome.debugger.onEvent.addListener((source, method, params) => {
extension/src/native.js+19
@@ -278,6 +278,25 @@ export async function loginState(args = {}) {   }; } +// ---- nbrowser_clear_cookies: BACKGROUND logout — drop a site's cookies (incl. httpOnly) -------------+// chrome.cookies can remove httpOnly/secure session cookies that page JS can't touch — a clean, background+// way to sign a site out. Scoped to ONE registrable domain so it never nukes unrelated sessions (e.g.+// clearing gusto.com leaves your google.com session intact, so Google SSO still works on the re-login).+export async function clearCookies(args = {}) {+  const raw = (args.host || args.domain || args.url || '').replace(/^https?:\/\//, '').split('/')[0];+  if (!raw) throw new Error('host required (e.g. host:"gusto.com")');+  const base = raw.split('.').slice(-2).join('.'); // registrable domain: app.gusto.com -> gusto.com+  const cookies = await chrome.cookies.getAll({ domain: base }); // matches base + all its subdomains+  let removed = 0; const hosts = new Set();+  for (const c of cookies) {+    const d = c.domain.replace(/^\./, '');+    const url = (c.secure ? 'https://' : 'http://') + d + (c.path || '/');+    try { await chrome.cookies.remove({ url, name: c.name, storeId: c.storeId }); removed++; hosts.add(d); } catch (e) {}+  }+  return { ok: true, domain: base, removed, hosts: [...hosts].slice(0, 8),+    _hint: `Cleared ${removed} cookie(s) for ${base} (background logout). Navigate to the site → it should show the login. Other sites' sessions (e.g. Google for SSO) are untouched.` };+}+ // ---- M4: authed fetch (uses the user's real cookies) + downloads ----------- export async function fetchUrl(args = {}) {   if (!args.url) throw new Error('url required');
extension/src/sw.js+5
@@ -148,6 +148,7 @@ async function dispatch(verb, args) {     case 'nbrowser_window_state':  return native.windowState(args);     case 'nbrowser_fullscreen':    return native.windowState({ ...args, state: args.state || 'fullscreen' });     case 'nbrowser_login_state':   return native.loginState(args);+    case 'nbrowser_clear_cookies': return native.clearCookies(args); // BACKGROUND logout — drops a site's cookies (incl. httpOnly)     case 'nbrowser_rescan':        return native.rescan();      // --- demo aids -------------------------------------------------------@@ -182,6 +183,10 @@ async function dispatch(verb, args) {       return cdp.inputDispatch(await native.resolveTabId(args), { ...args, type: 'dblclick' });     case 'nbrowser_right_click':       return cdp.inputDispatch(await native.resolveTabId(args), { ...args, type: 'rightclick' });+    case 'nbrowser_login_form':+      // Detect a login form's state (autofilled? SSO? 2FA?) and recommend the smartest path. ALWAYS call+      // before driving a login: if the form is already autofilled, just submit (background) — no popup/SSO.+      return cdp.detectLoginForm(await native.resolveTabId(args));     case 'nbrowser_autologin':       // Bridge injected args.__secret (the laptop-decrypted saved password). The extension injects it via       // CDP and returns ONLY {ok,loggedIn,needs2FA} — never the secret. See cdp.autologin.
install.sh+1−1
@@ -3,7 +3,7 @@ # (The extension itself loads into Chrome/Edge via "Load unpacked" from extension/, or the Web Store.) set -e DEST="${HOME}/.claude/skills"-for name in cross-platform-dev driving-the-extension driving-the-extension-demo driving-the-desktop; do+for name in cross-platform-dev driving-the-extension driving-the-extension-demo driving-the-desktop driving-sites driving-gusto; do   mkdir -p "$DEST/$name"   cp "skills/$name/SKILL.md" "$DEST/$name/SKILL.md"   echo "installed skill: $name"
package.json+4−2
@@ -1,6 +1,6 @@ {   "slug": "adom-browser-extension",-  "version": "0.3.7",+  "version": "0.3.8",   "type": "app",   "title": "Adom Browser Extension (Chrome + Edge)",   "description": "Drive your real, logged-in Chrome/Edge from the Adom ecosystem \u2014 the nbrowser_* native-browser bridge (counterpart to pup). Multi-profile driving, a read-only credential-library query over the browser's own saved logins (Chrome + Edge), and hands-free vendor login automation, so chip CAD / datasheets download past every login wall in your real signed-in session.",@@ -28,7 +28,9 @@     "screenshots/show-captcha.png",     "demo/adom-native-browser-demo.mp4",     "host/native-host.js",-    "bridge/server.js"+    "bridge/server.js",+    "skills/driving-sites/SKILL.md",+    "skills/driving-gusto/SKILL.md"   ],   "scripts": {     "install": "./install.sh",
skills/driving-gusto/SKILL.mdadded+54
@@ -0,0 +1,54 @@+---+name: driving-gusto+description: Site recipe for logging into and out of Gusto (login.gusto.com / app.gusto.com) with the Adom browser extension. Gusto is Keycloak (realm "zenpayroll") in front of a Rails app. Covers the smart login (the form usually AUTOFILLS — just submit, fully background), Google SSO, the 2FA gate, and the #1 gotcha: NEVER log out by wiping cookies (it nukes device-trust → forces 2FA → lockout) — use Gusto's own Sign out. Match when the user says "log into gusto", "gusto login", "open gusto", "sign into payroll", "log out of gusto", or you're driving login.gusto.com / app.gusto.com. Part of the adom-browser-extension site-recipe set (see driving-sites).+---+Part of the **adom-browser-extension** site-recipe set → see **driving-sites**. The generic mechanics+(autofill popup, owned popups, the debugger banner, background vs foreground) live in+**driving-the-desktop**; THIS page is just Gusto's quirks.++Gusto = **Keycloak** (realm `zenpayroll`, at `login.gusto.com`) in front of a Rails app (`app.gusto.com`).++## ✅ Logging IN — the smart, mostly-background flow+1. `nbrowser_navigate` to `https://app.gusto.com`. If logged out it redirects to the Keycloak Sign-In.+2. **`nbrowser_login_form` FIRST** (always). On Gusto's combined Sign-In form the browser **autofills email ++   password**, so you'll get `action:"submit"` / `passwordFilled:true` / recommendation *"JUST SUBMIT IT."*+3. **Just submit — fully background:** `nbrowser_click {selector:"button[name='btn-login']"}`.+   ⚠️ Clicking by **text "Continue" is UNRELIABLE** (matches other things / doesn't fire the form) — use the+   `button[name='btn-login']` selector, or focus `input[type=password]` and `nbrowser_press_key {key:"Enter"}`.+4. Verify: `location.href` left `login.gusto.com`, no `input[type=password]`, `twoFactorPrompt:false`.++**Alternatives the detector may report:**+- `action:"sso"` — **"Sign in with Google"** is offered. On a TRUSTED device with an active Google session it+  goes straight through (background, no password): click it → account chooser → pick `[email protected]` → Continue.+- `action:"autofill-popup"` — a **split** "Enter your password" step that did NOT autofill. Needs the ~1s+  foreground autofill popup (see driving-the-desktop). Prefer the combined-form path above, which autofills.++## 🔒 2FA+A fresh/untrusted device hits **"Enter the 6-digit code in your authenticator app"** (or **"Get text instead"**+= SMS). It's a human gate — surface it; you can't complete it. **CHECK "Remember device for 30 days"** before+the human enters the code, so the next login skips 2FA.++## ⛔⛔ Logging OUT — do NOT wipe cookies (this caused a real lockout, 2026-06-24)+**`nbrowser_clear_cookies` / any full cookie-wipe logs you out BUT removes the "remember this device" trust+cookie** → the very next login is forced through **2FA**. If the user has no authenticator set up, that's a+lockout you created. Don't do it.++**Use Gusto's OWN Sign out instead:**+- The account menu is top-right: **"John Lauer / Adom Industries"** — a `button[aria-haspopup="menu"]`.+- ⚠️ **It does NOT open via a CDP `nbrowser_click`** (finicky React menu). Open it with a **real desktop click**:+  `desktop_find_control {contains:"Adom Industries"}` → `desktop_click` its rect → then `desktop_find_control+  {contains:"Sign out"}` → click. (A brief foreground for the logout; the login itself stays background.)+- The Keycloak end-session URL (`/realms/zenpayroll/protocol/openid-connect/logout`) and `/logout` do **NOT**+  clear the Rails app session — don't rely on them.+- If you MUST clear cookies, a future **soft-logout** (clear only the session cookie, KEEP the+  `device`/2FA-remember cookie) avoids the 2FA trap — until that exists, prefer the UI Sign out.++## Selectors / controls (verified 2026-06-24)+| Thing | Selector / UIA |+|---|---|+| Email field | `input[type=email]` · UIA `automationId:"email"` |+| Password field | `input[type=password]` · UIA `automationId:"password"` |+| Submit | `button[name='btn-login']` (label "Continue") |+| Google SSO | text **"Sign in with Google"** |+| Account menu (for Sign out) | `button[aria-haspopup="menu"]`, text contains **"Adom Industries"** |+| 2FA | code field · **"Get text instead"** (SMS) · **"Remember device for 30 days"** checkbox |
skills/driving-sites/SKILL.mdadded+66
@@ -0,0 +1,66 @@+---+name: driving-sites+description: The index of SITE RECIPES for the Adom browser extension — per-site playbooks that teach the AI how to drive a specific website (login flow, key selectors, gotchas, 2FA, logout) so it doesn't re-discover them every time. Read this when about to automate a known site, when you keep fighting a site's quirks, or when you (or a third party) want to CONTRIBUTE a new site recipe. Match on: site recipe, driving a website, how to log into <site>, site-specific automation, contribute a site recipe, add a site playbook, navigate <vendor> site. Lists the known recipes (driving-gusto, …) and the template + breadcrumb flow for adding more.+---+Parent: **driving-the-extension** · generic OS/popup mechanics: **driving-the-desktop**.++Every website is idiosyncratic — split login forms, where the Sign-out lives, whether the password autofills,+SSO vs password, 2FA quirks. Without a recipe the AI re-discovers all of it every time (the Gusto login cost+hours once). A **site recipe** captures a site's playbook ONCE so the next run is fast and safe.++## Known recipes (in this repo)+| Recipe | Site | What it covers |+|---|---|---|+| **driving-gusto** | gusto.com | Autofill-and-submit login (background), Google SSO, 2FA, and ⛔ never-wipe-cookies-to-log-out |++## How a site recipe is USED+1. When you're about to drive a login/site, the **`nbrowser_login_form`** detector tells you the smart path+   (`submit` / `sso` / `autofill-popup` / `2fa`). Pair it with the site's recipe for the site-specific bits.+2. Recipes trigger on the **domain** + "log into <site>", so the AI matches the right one automatically.++## How to CONTRIBUTE a site recipe (two ways)+- **In this repo** (curated/common sites): add `skills/driving-<site>/SKILL.md`, wire it into `install.sh`,+  `uninstall.sh`, and `package.json` `files[]`, and add a row to the table above. Open a PR on the+  `adom-browser-extension` wiki page.+- **Your own page, breadcrumbed (the scaling path):** publish `driving-<site>` (or "I built a driver for+  <site>") as its OWN wiki page — you own + version it — and **breadcrumb it to the `adom-browser-extension`+  anchor** (see the wiki `wiki-breadcrumbs` / `wiki-family` skills). This is exactly the wiki Family pattern:+  site recipes are children with independent owners/cadences. The anchor lists them; discovery routes by domain.++## Site-recipe TEMPLATE (copy this)+```markdown+---+name: driving-<site>+description: Site recipe for <Site> (<domain>) with the Adom browser extension — login flow, selectors,+  gotchas, 2FA, logout. Match on "log into <site>", "<domain>", "open <site>". Part of the+  adom-browser-extension site-recipe set (see driving-sites).+---+Part of the **adom-browser-extension** site-recipe set → **driving-sites**. Generic mechanics live in+**driving-the-desktop**; this page is <Site>'s quirks only.++## ✅ Logging IN (prefer the most-background path)+- Always `nbrowser_login_form` FIRST; follow its `action` (submit / sso / autofill-popup).+- <does the form autofill on load? → just submit (background). Split form? SSO? exact submit selector?>++## 🔒 2FA+- <what gate, what options (authenticator/SMS), remember-device checkbox?>++## ⛔ Logging OUT+- <use the site's own Sign-out; where is it; does a CDP click open the menu or need a desktop click?>+- <⚠️ does wiping cookies break device-trust / force 2FA? if so, say so.>++## Selectors / controls (verified <date>)+| Thing | Selector / UIA |+|---|---|+| Email/username | `...` |+| Password | `...` |+| Submit | `...` |+| Logout menu | `...` |+```++## The hard-won rules every recipe should encode+- **Detect first, act second** — `nbrowser_login_form`; if the form is already autofilled, JUST SUBMIT (background).+- **Never wipe cookies to log out** unless you know it won't drop the device-trust/2FA-remember cookie — use+  the site's own Sign-out (Gusto's cookie-wipe caused a 2FA lockout).+- **Prefer CDP/background**; reserve the ~1s foreground only for the autofill popup a site won't auto-fill.+- **Submit by selector, not button text** when text-clicks prove flaky.
uninstall.sh+1−1
@@ -1,5 +1,5 @@ #!/usr/bin/env bash DEST="${HOME}/.claude/skills"-for name in cross-platform-dev driving-the-extension driving-the-extension-demo driving-the-desktop; do+for name in cross-platform-dev driving-the-extension driving-the-extension-demo driving-the-desktop driving-sites driving-gusto; do   rm -rf "$DEST/$name" && echo "removed skill: $name" done