← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.13+0.9.14
Cargo.lock+1−1
@@ -107,7 +107,7 @@ dependencies = [  [[package]] name = "swx-bridge"-version = "0.9.13"+version = "0.9.14" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.9.13"+version = "0.9.14" 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
@@ -461,7 +461,16 @@ not.  ## Roadmap -- v0.9.13 (this): **the bridge can sign you in.** Drew asked for the+- v0.9.14 (this): **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+  password flag (the password edit is now found by its name; a truncated tree is+  refused), clicks need a name, and the vault's reveal is gated by a consent+  toast the user clicks every time — waited for, with a plain sentence for the+  user, and `relay_forbidden` reported as a wiring bug. A missing login is found+  from the vault's host list and never raises the toast. 523 tests.+- v0.9.13: **the bridge can sign you in.** Drew asked for the   3DEXPERIENCE sign-in not to need him every time the platform session lapses.   Every time a launch reaches that window its accessible inventory is captured   (never a value); `swx_sign_in` recognizes the form from it, takes the login
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "solidworks",   "displayName": "SOLIDWORKS",-  "version": "0.9.13",+  "version": "0.9.14",   "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+10
@@ -1303,3 +1303,13 @@ consent `pup_credential_reveal` needs (`vault.consent` reports what ab says, pressed; the page that follows is reported `sign_in_unconfirmed` with its inventory, and a second `swx_sign_in` fills the password page — the outcome is verified each time, never assumed).++**MEASURED 2026-10-02 on ab 2.1.163 (harmless calls), and 0.9.14.**++| | Measured | 0.9.14 |+|---|---|---|+| `desktop_ui_set` | without a value: "desktop_ui_set requires `text` (the value to set)." Documented shape `{window\|hwnd, name\|contains, text}` (`submit:true` sets and commits); `automationId` appears in examples only as a reported field | 0.9.13 sent `value` — every fill would have failed. Now `text`, targeted by `name` + `contains` (`automationId` still sent, never relied on). `sensitive:true` is not documented; it is still sent (unknown arguments are ignored). **Whether ab logs the `text` is UNMEASURED** |+| `desktop_ui_tree {hwnd, maxDepth, maxNodes}` | top level `{foreground, hwnd, maxNodes, nodeCount, root, truncated, window, status, success}`; nodes `{role, name, automationId, invokable, settable, rect, children}` (per its docs also value / toggleState / expandState / selected); NO `controlType`, no `isPassword` on the nodes sampled | Read by `role` (and `controlType`, so both shapes work); `invokable` / `settable` honoured; the password edit is the one with a password flag OR a name / automation id matching /pass(word)?\|mot de passe\|contrase/i; a `truncated` tree is unrecognized, never a guess; a node's `value` is never copied. The measured node shape is a test fixture |+| `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.*`) |
install.sh

  
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "solidworks-bridge",-  "version": "0.9.13",+  "version": "0.9.14",   "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.",   "tags": [
references/launch-and-sign-in.md+3−2
@@ -212,8 +212,9 @@ Read this before starting SOLIDWORKS, while waiting through a launch or a 3DEXPE   window. It reads the window's inventory (control types, names, IsPassword —   never values; also in `swx_status.signInInventory` every time the window   appears), takes the login from the user's pup vault ON THEIR LAPTOP through-  ab (`pup_credential_reveal`, which may ask them to consent — `vault.consent`-  says how), types it through UI Automation, ticks "Remember me" if there is+  ab (`pup_credential_reveal` — the user clicks a consent toast "Sign in to+  SOLIDWORKS with your saved 3DEXPERIENCE login" EVERY time; an unanswered toast+  is `sign_in_consent_pending`, and calling again re-attaches to it), types it through UI Automation, ticks "Remember me" if there is   one, presses the sign-in button and checks the window actually closed. The   password never appears in its reply, the log or the ledger. It fails closed:   `sign_in_credential_missing` (the reply carries the import command for the
src/signin.rs+174−40
@@ -96,7 +96,7 @@ pub fn scrub(v: &Value, needles: &[String]) -> Value {             m.iter()                 .filter(|(k, _)| {                     let k = k.to_ascii_lowercase();-                    !matches!(k.as_str(), "password" | "secret" | "value" | "credential" | "username")+                    !matches!(k.as_str(), "password" | "secret" | "value" | "text" | "credential" | "username")                 })                 .map(|(k, x)| (k.clone(), scrub(x, needles)))                 .collect(),@@ -144,6 +144,9 @@ pub struct Element {     pub is_password: Option<bool>,     pub toggle_state: Option<String>,     pub enabled: Option<bool>,+    /// ab's `invokable` / `settable` (MEASURED 2026-10-02 on ab 2.1.163).+    pub invokable: Option<bool>,+    pub settable: Option<bool>, }  impl Element {@@ -155,6 +158,8 @@ impl Element {             "isPassword": self.is_password,             "toggleState": self.toggle_state,             "enabled": self.enabled,+            "invokable": self.invokable,+            "settable": self.settable,         })     }     fn is(&self, t: &str) -> bool {@@ -198,7 +203,19 @@ pub fn parse_inventory(reply: &Value) -> Vec<Element> {                             .or_else(|| m.get("checked"))                             .map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())),                         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()),                     });+                    // THE PASSWORD FIELD (MEASURED 2026-10-02: ab's nodes carry no+                    // `isPassword` key): an explicit password flag, OR an edit+                    // whose name / automation id says password.+                    if let Some(last) = out.last_mut() {+                        if last.control_type == "edit" && last.is_password.is_none() {+                            let flag = m.get("password").and_then(|x| x.as_bool());+                            let named = looks_like_password(&last.name) || looks_like_password(&last.automation_id);+                            last.is_password = Some(flag == Some(true) || named);+                        }+                    }                 }                 for (k, x) in m {                     if matches!(k.as_str(), "children" | "elements" | "nodes" | "tree" | "root" | "items" | "controls") {@@ -210,16 +227,32 @@ pub fn parse_inventory(reply: &Value) -> Vec<Element> {         }     }     let mut out = Vec::new();-    walk(&unwrap_ab(reply), &mut out);+    let top = unwrap_ab(reply);+    walk(&top, &mut out);+    // A TRUNCATED TREE IS NEVER A GUESS: marked, and recognize() refuses it.+    if top.get("truncated").and_then(|t| t.as_bool()) == Some(true) || reply.get("truncated").and_then(|t| t.as_bool()) == Some(true) {+        out.push(Element {+            control_type: "truncated".into(),+            name: "ab truncated the accessible tree; not every element was read".into(),+            ..Default::default()+        });+    }     out } +/// /pass(word)?|mot de passe|contrase/i — the measured rule for a password edit+/// whose node carries no password flag.+pub fn looks_like_password(s: &str) -> bool {+    let s = s.to_lowercase();+    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> {     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}), AB_TIMEOUT);+    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)));     }@@ -274,11 +307,16 @@ fn has_marker(inv: &[Element], markers: &[&str]) -> Option<String> {  /// What the window is asking for, from its inventory alone. pub fn recognize(inv: &[Element]) -> (Reading, Option<Form>) {+    if inv.iter().any(|e| e.is("truncated")) {+        return (Reading::Unrecognized("the accessible tree was truncated, so the form cannot be read whole".into()), None);+    }     if let Some(m) = has_marker(inv, SECOND_STEP) {         return (Reading::SecondStep(m), None);     }-    let passwords: Vec<&Element> = inv.iter().filter(|e| e.is("edit") && e.is_password == Some(true)).collect();-    let users: Vec<&Element> = inv.iter().filter(|e| e.is("edit") && e.is_password != Some(true)).collect();+    // Only edits that can be SET (ab's `settable`, when it says) count.+    let editable = |e: &&Element| e.is("edit") && e.settable != Some(false);+    let passwords: Vec<&Element> = inv.iter().filter(editable).filter(|e| e.is_password == Some(true)).collect();+    let users: Vec<&Element> = inv.iter().filter(editable).filter(|e| e.is_password != Some(true)).collect();     if passwords.len() > 1 {         return (Reading::Unrecognized(format!("{} password fields", passwords.len())), None);     }@@ -288,7 +326,8 @@ 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);     }-    let buttons: Vec<&Element> = inv.iter().filter(|e| e.is("button")).collect();+    // 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();     let named = |list: &[&str]| -> Vec<&Element> {         buttons             .iter()@@ -321,10 +360,14 @@ pub fn recognize(inv: &[Element]) -> (Reading, Option<Form>) {     ) } +/// ab targets a control by `name` / `contains` (MEASURED 2026-10-02: a click+/// with no name answers "control found but has NO InvokePattern"). The+/// automation id is sent too, as a reported field — never relied on. fn target(e: &Element, role: &str) -> Value {     json!({-        "automationId": if e.automation_id.is_empty() { Value::Null } else { json!(e.automation_id) },+        "name": e.name,         "contains": e.name,+        "automationId": if e.automation_id.is_empty() { Value::Null } else { json!(e.automation_id) },         "role": role,     }) }@@ -344,40 +387,128 @@ enum Vault {     Got(Secret, Value),     Missing(String),     Denied(String),+    Pending(String),+    RelayForbidden(String),     Unavailable(String), } -fn reveal(reason: &str) -> Vault {-    let host = vault_host();-    let r = crate::caller::ad_callback(-        "pup_credential_reveal",-        json!({"host": host, "reason": reason, "purpose": "type into the SOLIDWORKS 3DEXPERIENCE ID sign-in window on this desktop"}),-        Duration::from_secs(120),-    );-    let d = unwrap_ab(&r);-    // WHAT CONSENT IT TOOK, as ab says — never guessed.-    let consent = d-        .get("consent")-        .or_else(|| d.get("consentMode"))-        .or_else(|| r.get("consent"))-        .cloned()-        .unwrap_or(json!("unmeasured"));-    let pass = ["password", "secret"].iter().find_map(|k| d.get(*k).and_then(|v| v.as_str()).map(|s| s.as_bytes().to_vec()));-    let user = ["username", "user", "login", "email"].iter().find_map(|k| d.get(*k).and_then(|v| v.as_str()).map(|s| s.as_bytes().to_vec()));-    match pass {-        Some(p) if !p.is_empty() && ab_ok(&r) => Vault::Got(Secret { user, pass: p }, json!({"host": host, "consent": consent})),-        _ => {-            // The error text, scrubbed of any credential-bearing key first.-            let err = scrub(&json!(ab_error(&r)), &[]).as_str().unwrap_or("").to_string();-            let e = err.to_lowercase();-            if e.contains("not found") || e.contains("no credential") || e.contains("no entry") || e.contains("no saved") || e.contains("no login") || e.contains("no match") {-                Vault::Missing(err)-            } else if e.contains("consent") || e.contains("denied") || e.contains("declined") || e.contains("cancel") {-                Vault::Denied(err)-            } else {-                Vault::Unavailable(err)+/// What the user sees on pup's consent toast (it is shown to them — MEASURED+/// 2026-10-02: "reason 8+ chars; it is shown to the user").+pub const CONSENT_REASON: &str = "Sign in to SOLIDWORKS with your saved 3DEXPERIENCE login";++fn consent_secs() -> u64 {+    std::env::var("SWX_SIGN_IN_CONSENT_SECS").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(120)+}++/// 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> {+    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>) {+        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());+                }+                for (k, x) in m {+                    if k != "host" {+                        walk(x, out);+                    }+                }+            }+            _ => {}+        }+    }+    let mut out = Vec::new();+    walk(&unwrap_ab(&r), &mut out);+    out.sort();+    out.dedup();+    Ok(out)+}++/// The vault host for the 3DEXPERIENCE login: an exact `SWX_SIGN_IN_HOST`, or+/// any host on its registrable domain — preferring the identity service.+fn pick_host(hosts: &[String], want: &str) -> Option<String> {+    let want = want.to_lowercase();+    let on_domain: Vec<&String> = hosts.iter().filter(|h| *h == &want || h.ends_with(&format!(".{want}"))).collect();+    on_domain+        .iter()+        .find(|h| h.contains("iam."))+        .or_else(|| on_domain.iter().find(|h| h.contains("3dexperience")))+        .or_else(|| on_domain.first())+        .map(|h| h.to_string())+}++fn error_code_of(r: &Value) -> String {+    let d = unwrap_ab(r);+    [r.get("errorCode"), r.get("code"), r.get("status"), d.get("errorCode"), d.get("code"), d.get("status")]+        .iter()+        .flatten()+        .filter_map(|v| v.as_str())+        .map(|s| s.to_lowercase())+        .collect::<Vec<_>>()+        .join(" ")+        + " "+        + &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.+    let hosts = match vault_hosts() {+        Ok(h) => h,+        Err(e) => return 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()));+    };+    // 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.+    let deadline = std::time::Instant::now() + Duration::from_secs(consent_secs());+    loop {+        let r = crate::caller::ad_callback(+            "pup_credential_reveal",+            json!({"host": host, "reason": CONSENT_REASON}),+            Duration::from_secs(120),+        );+        let d = unwrap_ab(&r);+        let pass = ["password", "secret"].iter().find_map(|k| d.get(*k).and_then(|v| v.as_str()).map(|s| s.as_bytes().to_vec()));+        let user = ["username", "user", "login", "email"].iter().find_map(|k| d.get(*k).and_then(|v| v.as_str()).map(|s| s.as_bytes().to_vec()));+        let consent = d.get("consent").or_else(|| r.get("consent")).cloned().unwrap_or(json!("perUse"));+        if let Some(p) = pass.filter(|p| !p.is_empty()) {+            if ab_ok(&r) {+                return Vault::Got(+                    Secret { user, pass: p },+                    json!({"host": host, "consent": consent,+                           "consentBasis": "pup_credential_reveal's own hint: \"gated by a sticky consent toast the user must click\""}),+                );+            }+        }+        let code = error_code_of(&r);+        let err = scrub(&json!(ab_error(&r)), &[]).as_str().unwrap_or("").to_string();+        if code.contains("relay_forbidden") {+            return Vault::RelayForbidden(err);+        }+        if code.contains("consent_pending") || code.contains("pending") {+            if std::time::Instant::now() >= deadline {+                return Vault::Pending(err);             }+            std::thread::sleep(Duration::from_millis(1500));+            continue;         }+        let e = err.to_lowercase();+        return if e.contains("not found") || e.contains("no credential") || e.contains("no entry") || e.contains("no saved") || e.contains("no login") || e.contains("no match") {+            Vault::Missing(err)+        } else if e.contains("consent") || e.contains("denied") || e.contains("declined") || e.contains("cancel") {+            Vault::Denied(err)+        } else {+            Vault::Unavailable(err)+        };     } } @@ -476,10 +607,13 @@ fn sign_in_inner(reason: &str, user_confirmed_new_password: bool) -> Value {         }     };     // 2. THE SECRET, locally.-    let (secret, vault) = match reveal(reason) {+    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::Denied(e) => return err("sign_in_consent_denied", format!("the vault did not release the login: {e}"), "The user declined (or did not answer) the consent prompt. Nothing was typed.", json!({"inventory": inv_j})),+        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();@@ -491,7 +625,7 @@ fn sign_in_inner(reason: &str, user_confirmed_new_password: bool) -> Value {     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, "value": String::from_utf8_lossy(u), "sensitive": true, "reason": fill_reason})),+            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)}));@@ -503,7 +637,7 @@ fn sign_in_inner(reason: &str, user_confirmed_new_password: bool) -> Value {         if let Some(p_el) = &form.password {             let r = crate::caller::ad_callback(                 "desktop_ui_set",-                merge(target(p_el, "edit"), json!({"hwnd": hwnd, "value": String::from_utf8_lossy(&secret.pass), "sensitive": true, "reason": fill_reason})),+                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)}));
tests/signin.rs+138−6
@@ -86,6 +86,11 @@ fn ab_for(tree: Value, vault: Value) -> common::AbStub {     let ab = common::AbStub::start(json!([]));     ab.set_response("desktop_ui_tree", tree);     ab.set_response("pup_credential_reveal", vault);+    // The vault's host list (host + username, never a secret, no toast).+    ab.set_response("pup_credentials", json!({"success": true, "data": {"credentials": [+        {"host": "accounts.google.com", "username": "[email protected]"},+        {"host": "iam.3ds.com", "username": USER}+    ]}}));     ab.set_response("desktop_ui_set", json!({"success": true}));     ab.set_response("desktop_ui_click", json!({"success": true}));     ab.set_response("notify_user", json!({"success": true}));@@ -177,9 +182,9 @@ fn the_bridge_signs_in_from_the_vault_and_the_password_appears_nowhere() {     let sets = ab.calls_to("desktop_ui_set");     check!(         b,-        "the password was typed into the IsPassword element only, marked sensitive",-        sets.iter().any(|c| c["args"]["automationId"] == "password" && c["args"]["value"] == PW && c["args"]["sensitive"] == true)-            && !sets.iter().any(|c| c["args"]["automationId"] != "password" && c["args"]["value"] == PW),+        "the password was typed (ab's `text`) into the password element only, targeted by name",+        sets.iter().any(|c| c["args"]["name"] == "Password" && c["args"]["text"] == PW)+            && !sets.iter().any(|c| c["args"]["name"] != "Password" && c["args"]["text"] == PW),         format!("{sets:?}")     );     replies.push(b.call("swx_status", json!({})));@@ -232,7 +237,7 @@ 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"), "value": PW}));+    ab.set_response("desktop_ui_set", 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);@@ -253,7 +258,8 @@ fn an_ab_error_that_echoes_the_password_is_scrubbed() { fn it_fails_closed_without_a_vault_entry_an_unknown_form_or_a_second_step() {     // No vault entry: the exact import command, nothing typed.     let m = Machine::new("signin-missing");-    let ab = ab_for(form(vec![]), json!({"success": false, "error": "no saved login found for host 3ds.com"}));+    let ab = ab_for(form(vec![]), vault_ok());+    ab.set_response("pup_credentials", json!({"success": true, "data": {"credentials": [{"host": "accounts.google.com", "username": "x"}]}}));     let b = m.boot(TempDir::new("signin-missing-scratch"), &ab, &[]);     b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}));     wait_for_sign_in_stage(&b);@@ -262,7 +268,9 @@ fn it_fails_closed_without_a_vault_entry_an_unknown_form_or_a_second_step() {         b,         "no vault entry: sign_in_credential_missing with the import command; nothing typed",         r["errorCode"] == "sign_in_credential_missing" && r["importCommand"].as_str().unwrap_or("").contains("pup_import_browser_logins")-            && ab.calls_to("desktop_ui_set").is_empty(),+            && ab.calls_to("desktop_ui_set").is_empty()+            // M5: a missing login never raises the consent toast.+            && ab.calls_to("pup_credential_reveal").is_empty(),         s(&r)     ); @@ -423,3 +431,127 @@ fn an_abandoned_sign_in_record_is_reconciled_when_solidworks_is_attached() {         s(&st)     ); }++/// THE MEASURED TREE (ab 2.1.163, 2026-10-02): top-level {foreground, hwnd,+/// maxNodes, nodeCount, root, truncated, window, status, success}; nodes+/// {role, name, automationId, invokable, settable, rect, children} — no+/// `controlType`, no `isPassword`, and a `value` that is never copied. The+/// password edit is recognized by its name.+fn measured_tree(truncated: bool) -> Value {+    json!({+        "success": true, "status": "ok", "hwnd": 777001, "maxNodes": 400, "nodeCount": 9, "truncated": truncated,+        "foreground": false, "window": {"title": "Login | 3DEXPERIENCE ID | Dassault Systèmes"},+        "root": {"role": "Window", "name": "Login | 3DEXPERIENCE ID | Dassault Systèmes", "automationId": "",+                 "invokable": false, "settable": false, "rect": {"x": 0, "y": 0, "w": 480, "h": 640},+                 "children": [+            {"role": "Pane", "name": "3DEXPERIENCE ID", "automationId": "", "invokable": false, "settable": false, "rect": {}, "children": [+                {"role": "Edit", "name": "Email or username", "automationId": "username", "invokable": false, "settable": true, "value": "[email protected]", "rect": {}},+                {"role": "Edit", "name": "Password", "automationId": "password", "invokable": false, "settable": true, "value": "", "rect": {}},+                {"role": "CheckBox", "name": "Remember me", "automationId": "", "invokable": true, "settable": false, "toggleState": "Off", "rect": {}},+                {"role": "Button", "name": "Log in", "automationId": "", "invokable": true, "settable": false, "rect": {}},+                {"role": "Button", "name": "Forgot your password?", "automationId": "", "invokable": true, "settable": false, "rect": {}}+            ]}+        ]}+    })+}++#[test]+fn the_measured_tree_shape_signs_in_and_a_truncated_one_is_refused() {+    let m = Machine::new("signin-measured");+    let ab = ab_for(measured_tree(false), vault_ok());+    let b = m.boot(TempDir::new("signin-measured-scratch"), &ab, &[]);+    let mut replies = vec![b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}))];+    let st = wait_for_sign_in_stage(&b);+    check!(+        b,+        "the measured shape is read by role; the password edit is found by its name; no value is copied",+        st["signInInventory"].as_array().map(|a| a.iter().any(|e| e["name"] == "Password" && e["isPassword"] == true && e["controlType"] == "edit")).unwrap_or(false)+            && !st.to_string().contains("[email protected]"),+        s(&st)+    );+    replies.push(st);+    let r = std::thread::scope(|sc| {+        sc.spawn(|| close_window_on_submit(&m, &ab));+        b.call("swx_sign_in", json!({"reason": "sign in"}))+    });+    check!(b, "signed in on the measured shape", r["signedIn"] == true, s(&r));+    let reveal = ab.calls_to("pup_credential_reveal");+    check!(+        b,+        "the reveal names the vault host found on the 3ds.com domain and a plain reason for Drew's toast",+        reveal.len() == 1 && reveal[0]["args"]["host"] == "iam.3ds.com"+            && reveal[0]["args"]["reason"] == "Sign in to SOLIDWORKS with your saved 3DEXPERIENCE login",+        format!("{reveal:?}")+    );+    let clicks = ab.calls_to("desktop_ui_click");+    check!(b, "the submit was clicked BY NAME", clicks.iter().any(|c| c["args"]["name"] == "Log in"), format!("{clicks:?}"));+    replies.push(r);+    let all = everything_but_the_fill(&b, &ab, &replies);+    check!(b, "the password appears nowhere", !all.contains(PW), String::new());++    let m = Machine::new("signin-truncated");+    let ab = ab_for(measured_tree(true), vault_ok());+    let b = m.boot(TempDir::new("signin-truncated-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,+        "a truncated tree is unrecognized — never a guess; the vault never asked",+        r["errorCode"] == "sign_in_window_unrecognized" && ab.calls_to("pup_credential_reveal").is_empty(),+        s(&r)+    );+}++#[test]+fn consent_is_waited_for_and_relay_forbidden_is_a_wiring_bug() {+    // The toast is answered after a few polls: pending, pending, then the login.+    let m = Machine::new("signin-consent");+    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-scratch"), &ab, &[("SWX_SIGN_IN_CONSENT_SECS", "20")]);+    b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}));+    wait_for_sign_in_stage(&b);+    let r = std::thread::scope(|sc| {+        sc.spawn(|| {+            let deadline = Instant::now() + Duration::from_secs(15);+            while Instant::now() < deadline {+                if ab.calls_to("pup_credential_reveal").len() >= 2 {+                    ab.set_response("pup_credential_reveal", vault_ok());+                    return;+                }+                std::thread::sleep(Duration::from_millis(50));+            }+        });+        sc.spawn(|| close_window_on_submit(&m, &ab));+        b.call("swx_sign_in", json!({"reason": "sign in"}))+    });+    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.+    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")]);+    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,+        "an unanswered toast: sign_in_consent_pending, nothing typed",+        r["errorCode"] == "sign_in_consent_pending" && ab.calls_to("desktop_ui_set").is_empty(),+        s(&r)+    );++    // relay_forbidden: reported as the bridge's wiring bug.+    let m = Machine::new("signin-relay");+    let ab = ab_for(form(vec![]), json!({"success": false, "errorCode": "relay_forbidden", "error": "a relayed (cloud container) call is refused"}));+    let b = m.boot(TempDir::new("signin-relay-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,+        "relay_forbidden: sign_in_relay_forbidden, named a wiring bug",+        r["errorCode"] == "sign_in_relay_forbidden" && r["_hint"].as_str().unwrap_or("").contains("wiring bug"),+        s(&r)+    );+}
uninstall.sh