← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.24+0.9.25
Cargo.lock+1−1
@@ -122,7 +122,7 @@ dependencies = [  [[package]] name = "swx-bridge"-version = "0.9.24"+version = "0.9.25" dependencies = [  "miniz_oxide",  "serde",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.9.24"+version = "0.9.25" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"
README.md+10−1
@@ -492,7 +492,16 @@ not.  ## Roadmap -- v0.9.24 (this): **the vault answers.** Measured on the live sign-in window:+- v0.9.25 (this): **the page is prepared; the password stays with the user.**+  Measured on the live window: ab refuses `pup_credential_reveal` to a bridge+  ("requires a VERIFIED local transport … origin "bridge""). That is ab's+  policy, and the bridge does not work around it. `swx_sign_in` now fills the+  user name (from the vault's host list, no secret) and presses Continue BEFORE+  it asks for the password. A refused reveal is `sign_in_vault_policy`, with+  ab's words (`abSaid`), `pagePrepared {page, userNameFilled}` and `waysOut`:+  the user types the password, or ab issue #237 lands a fill verb that keeps the+  secret inside ab. Never retried. 560 tests.+- v0.9.24: **the vault answers.** Measured on the live sign-in window:   pup verbs reply `error:""` (an empty string) on success, and the bridge read   any non-null `error` as a failure, so `swx_sign_in` reported the vault   unavailable with an empty message. An error is now a non-empty string, or
SKILL.md+5−2
@@ -73,8 +73,11 @@ BEFORE you do it:    updater (`desktopUpdate`, a window titled just "3DEXPERIENCE" that parks    every app start). Tell the user, or call `swx_sign_in {reason}` (add    `userAuthorized:true, window:<hwnd>` on their say-so for a window the-   bridge's launch did not raise) to fill it from THEIR local vault. It never-   shows you the password, and never retries a rejected one. The measured+   bridge's launch did not raise). It fills the user name and moves to the+   password page first. Adom Bridge's policy currently keeps the saved password+   from bridges, so expect `sign_in_vault_policy`: the page is left ready, and+   the USER types the password. Never work around that. It never 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
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "solidworks",   "displayName": "SOLIDWORKS",-  "version": "0.9.24",+  "version": "0.9.25",   "description": "Bridge for SOLIDWORKS desktop CAD — 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+2
@@ -1371,6 +1371,8 @@ false, waitingOn: null`. The bridge saw nothing, while a sign-in sat on screen. | U5 — the set | `desktop_ui_set {hwnd, automationId:"username", text}` WORKS here (ValuePattern accepted), but the first set left the user name plus a stray trailing "s"; setting "" first, then the value, fixed it. `find_control`'s reply carries NO `value` | Every field found this way is CLEARED then set (`""`, then the value). The user name is read back when anything can read it (the tree, or a `value` in a find reply); on this host nothing can, so the step says `readBack: "unreadable"` rather than claiming a match. The password is never read back. On "NO ValuePattern" the keystroke path runs as before, with its geometry from `find_control` | | U6 — parked | Visualize never started while the updater's login was up | `swx_status` reports `waitingOn:"userSignIn"` + `signInWindow {hwnd, title, ownerImage, host, recognizedBy}` whenever ANY login window is up (`signInWindow` is now an object; it was a title). `visualize.waitingOn:"userSignIn"` + `parkedBehind` while Visualize is not running and the updater's login is up; `swx_start {app:"visualize"}` returns `stage:"waitingOn"` at once instead of waiting out `waitSeconds` and saying "launching". A SOLIDWORKS launch treats the updater's login as its own sign-in stage (deadlines paused, nothing killed); the updater counts as the bridge's own launch when it started at or after the bridge's invoke | +| U7 — ab's policy (0.9.25) | with the 0.9.24 envelope fix, `pup_credentials` answered (the vault holds iam.3ds.com) and `pup_credential_reveal` was refused: "pup_credential_reveal requires a VERIFIED local transport (ab 2.0.96+ X-Adom-Verified-Origin: local); this request arrived with origin \"bridge\"". ab POLICY: the secret goes only to a local desktop assistant. Filed adom/adom-bridge #237 (`desktop_credential_fill`, or a verified-local origin for hosted bridges) | The page is PREPARED BEFORE THE REVEAL: the user name (from `pup_credentials`' host list, no secret, no toast) is filled and Continue pressed, with the same idle/foreground rules; the password page is recognised; only then is the reveal asked, once. A verified/local/origin refusal is `sign_in_vault_policy` (not `sign_in_vault_unavailable`) with `abSaid` (ab's exact message), `pagePrepared {page, userNameFilled}`, `waysOut` (the user types the password; #237) and a toast telling the user. Never retried, never worked around. `swx_status` / Visualize `how` and `parkedBehind` texts say the same |+ **Unmeasured, stated:** what this host shows after "Log in" (a wrong password, a second step: rejection markers cannot be read from a blind tree, so an unclosed window is `sign_in_unconfirmed`, never retried); whether "Remember me"
install.sh

  
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "solidworks-bridge",-  "version": "0.9.24",+  "version": "0.9.25",   "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 — a single native swx-bridge.exe; Adom Bridge loads that.",   "tags": [
references/launch-and-sign-in.md+10
@@ -215,6 +215,16 @@ Read this before starting SOLIDWORKS, while waiting through a launch or a 3DEXPE   `stage:"waitingOn"` with `visualize.parkedBehind` instead of   `stage:"launching"`. Only the user can answer it; tell them, and poll   `swx_status`.+- **ab's policy keeps the password with the user (measured 2026-10-12).**+  `pup_credential_reveal` refuses a bridge: "requires a VERIFIED local transport+  … this request arrived with origin "bridge"". Since 0.9.25 `swx_sign_in`+  PREPARES the page before it asks for the password: it fills the user name from+  the vault's host list (no secret, no toast) and presses Continue. A refused+  reveal then answers `sign_in_vault_policy` with `abSaid`, `pagePrepared {page,+  userNameFilled}` and `waysOut`. The user types the password in the window+  that is left on page 2; nothing is retried or worked around. ab issue #237+  asks for a fill verb that keeps the secret inside ab. Other vault refusals+  (consent pending or denied) carry `pagePrepared` too. - **Signing in for the user — `swx_sign_in {reason}`** (0.9.13). Only for a   launch this bridge started whose own launcher is showing the 3DEXPERIENCE ID   window. It reads the window's inventory (control types, names, IsPassword —
skills/solidworks-sign-in/SKILL.md+19
@@ -146,6 +146,25 @@ Every line below is marked **[measured]** (seen on a real machine, 2026-10-02 to  ## Credentials +- [measured] 2026-10-12: Adom Bridge refused to hand the saved password to the+  SOLIDWORKS bridge: "pup_credential_reveal requires a VERIFIED local transport+  (ab 2.0.96+ X-Adom-Verified-Origin: local); this request arrived with origin+  "bridge"". That is Adom Bridge's POLICY: a saved password goes only to a local+  desktop assistant, and a bridge calls back with origin "bridge". It is not a+  bug, and nothing may work around it (no shelling out to a local command, no+  other route).+- What the bridge does instead: `swx_sign_in` PREPARES the page. It fills the+  user name (pup's host list carries it, with no secret and no toast), presses+  Continue, waits for the password page, and only then asks for the password.+  When that is refused, the reply is `sign_in_vault_policy`, with `abSaid` (Adom+  Bridge's exact words), `pagePrepared {page, userNameFilled}` and `waysOut`.+  The window is left on the password page for the user to type into. Nothing is+  retried.+- The way out that keeps the secret inside Adom Bridge: issue #237 on+  adom/adom-bridge asks for a fill verb (`desktop_credential_fill`) that types a+  saved login into a control without handing it to anyone, or a verified-local+  origin for the bridges it hosts.+ 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: 
src/signin.rs+176−48
@@ -839,6 +839,10 @@ enum Vault {     Denied(String),     Pending(String),     RelayForbidden(String),+    /// ab's POLICY refused the reveal (0.9.25): the secret goes only to a+    /// VERIFIED LOCAL caller, and a bridge ab hosts calls back with origin+    /// "bridge". Not a bug, and never worked around.+    Policy(String),     Unavailable(String), } @@ -852,17 +856,24 @@ fn consent_secs() -> u64 {  /// The hosts the vault holds a login for, from `pup_credentials {}` — host and /// username only, never a secret, and NO consent toast. `Err` = could not ask.-fn vault_hosts() -> Result<Vec<String>, String> {+/// The vault's host list — each host with the user name pup lists beside it+/// (host + username only: no secret, no toast).+fn vault_hosts() -> Result<Vec<(String, Option<String>)>, String> {     let r = crate::caller::ad_callback("pup_credentials", json!({}), AB_TIMEOUT);     if !ab_ok(&r) {         return Err(ab_error(&r));     }-    fn walk(v: &Value, out: &mut Vec<String>) {+    fn walk(v: &Value, out: &mut Vec<(String, Option<String>)>) {         match v {             Value::Array(a) => a.iter().for_each(|x| walk(x, out)),             Value::Object(m) => {                 if let Some(h) = m.get("host").and_then(|h| h.as_str()) {-                    out.push(h.to_lowercase());+                    let user = ["username", "user", "login", "email"]+                        .iter()+                        .find_map(|k| m.get(*k).and_then(|u| u.as_str()))+                        .filter(|u| !u.is_empty())+                        .map(String::from);+                    out.push((h.to_lowercase(), user));                 }                 for (k, x) in m {                     if k != "host" {@@ -876,7 +887,7 @@ fn vault_hosts() -> Result<Vec<String>, String> {     let mut out = Vec::new();     walk(&unwrap_ab(&r), &mut out);     out.sort();-    out.dedup();+    out.dedup_by(|a, b| a.0 == b.0);     Ok(out) } @@ -906,16 +917,31 @@ fn error_code_of(r: &Value) -> String {         + &ab_error(r).to_lowercase() } -fn reveal() -> Vault {-    // EXISTENCE FIRST, without a toast (M5): a missing login never asks the user-    // to consent to anything.+/// EXISTENCE FIRST, without a toast (M5): a missing login never asks the user+/// to consent to anything. The vault host, and the user name listed with it.+fn lookup() -> Result<(String, Option<String>), Vault> {     let hosts = match vault_hosts() {         Ok(h) => h,-        Err(e) => return Vault::Unavailable(format!("the vault's host list could not be read: {e}")),+        Err(e) => return Err(Vault::Unavailable(format!("the vault's host list could not be read: {e}"))),     };-    let Some(host) = pick_host(&hosts, &vault_host()) else {-        return Vault::Missing(format!("no saved login on {} ({} host(s) in the vault)", vault_host(), hosts.len()));+    let names: Vec<String> = hosts.iter().map(|(h, _)| h.clone()).collect();+    let Some(host) = pick_host(&names, &vault_host()) else {+        return Err(Vault::Missing(format!("no saved login on {} ({} host(s) in the vault)", vault_host(), hosts.len())));     };+    let user = hosts.iter().find(|(h, _)| *h == host).and_then(|(_, u)| u.clone());+    Ok((host, user))+}++/// Is this reveal refusal ab's VERIFIED-LOCAL-ORIGIN policy? MEASURED+/// 2026-10-12 (hosted 0.9.24): "pup_credential_reveal requires a VERIFIED local+/// transport (ab 2.0.96+ X-Adom-Verified-Origin: local); this request arrived+/// with origin \"bridge\"".+pub fn is_origin_policy(message: &str) -> bool {+    let m = message.to_lowercase();+    m.contains("verified-origin") || (m.contains("verified") && (m.contains("local") || m.contains("origin"))) || m.contains("arrived with origin")+}++fn reveal_at(host: &str) -> Vault {     // THE REVEAL: gated by a STICKY CONSENT TOAST the user must click (measured:     // pup's own text — consent is PER USE). A `consent_pending` answer     // re-attaches when called again, with no second prompt.@@ -944,6 +970,10 @@ fn reveal() -> Vault {         if code.contains("relay_forbidden") {             return Vault::RelayForbidden(err);         }+        // ab's POLICY: never retried, never worked around.+        if is_origin_policy(&code) {+            return Vault::Policy(err);+        }         if code.contains("consent_pending") || code.contains("pending") {             if std::time::Instant::now() >= deadline {                 return Vault::Pending(err);@@ -981,6 +1011,56 @@ fn record_rejection(why: &str) {  // ── the sign-in ───────────────────────────────────────────────────────────── +/// A vault answer that is not the login, as the reply. `prepared` = what the+/// bridge had already done to the page (`None`: nothing typed).+fn vault_refusal(v: Vault, extra: Value, prepared: Option<Value>) -> Value {+    let typed_user = prepared.as_ref().and_then(|p| p.get("userNameFilled")).and_then(|b| b.as_bool()) == Some(true);+    let nothing = if typed_user { "The password was not typed; the user name was filled (see pagePrepared)." } else { "Nothing was typed." };+    let extra = merge(extra, json!({"pagePrepared": prepared}));+    match v {+        Vault::Got(..) => err("sign_in_vault_unavailable", "internal: a revealed login reached the refusal path", nothing, extra),+        Vault::Missing(e) => err(+            "sign_in_credential_missing",+            format!("the vault has no 3DEXPERIENCE login for {}: {e}", vault_host()),+            format!(+                "{nothing} 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."+            ),+            merge(extra, json!({"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) => err("sign_in_consent_denied", format!("the user did not allow the saved login to be used: {e}"), format!("The user declined the consent toast. {nothing} Do not ask again unless they say so."), extra),+        Vault::Pending(e) => err("sign_in_consent_pending", format!("the consent toast was not answered within {}s: {e}", consent_secs()), format!("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}"), merge(extra, json!({"consentReason": CONSENT_REASON}))),+        Vault::RelayForbidden(e) => err("sign_in_relay_forbidden", format!("ab refused the reveal as a RELAYED call: {e}"), format!("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}"), extra),+        Vault::Policy(e) => {+            tell_user(if typed_user {+                "Please type your 3DEXPERIENCE password in the sign-in window. The bridge filled your user name; Adom Bridge does not hand saved passwords to bridges."+            } else {+                "Please sign in to 3DEXPERIENCE in the sign-in window. Adom Bridge does not hand saved passwords to bridges."+            });+            err(+                "sign_in_vault_policy",+                format!("ab's policy keeps the saved password from this bridge: {e}"),+                format!("ab gives a saved password only to a verified local caller, and a bridge is not one; the bridge prepared the page (pagePrepared) and the USER types the password — until ab issue #237 (a fill verb that keeps the secret inside ab) lands. {nothing} Not retried."),+                merge(extra, json!({+                    "abSaid": e,+                    "waysOut": [+                        if typed_user {+                            "the user types the password in the window (the bridge has filled the user name and advanced to page 2)"+                        } else {+                            "the user signs in by hand in the window (the vault lists no user name, so the bridge could not prepare the page)"+                        },+                        "ab issue #237: a fill verb that keeps the secret inside ab"+                    ],+                })),+            )+        }+        Vault::Unavailable(e) => err("sign_in_vault_unavailable", format!("the vault could not be asked: {e}"), format!("{nothing} The user can sign in by hand."), extra),+    }+}+ fn err(code: &str, msg: impl Into<String>, hint: impl Into<String>, extra: Value) -> Value {     merge(crate::verbs::err_code(code, msg, hint), extra) }@@ -1272,43 +1352,54 @@ fn sign_in_inner(reason: &str, opts: &Opts) -> Value {             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, "located": located_1, "cookieBanner": banner, "window": window_j}));         }     };-    // 2. THE SECRET, locally — ONCE for the whole sign-in, however many pages it-    //    takes (a second reveal would raise the consent toast twice).+    // 2. THE LOGIN'S HOST AND USER NAME — no toast, no secret: pup_credentials+    //    lists host + username. 0.9.25: THE PAGE IS PREPARED BEFORE THE+    //    PASSWORD IS ASKED FOR. MEASURED 2026-10-12 (hosted 0.9.24): ab refused+    //    the reveal to this bridge by POLICY ("requires a VERIFIED local+    //    transport … arrived with origin \"bridge\"") — so the user name is+    //    filled and Continue pressed first, and a refused reveal leaves the+    //    window on the password page with the name in place, for the user.     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!(-                "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 (host, listed_user) = match lookup() {+        Ok(x) => x,+        Err(v) => return vault_refusal(v, json!({"inventory": inv_j}), None),     };-    let needles = secret.needles();+    let mut needles: Vec<String> = listed_user.iter().cloned().collect();     let mut steps: Vec<Value> = Vec::new();     let mut pages: Vec<Value> = vec![json!({"page": 1, "inventory": inv_j, "wake": wake_1, "located": located_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.elements, &form, &secret, &mut steps, &mut remember_ticked) {-        drop(secret);-        return scrub(&fill_failure(fe, banner, &pages, &steps, &vault), &needles);+    let mut vault = json!({"host": host});+    // No user name listed beside the host: the reveal is the only source of+    // it, so it comes first (the old order).+    let mut secret: Option<Secret> = None;+    let mut user_name: Option<Vec<u8>> = listed_user.as_ref().map(|u| u.as_bytes().to_vec());+    if user_name.is_none() {+        match reveal_at(&host) {+            Vault::Got(sec, v) => {+                needles.extend(sec.needles());+                user_name = sec.user.clone();+                vault = v;+                secret = Some(sec);+            }+            other => return vault_refusal(other, json!({"inventory": inv_j}), Some(json!({"page": 1, "userNameFilled": false}))),+        }     }-    // 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 user_filled = false;     let mut two_page = false;+    let mut closed_early = false;+    let mut pw_els: Vec<Element> = read.elements.clone();+    let mut pw_form: Form = form.clone();+    let mut pw_page: u8 = if lw.page == Some(2) { 2 } else { 1 };+    // 3. PAGE ONE when it asks only for the user name (MEASURED 2026-10-02:+    //    "Email or username" + Continue): the name, Continue, then wait for the+    //    page to CHANGE and recognize the password page.     if form.password.is_none() {         two_page = true;+        if let Err(fe) = fill_page(hwnd, &read.elements, &form, user_name.as_deref(), None, &mut steps, &mut remember_ticked) {+            drop(secret);+            return scrub(&fill_failure(fe, banner, &pages, &steps, &vault), &needles);+        }+        user_filled = 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;@@ -1341,13 +1432,11 @@ fn sign_in_inner(reason: &str, opts: &Opts) -> Value {         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_els, &f2, &secret, &mut steps, &mut remember_ticked) {-                    drop(secret);-                    return scrub(&fill_failure(fe, banner, &pages, &steps, &vault), &needles);-                }+                pw_form = Form { user: None, ..f2 };+                pw_els = page2_els;+                pw_page = 2;             }-            None if still_up(os.as_ref(), &lw) == Some(false) => {}+            None if still_up(os.as_ref(), &lw) == Some(false) => closed_early = true,             None => {                 drop(secret);                 let last_j = inventory_json(&last);@@ -1365,8 +1454,44 @@ fn sign_in_inner(reason: &str, opts: &Opts) -> Value {                 return scrub(&out, &needles);             }         }+    } else if let Some(u_el) = &form.user {+        // A password page that also holds the user-name box (a one-page form,+        // or page two with the name prefilled): the name goes in first.+        let Some(u) = user_name.as_deref() else {+            drop(secret);+            return scrub(&fill_failure(FillErr::Failed { step: "user name".into(), why: "the saved login has no user name to type".into(), observed: None, may_be_banner: false }, banner, &pages, &steps, &vault), &needles);+        };+        if let Err(fe) = fill_field(hwnd, u_el, u, true, &mut steps) {+            drop(secret);+            return scrub(&fill_failure(fe, banner, &pages, &steps, &vault), &needles);+        }+        user_filled = true;+        pw_form = Form { user: None, ..form.clone() };     }-    drop(secret); // ZEROED. Only the needles (for scrubbing) remain, below.+    // 4. THE PASSWORD: the reveal, ONCE, only now — the page is prepared.+    if !closed_early {+        let sec = match secret.take() {+            Some(sec) => sec,+            None => match reveal_at(&host) {+                Vault::Got(sec, v) => {+                    needles.extend(sec.needles());+                    vault = v;+                    sec+                }+                other => {+                    let prepared = json!({"page": pw_page, "userNameFilled": user_filled});+                    let out = vault_refusal(other, json!({"pages": pages, "steps": steps, "vault": vault}), Some(prepared));+                    return scrub(&out, &needles);+                }+            },+        };+        let filled = fill_page(hwnd, &pw_els, &pw_form, None, Some(&sec.pass), &mut steps, &mut remember_ticked);+        drop(sec); // ZEROED. Only the needles (for scrubbing) remain, below.+        if let Err(fe) = filled {+            return scrub(&fill_failure(fe, banner, &pages, &steps, &vault), &needles);+        }+    }+    drop(secret);     // 5. VERIFY BY EFFECT: the window goes away.     let deadline = std::time::Instant::now() + Duration::from_secs(verify_secs());     loop {@@ -1414,15 +1539,18 @@ const FILL_REASON: &str = "signing in to 3DEXPERIENCE for a SOLIDWORKS launch th /// 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, page: &[Element], form: &Form, secret: &Secret, steps: &mut Vec<Value>, remember_ticked: &mut Value) -> Result<(), FillErr> {+fn fill_page(hwnd: i64, page: &[Element], form: &Form, user: Option<&[u8]>, pass: Option<&[u8]>, steps: &mut Vec<Value>, remember_ticked: &mut Value) -> Result<(), FillErr> {     if let Some(u_el) = &form.user {-        let Some(u) = &secret.user else {+        let Some(u) = 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, u_el, u, true, steps)?;     }     if let Some(p_el) = &form.password {-        fill_field(hwnd, p_el, &secret.pass, false, steps)?;+        let Some(p) = pass else {+            return Err(FillErr::Failed { step: "password".into(), why: "no password was revealed for this page".into(), observed: None, may_be_banner: false });+        };+        fill_field(hwnd, p_el, p, false, steps)?;     }     if let Some(rm) = &form.remember {         *remember_ticked = tick_remember(hwnd, page, rm);
src/verbs.rs+8−6
@@ -3017,7 +3017,7 @@ pub const CATALOG: &[(&str, &str, &str)] = &[     ("swx_close_all_safely", "exec", "save EVERY open document into a recovery folder, then close it — nothing closes before it is on disk; needs only a reason"),     ("swx_shutdown", "exec", "close_all_safely, then quit SOLIDWORKS; idempotent, and the quit is skipped unless the session really emptied"),     ("swx_recover_list", "read", "what previous sweeps preserved, and where — newest first"),-    ("swx_sign_in", "exec", "sign in to 3DEXPERIENCE for a launch this bridge started (or, with userAuthorized:true, any 3DEXPERIENCE ID login window, e.g. Visualize's or the platform updater's, recognized by its content; window:visualize|solidworks|desktopUpdate|<hwnd> picks one, and a named hwnd is probed by content, never refused on census grounds), from the user's local vault (the password never leaves the laptop or reaches a reply); fails closed and never retries a rejected password"),+    ("swx_sign_in", "exec", "sign in to 3DEXPERIENCE for a launch this bridge started (or, with userAuthorized:true, any 3DEXPERIENCE ID login window, e.g. Visualize's or the platform updater's, recognized by its content; window:visualize|solidworks|desktopUpdate|<hwnd> picks one, and a named hwnd is probed by content, never refused on census grounds), from the user's local vault (the password never leaves the laptop or reaches a reply); the page is prepared first (user name, Continue), and ab's policy (sign_in_vault_policy, measured) currently keeps the password for the user to type; fails closed and never retries a rejected password"),     ("swx_user_presence", "read", "is the user working in this SOLIDWORKS? foreground + last input + documents the bridge did not open (the guard every quit and sweep passes)"),     ("swx_kill", "process", "force-kill SOLIDWORKS (reason + confirm; discards unsaved work)"),     ("swx_window_info", "read", "SOLIDWORKS' windows and its main hwnd (read-only, nothing raised)"),@@ -3470,11 +3470,13 @@ fn dispatch_after_gate(ctx: &Ctx, command: &str, args: &Value) -> Value {                 }                 m.insert(                     "_signInHint".into(),-                    json!("A 3DEXPERIENCE sign-in window is waiting for the user (signInWindow). Only the user \-                           can answer it; a launch's deadlines are paused and nothing is killed. Tell the user, \-                           or — if they have a 3DEXPERIENCE login saved in their local vault — call swx_sign_in \-                           {reason} (a launch this bridge started) or swx_sign_in {reason, userAuthorized:true, \-                           window:<signInWindow.hwnd>} on the user's say-so. Then poll swx_status."),+                    json!("A 3DEXPERIENCE sign-in window is waiting for the user (signInWindow). A launch's \+                           deadlines are paused and nothing is killed. swx_sign_in {reason} (a launch this bridge \+                           started) or swx_sign_in {reason, userAuthorized:true, window:<signInWindow.hwnd>} (on the \+                           user's say-so) PREPARES the page: it fills the user name from the vault's host list and \+                           moves to the password page. ab's policy keeps the saved password with the user \+                           (measured: a bridge is not a verified local caller), so the USER types it until ab \+                           issue #237 (a fill verb that keeps the secret inside ab) lands. Then poll swx_status."),                 );             }         }
src/visualize.rs+4−4
@@ -131,8 +131,8 @@ pub fn status() -> Value {             return json!({"running": false, "pid": Value::Null, "hwnd": Value::Null, "ready": false,                           "waitingOn": "userSignIn",                           "signInWindow": {"hwnd": hwnd, "title": title, "ownerImage": crate::signin::DESKTOP_UPDATE_IMAGE, "host": "desktopUpdate"},-                          "parkedBehind": "the 3DEXPERIENCE platform sign-in (SWXDesktopUpdate.exe) — Visualize starts after it",-                          "how": "Only the user can answer it — or swx_sign_in {reason, userAuthorized:true, window:<signInWindow.hwnd>} on the user's say-so."});+                          "parkedBehind": "the 3DEXPERIENCE platform sign-in (SWXDesktopUpdate.exe) — Visualize starts after it; the bridge can prepare that page (user name, Continue), but ab's policy keeps the password with the user until ab issue #237 lands",+                          "how": "On the user's say-so, swx_sign_in {reason, userAuthorized:true, window:<signInWindow.hwnd>} fills the user name and moves to the password page; the USER types the password (ab gives saved passwords only to a verified local caller, not a bridge — ab issue #237)."});         }         return json!({"running": false, "pid": Value::Null, "hwnd": Value::Null, "waitingOn": Value::Null});     };@@ -155,7 +155,7 @@ pub fn status() -> Value {         if let Some(kind) = classify(&w.title, &w.class_name, &words) {             waiting = Some(match kind {                 "signIn" => json!({"waitingOn": "userSignIn", "hwnd": w.hwnd, "title": w.title,-                                   "how": "swx_sign_in {reason, userAuthorized:true, window:\"visualize\"} — only on the user's say-so"}),+                                   "how": "swx_sign_in {reason, userAuthorized:true, window:\"visualize\"} — only on the user's say-so — prepares the page (user name, Continue); the user types the password (ab's policy, until ab issue #237)"}),                 "contentFolder" => json!({"waitingOn": "userVisualizeContentFolder", "hwnd": w.hwnd, "title": w.title,                                           "text": words, "prefilledPath": path,                                           "how": "The user chooses where Visualize keeps its content (it creates a folder on their machine). The bridge never answers this."}),@@ -237,7 +237,7 @@ pub fn start(ctx: &crate::verbs::Ctx, args: &Value) -> Value {         "pathNote": path_note,         "_hint": match stage {             "ready" => "Visualize is up (its main window exists, no dialog is waiting).",-            "waitingOn" if st.get("waitingOn") == Some(&json!("userSignIn")) && st.get("running") == Some(&json!(false)) => "Visualize has not started: the launch is parked behind the 3DEXPERIENCE platform sign-in (visualize.signInWindow, hosted by SWXDesktopUpdate.exe). Tell the user, or on their say-so call swx_sign_in {reason, userAuthorized:true, window:<signInWindow.hwnd>}; Visualize starts after it. Then poll swx_status.",+            "waitingOn" if st.get("waitingOn") == Some(&json!("userSignIn")) && st.get("running") == Some(&json!(false)) => "Visualize has not started: the launch is parked behind the 3DEXPERIENCE platform sign-in (visualize.signInWindow, hosted by SWXDesktopUpdate.exe). On the user's say-so, swx_sign_in {reason, userAuthorized:true, window:<signInWindow.hwnd>} prepares the page (user name, Continue); the USER types the password — ab's policy keeps it with them until ab issue #237. Visualize starts after it. Then poll swx_status.",             "waitingOn" => "Visualize is waiting on the USER (see visualize.dialog). The bridge never answers Visualize's dialogs; a 3DEXPERIENCE login can be answered with swx_sign_in {userAuthorized:true, window:\"visualize\"} on the user's say-so.",             _ => "Visualize is starting (measured: ~45 s). Poll swx_status — its `visualize` block says running / ready / waitingOn.",         },
tests/signin.rs+74−6
@@ -243,7 +243,9 @@ fn a_rejected_password_is_never_retried_and_never_echoed() { fn an_ab_error_that_echoes_the_password_is_scrubbed() {     let m = Machine::new("signin-echo");     let ab = ab_for(form(vec![]), vault_ok());-    ab.set_response("desktop_ui_set", json!({"success": false, "error": format!("ValuePattern.SetValue('{PW}') failed: element not enabled"), "text": PW}));+    // The user name goes in (0.9.25: before the reveal); the PASSWORD set fails+    // with an error that echoes it.+    ab.when("desktop_ui_set", json!({"name": "Password"}), json!({"success": false, "error": format!("ValuePattern.SetValue('{PW}') failed: element not enabled"), "text": PW}));     let b = m.boot(TempDir::new("signin-echo-scratch"), &ab, &[]);     let mut replies = vec![b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}))];     wait_for_sign_in_stage(&b);@@ -533,7 +535,8 @@ fn consent_is_waited_for_and_relay_forbidden_is_a_wiring_bug() {     });     check!(b, "consent pending, then given: signed in, consent reported perUse", r["signedIn"] == true && r["vault"]["consent"] == "perUse", s(&r)); -    // Never answered: sign_in_consent_pending, nothing typed.+    // Never answered: sign_in_consent_pending, the password not typed (0.9.25:+    // the user name went in first — the page is prepared before the reveal).     let m = Machine::new("signin-consent-timeout");     let ab = ab_for(form(vec![]), json!({"success": false, "errorCode": "consent_pending", "error": "waiting for the user to allow"}));     let b = m.boot(TempDir::new("signin-consent-timeout-scratch"), &ab, &[("SWX_SIGN_IN_CONSENT_SECS", "2")]);@@ -542,8 +545,9 @@ fn consent_is_waited_for_and_relay_forbidden_is_a_wiring_bug() {     let r = b.call("swx_sign_in", json!({"reason": "sign in"}));     check!(         b,-        "an unanswered toast: sign_in_consent_pending, nothing typed",-        r["errorCode"] == "sign_in_consent_pending" && ab.calls_to("desktop_ui_set").is_empty(),+        "an unanswered toast: sign_in_consent_pending, the password not typed, the page prepared",+        r["errorCode"] == "sign_in_consent_pending" && !ab.calls_to("desktop_ui_set").iter().any(|c| c["args"]["text"] == PW)+            && r["pagePrepared"]["userNameFilled"] == true,         s(&r)     ); @@ -749,10 +753,10 @@ fn a_password_page_that_never_comes_types_no_password() {     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",+        "no password page: sign_in_unconfirmed, the password never typed — and never even asked for (0.9.25: the reveal waits for the password page)",         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,+            && ab.calls_to("pup_credential_reveal").is_empty(),         s(&r)     ); }@@ -1300,3 +1304,67 @@ fn a_launch_parked_behind_the_updater_login_says_so() {         format!("{}\n{}", s(&r), s(&st))     ); }++// ── 0.9.25: ab's policy keeps the password; the bridge prepares the page ────++/// MEASURED 2026-10-12 (hosted 0.9.24), ab's exact refusal.+const ORIGIN_POLICY: &str = "pup_credential_reveal requires a VERIFIED local transport (ab 2.0.96+ X-Adom-Verified-Origin: local); this request arrived with origin \"bridge\"";++#[test]+fn the_reveal_refused_by_ab_policy_leaves_the_page_prepared_for_the_user() {+    let m = update_machine("signin-policy");+    let ab = update_ab();+    ab.set_response("pup_credential_reveal", json!({"success": false, "error": ORIGIN_POLICY}));+    let b = m.boot(TempDir::new("signin-policy-scratch"), &ab, &[("SWX_SIGN_IN_WAKE_MS", "20"), ("SWX_SIGN_IN_VERIFY_SECS", "8"), ("SWX_SIGN_IN_CONSENT_SECS", "3")]);+    let r = std::thread::scope(|sc| {+        // Continue → page two. (No Log in is ever expected here.)+        sc.spawn(|| {+            let deadline = Instant::now() + Duration::from_secs(15);+            while Instant::now() < deadline {+                if ab.calls_to("desktop_ui_click").iter().any(|c| c["args"]["name"] == "Continue") {+                    update_page_two(&ab);+                    return;+                }+                std::thread::sleep(Duration::from_millis(15));+            }+        });+        b.call("swx_sign_in", json!({"reason": "Drew: sign me in", "userAuthorized": true, "window": UPDATE_HWND}))+    });+    check!(+        b,+        "sign_in_vault_policy (not vault_unavailable), ab's exact words as abSaid, the page prepared: page 2, user name filled",+        r["errorCode"] == "sign_in_vault_policy" && r["abSaid"] == ORIGIN_POLICY+            && r["pagePrepared"]["page"] == 2 && r["pagePrepared"]["userNameFilled"] == true,+        s(&r)+    );+    let ways = r["waysOut"].as_array().cloned().unwrap_or_default();+    check!(+        b,+        "waysOut: the user types the password; ab issue #237. The hint says the same",+        ways.len() == 2 && ways[0].as_str().unwrap_or("").contains("user types the password") && ways[1].as_str().unwrap_or("").contains("#237")+            && r["_hint"].as_str().unwrap_or("").contains("#237"),+        s(&r)+    );+    let calls = ab.calls();+    let at = |pred: &dyn Fn(&Value) -> bool| calls.iter().position(|c| pred(c));+    let continued = at(&|c| c["command"] == "desktop_ui_click" && c["args"]["name"] == "Continue");+    let revealed = at(&|c| c["command"] == "pup_credential_reveal");+    check!(+        b,+        "THE PAGE FIRST: the user name was set and Continue pressed BEFORE the reveal was asked",+        matches!((continued, revealed), (Some(c), Some(r)) if c < r)+            && ab.calls_to("desktop_ui_set").iter().any(|c| c["args"]["automationId"] == "username" && c["args"]["text"] == USER),+        format!("{calls:?}")+    );+    check!(+        b,+        "asked ONCE, never retried; no password typed; Log in never pressed; the window is still up for the user",+        ab.calls_to("pup_credential_reveal").len() == 1+            && !ab.calls_to("desktop_ui_set").iter().any(|c| c["args"]["automationId"] == "password" && c["args"]["text"] != "")+            && !ab.calls_to("desktop_ui_click").iter().any(|c| c["args"]["name"] == "Log in")+            && m.sign_in_up(),+        format!("{:?}", ab.calls_to("desktop_ui_set"))+    );+    let all = everything_but_the_fill(&b, &ab, &[r]);+    check!(b, "the user name appears in no reply, log or other request", !all.contains(USER), String::new());+}
uninstall.sh