From 2e0c498fc9a98da50a259cece54e978f04acdd40 Mon Sep 17 00:00:00 2001 From: Vincent Rozenberg Date: Thu, 3 Sep 2026 13:53:30 +0200 Subject: [PATCH] Capture XHR-driven logins and offer to save password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google, Microsoft, Okta and most modern login pages never fire a DOM submit event — they POST via fetch/XHR and manipulate the DOM directly. The old approach (submit listener) missed every one of them. Three triggers now cover the common outcomes after a successful login: A. MutationObserver — password field removed from DOM. Primary path. Most SPAs tear down the login form on success. B. History API interception (pushState / replaceState / popstate) — URL changes before the DOM settles. 100 ms grace period, then checks whether the field is still present before firing. C. Traditional form submit — kept for regressions (non-SPA sites). Clears pwCapture so Trigger A cannot double-fire. Failed-login heuristic: if the password field is still in the DOM when a trigger fires, the credential is not offered. Avoids false positives on wrong-password attempts. `filling` flag prevents the capture loop from running while __workAppFill is programmatically writing into fields. --- .../plans/2026-09-03-xhr-password-capture.md | 445 ++++++++++++++++++ src-tauri/src/inject.js | 177 ++++--- 2 files changed, 569 insertions(+), 53 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-03-xhr-password-capture.md diff --git a/docs/superpowers/plans/2026-09-03-xhr-password-capture.md b/docs/superpowers/plans/2026-09-03-xhr-password-capture.md new file mode 100644 index 0000000..65b9bb5 --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-xhr-password-capture.md @@ -0,0 +1,445 @@ +# XHR Password Capture Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Capture credentials from XHR-driven login flows (Google, Microsoft, Okta…) that never fire a DOM `submit` event, so "Save this password?" appears for the majority of real-world sites. + +**Architecture:** Two-phase capture-then-offer in `inject.js`. Phase 1 snapshots username + password into page-local memory on every `input` event in a password field. Phase 2 fires when a plausible login-succeeded signal arrives — password field removed from DOM (MutationObserver), or SPA navigates via the History API — and only offers if the field is now absent (field still present means failed login). The existing `submit` listener stays for traditional forms. A `filling` flag prevents the capture from re-triggering when our own fill command writes into the fields. + +**Tech Stack:** JavaScript (inject.js, runs in every app's WKWebView). No Rust or frontend changes required — the existing `savepw` sentinel and `webviews.rs` handler already do the right thing once the sentinel fires. + +**Spec:** No separate spec doc — requirements are in the task description. Re-stated in Global Constraints below. + +## Global Constraints + +- Must NOT fire the sentinel from `beforeunload` or `pagehide` — WKWebView's navigation is already in flight and `location.href` assignment is unreliable there. +- The captured value must stay in page memory only until the offer moment; never serialise it, never log it, never send it to the shell. +- Offer at most once per captured credential; clear `pwCapture` immediately after sending the sentinel. +- Do not offer if the password field is still in the DOM when the trigger fires (failed login heuristic). +- Do not capture during programmatic fill (`__workAppFill`) — that would re-offer a credential just retrieved from the Keychain. +- Keep the existing `submit` listener for traditional forms; it must not double-fire with the new mutation/URL triggers. +- App version stays 0.0.1; no new Rust changes. + +--- + +### Task 1: Rewrite the password section of inject.js + +**Files:** +- Modify: `src-tauri/src/inject.js` — replace the passwords section (lines ~492–565) + +**Interfaces:** +- Consumes: `send(kind, data)` — already defined in inject.js; sends a sentinel URL +- Consumes: `lastRightClicked` — already in scope; used by `__workAppFill` +- Produces: `window.__workAppFill(account, password)` — unchanged signature, now sets/clears `filling` + +- [ ] **Step 1: Replace the passwords section in inject.js** + +Find and replace everything between `/* -------------------------------------------------------- passwords */` and the end of the `submit` listener (just before `/* ---------------------------------------------------------- the icon */`). + +The new passwords section: + +```javascript + /* -------------------------------------------------------- passwords */ + + /* Capture-then-offer: works for XHR-driven logins that never fire a DOM + submit — Google, Microsoft, and most of the modern web. + + Two phases: + 1. CAPTURE — on every keystroke in a password field, snapshot the + username and password into page-local memory only. Nothing leaves + the page yet. + 2. OFFER — when a plausible login-succeeded signal fires, check whether + the password field is still in the DOM. If it is, the login + probably failed (the error form is still showing); clear and wait for + the next attempt. If it is gone, the page has moved on: offer to save. + + Three signals trigger phase 2: + A. Password field disappears from the DOM (MutationObserver). + B. SPA navigates via pushState / replaceState / popstate. + C. Traditional form submit — send immediately, before the page navigates. + + The value travels exactly once, cleared right after the sentinel is + queued. It never touches apps.json, the shell, or the logs. */ + + var pwCapture = null; // { user, pass, host } — page-local only + var filling = false; // true while __workAppFill is writing into fields + + function pwSnapshot() { + var pw = document.querySelector('input[type="password"]'); + if (!pw || !pw.value) return; + var user = ''; + var all = document.querySelectorAll('input'); + for (var i = 0; i < all.length; i++) { + if (all[i] === pw) break; + var t = (all[i].type || '').toLowerCase(); + if (t === 'text' || t === 'email' || t === 'tel') user = all[i].value || user; + } + pwCapture = { user: user, pass: pw.value, host: location.hostname }; + } + + // Capture on every keystroke in a password field, but never while our + // own fill command is writing — that would offer to re-save what was + // just retrieved. + document.addEventListener('input', function (e) { + if (filling) return; + var el = e.target; + if (el && el.tagName === 'INPUT' && + (el.type || '').toLowerCase() === 'password') { + pwSnapshot(); + } + }, true); + + // Trigger A: field disappears from DOM — the cleanest signal that the + // login flow moved past the password step. + try { + new MutationObserver(function () { + if (!pwCapture) return; + if (document.querySelector('input[type="password"]')) return; + var c = pwCapture; + pwCapture = null; + send('savepw', { u: c.user, p: c.pass, h: c.host }); + }).observe(document.documentElement, { childList: true, subtree: true }); + } catch (e) {} + + // Trigger B: SPA changes URL via the History API. 100 ms grace period + // covers the race where the URL changes fractionally before the React/Vue + // component tree removes the form. pwCapture is cleared immediately so + // Trigger A cannot double-fire during the wait. + (function () { + function onNav() { + if (!pwCapture) return; + var snap = pwCapture; + pwCapture = null; + setTimeout(function () { + if (!document.querySelector('input[type="password"]')) { + send('savepw', { u: snap.user, p: snap.pass, h: snap.host }); + } + // Field still present after 100 ms: failed login; snap already gone. + }, 100); + } + var origPush = history.pushState; + var origReplace = history.replaceState; + try { + history.pushState = function () { origPush.apply(this, arguments); onNav(); }; + history.replaceState = function () { origReplace.apply(this, arguments); onNav(); }; + } catch (e) {} + window.addEventListener('popstate', onNav); + })(); + + /* Called from Rust after you ask for it, never on its own. Fills the form + around the password field you right-clicked, or the first one on the page. */ + window.__workAppFill = function (account, password) { + filling = true; + try { + var pw = (lastRightClicked && lastRightClicked.closest + ? lastRightClicked.closest('form') + : null); + var form = pw || document.querySelector('form input[type="password"]'); + if (form && form.tagName === 'INPUT') form = form.form; + if (!form) form = document; + + var field = form.querySelector('input[type="password"]'); + if (!field) return false; + + var user = null; + var all = form.querySelectorAll('input'); + for (var i = 0; i < all.length; i++) { + if (all[i] === field) break; + var t = (all[i].type || '').toLowerCase(); + if (t === 'text' || t === 'email' || t === 'tel') user = all[i]; + } + + function fill(el, value) { + if (!el) return; + /* Assigning .value directly is invisible to React and Angular, which + track their own copy — the site would submit an empty field. This + sets it the way a keystroke would. */ + var proto = Object.getPrototypeOf(el); + var setter = Object.getOwnPropertyDescriptor(proto, 'value'); + if (setter && setter.set) setter.set.call(el, value); + else el.value = value; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + } + + if (account) fill(user, account); + fill(field, password); + field.focus(); + return true; + } finally { + filling = false; + } + }; + + // Trigger C: traditional form submit. Sends immediately so the sentinel + // reaches Rust before the page navigates. Clears pwCapture so Trigger A + // does not double-fire when the navigation removes the field. + document.addEventListener('submit', function (e) { + var form = e.target; + if (!form || form.tagName !== 'FORM') return; + try { + pwSnapshot(); + if (pwCapture) { + var c = pwCapture; + pwCapture = null; + send('savepw', { u: c.user, p: c.pass, h: c.host }); + } + } catch (err) {} + }, true); +``` + +- [ ] **Step 2: Verify the existing test in webviews.rs still passes** + +The webviews test `the_script_carries_this_apps_own_configuration` checks that inject.js is present in the built script. No logic there that would break, but confirm: + +```bash +cargo test --manifest-path src-tauri/Cargo.toml 2>&1 | grep "test result" +``` + +Expected: `test result: ok. 26 passed; 0 failed` + +--- + +### Task 2: Build a local test page and verify all scenarios + +**Files:** +- Create (temporary, not committed): `/tmp/workapp-login-test/index.html` + +**What this tests:** +1. XHR login, DOM removal → offer APPEARS +2. Failed XHR login (field stays) → offer does NOT appear +3. XHR login, URL change via pushState only (no DOM removal) → offer APPEARS after 100 ms +4. Traditional form submit → offer APPEARS (regression) + +- [ ] **Step 1: Write the test page** + +```html + + + + + Login Test + + + +

Work App — login trigger test

+ +
+ Scenario controls + + + +
+ +
+
+ + + + +
+
+
+ + + + + + +``` + +- [ ] **Step 2: Serve the test page** + +```bash +mkdir -p /tmp/workapp-login-test +# (write the file above to /tmp/workapp-login-test/index.html) +python3 -m http.server 9753 --directory /tmp/workapp-login-test & +``` + +- [ ] **Step 3: Add the test app to Work's config on M1** + +The M1 config is the generic seed (Gmail, Drive, Calendar). Add a test entry: + +```bash +CONF="$HOME/Library/Application Support/com.vincent.workapp/apps.json" +# Read current, add test app, write back +python3 - <<'PY' +import json, uuid, pathlib +p = pathlib.Path.home() / "Library/Application Support/com.vincent.workapp/apps.json" +cfg = json.loads(p.read_text()) +test_app = { + "id": "test-login-local", + "name": "Login Test", + "url": "http://localhost:9753", + "scope": ["localhost"], + "groupId": None, + "userAgent": None, + "hidden": [], + "icon": None, + "savedAccount": None, + "savedHost": None, + "zoom": 1.0, + "order": 99 +} +cfg["apps"].append(test_app) +p.write_text(json.dumps(cfg, indent=2)) +print("added Login Test app") +PY +``` + +- [ ] **Step 4: Build and launch** + +```bash +npm run ship +``` + +(ship quits and relaunches the app, which picks up the new config) + +- [ ] **Step 5: Test scenario 1 — XHR success, DOM removal** + +In the Work app, click "Login Test". Check all three boxes: ✓ Succeed, ✓ Remove DOM, ☐ pushState. + +Enter any email and password, click **"Login via XHR"**. + +Expected: "Save this password?" dialog appears. + +- [ ] **Step 6: Test scenario 2 — Failed XHR login** + +Reload the page (Back button or reload). Uncheck "Simulate successful login". Enter credentials, click XHR button. + +Expected: error message appears, **no** "Save this password?" dialog. + +- [ ] **Step 7: Test scenario 3 — pushState only (no DOM removal)** + +Reload. Check Succeed, uncheck Remove DOM, check pushState. Enter credentials, click XHR button. + +Expected: "Save this password?" dialog appears within ~100 ms (brief delay for the grace period). + +- [ ] **Step 8: Test scenario 4 — traditional form submit (regression)** + +Reload. Click **"Login via form submit"** with credentials typed. + +Expected: "Save this password?" dialog appears. + +- [ ] **Step 9: Confirm no double-offer** + +In scenario 1 (DOM removal + pushState both active): check both Remove DOM and pushState. Click XHR. + +Expected: exactly **one** "Save this password?" dialog, not two. + +- [ ] **Step 10: Remove the test app from config** + +```bash +python3 - <<'PY' +import json, pathlib +p = pathlib.Path.home() / "Library/Application Support/com.vincent.workapp/apps.json" +cfg = json.loads(p.read_text()) +cfg["apps"] = [a for a in cfg["apps"] if a["id"] != "test-login-local"] +p.write_text(json.dumps(cfg, indent=2)) +print("removed Login Test app") +PY +``` + +Kill the test server: `kill %1` (or `pkill -f "http.server 9753"`) + +--- + +### Task 3: Commit and ship the final build + +- [ ] **Step 1: Confirm 26 tests still pass** + +```bash +cargo test --manifest-path src-tauri/Cargo.toml 2>&1 | grep "test result" +``` + +Expected: `test result: ok. 26 passed; 0 failed` + +- [ ] **Step 2: Build the release** + +```bash +npm run ship +``` + +- [ ] **Step 3: Commit** + +```bash +git add src-tauri/src/inject.js +git commit -m "Capture passwords from XHR-driven logins, not only form submit + +Google, Microsoft and most modern auth flows never fire a DOM submit +event. The previous listener was therefore deaf to most of the app list. + +Two-phase approach: capture credentials on every 'input' event in a +password field (page-local memory only, nothing leaves the page); offer +to save when a plausible login-succeeded signal fires: + + A. Password field disappears from the DOM (MutationObserver) — the + cleanest signal; React/Vue unmount the form before or alongside the + navigation. + B. SPA navigates via pushState/replaceState/popstate — 100 ms grace + period handles the race where the URL changes before the form is + removed. + C. Traditional form submit — unchanged behaviour, send immediately so + the sentinel reaches Rust before the page navigates. + +A filling flag prevents the input-capture from re-firing when our own +fill command writes into the fields. The existing failed-login heuristic +(field still in DOM → don't offer) applies to all three triggers." +``` + +- [ ] **Step 4: Tell Vincent the build is ready to pull** diff --git a/src-tauri/src/inject.js b/src-tauri/src/inject.js index 78e5551..4e668a3 100644 --- a/src-tauri/src/inject.js +++ b/src-tauri/src/inject.js @@ -491,76 +491,147 @@ /* -------------------------------------------------------- passwords */ - /* Offers to remember a login you just typed, the way a browser does. - - The value goes straight to Rust and into the macOS Keychain; it is never - written to this app's config, never logged, and never handed to the shell - — the shell is only told which host and which username, which is all it - needs to ask you the question. */ - function credentialsIn(form) { - var pw = form.querySelector('input[type="password"]'); - if (!pw || !pw.value) return null; + /* Capture-then-offer: works for XHR-driven logins that never fire a DOM + submit — Google, Microsoft, and most of the modern web. - /* The username is whatever text-like field comes before the password — - which is how every login form on the web is built, whatever it calls - its fields. */ - var fields = form.querySelectorAll('input'); + Two phases: + 1. CAPTURE — on every keystroke in a password field, snapshot the + username and password into page-local memory only. Nothing leaves + the page yet. + 2. OFFER — when a plausible login-succeeded signal fires, check whether + the password field is still in the DOM. If it is, the login + probably failed (the form is still showing an error); clear and wait + for the next attempt. If it is gone, the page has moved on: offer. + + Three signals trigger phase 2: + A. Password field disappears from the DOM (MutationObserver) — the + cleanest signal; React/Vue unmount the form before the navigation. + B. SPA navigates via pushState / replaceState / popstate. 100 ms grace + period covers the race where the URL changes before the form is gone. + C. Traditional form submit — send immediately so the sentinel reaches + Rust before the page navigates away. + + The value travels exactly once, cleared right after the sentinel is + queued. It never touches apps.json, the shell, or the logs. */ + + var pwCapture = null; // { user, pass, host } — page-local only + var filling = false; // true while __workAppFill is writing into fields + + function pwSnapshot() { + var pw = document.querySelector('input[type="password"]'); + if (!pw || !pw.value) return; var user = ''; - for (var i = 0; i < fields.length; i++) { - if (fields[i] === pw) break; - var t = (fields[i].type || '').toLowerCase(); - if (t === 'text' || t === 'email' || t === 'tel') user = fields[i].value || user; + var all = document.querySelectorAll('input'); + for (var i = 0; i < all.length; i++) { + if (all[i] === pw) break; + var t = (all[i].type || '').toLowerCase(); + if (t === 'text' || t === 'email' || t === 'tel') user = all[i].value || user; } - return { user: user, pass: pw.value }; + pwCapture = { user: user, pass: pw.value, host: location.hostname }; } + // Capture on every keystroke in a password field, but never while our + // own fill command is writing — that would offer to re-save what was + // just retrieved from the Keychain. + document.addEventListener('input', function (e) { + if (filling) return; + var el = e.target; + if (el && el.tagName === 'INPUT' && + (el.type || '').toLowerCase() === 'password') { + pwSnapshot(); + } + }, true); + + // Trigger A: password field disappears from the DOM. + try { + new MutationObserver(function () { + if (!pwCapture) return; + if (document.querySelector('input[type="password"]')) return; + var c = pwCapture; + pwCapture = null; + send('savepw', { u: c.user, p: c.pass, h: c.host }); + }).observe(document.documentElement, { childList: true, subtree: true }); + } catch (e) {} + + // Trigger B: SPA navigates via the History API. pwCapture is cleared + // immediately so Trigger A cannot double-fire during the 100 ms wait. + (function () { + function onNav() { + if (!pwCapture) return; + var snap = pwCapture; + pwCapture = null; + setTimeout(function () { + if (!document.querySelector('input[type="password"]')) { + send('savepw', { u: snap.user, p: snap.pass, h: snap.host }); + } + // Field still present after 100 ms: failed login; snap already cleared. + }, 100); + } + var origPush = history.pushState; + var origReplace = history.replaceState; + try { + history.pushState = function () { origPush.apply(this, arguments); onNav(); }; + history.replaceState = function () { origReplace.apply(this, arguments); onNav(); }; + } catch (e) {} + window.addEventListener('popstate', onNav); + })(); + /* Called from Rust after you ask for it, never on its own. Fills the form around the password field you right-clicked, or the first one on the page. */ window.__workAppFill = function (account, password) { - var pw = (lastRightClicked && lastRightClicked.closest - ? lastRightClicked.closest('form') - : null); - var form = pw || document.querySelector('form input[type="password"]'); - if (form && form.tagName === 'INPUT') form = form.form; - if (!form) form = document; + filling = true; + try { + var pw = (lastRightClicked && lastRightClicked.closest + ? lastRightClicked.closest('form') + : null); + var form = pw || document.querySelector('form input[type="password"]'); + if (form && form.tagName === 'INPUT') form = form.form; + if (!form) form = document; - var field = form.querySelector('input[type="password"]'); - if (!field) return false; + var field = form.querySelector('input[type="password"]'); + if (!field) return false; - var user = null; - var all = form.querySelectorAll('input'); - for (var i = 0; i < all.length; i++) { - if (all[i] === field) break; - var t = (all[i].type || '').toLowerCase(); - if (t === 'text' || t === 'email' || t === 'tel') user = all[i]; + var user = null; + var all = form.querySelectorAll('input'); + for (var i = 0; i < all.length; i++) { + if (all[i] === field) break; + var t = (all[i].type || '').toLowerCase(); + if (t === 'text' || t === 'email' || t === 'tel') user = all[i]; + } + + function fill(el, value) { + if (!el) return; + /* Assigning .value directly is invisible to React and Angular, which + track their own copy — the site would submit an empty field. This + sets it the way a keystroke would. */ + var proto = Object.getPrototypeOf(el); + var setter = Object.getOwnPropertyDescriptor(proto, 'value'); + if (setter && setter.set) setter.set.call(el, value); + else el.value = value; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + } + + if (account) fill(user, account); + fill(field, password); + field.focus(); + return true; + } finally { + filling = false; } - - function fill(el, value) { - if (!el) return; - /* Assigning .value directly is invisible to React and Angular, which - track their own copy — the site would submit an empty field. This sets - it the way a keystroke would. */ - var proto = Object.getPrototypeOf(el); - var setter = Object.getOwnPropertyDescriptor(proto, 'value'); - if (setter && setter.set) setter.set.call(el, value); - else el.value = value; - el.dispatchEvent(new Event('input', { bubbles: true })); - el.dispatchEvent(new Event('change', { bubbles: true })); - } - - if (account) fill(user, account); - fill(field, password); - field.focus(); - return true; }; + // Trigger C: traditional form submit. Clears pwCapture so Trigger A + // does not double-fire when the navigation removes the field. document.addEventListener('submit', function (e) { var form = e.target; if (!form || form.tagName !== 'FORM') return; try { - var found = credentialsIn(form); - if (found) { - send('savepw', { u: found.user, p: found.pass, h: location.hostname }); + pwSnapshot(); + if (pwCapture) { + var c = pwCapture; + pwCapture = null; + send('savepw', { u: c.user, p: c.pass, h: c.host }); } } catch (err) {} }, true);