Files
work-app/docs/superpowers/plans/2026-09-03-xhr-password-capture.md
Vincent Rozenberg 2e0c498fc9 Capture XHR-driven logins and offer to save password
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.
2026-09-03 13:53:30 +02:00

17 KiB
Raw Permalink Blame History

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 ~492565)

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:

  /* -------------------------------------------------------- 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:

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
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Login Test</title>
  <style>
    body { font-family: -apple-system, sans-serif; max-width: 380px;
           margin: 80px auto; padding: 0 20px; }
    input, button { display: block; width: 100%; margin: 8px 0;
                    padding: 10px; box-sizing: border-box; font-size: 14px; }
    button { cursor: pointer; background: #0ea5e9; color: white;
             border: none; border-radius: 6px; }
    button.sec { background: #64748b; }
    .err  { color: #dc2626; margin: 8px 0; font-size: 13px; }
    .ok   { color: #16a34a; margin: 8px 0; font-size: 13px; }
    label { display: flex; align-items: center; gap: 8px;
            font-size: 13px; margin: 4px 0; }
    fieldset { border: 1px solid #e2e8f0; border-radius: 8px;
               padding: 12px; margin-bottom: 16px; }
    legend { font-size: 12px; font-weight: 600; color: #475569; }
  </style>
</head>
<body>
  <h2>Work App — login trigger test</h2>

  <fieldset>
    <legend>Scenario controls</legend>
    <label><input type="checkbox" id="succeed" checked> Simulate successful login</label>
    <label><input type="checkbox" id="removeDom" checked> Remove form from DOM on success</label>
    <label><input type="checkbox" id="pushUrl"> Change URL via pushState on success</label>
  </fieldset>

  <div id="loginSection">
    <form id="loginForm" onsubmit="return false">
      <input type="email" id="email" placeholder="Email" autocomplete="username">
      <input type="password" id="password" placeholder="Password"
             autocomplete="current-password">
      <button type="button" onclick="doXhr()">Login via XHR (no submit event)</button>
      <button type="submit" class="sec" onclick="doFormSubmit(event)">
        Login via form submit (classic)
      </button>
    </form>
    <div id="msg"></div>
  </div>

  <div id="dashboard" style="display:none">
    <div class="ok">✓ Logged in — form removed from DOM.</div>
    <div class="ok" id="urlNote"></div>
    <button onclick="location.reload()">Reload to reset</button>
  </div>

  <script>
    function doXhr() {
      var succeed  = document.getElementById('succeed').checked;
      var rmDom    = document.getElementById('removeDom').checked;
      var pushUrl  = document.getElementById('pushUrl').checked;
      var msg      = document.getElementById('msg');

      msg.className = '';
      msg.textContent = '';

      if (!succeed) {
        msg.className = 'err';
        msg.textContent = 'Invalid credentials. Try again.';
        return; // field stays — no offer should fire
      }

      // Success path
      if (rmDom) {
        document.getElementById('loginSection').remove();
        document.getElementById('dashboard').style.display = '';
      }
      if (pushUrl) {
        history.pushState({}, '', '/dashboard');
        document.getElementById('urlNote').textContent =
          '✓ URL changed to /dashboard via pushState.';
      }
    }

    function doFormSubmit(e) {
      // Let the submit event bubble so inject.js sees it,
      // then prevent default so we don't actually navigate.
      // inject.js's listener fires first (capture phase).
    }
  </script>
</body>
</html>
  • Step 2: Serve the test page
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:

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
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
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
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
npm run ship
  • Step 3: Commit
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