app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
← Commit history
0.9.14 source
12 files changed
+340−54
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.shpackage.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