# 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**