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.25 source
16 files changed
+313−72
BRIDGE_VERSION+1−1Cargo.lock+1−1Cargo.toml+1−1README.md+10−1SKILL.md+5−2bridge.json+1−1docs/LAUNCH-RESILIENCE.md+2install.shpackage.json+1−1references/launch-and-sign-in.md+10skills/solidworks-sign-in/SKILL.md+19src/signin.rs+176−48src/verbs.rs+8−6src/visualize.rs+4−4tests/signin.rs+74−6uninstall.shBRIDGE_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.shpackage.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