← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.14+0.9.15
Cargo.lock+1−1
@@ -107,7 +107,7 @@ dependencies = [  [[package]] name = "swx-bridge"-version = "0.9.14"+version = "0.9.15" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.9.14"+version = "0.9.15" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"
README.md+13−1
@@ -461,7 +461,19 @@ not.  ## Roadmap -- v0.9.14 (this): **the sign-in speaks ab's measured dialect.** Before the+- v0.9.15 (this): **the real login window, as measured.** The first real+  3DEXPERIENCE ID window (raised by Visualize) was measured on 2026-10-02: its+  accessibility tree is lazy (13 nodes until a point is queried, then 38), the+  form is two pages (user name + Continue, then the password), a cookie banner+  sits on it, and it refuses a background text set. `swx_sign_in` is now one+  flow over both pages with ONE consent, wakes the tree, never touches the+  cookie banner, and types real keystrokes only safely — never while the user is+  typing (a measured collision put the user's keystrokes in the box), only with+  the login window in front, select-all first, the user name read back before+  Continue. A missing login names every way to grow the vault. A second skill,+  **solidworks-sign-in**, ships the measured flow for every agent — with no+  credentials. 533 tests.+- v0.9.14: **the sign-in speaks ab's measured dialect.** Before the   first real sign-in, the three ab verbs it depends on were measured on Drew's   laptop: `desktop_ui_set` takes `text` (0.9.13 sent `value` — every fill would   have failed), the UI tree's nodes carry `role`/`invokable`/`settable` and no
SKILL.md+2−1
@@ -70,7 +70,8 @@ BEFORE you do it:    and call again. `startingUnverifiable:true` means only reads may run.    `waitingOn:"userSignIn"` means the 3DEXPERIENCE sign-in is up: tell the user,    or call `swx_sign_in {reason}` to fill it from THEIR local vault (it never-   shows you the password, and never retries a rejected one). Never launch+   shows you the password, and never retries a rejected one). The measured+   login flow is in the **solidworks-sign-in** skill. Never launch    `sldworks.exe` or the launcher any other way. 7. **Name the document you mean.** Pass `targetDocument` (a title) or `path` on    every verb that changes something; the active document follows window focus,
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "solidworks",   "displayName": "SOLIDWORKS",-  "version": "0.9.14",+  "version": "0.9.15",   "description": "Bridge for SOLIDWORKS desktop CAD \u2014 create and open documents, a modeling DSL, file management (find/open/save/export/Pack and Go), custom properties, BOM generation, design analysis (mass properties, bodies, parameters, interference, rebuild health) and background window/dialog etiquette, over the SOLIDWORKS COM API. Single native binary: no interpreter, no PowerShell, ~10ms cold start.",   "author": "Adom Inc.",   "license": "MIT",
docs/LAUNCH-RESILIENCE.md+33
@@ -1313,3 +1313,36 @@ verified each time, never assumed). | `pup_credential_reveal {}` | "needs {host, reason} (reason 8+ chars; it is shown to the user)"; hint: "Reveal ONE saved login (username + password) to a LOCAL caller on the desktop, gated by a sticky consent toast the user must click. One host per call, never bulk. A relayed (cloud container) call is refused with relay_forbidden" | Consent is PER USE (`vault.consent:"perUse"`, from ab's own text). The toast reads "Sign in to SOLIDWORKS with your saved 3DEXPERIENCE login". `consent_pending` is re-called until answered or `SWX_SIGN_IN_CONSENT_SECS` (120 s) → `sign_in_consent_pending`; a refusal → `sign_in_consent_denied`; `relay_forbidden` → `sign_in_relay_forbidden`, reported as a bridge wiring bug (the bridge must reach ab as a local caller), not a user problem | | `desktop_ui_click {hwnd}` with no name | "control found but has NO InvokePattern…" — it needs `name`/`contains` | Every click targets by `name` + `contains` | | The vault | no 3DEXPERIENCE entry yet (import consent pending on Drew's side) | EXISTENCE FIRST: `pup_credentials {}` (host + username, no toast) is read before any reveal; no login on the `3ds.com` domain → `sign_in_credential_missing` WITHOUT raising the consent toast. The reveal asks the vault host found there (preferring `iam.*`) |++**THE FIRST REAL LOGIN WINDOW, MEASURED 2026-10-02, and 0.9.15.** Raised by+SOLIDWORKS Visualize 2026 (pid 56488): class `WindowsForms10.Window.8.app…`,+title "Login - 3DEXPERIENCE ID - Dassault Systemes", 968x968 — the same web page+SOLIDWORKS's launcher hosts in `DSYSysLoginPanelWebView2Class` ("Login |+3DEXPERIENCE ID | Dassault Systèmes"), which is still unmeasured.++| | Measured | 0.9.15 |+|---|---|---|+| T1 — the tree is lazy | the first `desktop_ui_tree {maxDepth:30}` gave 13 nodes, one control (Button "Close"); after one `desktop_ui_from_point` query on the page, 38 nodes with the form | A read with no form wakes the page — `desktop_ui_from_point` at the window's centre, a short pause, read again, up to three times — and records every node count (`wake.nodeCounts`, `signInWake`) |+| T2 — two pages | page 1: Edit "Email or username" (automationId `username`, settable), Button "Continue", hyperlinks, a cookie banner, Button "Close"; NO password box. Page 2 unmeasured | ONE flow: page 1 recognized (one box that says it is the user name + a Continue-like button) → user name → Continue → wait for the page to CHANGE (woken) → page 2 recognized (a password box + a submit), its inventory captured → password → submit → verified by effect. The vault is asked ONCE; the secret is held only across the two fills, then wiped. A password page that never comes types no password (`sign_in_unconfirmed`) |+| T3 — cookie banner | "Accept All", "Manage my preferences", Text "Continue with only necessary cookies"; it does not cover the form | Never clicked (the choice is the user's), never a submit candidate; reported `cookieBanner:true`; a set or click that fails with the banner up is `sign_in_needs_user`, and the user is told |+| T4 — both hosts | — | The title rule already matches the Visualize host ("3DEXPERIENCE ID" + "Login"); the measured cold (13) and warm (38) page-1 reads are test fixtures. The bridge does not launch Visualize, so a Visualize login is not one `swx_sign_in` will answer |+| K1 — no background set | `desktop_ui_set {name:"Email or username", text}` on the `settable:true` Edit: "control found but has NO ValuePattern (can't set text via UIA — try a foregrounded desktop_type)" | The UIA set is tried first; on that refusal the bridge types REAL keystrokes: `desktop_screenshot_window` → `desktop_click {space:"image", shotId, x, y}` on the box → `desktop_press_key ["ctrl+a"]` → `desktop_type`, every call with a `reason` |+| K2 — collision | keystrokes sent while Drew typed in Chrome (last input 0 s) left "ogin" — HIS keystrokes — in the box, and the text did not land | No keystroke unless the user has been idle (`SWX_SIGN_IN_IDLE_SECS`, 5 s; unreadable = not idle) → `sign_in_needs_user_idle` + a toast asking for five seconds hands-off; the login window confirmed as the FOREGROUND window before every batch (aborts if focus moved); the user-name box READ BACK and compared (the password box never is); a mismatch stops with what was observed. Enter is never pressed |+| K3 — dirty field | "ogin" stayed in the box after the failed attempt | Select-all before typing |+| K4 — background submit | `desktop_ui_click {name:"Continue"}` worked without focus | Submits stay background Invokes |+| K5 — the vault | `pup_import_browser_logins {hostFilter:"3ds.com"}` answered `no_data` — Drew's browser holds no saved 3DEXPERIENCE login | `sign_in_credential_missing` names all three ways to grow the vault: the import, signing in once in a pup window (pup captures it), or `credential_set` by the user on their desktop |++**Unmeasured, stated:** whether keystrokes posted to the embedded browser's own+child window work without focus (would avoid stealing focus); the image-space+mapping (`x = element centre − window origin` on a window screenshot); page 2.++**T5 — SOLIDWORKS Visualize 2026, measured.** Installed at `…\SOLIDWORKS+3DEXPERIENCE R2026x\Visualize\SLDWORKSVisualize.exe`; it takes a model path as+its argument; it ships no scripting interface on disk (UI automation only); and+it demands its OWN platform sign-in even while SOLIDWORKS is signed in. No+rendering verbs in this build: Drew wants rendering intelligence only once a+render has worked by hand, and that waits on his sign-in.++**The dependent skill.** `skills/solidworks-sign-in/SKILL.md` ships in the+package as a second user skill: the measured flow, each line marked measured or+unmeasured, and NO credentials — only where they come from in general terms.
install.sh+9
@@ -8,4 +8,13 @@ for D in "$HOME/.claude/skills" "$HOME/.codex/skills"; do   # SKILL.md holds the rules that never bend; the detail it points at lives here.   cp -f "$H"/references/*.md "$D/solidworks-bridge/references/" done+# A dependent skill: the measured 3DEXPERIENCE sign-in flow (no credentials).+install_skill() { # <name> <source dir>+  for D in "$HOME/.claude/skills" "$HOME/.codex/skills"; do+    [ -d "$(dirname "$D")" ] || continue+    mkdir -p "$D/$1"+    cp -f "$2"/SKILL.md "$D/$1/SKILL.md"+  done+}+install_skill solidworks-sign-in "$H/skills/solidworks-sign-in" echo "[solidworks-bridge] skills installed."
package.json+3−2
@@ -1,8 +1,8 @@ {   "slug": "solidworks-bridge",-  "version": "0.9.14",+  "version": "0.9.15",   "type": "app",-  "description": "Skills for your container so your AI knows how to drive the SOLIDWORKS bridge (swx_* verbs). The bridge runtime itself is the release zip \u2014 a single native swx-bridge.exe; Adom Bridge loads that.",+  "description": "Skills for your container so your AI knows how to drive the SOLIDWORKS bridge (swx_* verbs). The bridge runtime itself is the release zip — a single native swx-bridge.exe; Adom Bridge loads that.",   "tags": [     "solidworks",     "mcad",@@ -27,6 +27,7 @@     "references/launch-and-sign-in.md",     "references/writes-and-dialogs.md",     "references/modelling-and-export.md",+    "skills/solidworks-sign-in/SKILL.md",     "package.json",     "install.sh",     "uninstall.sh"
references/launch-and-sign-in.md+3
@@ -225,3 +225,6 @@ Read this before starting SOLIDWORKS, while waiting through a launch or a 3DEXPE   (`userConfirmedNewPassword:true`, on their word only).   `swx_start {signIn:"auto"}` lets the bridge do it once by itself; the default   is to wait for the user.+- **The measured login flow** (two pages, a lazy tree, a cookie banner, real+  keystrokes only while the user is idle) is in the **solidworks-sign-in**+  skill, which ships with this one.
skills/solidworks-sign-in/SKILL.mdadded+88
@@ -0,0 +1,88 @@+---+name: solidworks-sign-in+description: "How to get through the 3DEXPERIENCE ID login that SOLIDWORKS and SOLIDWORKS Visualize raise when the platform session has lapsed - where the window comes from, how to find it, the measured two-page flow, what to type with and what never to touch, and where the login comes from (never from this skill). Use when a SOLIDWORKS launch is waiting on a sign-in, when swx_status says waitingOn userSignIn, or when a License Manager notice says it failed to check a license. Trigger words - solidworks sign in, 3DEXPERIENCE login, sign me in, login window, License Manager failed to check license, session expired, Visualize login."+user-invocable: true+---++Parent skill: **solidworks-bridge**++Every line below is marked **[measured]** (seen on a real machine, 2026-10-02) or+**[unmeasured]** (not seen yet — treat as a guess and read the window first).++## When the window appears++- [measured] It appears when the 3DEXPERIENCE platform session has lapsed. It+  did not appear on about fifteen launches on 09-30 and 10-01, then appeared on+  10-02 after licence checks failed overnight.+- [measured] A session that is already running shows the warning first: a+  "License Manager" notice, "Failed to check 1 license(s): 3DEXPERIENCE+  SOLIDWORKS Professional: Unable to connect to server. The session will end+  after the next 5 unsuccessful attempts.", OK only. Notices can queue (two+  identical ones). The bridge never answers them.+- [measured] SOLIDWORKS and SOLIDWORKS Visualize each ask separately. Visualize+  wants its own sign-in even while SOLIDWORKS is signed in.++## Finding the window++- [measured] SOLIDWORKS's launcher shows it: class+  `DSYSysLoginPanelWebView2Class`, title "Login | 3DEXPERIENCE ID | Dassault+  Systèmes", owned by SWXDesktopLauncher.exe.+- [measured] Visualize shows the same web page in a WinForms window: title+  "Login - 3DEXPERIENCE ID - Dassault Systemes", 968x968.+- Find it by a title containing "3DEXPERIENCE ID". `swx_status` reports+  `waitingOn: "userSignIn"` and `signInWindow` while the launcher's window is up.++## The flow++1. [measured] The accessibility tree is LAZY. The first `desktop_ui_tree` read+   gave 13 nodes with one control (Button "Close"); after one+   `desktop_ui_from_point` query on a point inside the page, the same read gave+   38 nodes with the form. Wake it before deciding anything.+2. [measured] Page 1: Edit "Email or username" (automationId `username`) and+   Button "Continue". There is NO password box on page 1.+3. [measured] `desktop_ui_set` on that Edit is refused ("control found but has+   NO ValuePattern"), even though the tree says `settable: true`. Typing needs+   real keystrokes: `desktop_click` on the box, `desktop_press_key ["ctrl+a"]`+   (a failed attempt leaves the box dirty), then `desktop_type`. Each one needs a+   `reason`, and each takes focus (the login window comes to the front).+4. [measured] `desktop_ui_click {name: "Continue"}` works in the background.+5. [unmeasured] Page 2 should hold the password box and a sign-in button. Read+   its inventory first; do not assume its names.+6. [measured] A cookie banner sits on the page ("Accept All", "Manage my+   preferences"). Never touch it: that choice belongs to the user. In the+   measured layout it does not cover the form.++## Rules that never bend++- [measured] NEVER send keystrokes while the user is typing. On 10-02 keystrokes+  sent while the user typed in another window left fragments of THEIR typing in+  the login box, and the intended text did not land. Check `swx_user_presence`+  (seconds since last input) first, and bring the login window to the front and+  confirm it is in front before every keystroke batch.+- Read the user-name box back after typing and compare before pressing anything.+  Never read the password box back.+- Never retry a rejected password; an account lockout is worse than a manual+  sign-in.+- A second factor (a code, a captcha, a new-device check) is the user's to+  answer. Tell them; do not try.++## Credentials++This skill carries NO login, and it never will. The login comes from the user's+own secret store on their desktop, in general terms:++- the pup vault on the user's desktop, through `swx_sign_in {reason}` — the+  bridge reads it locally, the user clicks one consent toast per use, and the+  password never reaches the AI, a reply or a log;+- growing that vault, by the user on their desktop: `pup_import_browser_logins+  {"hostFilter": "3ds.com"}` (it found nothing on one machine because the+  browser held no saved 3DEXPERIENCE login), or signing in to 3DEXPERIENCE once+  in a pup window so the vault captures it;+- or the user's own PRIVATE skill pack, if they keep one, for where their logins+  live — never this skill.++An agent must never write a password into this skill, the bridge repository, a+wiki page, a log line or a chat message.++Once `swx_sign_in` has succeeded on a real window, it is the preferred path: it+does every step above, in order, and fails closed with a reason when it cannot.
src/launch.rs+14−2
@@ -510,6 +510,10 @@ pub fn note_sign_in_inventory(inv: Value) {     update(move |a| a.sign_in_inventory = Some(inv)); } +pub fn note_sign_in_wake(wake: Value) {+    update(move |a| a.sign_in_wake = Some(wake));+}+ pub fn note_sign_in_rejected() {     update(|a| a.sign_in_rejected = true); }@@ -528,8 +532,9 @@ pub fn arm_auto_sign_in(os: &dyn LaunchOs) { /// the launch's own polling never waits on ab. fn on_sign_in_window(hwnd: i64) {     std::thread::spawn(move || {-        if let Ok(inv) = crate::signin::inventory(hwnd) {-            note_sign_in_inventory(crate::signin::inventory_json(&inv));+        if let Ok(r) = crate::signin::inventory(hwnd) {+            note_sign_in_inventory(crate::signin::inventory_json(&r.elements));+            note_sign_in_wake(r.wake_json());         }         let auto = attempt().map(|a| a.sign_in_auto && !a.sign_in_auto_tried && !a.sign_in_rejected).unwrap_or(false);         if auto {@@ -782,6 +787,9 @@ pub struct Attempt {     /// reaches the sign-in (0.9.13): control types, names, automation ids,     /// IsPassword, toggle states — never a value.     pub sign_in_inventory: Option<Value>,+    /// How that inventory was read: ab's node count on each read (cold first)+    /// and how many wakes it took (0.9.15 — the tree is lazy).+    pub sign_in_wake: Option<Value>,     /// The saved password was rejected in this launch: never retried.     pub sign_in_rejected: bool,     /// `swx_start {signIn:"auto"}`: the bridge may sign in by itself, ONCE.@@ -884,6 +892,7 @@ impl Attempt {             "signInSeen": self.sign_in_seen,             "signInWindow": self.sign_in_window,             "signInInventory": self.sign_in_inventory,+            "signInWake": self.sign_in_wake,             "signInRejected": self.sign_in_rejected,             "signInMode": if self.sign_in_auto { "auto" } else { "wait" },             "signInAutoTried": self.sign_in_auto_tried,@@ -1030,6 +1039,7 @@ pub fn load() -> LaunchStore {         sign_in_seen: a.get("signInSeen").and_then(|x| x.as_bool()).unwrap_or(false),         sign_in_window: a.get("signInWindow").and_then(|x| x.as_str()).map(String::from),         sign_in_inventory: a.get("signInInventory").filter(|x| !x.is_null()).cloned(),+        sign_in_wake: a.get("signInWake").filter(|x| !x.is_null()).cloned(),         sign_in_rejected: a.get("signInRejected").and_then(|x| x.as_bool()).unwrap_or(false),         sign_in_auto: a.get("signInMode").and_then(|x| x.as_str()) == Some("auto"),         sign_in_auto_tried: a.get("signInAutoTried").and_then(|x| x.as_bool()).unwrap_or(false),@@ -1398,6 +1408,7 @@ pub fn begin(             sign_in_seen: false,             sign_in_window: None,             sign_in_inventory: None,+            sign_in_wake: None,             sign_in_rejected: false,             sign_in_auto: false,             sign_in_auto_tried: false,@@ -1466,6 +1477,7 @@ fn unfinished_placeholder(shortcut: &str) -> Attempt {         sign_in_seen: false,         sign_in_window: None,         sign_in_inventory: None,+            sign_in_wake: None,         sign_in_rejected: false,         sign_in_auto: false,         sign_in_auto_tried: false,
src/lib.rs+1
@@ -1,3 +1,4 @@+#![recursion_limit = "256"] //! Adom SOLIDWORKS bridge (`swx`) — one binary, two modes. //! //! ```text
src/signin.rs+449−69
@@ -147,6 +147,8 @@ pub struct Element {     /// ab's `invokable` / `settable` (MEASURED 2026-10-02 on ab 2.1.163).     pub invokable: Option<bool>,     pub settable: Option<bool>,+    /// Screen rectangle (x, y, w, h), when ab reports one — for a real click.+    pub rect: Option<[i64; 4]>, }  impl Element {@@ -205,6 +207,7 @@ pub fn parse_inventory(reply: &Value) -> Vec<Element> {                         enabled: m.get("isEnabled").or_else(|| m.get("enabled")).and_then(|x| x.as_bool()),                         invokable: m.get("invokable").and_then(|x| x.as_bool()),                         settable: m.get("settable").and_then(|x| x.as_bool()),+                        rect: m.get("rect").and_then(rect_xywh),                     });                     // THE PASSWORD FIELD (MEASURED 2026-10-02: ab's nodes carry no                     // `isPassword` key): an explicit password flag, OR an edit@@ -247,20 +250,94 @@ pub fn looks_like_password(s: &str) -> bool {     s.contains("pass") || s.contains("mot de passe") || s.contains("contrase") } -/// Read the login window's inventory through ab. `Err` = could not be read.-pub fn inventory(hwnd: i64) -> Result<Vec<Element>, String> {+thread_local! {+    static WINDOW_RECT: std::cell::RefCell<Option<[i64; 4]>> = const { std::cell::RefCell::new(None) };+}++fn wake_ms() -> u64 {+    std::env::var("SWX_SIGN_IN_WAKE_MS").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(700)+}++fn rect_xywh(r: &Value) -> Option<[i64; 4]> {+    let n = |k: &str| r.get(k).and_then(|v| v.as_f64());+    let (x, y) = (n("x").or(n("left"))?, n("y").or(n("top"))?);+    let w = n("w").or(n("width")).or_else(|| n("right").map(|r| r - x))?;+    let h = n("h").or(n("height")).or_else(|| n("bottom").map(|b| b - y))?;+    Some([x as i64, y as i64, w as i64, h as i64])+}++/// One read: the elements, ab's node count, and the window's centre if known.+fn read_once(hwnd: i64) -> Result<(Vec<Element>, u64, Option<(i64, i64)>), String> {+    let r = crate::caller::ad_callback("desktop_ui_tree", json!({"hwnd": hwnd, "maxDepth": 30, "maxNodes": 400}), AB_TIMEOUT);+    if !ab_ok(&r) {+        return Err(format!("desktop_ui_tree failed: {}", ab_error(&r)));+    }+    let top = unwrap_ab(&r);+    let els = parse_inventory(&r);+    let count = top.get("nodeCount").and_then(|v| v.as_u64()).unwrap_or(els.len() as u64);+    let wr = top.pointer("/window/rect").and_then(rect_xywh).or_else(|| top.pointer("/root/rect").and_then(rect_xywh));+    let center = wr.map(|[x, y, w, h]| (x + w / 2, y + h / 2));+    WINDOW_RECT.with(|c| *c.borrow_mut() = wr);+    Ok((els, count, center))+}++/// The login window, read — woken first if it is lazy.+pub struct Read {+    pub elements: Vec<Element>,+    /// The window's screen rectangle, for turning an element's screen point+    /// into a point on a window screenshot.+    pub window_rect: Option<[i64; 4]>,+    /// ab's node count on every read, cold first.+    pub node_counts: Vec<u64>,+    pub wakes: u32,+}++impl Read {+    pub fn wake_json(&self) -> Value {+        json!({"nodeCounts": self.node_counts, "wakes": self.wakes})+    }+}++/// Does this read show a form (or a question) yet?+fn shows_something(els: &[Element]) -> bool {+    els.iter().any(|e| e.is("edit")) || has_marker(els, SECOND_STEP).is_some() || has_marker(els, REJECTED).is_some()+}++/// READ THE LOGIN WINDOW, WAKING IT IF IT IS LAZY (0.9.15). MEASURED+/// 2026-10-02 on a 3DEXPERIENCE ID login window: the first `desktop_ui_tree`+/// returned 13 nodes with ONE control (Button "Close"); after a+/// `desktop_ui_from_point` query on the page the same call returned 38 nodes+/// with the form. So a read that shows no form queries a point inside the+/// window (read-only) and reads again — up to three times — and both counts+/// are recorded.+pub fn inventory(hwnd: i64) -> Result<Read, String> {     if !crate::caller::ab_available() {         return Err("ab's direct API is not available to this bridge (it was not spawned by Adom Bridge)".into());     }-    let r = crate::caller::ad_callback("desktop_ui_tree", json!({"hwnd": hwnd, "maxDepth": 16, "maxNodes": 400}), AB_TIMEOUT);-    if !ab_ok(&r) {-        return Err(format!("desktop_ui_tree failed: {}", ab_error(&r)));+    let (mut els, n, mut center) = read_once(hwnd)?;+    let mut counts = vec![n];+    let mut wakes = 0u32;+    while !shows_something(&els) && wakes < 3 {+        let mut args = json!({"hwnd": hwnd, "reason": "waking the 3DEXPERIENCE sign-in page's accessibility tree (read-only)"});+        if let (Some((x, y)), Value::Object(m)) = (center, &mut args) {+            m.insert("x".into(), json!(x));+            m.insert("y".into(), json!(y));+        }+        let _ = crate::caller::ad_callback("desktop_ui_from_point", args, AB_TIMEOUT);+        wakes += 1;+        std::thread::sleep(Duration::from_millis(wake_ms()));+        let (e, n, c) = read_once(hwnd)?;+        els = e;+        counts.push(n);+        if c.is_some() {+            center = c;+        }     }-    let inv = parse_inventory(&r);-    if inv.is_empty() {+    if els.is_empty() {         return Err("desktop_ui_tree answered, but no element with a control type could be read from it".into());     }-    Ok(inv)+    let window_rect = WINDOW_RECT.with(|c| *c.borrow());+    Ok(Read { elements: els, window_rect, node_counts: counts, wakes }) }  pub fn inventory_json(inv: &[Element]) -> Value {@@ -281,6 +358,18 @@ const REJECTED: &[&str] = &[ const SUBMIT_PRIMARY: &[&str] = &["log in", "login", "sign in", "sign-in", "signin", "connect", "log on"]; const SUBMIT_SECONDARY: &[&str] = &["continue", "next", "submit", "ok"]; const REMEMBER: &[&str] = &["remember", "stay signed", "keep me signed", "keep me logged"];+const USER_FIELD: &[&str] = &["email", "user", "login", "identifier", "account"];+/// The cookie banner's buttons — a consent choice that is the USER's: never+/// clicked (MEASURED 2026-10-02: "Accept All", "Manage my preferences").+const COOKIE_CHOICES: &[&str] = &["accept all", "accept all cookies", "manage my preferences", "reject all", "accept"];++/// Is a cookie banner on the page? Reported, never answered.+pub fn cookie_banner(inv: &[Element]) -> bool {+    inv.iter().any(|e| {+        let n = e.name.trim().to_lowercase();+        (e.is("button") && COOKIE_CHOICES.contains(&n.as_str())) || n.contains("cookies")+    })+}  /// The recognized form. #[derive(Debug, Clone)]@@ -326,8 +415,22 @@ pub fn recognize(inv: &[Element]) -> (Reading, Option<Form>) {     if passwords.is_empty() && users.is_empty() {         return (Reading::Unrecognized("no text field at all".into()), None);     }+    // PAGE ONE of a two-page sign-in (MEASURED 2026-10-02: "Email or username"+    // + Continue, no password field): the one field must SAY it is the user name.+    if passwords.is_empty() {+        let u = users[0];+        let says = format!("{} {}", u.name, u.automation_id).to_lowercase();+        if !USER_FIELD.iter().any(|w| says.contains(w)) {+            return (Reading::Unrecognized(format!("one text field (\"{}\") that does not say it is the user name, and no password field", u.name)), None);+        }+    }     // Buttons that can be INVOKED (ab's `invokable`, when it says).-    let buttons: Vec<&Element> = inv.iter().filter(|e| e.is("button") && e.invokable != Some(false)).collect();+    // (A cookie-consent button is never a candidate: that choice is the user's.)+    let buttons: Vec<&Element> = inv+        .iter()+        .filter(|e| e.is("button") && e.invokable != Some(false))+        .filter(|e| !COOKIE_CHOICES.contains(&e.name.trim().to_lowercase().as_str()))+        .collect();     let named = |list: &[&str]| -> Vec<&Element> {         buttons             .iter()@@ -587,88 +690,124 @@ fn sign_in_inner(reason: &str, user_confirmed_new_password: bool) -> Value {             json!({"rejectedBefore": rejected_before()}),         );     }-    // 1. THE INVENTORY, captured every time.-    let inv = match inventory(hwnd) {-        Ok(i) => i,+    // 1. THE INVENTORY, captured every time (woken first if the page is lazy).+    let read = match inventory(hwnd) {+        Ok(r) => r,         Err(e) => return err("sign_in_inventory_unreadable", e, "Nothing was typed. The user can sign in by hand.", json!({"hwnd": hwnd})),     };-    crate::launch::note_sign_in_inventory(inventory_json(&inv));-    let (reading, form) = recognize(&inv);-    let inv_j = inventory_json(&inv);+    crate::launch::note_sign_in_inventory(inventory_json(&read.elements));+    crate::launch::note_sign_in_wake(read.wake_json());+    let inv_j = inventory_json(&read.elements);+    let wake_1 = read.wake_json();+    let banner = cookie_banner(&read.elements);+    let (reading, form) = recognize(&read.elements);     let form = match (reading, form) {         (Reading::Form, Some(f)) => f,         (Reading::SecondStep(m), _) => {             tell_user(&format!("The 3DEXPERIENCE sign-in is asking for a second step (\"{m}\"). Please complete it in the sign-in window — the bridge will not."));-            return err("sign_in_needs_user", format!("the sign-in window asks for a second step (\"{m}\") that only the user can answer"), "The user has been notified. Nothing was typed.", json!({"inventory": inv_j}));+            return err("sign_in_needs_user", format!("the sign-in window asks for a second step (\"{m}\") that only the user can answer"), "The user has been notified. Nothing was typed.", json!({"inventory": inv_j, "wake": wake_1}));         }         (r, _) => {             let why = match r { Reading::Unrecognized(w) | Reading::Rejected(w) => w, _ => "unrecognized".into() };-            return err("sign_in_window_unrecognized", format!("the sign-in window does not look like the form this bridge fills: {why}"), "Nothing was typed. The inventory is attached so the form can be recognized next time; the user can sign in by hand.", json!({"inventory": inv_j}));+            return err("sign_in_window_unrecognized", format!("the sign-in window does not look like the form this bridge fills: {why}"), "Nothing was typed. The inventory is attached so the form can be recognized next time; the user can sign in by hand.", json!({"inventory": inv_j, "wake": wake_1, "cookieBanner": banner}));         }     };-    // 2. THE SECRET, locally.+    // 2. THE SECRET, locally — ONCE for the whole sign-in, however many pages it+    //    takes (a second reveal would raise the consent toast twice).     let _ = reason; // the AI's reason is ledgered; the user's toast carries CONSENT_REASON     let (secret, vault) = match reveal() {         Vault::Got(s, v) => (s, v),-        Vault::Missing(e) => return err("sign_in_credential_missing", format!("the vault has no 3DEXPERIENCE login for {}: {e}", vault_host()), format!("Ask the user to import their saved browser login into the vault on their laptop: ab {IMPORT_COMMAND} (it asks for their consent). Nothing was typed."), json!({"inventory": inv_j, "importCommand": IMPORT_COMMAND})),+        Vault::Missing(e) => return err(+            "sign_in_credential_missing",+            format!("the vault has no 3DEXPERIENCE login for {}: {e}", vault_host()),+            format!(+                "Nothing was typed. The user's vault on their laptop needs the login, in one of three ways (each on their desktop, by them — never a password in chat): \+                 (1) import it from their browser: ab {IMPORT_COMMAND} — measured 2026-10-02 on Drew's machine it answered no_data (his browser holds none); \+                 (2) sign in to 3DEXPERIENCE once in a pup window, so pup captures the login; \+                 (3) store it themselves with pup's credential_set on their desktop."+            ),+            json!({"inventory": inv_j, "importCommand": IMPORT_COMMAND,+                   "growTheVault": ["pup_import_browser_logins {\"hostFilter\":\"3ds.com\"}", "sign in once in a pup window (pup captures it)", "credential_set on the user's desktop, by the user"]}),+        ),         Vault::Denied(e) => return err("sign_in_consent_denied", format!("the user did not allow the saved login to be used: {e}"), "The user declined the consent toast. Nothing was typed; do not ask again unless they say so.", json!({"inventory": inv_j})),         Vault::Pending(e) => return err("sign_in_consent_pending", format!("the consent toast was not answered within {}s: {e}", consent_secs()), "The toast \"Sign in to SOLIDWORKS with your saved 3DEXPERIENCE login\" is waiting on the user's desktop. Tell them; calling swx_sign_in again re-attaches to it without a second prompt. Nothing was typed.", json!({"inventory": inv_j, "consentReason": CONSENT_REASON})),         Vault::RelayForbidden(e) => return err("sign_in_relay_forbidden", format!("ab refused the reveal as a RELAYED call: {e}"), "This is a bridge wiring bug, not a user problem: the bridge runs on the desktop and must call ab as a LOCAL caller. Report it (see _reportIssues). Nothing was typed.", json!({"inventory": inv_j})),         Vault::Unavailable(e) => return err("sign_in_vault_unavailable", format!("the vault could not be asked: {e}"), "Nothing was typed. The user can sign in by hand.", json!({"inventory": inv_j})),     };     let needles = secret.needles();-    let fill_reason = "signing in to 3DEXPERIENCE for a SOLIDWORKS launch this bridge started";     let mut steps: Vec<Value> = Vec::new();-    // 3. FILL — the user name (if the form has one and the vault has one), then-    //    the password.-    let mut fail: Option<(String, String)> = None;-    if let (Some(u_el), Some(u)) = (&form.user, &secret.user) {-        let r = crate::caller::ad_callback(-            "desktop_ui_set",-            merge(target(u_el, "edit"), json!({"hwnd": hwnd, "text": String::from_utf8_lossy(u), "sensitive": true, "reason": fill_reason})),-            AB_TIMEOUT,-        );-        steps.push(json!({"step": "user", "ok": ab_ok(&r)}));-        if !ab_ok(&r) {-            fail = Some(("user".into(), ab_error(&r)));-        }+    let mut pages: Vec<Value> = vec![json!({"page": 1, "inventory": inv_j, "wake": wake_1, "cookieBanner": banner})];+    let mut remember_ticked = Value::Null;+    // 3. PAGE ONE (and, on a one-page form, the whole sign-in).+    if let Err(fe) = fill_page(hwnd, read.window_rect, &form, &secret, &mut steps, &mut remember_ticked) {+        drop(secret);+        return scrub(&fill_failure(fe, banner, &pages, &steps, &vault), &needles);     }-    if fail.is_none() {-        if let Some(p_el) = &form.password {-            let r = crate::caller::ad_callback(-                "desktop_ui_set",-                merge(target(p_el, "edit"), json!({"hwnd": hwnd, "text": String::from_utf8_lossy(&secret.pass), "sensitive": true, "reason": fill_reason})),-                AB_TIMEOUT,-            );-            steps.push(json!({"step": "password", "ok": ab_ok(&r)}));-            if !ab_ok(&r) {-                fail = Some(("password".into(), ab_error(&r)));+    // 4. PAGE TWO, if page one asked only for the user name (MEASURED 2026-10-02:+    //    "Email or username" + Continue). Wait for the page to CHANGE, read it+    //    (woken), recognize the password page, fill it, submit.+    let mut two_page = false;+    if form.password.is_none() {+        two_page = true;+        let deadline = std::time::Instant::now() + Duration::from_secs(verify_secs());+        let mut last: Vec<Element> = Vec::new();+        let mut page2: Option<Form> = None;+        let mut page2_rect = read.window_rect;+        loop {+            if let Some(None) = crate::launch::sign_in_now(os.as_ref()) {+                break; // the window closed after the user name alone+            }+            if let Ok(r2) = inventory(hwnd) {+                last = r2.elements.clone();+                match recognize(&r2.elements) {+                    (Reading::Form, Some(f2)) if f2.password.is_some() => {+                        pages.push(json!({"page": 2, "inventory": inventory_json(&r2.elements), "wake": r2.wake_json(),+                                          "cookieBanner": cookie_banner(&r2.elements)}));+                        crate::launch::note_sign_in_inventory(inventory_json(&r2.elements));+                        page2_rect = r2.window_rect.or(page2_rect);+                        page2 = Some(f2);+                        break;+                    }+                    (Reading::SecondStep(_), _) => break,+                    _ if has_marker(&r2.elements, REJECTED).is_some() => break,+                    _ => {}+                }+            }+            if std::time::Instant::now() >= deadline {+                break;             }+            std::thread::sleep(Duration::from_millis(500));         }-    }-    drop(secret); // ZEROED. Only the needles (for scrubbing) remain, below.-    if let Some((step, e)) = fail {-        let out = err("sign_in_fill_failed", format!("typing the {step} into the sign-in window failed: {e}"), "Nothing was submitted. The user can sign in by hand.", json!({"inventory": inv_j, "steps": steps, "vault": vault}));-        return scrub(&out, &needles);-    }-    // 4. "REMEMBER ME", if the form has one and it is not already on.-    let mut remember_ticked = Value::Null;-    if let Some(rm) = &form.remember {-        let on = rm.toggle_state.as_deref().map(|t| t.to_lowercase()).map(|t| t == "on" || t == "true" || t == "1" || t == "checked");-        if on == Some(true) {-            remember_ticked = json!("alreadyOn");-        } else {-            let r = crate::caller::ad_callback("desktop_ui_click", merge(target(rm, "checkbox"), json!({"hwnd": hwnd, "reason": fill_reason})), AB_TIMEOUT);-            remember_ticked = json!(ab_ok(&r));+        match page2 {+            Some(f2) => {+                // The user name was typed on page one; page two takes the password.+                let f2 = Form { user: None, ..f2 };+                if let Err(fe) = fill_page(hwnd, page2_rect, &f2, &secret, &mut steps, &mut remember_ticked) {+                    drop(secret);+                    return scrub(&fill_failure(fe, banner, &pages, &steps, &vault), &needles);+                }+            }+            None if crate::launch::sign_in_now(os.as_ref()) == Some(None) => {}+            None => {+                drop(secret);+                let last_j = inventory_json(&last);+                let out = if let Some(m) = has_marker(&last, SECOND_STEP) {+                    tell_user(&format!("The 3DEXPERIENCE sign-in is asking for a second step (\"{m}\"). Please complete it in the sign-in window."));+                    err("sign_in_needs_user", format!("after the user name, the sign-in window asks for a second step (\"{m}\")"), "The user has been notified. The password was not typed.", json!({"pages": pages, "inventoryAfter": last_j, "steps": steps, "vault": vault}))+                } else if let Some(m) = has_marker(&last, REJECTED) {+                    crate::launch::note_sign_in_rejected();+                    record_rejection(&m);+                    tell_user("The 3DEXPERIENCE sign-in did not accept the saved user name. The bridge will not try again — please sign in by hand.");+                    err("sign_in_rejected", format!("the sign-in window rejected the saved user name (\"{m}\"); it is NEVER retried"), "Ask the user to sign in by hand and check the saved login in the vault.", json!({"pages": pages, "inventoryAfter": last_j, "steps": steps, "vault": vault}))+                } else {+                    err("sign_in_unconfirmed", format!("the user name was submitted, but no password page appeared within {}s", verify_secs()), "Not retried, and the password was not typed. The inventory says what the window shows now; the user can finish by hand.", json!({"pages": pages, "inventoryAfter": last_j, "steps": steps, "vault": vault}))+                };+                return scrub(&out, &needles);+            }         }     }-    // 5. SUBMIT.-    let r = crate::caller::ad_callback("desktop_ui_click", merge(target(&form.submit, "button"), json!({"hwnd": hwnd, "reason": fill_reason})), AB_TIMEOUT);-    if !ab_ok(&r) {-        let out = err("sign_in_fill_failed", format!("pressing \"{}\" failed: {}", form.submit.name, ab_error(&r)), "The fields may be filled but nothing was submitted. The user can press it.", json!({"inventory": inv_j, "steps": steps, "vault": vault}));-        return scrub(&out, &needles);-    }-    // 6. VERIFY BY EFFECT: the window goes away.+    drop(secret); // ZEROED. Only the needles (for scrubbing) remain, below.+    // 5. VERIFY BY EFFECT: the window goes away.     let deadline = std::time::Instant::now() + Duration::from_secs(verify_secs());     loop {         match crate::launch::sign_in_now(os.as_ref()) {@@ -676,10 +815,13 @@ fn sign_in_inner(reason: &str, user_confirmed_new_password: bool) -> Value {                 let out = crate::verbs::ok(json!({                     "signedIn": true,                     "verifiedBy": "the sign-in window closed after the submit",+                    "twoPage": two_page,+                    "cookieBanner": banner,                     "rememberTicked": remember_ticked,                     "steps": steps,                     "vault": vault,                     "inventory": inv_j,+                    "pages": pages,                     "_hint": "Signed in. The launch continues on its own; poll swx_status for `ready`.",                 }));                 return scrub(&out, &needles);@@ -689,22 +831,245 @@ fn sign_in_inner(reason: &str, user_confirmed_new_password: bool) -> Value {         }     }     // STILL UP: read it again and say what it is asking.-    let after = inventory(hwnd).unwrap_or_default();+    let after = inventory(hwnd).map(|r| r.elements).unwrap_or_default();     let after_j = inventory_json(&after);     let out = if let Some(m) = has_marker(&after, REJECTED) {         crate::launch::note_sign_in_rejected();         record_rejection(&m);         tell_user("The saved 3DEXPERIENCE password was rejected. The bridge will not try it again — please sign in by hand, and update the saved login.");-        err("sign_in_rejected", format!("the sign-in window rejected the saved password (\"{m}\"); it is NEVER retried"), "Ask the user to sign in by hand and update the saved login in the vault.", json!({"inventoryAfter": after_j, "steps": steps, "vault": vault, "rememberTicked": remember_ticked}))+        err("sign_in_rejected", format!("the sign-in window rejected the saved password (\"{m}\"); it is NEVER retried"), "Ask the user to sign in by hand and update the saved login in the vault.", json!({"inventoryAfter": after_j, "pages": pages, "steps": steps, "vault": vault, "rememberTicked": remember_ticked}))     } else if let Some(m) = has_marker(&after, SECOND_STEP) {         tell_user(&format!("The 3DEXPERIENCE sign-in is asking for a second step (\"{m}\"). Please complete it in the sign-in window."));-        err("sign_in_needs_user", format!("after the password, the sign-in window asks for a second step (\"{m}\")"), "The user has been notified.", json!({"inventoryAfter": after_j, "steps": steps, "vault": vault, "rememberTicked": remember_ticked}))+        err("sign_in_needs_user", format!("after the password, the sign-in window asks for a second step (\"{m}\")"), "The user has been notified.", json!({"inventoryAfter": after_j, "pages": pages, "steps": steps, "vault": vault, "rememberTicked": remember_ticked}))     } else {-        err("sign_in_unconfirmed", format!("the form was submitted, but the sign-in window is still open after {}s and says nothing this bridge recognizes", verify_secs()), "Not retried. swx_status shows whether the launch advanced; the inventory says what the window shows now.", json!({"inventoryAfter": after_j, "steps": steps, "vault": vault, "rememberTicked": remember_ticked}))+        err("sign_in_unconfirmed", format!("the form was submitted, but the sign-in window is still open after {}s and says nothing this bridge recognizes", verify_secs()), "Not retried. swx_status shows whether the launch advanced; the inventory says what the window shows now.", json!({"inventoryAfter": after_j, "pages": pages, "steps": steps, "vault": vault, "rememberTicked": remember_ticked}))     };     scrub(&out, &needles) } +const FILL_REASON: &str = "signing in to 3DEXPERIENCE for a SOLIDWORKS launch this bridge started";++/// Fill ONE page: the user name (if the page has the field), the password (if+/// it has that one), "Remember me" (if present and off), then press its submit+/// as a background Invoke (MEASURED 2026-10-02: `desktop_ui_click {name:+/// "Continue"}` works without focus). Never touches a cookie-consent button.+fn fill_page(hwnd: i64, origin: Option<[i64; 4]>, form: &Form, secret: &Secret, steps: &mut Vec<Value>, remember_ticked: &mut Value) -> Result<(), FillErr> {+    if let Some(u_el) = &form.user {+        let Some(u) = &secret.user else {+            return Err(FillErr::Failed { step: "user name".into(), why: "the saved login has no user name to type".into(), observed: None, may_be_banner: false });+        };+        fill_field(hwnd, origin, u_el, u, true, steps)?;+    }+    if let Some(p_el) = &form.password {+        fill_field(hwnd, origin, p_el, &secret.pass, false, steps)?;+    }+    if let Some(rm) = &form.remember {+        let on = rm.toggle_state.as_deref().map(|t| t.to_lowercase()).map(|t| t == "on" || t == "true" || t == "1" || t == "checked");+        if on == Some(true) {+            *remember_ticked = json!("alreadyOn");+        } else {+            let r = crate::caller::ad_callback("desktop_ui_click", merge(target(rm, "checkbox"), json!({"hwnd": hwnd, "reason": FILL_REASON})), AB_TIMEOUT);+            *remember_ticked = json!(ab_ok(&r));+        }+    }+    let r = crate::caller::ad_callback("desktop_ui_click", merge(target(&form.submit, "button"), json!({"hwnd": hwnd, "reason": FILL_REASON})), AB_TIMEOUT);+    steps.push(json!({"step": format!("press \"{}\"", form.submit.name), "ok": ab_ok(&r), "via": "uiaInvoke"}));+    if !ab_ok(&r) {+        return Err(FillErr::Failed { step: format!("press of \"{}\"", form.submit.name), why: ab_error(&r), observed: None, may_be_banner: true });+    }+    Ok(())+}++pub(crate) enum FillErr {+    /// The user is typing (or the idle time cannot be read): no keystroke sent.+    NeedsIdle(String),+    /// `may_be_banner`: a failure a cookie banner covering the form could cause+    /// (a set or a click that did not land) — never a focus, idle or read-back+    /// stop, which say exactly what happened.+    Failed { step: String, why: String, observed: Option<Value>, may_be_banner: bool },+}++fn idle_secs_needed() -> u64 {+    std::env::var("SWX_SIGN_IN_IDLE_SECS").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(5)+}++/// IS THE USER'S KEYBOARD QUIET? MEASURED 2026-10-02: real keystrokes sent+/// while Drew was typing in another window left "ogin" — fragments of HIS+/// keystrokes — in the login box, and the bridge's text did not land.+fn user_idle() -> Result<u64, String> {+    let os = crate::launchos::os();+    match os.seconds_since_last_input() {+        Ok(n) if n >= idle_secs_needed() => Ok(n),+        Ok(n) => Err(format!("the user gave input {n}s ago (needs {}s of quiet)", idle_secs_needed())),+        Err(e) => Err(format!("the time since the user's last input could not be read ({e})")),+    }+}++/// Is the login window the foreground window, right now?+fn login_in_front(hwnd: i64) -> Result<(), String> {+    match crate::launchos::os().foreground_window() {+        Ok(Some(w)) if w.hwnd == hwnd => Ok(()),+        Ok(Some(w)) => Err(format!("the foreground moved to another window (\"{}\")", w.title)),+        Ok(None) => Err("no window has the foreground".into()),+        Err(e) => Err(format!("the foreground window could not be read ({e})")),+    }+}++/// THE USER NAME FIELD'S CONTENT, read back from the tree (the tree DOES carry+/// values). Used ONLY for the user-name field — the password field is never+/// read back.+fn field_value(hwnd: i64, el: &Element) -> Option<String> {+    let r = crate::caller::ad_callback("desktop_ui_tree", json!({"hwnd": hwnd, "maxDepth": 30, "maxNodes": 400}), AB_TIMEOUT);+    fn walk(v: &Value, el: &Element) -> Option<String> {+        match v {+            Value::Array(a) => a.iter().find_map(|x| walk(x, el)),+            Value::Object(m) => {+                let role = str_of(m, &["controlType", "control_type", "role", "type"]);+                if norm_type(&role) == "edit"+                    && str_of(m, &["name"]) == el.name+                    && str_of(m, &["automationId", "automation_id", "id"]) == el.automation_id+                {+                    if let Some(v) = m.get("value").and_then(|v| v.as_str()) {+                        return Some(v.to_string());+                    }+                }+                m.iter().filter(|(k, _)| matches!(k.as_str(), "children" | "elements" | "nodes" | "tree" | "root" | "items" | "controls")).find_map(|(_, x)| walk(x, el))+            }+            _ => None,+        }+    }+    walk(&unwrap_ab(&r), el)+}++/// FILL ONE FIELD. First the background UIA ValuePattern set; MEASURED+/// 2026-10-02 the 3DEXPERIENCE ID page refuses it ("control found but has NO+/// ValuePattern … try a foregrounded desktop_type") even on an edit the tree+/// reports `settable:true`. Then real keystrokes, and only safely:+///   * only while the user's keyboard has been quiet (`SWX_SIGN_IN_IDLE_SECS`,+///     5 s) — checked before every batch;+///   * a real click on the field (it brings the login window to the front), and+///     the login window confirmed as the FOREGROUND window before every batch —+///     aborted if focus moved;+///   * select-all first (a failed attempt leaves the field dirty — measured:+///     "ogin" was still in it), then type;+///   * the user-name field READ BACK and compared before anything continues; a+///     mismatch stops with what was observed. The password is never read back.+/// Enter is never pressed: the submit is a background Invoke.+fn fill_field(hwnd: i64, origin: Option<[i64; 4]>, el: &Element, text: &[u8], read_back: bool, steps: &mut Vec<Value>) -> Result<(), FillErr> {+    let label = if read_back { "user name" } else { "password" };+    let typed = String::from_utf8_lossy(text).to_string();+    let r = crate::caller::ad_callback(+        "desktop_ui_set",+        merge(target(el, "edit"), json!({"hwnd": hwnd, "text": typed, "sensitive": true, "reason": FILL_REASON})),+        AB_TIMEOUT,+    );+    if ab_ok(&r) {+        steps.push(json!({"step": label, "ok": true, "via": "uiaValuePattern"}));+        return Ok(());+    }+    let why = ab_error(&r);+    // The measured refusal: "control found but has NO ValuePattern".+    if !why.to_lowercase().contains("no valuepattern") {+        steps.push(json!({"step": label, "ok": false, "via": "uiaValuePattern"}));+        return Err(FillErr::Failed { step: format!("{label} (UIA set)"), why, observed: None, may_be_banner: true });+    }+    // ── REAL KEYSTROKES ──────────────────────────────────────────────────────+    user_idle().map_err(FillErr::NeedsIdle)?;+    // The click lands on the field through a screenshot of the window+    // (`desktop_click {space:"image", shotId, x, y}`).+    let (Some([ex, ey, ew, eh]), Some([wx, wy, _, _])) = (el.rect, origin) else {+        return Err(FillErr::Failed { step: format!("{label} (keystrokes)"), why: "the field's or the window's position is unknown, so the field cannot be clicked".into(), observed: None, may_be_banner: false });+    };+    let shot = crate::caller::ad_callback("desktop_screenshot_window", json!({"hwnd": hwnd, "reason": FILL_REASON}), AB_TIMEOUT);+    let shot_d = unwrap_ab(&shot);+    let Some(shot_id) = ["shotId", "shot_id", "id"].iter().find_map(|k| shot_d.get(*k).or_else(|| shot.get(*k)).and_then(|v| v.as_str()).map(String::from)) else {+        return Err(FillErr::Failed { step: format!("{label} (keystrokes)"), why: format!("the window screenshot gave no shotId: {}", ab_error(&shot)), observed: None, may_be_banner: false });+    };+    let (x, y) = (ex + ew / 2 - wx, ey + eh / 2 - wy);+    let r = crate::caller::ad_callback(+        "desktop_click",+        json!({"space": "image", "shotId": shot_id, "x": x, "y": y, "hwnd": hwnd, "reason": FILL_REASON}),+        AB_TIMEOUT,+    );+    if !ab_ok(&r) {+        return Err(FillErr::Failed { step: format!("click on the {label} field"), why: ab_error(&r), observed: None, may_be_banner: true });+    }+    // THE CLICK TAKES FOCUS — asynchronously: give the window a moment to come+    // to the front before the first check.+    let settle = std::time::Instant::now() + Duration::from_millis(1500);+    while login_in_front(hwnd).is_err() && std::time::Instant::now() < settle {+        std::thread::sleep(Duration::from_millis(100));+    }+    let guard = |what: &str| -> Result<(), FillErr> {+        user_idle().map_err(FillErr::NeedsIdle)?;+        login_in_front(hwnd).map_err(|e| FillErr::Failed { step: format!("{label}: {what}"), why: e, observed: None, may_be_banner: false })+    };+    guard("before select-all")?;+    let r = crate::caller::ad_callback("desktop_press_key", json!({"keys": ["ctrl+a"], "hwnd": hwnd, "reason": FILL_REASON}), AB_TIMEOUT);+    if !ab_ok(&r) {+        return Err(FillErr::Failed { step: format!("{label}: select-all"), why: ab_error(&r), observed: None, may_be_banner: false });+    }+    guard("before typing")?;+    let r = crate::caller::ad_callback("desktop_type", json!({"text": typed, "hwnd": hwnd, "sensitive": true, "reason": FILL_REASON}), AB_TIMEOUT);+    if !ab_ok(&r) {+        return Err(FillErr::Failed { step: format!("{label}: typing"), why: ab_error(&r), observed: None, may_be_banner: false });+    }+    // A keystroke that went astray is evidence too: still in front?+    login_in_front(hwnd).map_err(|e| FillErr::Failed { step: format!("{label}: after typing"), why: e, observed: None, may_be_banner: false })?;+    if read_back {+        // The page updates its field asynchronously: read back for a moment.+        let until = std::time::Instant::now() + Duration::from_millis(1500);+        let mut got = field_value(hwnd, el);+        while got.as_deref() != Some(typed.as_str()) && std::time::Instant::now() < until {+            std::thread::sleep(Duration::from_millis(150));+            got = field_value(hwnd, el);+        }+        if got.as_deref() != Some(typed.as_str()) {+            steps.push(json!({"step": label, "ok": false, "via": "keystrokes", "readBack": "mismatch"}));+            return Err(FillErr::Failed {+                step: format!("{label}: read-back"),+                why: "the user-name field does not hold what was typed — keystrokes may have collided with the user's, or gone to another window".into(),+                observed: Some(json!({"fieldText": got, "length": got.as_ref().map(|g| g.chars().count())})),+                may_be_banner: false,+            });+        }+    }+    steps.push(json!({"step": label, "ok": true, "via": "keystrokes", "readBack": if read_back { json!("matched") } else { json!("notReadPassword") }}));+    Ok(())+}++fn fill_failure(fe: FillErr, banner: bool, pages: &[Value], steps: &[Value], vault: &Value) -> Value {+    let (step, e, observed, may_be_banner) = match fe {+        FillErr::NeedsIdle(why) => {+            tell_user("The bridge is ready to sign you in to 3DEXPERIENCE, but it types real keystrokes. Please take your hands off the keyboard and mouse for five seconds, then ask again.");+            return err(+                "sign_in_needs_user_idle",+                format!("no keystroke was sent: {why}. Typing while the user types collides (measured 2026-10-02: the user's keystrokes landed in the login box)"),+                "The user has been asked for five seconds hands-off; call swx_sign_in again after that (it re-attaches to the same consent).",+                json!({"pages": pages, "steps": steps, "vault": vault}),+            );+        }+        FillErr::Failed { step, why, observed, may_be_banner } => (step, why, observed, may_be_banner),+    };+    let (step, e, observed, may_be_banner) = (step, e, observed, may_be_banner);+    if banner && may_be_banner {+        // THE COOKIE BANNER MAY BE IN THE WAY — and its choice is the user's.+        tell_user("The 3DEXPERIENCE sign-in's cookie banner may be covering the form. Please choose your cookie setting in the sign-in window — the bridge will not choose it for you.");+        return err(+            "sign_in_needs_user",+            format!("the {step} failed with a cookie banner on the page ({e}); it may be covering the form, and choosing a cookie setting is the user's decision"),+            "The user has been notified to answer the cookie banner. Nothing was submitted.",+            json!({"cookieBanner": true, "pages": pages, "steps": steps, "vault": vault, "observed": observed}),+        );+    }+    err(+        "sign_in_fill_failed",+        format!("the {step} in the sign-in window failed: {e}"),+        "Nothing further was submitted and Enter was never pressed. The user can sign in by hand.",+        json!({"pages": pages, "steps": steps, "vault": vault, "observed": observed}),+    )+}+ #[cfg(test)] mod tests {     use super::*;@@ -729,6 +1094,21 @@ mod tests {         assert!(matches!(recognize(&code).0, Reading::SecondStep(_)));     } +    #[test]+    fn page_one_is_a_user_name_box_and_continue_and_nothing_else() {+        // The measured page one: "Email or username" + Continue, no password.+        let p1 = vec![el("edit", "Email or username", Some(false)), el("button", "Continue", None), el("button", "Accept All", None)];+        let (r, f) = recognize(&p1);+        assert_eq!(r, Reading::Form);+        let f = f.unwrap();+        assert!(f.password.is_none() && f.user.is_some() && f.submit.name == "Continue");+        // One box that does NOT say it is the user name is not page one.+        let other = vec![el("edit", "Search", Some(false)), el("button", "Continue", None)];+        assert!(matches!(recognize(&other).0, Reading::Unrecognized(_)));+        // The cookie banner is reported, and never the submit.+        assert!(cookie_banner(&p1));+    }+     #[test]     fn scrub_removes_the_secret_everywhere() {         let v = json!({"error": "could not set 'hunter2-xyz'", "value": "hunter2-xyz", "nested": [{"a": "x hunter2-xyz y"}]});
tests/signin.rs+381−1
@@ -1,3 +1,4 @@+#![recursion_limit = "256"] //! UNATTENDED 3DEXPERIENCE SIGN-IN (0.9.13). //! //! The login window (class DSYSysLoginPanelWebView2Class, owned by our launcher@@ -129,7 +130,8 @@ fn everything_but_the_fill(b: &Bridge, ab: &common::AbStub, replies: &[Value]) -     }     walk(std::path::Path::new(&b.scratch.path()), &mut all);     for c in ab.calls() {-        let is_fill = c["command"] == "desktop_ui_set";+        // The fills: a UIA set, or real keystrokes (0.9.15).+        let is_fill = c["command"] == "desktop_ui_set" || c["command"] == "desktop_type";         if !is_fill {             all.push_str(&c.to_string());         }@@ -555,3 +557,381 @@ fn consent_is_waited_for_and_relay_forbidden_is_a_wiring_bug() {         s(&r)     ); }++// ── 0.9.15: the MEASURED two-page, lazy 3DEXPERIENCE ID login ───────────────++/// The COLD read, measured 2026-10-02 (13 nodes, ONE control: Button "Close").+fn cold_tree() -> Value {+    json!({"success": true, "status": "ok", "hwnd": 777001, "maxNodes": 400, "nodeCount": 13, "truncated": false,+           "window": {"title": "Login - 3DEXPERIENCE ID - Dassault Systemes", "rect": {"x": 100, "y": 50, "w": 968, "h": 968}},+           "root": {"role": "Window", "name": "Login - 3DEXPERIENCE ID - Dassault Systemes", "automationId": "", "invokable": false, "settable": false,+                    "rect": {"x": 100, "y": 50, "w": 968, "h": 968}, "children": [+               {"role": "Pane", "name": "", "automationId": "", "invokable": false, "settable": false, "children": [+                   {"role": "Pane", "name": "", "automationId": "", "invokable": false, "settable": false},+                   {"role": "Button", "name": "Close", "automationId": "", "invokable": true, "settable": false}+               ]}+           ]}})+}++/// The WARM read of page ONE, measured 2026-10-02 (38 nodes): "Email or+/// username" + Continue, the hyperlinks, and the COOKIE BANNER — no password.+fn page_one() -> Value {+    json!({"success": true, "status": "ok", "hwnd": 777001, "maxNodes": 400, "nodeCount": 38, "truncated": false,+           "window": {"title": "Login - 3DEXPERIENCE ID - Dassault Systemes", "rect": {"x": 100, "y": 50, "w": 968, "h": 968}},+           "root": {"role": "Window", "name": "Login - 3DEXPERIENCE ID - Dassault Systemes", "automationId": "", "invokable": false, "settable": false, "children": [+               {"role": "Document", "name": "3DEXPERIENCE ID", "automationId": "", "invokable": false, "settable": false, "children": [+                   {"role": "Edit", "name": "Email or username", "automationId": "username", "invokable": false, "settable": true, "value": "", "rect": {"x": 384, "y": 450, "w": 400, "h": 40}},+                   {"role": "Button", "name": "Continue", "automationId": "", "invokable": true, "settable": false},+                   {"role": "Hyperlink", "name": "Create your account", "automationId": "", "invokable": true, "settable": false},+                   {"role": "Hyperlink", "name": "Forgot your password?", "automationId": "", "invokable": true, "settable": false},+                   {"role": "Hyperlink", "name": "\u{e900} English", "automationId": "", "invokable": true, "settable": false},+                   {"role": "Hyperlink", "name": "Help", "automationId": "", "invokable": true, "settable": false},+                   {"role": "Hyperlink", "name": "Manage cookies", "automationId": "", "invokable": true, "settable": false},+                   {"role": "Hyperlink", "name": "privacy policy", "automationId": "", "invokable": true, "settable": false},+                   {"role": "Button", "name": "Accept All", "automationId": "", "invokable": true, "settable": false},+                   {"role": "Button", "name": "Manage my preferences", "automationId": "", "invokable": true, "settable": false},+                   {"role": "Text", "name": "Continue with only necessary cookies", "automationId": "", "invokable": false, "settable": false}+               ]},+               {"role": "Button", "name": "Close", "automationId": "", "invokable": true, "settable": false}+           ]}})+}++/// Page TWO — UNMEASURED (nobody has seen it): a password edit and a submit.+fn page_two() -> Value {+    json!({"success": true, "status": "ok", "hwnd": 777001, "nodeCount": 30, "truncated": false,+           "window": {"title": "Login - 3DEXPERIENCE ID - Dassault Systemes", "rect": {"x": 100, "y": 50, "w": 968, "h": 968}},+           "root": {"role": "Window", "name": "Login - 3DEXPERIENCE ID - Dassault Systemes", "automationId": "", "children": [+               {"role": "Edit", "name": "Password", "automationId": "password", "invokable": false, "settable": true, "value": "", "rect": {"x": 384, "y": 450, "w": 400, "h": 40}},+               {"role": "CheckBox", "name": "Remember me", "automationId": "", "invokable": true, "settable": false, "toggleState": "Off"},+               {"role": "Button", "name": "Log in", "automationId": "", "invokable": true, "settable": false},+               {"role": "Button", "name": "Close", "automationId": "", "invokable": true, "settable": false}+           ]}})+}++/// Drive the simulated window: lazy until a point is queried; page two (lazy+/// again) after Continue; closed after Log in. `to_page_two:false` = the page+/// never changes after Continue.+fn drive_two_pages(m: &Machine, ab: &common::AbStub, to_page_two: bool) {+    let deadline = Instant::now() + Duration::from_secs(25);+    let mut state = 0;+    let mut fp_at_continue = 0;+    while Instant::now() < deadline {+        let fp = ab.calls_to("desktop_ui_from_point").len();+        let clicks = ab.calls_to("desktop_ui_click");+        let clicked = |n: &str| clicks.iter().any(|c| c["args"]["name"] == n);+        match state {+            0 if fp >= 1 => {+                ab.set_response("desktop_ui_tree", page_one());+                state = 1;+            }+            1 if clicked("Continue") => {+                if !to_page_two {+                    return;+                }+                ab.set_response("desktop_ui_tree", cold_tree());+                fp_at_continue = ab.calls_to("desktop_ui_from_point").len();+                state = 2;+            }+            2 if fp > fp_at_continue => {+                ab.set_response("desktop_ui_tree", page_two());+                state = 3;+            }+            3 if clicked("Log in") => {+                let _ = std::fs::remove_file(m.dir.join("sign-in.json"));+                return;+            }+            _ => {}+        }+        std::thread::sleep(Duration::from_millis(20));+    }+}++#[test]+fn the_measured_two_page_lazy_login_is_one_flow_with_one_consent() {+    assert!(+        swx_bridge::launch::is_sign_in_window("WindowsForms10.Window.8.app.0.141b42a_r6_ad1", "Login - 3DEXPERIENCE ID - Dassault Systemes"),+        "the Visualize-hosted login window (measured 2026-10-02) is a sign-in window"+    );+    let m = Machine::new("signin-two-page");+    std::fs::write(+        m.dir.join("sign-in.json"),+        json!({"hwnd": 777001, "className": "WindowsForms10.Window.8.app.0.141b42a_r6_ad1",+               "title": "Login - 3DEXPERIENCE ID - Dassault Systemes"}).to_string(),+    )+    .unwrap();+    let ab = ab_for(cold_tree(), vault_ok());+    let b = m.boot(TempDir::new("signin-two-page-scratch"), &ab, &[("SWX_SIGN_IN_WAKE_MS", "60"), ("SWX_SIGN_IN_VERIFY_SECS", "8")]);+    let mut replies = vec![b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}))];+    let (st, r) = std::thread::scope(|sc| {+        sc.spawn(|| drive_two_pages(&m, &ab, true));+        // The window is up; the monitor's inventory read wakes it.+        let deadline = Instant::now() + Duration::from_secs(10);+        let mut st = wait_for_sign_in_stage(&b);+        while st["launch"]["signInWake"].is_null() && Instant::now() < deadline {+            std::thread::sleep(Duration::from_millis(100));+            st = b.call("swx_status", json!({}));+        }+        let r = b.call("swx_sign_in", json!({"reason": "Drew: sign me in"}));+        (st, r)+    });+    replies.push(st);+    check!(+        b,+        "ONE flow over two pages: signed in, two-page, the cookie banner reported, verified by the window closing",+        r["signedIn"] == true && r["twoPage"] == true && r["cookieBanner"] == true && !m.sign_in_up(),+        s(&r)+    );+    check!(b, "the consent toast was raised ONCE (one reveal for both pages)", ab.calls_to("pup_credential_reveal").len() == 1, String::new());+    let clicks = ab.calls_to("desktop_ui_click");+    check!(+        b,+        "the cookie choice was never made: no click on Accept All or Manage my preferences",+        !clicks.iter().any(|c| c["args"]["name"] == "Accept All" || c["args"]["name"] == "Manage my preferences"),+        format!("{clicks:?}")+    );+    let sets = ab.calls_to("desktop_ui_set");+    check!(+        b,+        "the user name went into 'Email or username' on page one, the password into 'Password' on page two",+        sets.len() == 2 && sets[0]["args"]["name"] == "Email or username" && sets[0]["args"]["text"] == USER+            && sets[1]["args"]["name"] == "Password" && sets[1]["args"]["text"] == PW,+        format!("{sets:?}")+    );+    let pages = r["pages"].as_array().cloned().unwrap_or_default();+    // Whoever read page one first (the launch monitor, the moment the window+    // appeared, or swx_sign_in) woke it; the FIRST capture holds both counts.+    let woke = |w: &Value| w["nodeCounts"].as_array().map(|a| a.first() == Some(&json!(13)) && a.contains(&json!(38))).unwrap_or(false);+    let first_wake = replies[1]["launch"]["signInWake"].clone();+    check!(+        b,+        "THE LAZY TREE: page one was woken, both node counts recorded (13 cold, 38 warm); page two's inventory captured",+        pages.len() == 2+            && (woke(&pages[0]["wake"]) || woke(&first_wake))+            && pages[1]["inventory"].as_array().map(|a| a.iter().any(|e| e["name"] == "Password" && e["isPassword"] == true)).unwrap_or(false),+        s(&r["pages"])+    );+    replies.push(r);+    let all = everything_but_the_fill(&b, &ab, &replies);+    check!(b, "the password appears nowhere", !all.contains(PW), String::new());+    check!(b, "...nor the user name", !all.contains(USER), String::new());+}++#[test]+fn a_password_page_that_never_comes_types_no_password() {+    let m = Machine::new("signin-no-page-two");+    let ab = ab_for(page_one(), vault_ok());+    let b = m.boot(TempDir::new("signin-no-page-two-scratch"), &ab, &[("SWX_SIGN_IN_WAKE_MS", "20"), ("SWX_SIGN_IN_VERIFY_SECS", "2")]);+    b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}));+    wait_for_sign_in_stage(&b);+    let r = b.call("swx_sign_in", json!({"reason": "sign in"}));+    check!(+        b,+        "no password page: sign_in_unconfirmed, the password never typed, one consent",+        r["errorCode"] == "sign_in_unconfirmed"+            && !ab.calls_to("desktop_ui_set").iter().any(|c| c["args"]["text"] == PW)+            && ab.calls_to("pup_credential_reveal").len() == 1,+        s(&r)+    );+}++#[test]+fn a_cookie_banner_in_the_way_is_the_users_choice() {+    let m = Machine::new("signin-cookie");+    let ab = ab_for(page_one(), vault_ok());+    ab.set_response("desktop_ui_set", json!({"success": false, "error": "element is not visible (obscured)"}));+    let b = m.boot(TempDir::new("signin-cookie-scratch"), &ab, &[]);+    b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}));+    wait_for_sign_in_stage(&b);+    let r = b.call("swx_sign_in", json!({"reason": "sign in"}));+    check!(+        b,+        "the fill failed with the banner up: sign_in_needs_user naming the banner, the user told, the banner never answered",+        r["errorCode"] == "sign_in_needs_user" && r["cookieBanner"] == true+            && !ab.calls_to("notify_user").is_empty()+            && !ab.calls_to("desktop_ui_click").iter().any(|c| c["args"]["name"] == "Accept All" || c["args"]["name"] == "Manage my preferences"),+        s(&r)+    );+}++// ── K1–K5 (2026-10-02): the page refuses a background set; real keystrokes ──++fn no_value_pattern() -> Value {+    json!({"success": false, "error": "control found but has NO ValuePattern (can't set text via UIA — try a foregrounded desktop_type)"})+}++fn with_username_value(v: &str) -> Value {+    fn walk(n: &mut Value, v: &str) {+        match n {+            Value::Array(a) => a.iter_mut().for_each(|x| walk(x, v)),+            Value::Object(m) => {+                if m.get("automationId") == Some(&json!("username")) {+                    m.insert("value".into(), json!(v));+                }+                m.values_mut().for_each(|x| walk(x, v));+            }+            _ => {}+        }+    }+    let mut t = page_one();+    walk(&mut t, v);+    t+}++fn keystroke_ab() -> common::AbStub {+    let ab = ab_for(page_one(), vault_ok());+    ab.set_response("desktop_ui_set", no_value_pattern());+    ab.set_response("desktop_screenshot_window", json!({"success": true, "data": {"shotId": "shot-1"}}));+    ab.set_response("desktop_click", json!({"success": true}));+    ab.set_response("desktop_press_key", json!({"success": true}));+    ab.set_response("desktop_type", json!({"success": true}));+    ab+}++/// The machine reacts like the measured page: a click on the field brings the+/// login window to the front; typing the user name puts `typed_lands` in the+/// field; Continue shows page two; Log in closes the window.+fn drive_keystrokes(m: &Machine, ab: &common::AbStub, typed_lands: Option<String>, focus_follows: bool) {+    let deadline = Instant::now() + Duration::from_secs(25);+    let mut state = 0;+    while Instant::now() < deadline {+        let clicks = ab.calls_to("desktop_ui_click");+        let clicked = |n: &str| clicks.iter().any(|c| c["args"]["name"] == n);+        if focus_follows && !ab.calls_to("desktop_click").is_empty() {+            std::fs::write(m.dir.join("foreground.json"),+                json!({"hwnd": 777001, "pid": 1, "title": "Login - 3DEXPERIENCE ID", "className": "x"}).to_string()).unwrap();+        }+        match state {+            0 if !ab.calls_to("desktop_type").is_empty() => {+                if let Some(t) = &typed_lands {+                    ab.set_response("desktop_ui_tree", with_username_value(t));+                }+                state = 1;+            }+            1 if clicked("Continue") => {+                ab.set_response("desktop_ui_tree", page_two());+                state = 2;+            }+            2 if clicked("Log in") => {+                let _ = std::fs::remove_file(m.dir.join("sign-in.json"));+                return;+            }+            _ => {}+        }+        std::thread::sleep(Duration::from_millis(15));+    }+}++#[test]+fn the_page_that_refuses_a_background_set_is_typed_safely_and_read_back() {+    let m = Machine::new("signin-keys");+    let ab = keystroke_ab();+    let b = m.boot(TempDir::new("signin-keys-scratch"), &ab, &[("SWX_SIGN_IN_VERIFY_SECS", "8")]);+    let mut replies = vec![b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}))];+    wait_for_sign_in_stage(&b);+    let r = std::thread::scope(|sc| {+        sc.spawn(|| drive_keystrokes(&m, &ab, Some(USER.to_string()), true));+        b.call("swx_sign_in", json!({"reason": "sign in"}))+    });+    check!(b, "signed in by keystrokes over two pages", r["signedIn"] == true && r["twoPage"] == true, s(&r));+    let steps = r["steps"].to_string();+    check!(+        b,+        "the user name was typed and READ BACK (matched); the password was typed and never read back",+        steps.contains("\"readBack\":\"matched\"") && steps.contains("notReadPassword"),+        steps.clone()+    );+    let keys = ab.calls_to("desktop_press_key");+    check!(+        b,+        "select-all before every typing; Enter NEVER pressed (the submit is a background Invoke); every call carries a reason",+        keys.len() == 2 && keys.iter().all(|k| k["args"]["keys"] == json!(["ctrl+a"]) && k["args"]["reason"].is_string())+            && ab.calls_to("desktop_type").iter().all(|t| t["args"]["reason"].is_string())+            && ab.calls_to("desktop_click").iter().all(|c| c["args"]["space"] == "image" && c["args"]["shotId"] == "shot-1"+                && c["args"]["x"] == 484 && c["args"]["y"] == 420),+        format!("{keys:?} {:?}", ab.calls_to("desktop_click"))+    );+    replies.push(r);+    let all = everything_but_the_fill(&b, &ab, &replies);+    check!(b, "the password appears nowhere but the fill", !all.contains(PW), String::new());+}++#[test]+fn no_keystroke_is_sent_while_the_user_is_typing() {+    let m = Machine::new("signin-collision");+    std::fs::write(m.dir.join("last-input-secs"), "0").unwrap();+    let ab = keystroke_ab();+    let b = m.boot(TempDir::new("signin-collision-scratch"), &ab, &[]);+    b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}));+    wait_for_sign_in_stage(&b);+    let r = b.call("swx_sign_in", json!({"reason": "sign in"}));+    check!(+        b,+        "the user typed 0 s ago: sign_in_needs_user_idle, the user asked for five seconds hands-off, NOTHING typed or clicked",+        r["errorCode"] == "sign_in_needs_user_idle"+            && !ab.calls_to("notify_user").is_empty()+            && ab.calls_to("desktop_type").is_empty()+            && ab.calls_to("desktop_click").is_empty()+            && ab.calls_to("desktop_press_key").is_empty(),+        s(&r)+    );+}++#[test]+fn a_collided_user_name_stops_before_continue_and_says_what_was_seen() {+    let m = Machine::new("signin-ogin");+    let ab = keystroke_ab();+    let b = m.boot(TempDir::new("signin-ogin-scratch"), &ab, &[]);+    b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}));+    wait_for_sign_in_stage(&b);+    let r = std::thread::scope(|sc| {+        // MEASURED: the field ended up holding "ogin" — the user's keystrokes.+        sc.spawn(|| drive_keystrokes(&m, &ab, Some("ogin".to_string()), true));+        b.call("swx_sign_in", json!({"reason": "sign in"}))+    });+    check!(+        b,+        "read-back mismatch: sign_in_fill_failed with what was observed; Continue never pressed; the password never typed",+        r["errorCode"] == "sign_in_fill_failed" && r["observed"]["fieldText"] == "ogin"+            && !ab.calls_to("desktop_ui_click").iter().any(|c| c["args"]["name"] == "Continue")+            && !ab.calls_to("desktop_type").iter().any(|c| c["args"]["text"] == PW),+        s(&r)+    );+}++#[test]+fn keystrokes_stop_when_the_login_window_is_not_in_front() {+    let m = Machine::new("signin-focus");+    let ab = keystroke_ab();+    let b = m.boot(TempDir::new("signin-focus-scratch"), &ab, &[]);+    b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}));+    wait_for_sign_in_stage(&b);+    // The click does not bring the login window forward: focus stays elsewhere.+    let r = b.call("swx_sign_in", json!({"reason": "sign in"}));+    check!(+        b,+        "focus is elsewhere after the click: sign_in_fill_failed naming the foreground, NOTHING typed",+        r["errorCode"] == "sign_in_fill_failed" && r["error"].as_str().unwrap_or("").contains("foreground")+            && ab.calls_to("desktop_type").is_empty() && ab.calls_to("desktop_press_key").is_empty(),+        s(&r)+    );+}++#[test]+fn a_missing_login_names_every_way_to_grow_the_vault() {+    let m = Machine::new("signin-grow");+    let ab = ab_for(page_one(), vault_ok());+    ab.set_response("pup_credentials", json!({"success": true, "data": {"credentials": []}}));+    let b = m.boot(TempDir::new("signin-grow-scratch"), &ab, &[]);+    b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}));+    wait_for_sign_in_stage(&b);+    let r = b.call("swx_sign_in", json!({"reason": "sign in"}));+    let ways = r["growTheVault"].to_string();+    check!(+        b,+        "credential_missing names the import, a pup-window sign-in, and credential_set",+        r["errorCode"] == "sign_in_credential_missing" && ways.contains("pup_import_browser_logins")+            && ways.contains("pup window") && ways.contains("credential_set")+            && ab.calls_to("pup_credential_reveal").is_empty(),+        s(&r)+    );+}
tests/wiring.rs+27
@@ -1206,3 +1206,30 @@ fn the_release_script_syncs_the_wiki_in_order_and_verifies() {     assert_eq!(page["tags"], pkg["tags"], "page.json tags drifted from package.json — run tools/sync-page.py");     assert_eq!(page["hero"]["path"], "screenshots/hero.png", "page.json must keep the hero"); }++/// THE DEPENDENT SIGN-IN SKILL (0.9.15) carries the measured flow and NO+/// credential, is packaged and installed explicitly, and is governed like every+/// other file an agent follows (the never-ask scan walks every .md).+#[test]+fn the_sign_in_skill_is_shipped_and_carries_no_credential() {+    let root = Path::new(env!("CARGO_MANIFEST_DIR"));+    let skill = std::fs::read_to_string(root.join("skills/solidworks-sign-in/SKILL.md")).expect("the skill");+    assert!(skill.contains("name: solidworks-sign-in") && skill.contains("Parent skill: **solidworks-bridge**"));+    assert!(skill.contains("[measured]") && skill.contains("[unmeasured]"), "every line marked measured or unmeasured");+    assert!(skill.contains("never write a password"), "the skill must forbid writing a password into it");+    // No credential-shaped content: no e-mail addresses, no `password: x` / `password=x`.+    let lower = skill.to_lowercase();+    let at = skill.char_indices().filter(|(_, c)| *c == '@').count();+    assert_eq!(at, 0, "an address-like '@' in the sign-in skill");+    for pat in ["password:", "password=", "password =", "pwd", "passwd"] {+        assert!(!lower.contains(pat), "credential-shaped text {pat:?} in the sign-in skill");+    }+    let pkg: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(root.join("package.json")).unwrap()).unwrap();+    assert!(pkg["files"].as_array().unwrap().iter().any(|f| f == "skills/solidworks-sign-in/SKILL.md"), "listed in package.json files");+    let install = std::fs::read_to_string(root.join("install.sh")).unwrap();+    let uninstall = std::fs::read_to_string(root.join("uninstall.sh")).unwrap();+    assert!(install.contains("install_skill solidworks-sign-in") && install.contains(".claude/skills") && install.contains(".codex/skills"));+    assert!(uninstall.contains("uninstall_skill solidworks-sign-in"));+    let verify = std::fs::read_to_string(root.join("tools/verify-wiki.py")).unwrap();+    assert!(verify.contains("skill_count") && verify.contains("== 2"), "verify-wiki expects two skills");+}
tools/verify-wiki.py+2
@@ -46,6 +46,8 @@ def main():     tags = json.loads(tags) if isinstance(tags, str) else tags     check(sorted(tags or []) == sorted(pkg.get("tags", [])), "the tags", str(tags))     check(page.get("version") == ver, "the page's package version", str(page.get("version")))+    # Two user skills: solidworks-bridge and the dependent solidworks-sign-in.+    check(page.get("skill_count") == 2, "the package ships 2 skills", str(page.get("skill_count")))     bv = get(API + "/files/BRIDGE_VERSION", raw=True).decode().strip()     check(bv == ver, "the page's BRIDGE_VERSION", bv)     want_title = os.environ.get("RELEASE_TITLE")
uninstall.sh+4
@@ -3,4 +3,8 @@ set -euo pipefail for D in "$HOME/.claude/skills" "$HOME/.codex/skills"; do   rm -rf "$D/solidworks-bridge" done+uninstall_skill() { # <name>+  for D in "$HOME/.claude/skills" "$HOME/.codex/skills"; do rm -rf "$D/$1"; done+}+uninstall_skill solidworks-sign-in echo "[solidworks-bridge] skills removed."