← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.12+0.9.13
Cargo.lock+1−1
@@ -107,7 +107,7 @@ dependencies = [  [[package]] name = "swx-bridge"-version = "0.9.12"+version = "0.9.13" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.9.12"+version = "0.9.13" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"
README.md+13−2
@@ -9,7 +9,7 @@ One native binary. No Python, no PowerShell, no interpreter to provision: ab spawns `swx-bridge.exe` directly (`spawn.kind: "exe"`) and it is serving in about ten milliseconds. -## What it does — 67 verbs+## What it does — 68 verbs  | Area | Verbs | |---|---|@@ -21,6 +21,7 @@ about ten milliseconds. | Export | `swx_export` plus per-format aliases `swx_export_step/iges/stl/3mf/glb/sat/parasolid/pdf/dxf/dwg`, `swx_pack_and_go` | | Read the design back | `swx_design_info`, `swx_inspect_bodies` (geometry in mm), `swx_get_parameters`, `swx_set_parameter`, `swx_mass_properties`, `swx_bom`, `swx_interference`, `swx_rebuild_check`, `swx_document_info` | | Metadata | `swx_props_get`, `swx_props_set` (custom properties, file- or configuration-scope) |+| Sign-in | `swx_sign_in` — signs in to 3DEXPERIENCE for a launch this bridge started, from the user's own local vault; the password never reaches a reply, a log or the container, and a rejected password is never retried. | | App & documents | `swx_get_app_state`, `swx_state`, `swx_status`, `swx_readiness`, `swx_progress`, `swx_start`, `swx_stop`, `swx_kill`, `swx_activate_document`, `swx_close_all_documents`, `swx_reset_workspace`, `swx_show_part/_assembly/_drawing` | | Windows & etiquette | `swx_window_info`, `swx_dismiss_blocking_dialogs`, `swx_close_window`, `swx_check_recovery`, `swx_screenshot`, `swx_set_driving_badge`, `swx_action_ledger`, `swx_verb_times` | | Preferences & cloud | `swx_get_preferences`, `swx_set_preference`, `swx_cloud_status` |@@ -460,7 +461,17 @@ not.  ## Roadmap -- v0.9.12 (this): **an assembly session closes completely.** 0.9.11 was proven+- v0.9.13 (this): **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+  from the user's own vault on their laptop, types it through UI Automation,+  ticks "Remember me", and verifies the window closed — the password never+  appears in a reply, log or ledger, and a rejected one is never retried.+  `swx_start {signIn:"auto"}` does it unattended; the default still waits. A+  respawned bridge sees a sign-in from a live census, and a record left at+  `helperSignIn` is reconciled once SOLIDWORKS attaches. 521 tests.+- v0.9.12: **an assembly session closes completely.** 0.9.11 was proven   LIVE on 2026-10-02 on Drew's 34-document crate session (lock-out fixed; the   presence guard refused the sweep until Drew's own words overrode it; Part1   preserved, 33 closed in place untouched; quit 3.5 s) — and the sweep could not
SKILL.md+4−2
@@ -68,8 +68,10 @@ BEFORE you do it:    SOLIDWORKS (measured 2026-10-01), so every verb that reaches it refuses    `host_app_starting` until `solidworks.ready` is true — wait `retryAfterSeconds`    and call again. `startingUnverifiable:true` means only reads may run.-   `stage:"helperSignIn"` means a person must sign in — only the user can. Never-   launch `sldworks.exe` or the launcher any other way.+   `waitingOn:"userSignIn"` means the 3DEXPERIENCE sign-in is up: tell the user,+   or call `swx_sign_in {reason}` to fill it from THEIR local vault (it never+   shows you the password, and never retries a rejected one). Never launch+   `sldworks.exe` or the launcher any other way. 7. **Name the document you mean.** Pass `targetDocument` (a title) or `path` on    every verb that changes something; the active document follows window focus,    and the user may click something else mid-run.
bridge.json+4−2
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "solidworks",   "displayName": "SOLIDWORKS",-  "version": "0.9.12",+  "version": "0.9.13",   "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",@@ -79,6 +79,7 @@     "swx_show_drawing",     "swx_show_part",     "swx_shutdown",+    "swx_sign_in",     "swx_start",     "swx_state",     "swx_status",@@ -145,7 +146,8 @@       "close_all_safely": 600,       "shutdown": 900,       "close_import_temps": 600,-      "stop": 420+      "stop": 420,+      "sign_in": 240     }   },   "detect": {
docs/ECOSYSTEM-TABLE.md+1
@@ -113,6 +113,7 @@ templates**, alisamsam **22**.  | 48 | **A title collision between SAVED documents is impossible here — measured, not assumed** | ✗ | **✓ MEASURED LIVE 2026-09-17** — `tools/drain-gate.py --fixture new` copied a bridge-created document's own file to a second directory under the same basename and called `swx_open` on it. SOLIDWORKS REFUSED: `errors = 65536` (`swFileWithSameTitleAlreadyOpen`). So the drain's title-collision guard — which refuses to close any document whose title another open document shares, and names both paths — can only ever fire for PATHLESS documents, which is exactly the state identity-at-birth removes. The guard stays (a document the bridge did not create can still be pathless, and an older bridge's leftovers are), and the measurement is recorded next to it in `com.rs` so nobody "simplifies" it on the assumption that saved collisions are the common case. No MCP server measures this at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 52 | **Never ends a session the user is working in** (a measured user-presence guard on every quit and sweep) | ✗ | **✓ 0.9.9, from an event MEASURED 2026-10-01**: Drew took over a bridge-launched session — a "leave as found" quit met his File Open dialog, and minutes later the session held his 34-document crate assembly. Every quit, sweep and priming now measures presence (SOLIDWORKS foreground + input under 120 s, documents the bridge did not open, an earlier take-over), refuses `user_is_active` with the evidence, fails closed on an unreadable measurement, and marks the session taken over for good; only the user's explicit override proceeds, and the gates never pass it. 0.9.10: a document is the bridge's only if a bridge verb opened or created it — in a session the user launched, every document is theirs. No MCP server has a presence guard at all | ✗ | ✗ | ✗ | ✗ | ✗ |+| 53 | **Signs the user in, unattended, without ever seeing the password** (3DEXPERIENCE ID sign-in from the user's own local vault) | ✗ | **✓ 0.9.13, from Drew's request 2026-10-02**: the login window's accessible inventory is captured every time it appears; `swx_sign_in` recognizes the form from that inventory alone, takes the login from the user's pup vault on their laptop through ab, types it through UI Automation, ticks "Remember me", verifies by the window closing, and scrubs the password from every reply, log and ledger line; it fails closed on a missing login, an unknown form or a second step, and never retries a rejected password. No MCP server signs in at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 51 | **Dialogs judged by MEASURED MODALITY, not by existence** (a notification never blocks; a modal always does) | ✗ | **✓ LIVE-PROVEN 2026-10-01 on 0.9.4** — with the 3DEXPERIENCE "Connected" notice on screen, `swx_stop` quit in 5.1 s and reported it as a notification with its evidence. **0.9.4, from a blocker MEASURED 2026-10-01**: the 3DEXPERIENCE "Connected to …" notice — a title-less `#32770` tool window whose owner frame stayed ENABLED for 95 s, with no Button-class control — blocked every quit, sweep and import for minutes. Now a window blocks when its kind is known-blocking, its owner frame is disabled, or it has a Button-class control; it is a `notification` (reported with owner, ownerEnabled, styles, rect and child controls; never answered) only when all of that was measured; unread evidence fails closed. Template placeholder captions are flagged, never presented as the window's words. **0.9.5 (adversary pass 43): the window's WORDS count — a save/recovery/import kind in its text blocks whatever the evidence, two or more button-like captions in any control class block, and measured notification evidence overrides a licence-type title alone; 0.9.7: swx_dismiss_blocking_dialogs uses the same title-and-words classifier before it closes anything; 0.9.8, from a live measurement: an owner frame read disabled for an instant (SOLIDWORKS busy) is re-read before a notice is called a modal.** No MCP server looks at a dialog at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 50 | **Readiness gating — attached is not ready** (no document command reaches a SOLIDWORKS that has not finished starting) | ✗ | **✓ 0.9.3, from a crash MEASURED 2026-10-01**: `swx_new_document` 8 s after COM attach, `swx_open` at 9 s and `swx_close_all_safely` at 12 s crashed SOLIDWORKS while its 3DEXPERIENCE add-in was still initialising (performance log: LOAD_ADDIN still running, SWEXH/UNHANDLED, the 3DEXPERIENCE marker only after the document commands, SWERR_REPORT); a control launch with no document commands crashed nothing. Now a READY stage follows `attached`, decided by two independent signals — COM `StartupProcessCompleted` and the performance log's 3DEXPERIENCE marker after the last LOAD_ADDIN — with an honest grace and timeout that never promote to ready; every verb that reaches SOLIDWORKS refuses `host_app_starting` with `retryAfterSeconds` until then — **0.9.5: gated by default with a pinned 16-verb exemption list, the performance log bound to this session's process creation time (an earlier session's log is never "complete"), COM alone never enough on a machine that writes markers, an on-demand add-in told from a start by quiet + which add-in, the grace unlocking reads only, and the window-message quit rungs ungated under userAuthorized; LIVE 2026-10-01 a document verb was refused during startup. 0.9.6: every gated reply carries the readiness its gate evaluated, the log is read from its per-start header, and the on-demand rule keys on the platform DLL SWK3DCompassAddin.dll; 0.9.7 (LIVE-PROVEN 0.9.6): no marker history — a disabled platform add-in never locks the bridge out, and a log still being written holds even a machine's first start; 0.9.11, from a live lock-out: `UNLOAD_ADDIN` is never read as a load, a session older than ten minutes with COM's start complete is never held by a log rule unless startup lines are still changing, and the ready latch survives a bridge restart**; a sweep during which SOLIDWORKS dies says `host_app_gone` with the log's crash markers. The rescue held under the crash (26,129 bytes verified). No MCP server distinguishes attached from ready at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 49 | **The user's rescued work survives the bridge's own updates — and ab's** (recovery never lives in a directory anything else owns) | ✗ | **✓ 0.9.1: the data root is `%LOCALAPPDATA%\adom-solidworks-bridge\`, a directory only this bridge writes to** — moved out of `Adom Bridge\` altogether after adversary pass 39 found 0.9.0's `Adom Bridge\solidworks-data` sitting inside the directory ab's installer and uninstaller own, whose fate on an ab uninstall or reinstall is documented nowhere; both earlier locations are migrated oldest first, every copy recorded by (root, path, size, mtime, sha256) so a legacy file that changes after its copy is copied again BESIDE it and reported `changedSinceCopy`, never settled by name — and since 0.9.2 a small file whose size and mtime match is hashed too, and two versions of the ledger or the ownership registry are MERGED (union, both versions kept beside) rather than one picked; a temp-directory fallback makes every sweep refuse (`data_root_is_temp`), and an unwritable recovery folder is refused once before the first save (`recovery_dir_unwritable`). **0.9.0, from a SEV-1 MEASURED 2026-09-30**: `bridges-cache\solidworks` was found CREATED 2026-09-18 09:42 by ab's auto-install of 0.8.9 — the updater re-extracts the whole directory, and every recovery folder from the day before, the ledger and the ownership registry went with it (test documents only; nothing of Drew's was lost). The data root is now `%LOCALAPPDATA%\Adom Bridge\solidworks-data\`, a sibling of the install cache; the first start MIGRATES what an older bridge left (copied under a temporary name and renamed into place once verified byte for byte — so a start that dies mid-copy never leaves a truncated rescue under the real name — never over an existing file, originals left in place, each file once, a copy that does not verify removed and retried next start); `swx_recover_list` still reads the old location and marks each rescue there `atRiskFromUpdate`, saying whether a verified copy exists — and in the data root calls a file whose copy has not landed `notCopiedYet` (pointing at its only copy), never "gone"; and a sweep REFUSES (`recovery_inside_install_dir`, nothing written, nothing closed) if its recovery folder would land under the install directory or the install cache — however the path is spelled (links, `..`, case). Every rule mutation-verified. No MCP server keeps a recovery folder at all | ✗ | ✗ | ✗ | ✗ | ✗ |
docs/LAUNCH-RESILIENCE.md+38
@@ -1265,3 +1265,41 @@ bounded retry stays only for a script that never ran); the UIA capture says why has nothing (no direct-API address in that shell, curl's exit and output, or the exception) and, being optional and read-only, can no longer abort a gate whose claims passed — a failure is a NOTE.++## Unattended sign-in — 0.9.13 (2026-10-02)++Drew: *"can we please figure out how to sign me in? … you can't launch the thing+without me manually typing in my password every time. Can you type in that+window at all?"*++**Measured:** the sign-in is a native window, class+`DSYSysLoginPanelWebView2Class`, title "Login | 3DEXPERIENCE ID | Dassault+Systèmes", owned by SWXDesktopLauncher.exe — WebView2 content, reachable through+UI Automation. It appears when the 3DEXPERIENCE platform session has EXPIRED: on+none of ~15 launches on 09-30/10-01, then on 10-02 after overnight licence+failures. So "every time" is "every time the platform session lapses".++**What 0.9.13 does.**++| | |+|---|---|+| Inventory first | Every time a launch reaches the sign-in, the window's accessible inventory — control type, name, automation id, IsPassword, toggle state, enabled; NEVER a value — is read through ab's `desktop_ui_tree` and kept in the launch state (`swx_status.signInInventory`). Nobody has seen that tree yet; the next expiry measures it even if the user signs in by hand |+| `swx_sign_in {reason}` | Only for a launch this bridge started, its own chain showing the window. Recognizes the form from the inventory alone (exactly one IsPassword edit, at most one other edit, one button named like a sign-in); takes the login from the user's pup vault LOCALLY through ab (`pup_credential_reveal`); types it with `desktop_ui_set` (`sensitive:true`); ticks a "Remember me"-type checkbox if present; presses submit; zeroes its copy; verifies by EFFECT — the window closes |+| Fails closed | `sign_in_credential_missing` (+ the import command `pup_import_browser_logins {"hostFilter":"3ds.com"}`), `sign_in_window_unrecognized` (+ inventory), `sign_in_needs_user` (a second step; the user is toasted), `sign_in_rejected` — NEVER retried, persisted (`sign-in-rejected.json`) until the user says the saved password changed, `sign_in_unconfirmed`, `sign_in_vault_unavailable`, `sign_in_consent_denied`, `sign_in_fill_failed` |+| The secret | Never in a reply, a log line, the ledger, an error text, a fixture or a command line: every value leaving the module is scrubbed of it (and of credential-bearing keys); its copy is zeroed after the fill. Best effort: the HTTP buffers that carried it are dropped, not wiped |+| `signIn:"auto"` | `swx_start {signIn:"auto"}` signs in once by itself when the window appears; the default stays `wait` |+| Ledger | `solidworks_sign_in`: who asked, why, the outcome, the consent ab reported — no secret |+| Status after a respawn | `waitingOn`/`signInWindow` come from a LIVE census of the recorded chain whenever the record names the sign-in stage (measured 10-02: a respawn mid-launch left them null) |+| Reconciled on attach | A record still saying `helperSignIn`/`abandoned` once COM is attached moves to stage `attached`; outcome and attribution keep their evidence, and the re-attribution refusal is kept as `supersededByAttach` (measured 10-02) |++**Unmeasured, and the first live questions:** the real reply shapes of+`desktop_ui_tree`, `desktop_ui_set` and `pup_credential_reveal` (the parser is+lenient, the addressing passes `automationId` + `contains` + `role`); what+consent `pup_credential_reveal` needs (`vault.consent` reports what ab says,+`unmeasured` otherwise); whether ab itself logs a `desktop_ui_set` value marked+`sensitive`; which vault host the 3DEXPERIENCE login is saved under+(`SWX_SIGN_IN_HOST`, default `3ds.com`); and whether the form is one page or two+(on a username-only first page the user field is filled and the button+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).
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "solidworks-bridge",-  "version": "0.9.12",+  "version": "0.9.13",   "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+17
@@ -207,3 +207,20 @@ Read this before starting SOLIDWORKS, while waiting through a launch or a 3DEXPE - **`waitingOn:"userSignIn"`** on `swx_start` / `swx_status` means the launch is   waiting for the user to sign in to 3DEXPERIENCE in the window `signInWindow`   names. Only the user can answer it; tell them, and poll `swx_status`.+- **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 —+  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+  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+  user), `sign_in_window_unrecognized` (with the inventory),+  `sign_in_needs_user` (a code / second step: the user is notified),+  `sign_in_rejected` — never retried; later calls refuse+  `sign_in_rejected_before` until the user says the saved password changed+  (`userConfirmedNewPassword:true`, on their word only).+  `swx_start {signIn:"auto"}` lets the bridge do it once by itself; the default+  is to wait for the user.
src/launch.rs+109−3
@@ -453,9 +453,9 @@ fn announce_sign_in(title: &str) {         json!({             "title": "SOLIDWORKS needs your 3DEXPERIENCE sign-in",             "message": format!(-                "The launch is waiting at \"{title}\". Type your password in that window — this \-                 bridge cannot (and never stores one). Tick \"Remember me\" so the next launch \-                 does not stop here."+                "The launch is waiting at \"{title}\". Type your password in that window and tick \+                 \"Remember me\" — or, if your 3DEXPERIENCE login is saved in your local vault, \+                 ask the AI to sign you in (swx_sign_in). The bridge never stores a password."             ),         }),         Duration::from_secs(10),@@ -493,6 +493,56 @@ pub fn sign_in_window(os: &dyn LaunchOs, ours: &[u32]) -> Option<Option<(i64, St     } } +/// IS OUR LAUNCH SHOWING A SIGN-IN WINDOW RIGHT NOW? From a LIVE census of the+/// recorded chain's pids — so it is right even when the monitor thread that+/// first saw the window died with an older bridge process (0.9.13; measured+/// 2026-10-02: after a respawn mid-launch, swx_status said `helperSignIn` with+/// `waitingOn` null). `None` = the windows could not be read.+pub fn sign_in_now(os: &dyn LaunchOs) -> Option<Option<(i64, String)>> {+    let ours = chain_pids_now();+    if ours.is_empty() {+        return Some(None);+    }+    sign_in_window(os, &ours)+}++pub fn note_sign_in_inventory(inv: Value) {+    update(move |a| a.sign_in_inventory = Some(inv));+}++pub fn note_sign_in_rejected() {+    update(|a| a.sign_in_rejected = true);+}++/// `swx_start {signIn:"auto"}` — recorded on the attempt in flight; if the+/// sign-in window is ALREADY up and no unattended attempt has been made, it is+/// made now (once).+pub fn arm_auto_sign_in(os: &dyn LaunchOs) {+    update(|a| a.sign_in_auto = true);+    if let Some(Some((hwnd, _))) = sign_in_now(os) {+        on_sign_in_window(hwnd);+    }+}++/// INVENTORY + (when armed) THE UNATTENDED SIGN-IN, off the monitor thread so+/// the launch's own polling never waits on ab.+fn on_sign_in_window(hwnd: i64) {+    std::thread::spawn(move || {+        if let Ok(inv) = crate::signin::inventory(hwnd) {+            note_sign_in_inventory(crate::signin::inventory_json(&inv));+        }+        let auto = attempt().map(|a| a.sign_in_auto && !a.sign_in_auto_tried && !a.sign_in_rejected).unwrap_or(false);+        if auto {+            update(|a| a.sign_in_auto_tried = true);+            crate::signin::sign_in(+                "swx_start {signIn:\"auto\"}: the launch reached the 3DEXPERIENCE sign-in",+                false,+                crate::caller::self_caller(),+            );+        }+    });+}+ // ── tunables (test knobs; the defaults are the measured machine) ────────────  fn secs_env(name: &str, default: u64) -> u64 {@@ -728,6 +778,18 @@ pub struct Attempt {     /// THE SIGN-IN WINDOW ON SCREEN NOW, by title (0.9.12) — `None` when none is     /// up. What `waitingOn:"userSignIn"` names.     pub sign_in_window: Option<String>,+    /// THE LOGIN WINDOW'S ACCESSIBLE INVENTORY, captured every time a launch+    /// reaches the sign-in (0.9.13): control types, names, automation ids,+    /// IsPassword, toggle states — never a value.+    pub sign_in_inventory: Option<Value>,+    /// The saved password was rejected in this launch: never retried.+    pub sign_in_rejected: bool,+    /// `swx_start {signIn:"auto"}`: the bridge may sign in by itself, ONCE.+    pub sign_in_auto: bool,+    pub sign_in_auto_tried: bool,+    /// The launch record ended (abandoned, timed out) before SOLIDWORKS came up,+    /// and SOLIDWORKS was later found attached — the clause re-attribution gave.+    pub superseded_by_attach: Option<String>,     /// LAUNCHER PROCESSES THAT APPEARED AFTER OUR INVOKE AND CANNOT BE PROVED     /// EITHER WAY. An unattributable route (`explorer`, `cmdStart`) cannot say     /// what it started, so a launcher that shows up while one is running is@@ -821,6 +883,11 @@ impl Attempt {             "runBeginSeen": self.run_begin_seen,             "signInSeen": self.sign_in_seen,             "signInWindow": self.sign_in_window,+            "signInInventory": self.sign_in_inventory,+            "signInRejected": self.sign_in_rejected,+            "signInMode": if self.sign_in_auto { "auto" } else { "wait" },+            "signInAutoTried": self.sign_in_auto_tried,+            "supersededByAttach": self.superseded_by_attach,             // Launcher processes that appeared while an UNATTRIBUTABLE route was             // running: not proved ours, not proved anybody else's, and never             // terminated on either reading.@@ -962,6 +1029,11 @@ pub fn load() -> LaunchStore {         run_begin_seen: a.get("runBeginSeen").and_then(|x| x.as_bool()).unwrap_or(false),         sign_in_seen: a.get("signInSeen").and_then(|x| x.as_bool()).unwrap_or(false),         sign_in_window: a.get("signInWindow").and_then(|x| x.as_str()).map(String::from),+        sign_in_inventory: a.get("signInInventory").filter(|x| !x.is_null()).cloned(),+        sign_in_rejected: a.get("signInRejected").and_then(|x| x.as_bool()).unwrap_or(false),+        sign_in_auto: a.get("signInMode").and_then(|x| x.as_str()) == Some("auto"),+        sign_in_auto_tried: a.get("signInAutoTried").and_then(|x| x.as_bool()).unwrap_or(false),+        superseded_by_attach: a.get("supersededByAttach").and_then(|x| x.as_str()).map(String::from),         possibly_ours: a             .get("possiblyOurs")             .and_then(|x| x.as_array())@@ -1325,6 +1397,11 @@ pub fn begin(             run_begin_seen: false,             sign_in_seen: false,             sign_in_window: None,+            sign_in_inventory: None,+            sign_in_rejected: false,+            sign_in_auto: false,+            sign_in_auto_tried: false,+            superseded_by_attach: None,             possibly_ours: Vec::new(),             chain_last_seen: Vec::new(),             attributed_sldworks: None,@@ -1388,6 +1465,11 @@ fn unfinished_placeholder(shortcut: &str) -> Attempt {         run_begin_seen: false,         sign_in_seen: false,         sign_in_window: None,+        sign_in_inventory: None,+        sign_in_rejected: false,+        sign_in_auto: false,+        sign_in_auto_tried: false,+        superseded_by_attach: None,         possibly_ours: Vec::new(),         chain_last_seen: Vec::new(),         attributed_sldworks: None,@@ -2135,6 +2217,9 @@ fn watch_route(channel: &Channel, os: &dyn LaunchOs, route: &str, w: &Watch) ->                             let t = title.clone();                             update(move |a| a.sign_in_window = Some(t));                         }+                        // INVENTORY FIRST, every time (0.9.13) — and the+                        // unattended sign-in when the caller armed it.+                        on_sign_in_window(hwnd);                         sign_in_since = Some(std::time::Instant::now());                         sign_in_hwnd = Some(hwnd);                         set_stage_at_least(Stage::HelperSignIn);@@ -3135,6 +3220,27 @@ pub fn report_with_attach(os: &dyn LaunchOs, attached: bool) -> Value {     // is now running.     let evidence = reattribute_after_attach(os, attached);     let refused = reattribution_refused_because();+    // RECONCILED ON ATTACH (0.9.13). MEASURED 2026-10-02: after Drew signed in,+    // the record stayed {stage:"helperSignIn", outcome:"abandoned"} while+    // SOLIDWORKS was running and ready (the bridge had been respawned mid-launch,+    // so no supervisor saw the attach). When COM is attached and the record still+    // names a pre-attach stage, the STAGE moves to `attached`; the outcome and+    // the attribution keep their evidence, and the reason re-attribution gave is+    // kept as `supersededByAttach`.+    if attached && evidence.is_none() {+        if let Some(a) = attempt() {+            if a.finished && a.stage != Stage::Attached && a.outcome.as_deref() != Some("attached") {+                let why = refused.clone().unwrap_or_else(|| "not re-attributed".into());+                mutate_store(|s| {+                    if let Some(at) = s.attempt.as_mut() {+                        at.stage = Stage::Attached;+                        at.superseded_by_attach = Some(why);+                        at.sign_in_window = None;+                    }+                });+            }+        }+    }     let store = read_store(|s| s.clone());     let live = in_flight(os);     let backoff = backoff_remaining();
src/lib.rs+1
@@ -43,6 +43,7 @@ pub mod quit; pub mod readiness; pub mod disk; pub mod scratch;+pub mod signin; pub mod sha256; pub mod script; pub mod server;
src/signin.rsadded+613
@@ -0,0 +1,613 @@+//! UNATTENDED 3DEXPERIENCE SIGN-IN (0.9.13).+//!+//! Drew, 2026-10-02: *"can we please figure out how to sign me in? … you can't+//! launch the thing without me manually typing in my password every time."*+//!+//! MEASURED: the sign-in is a native window, class+//! `DSYSysLoginPanelWebView2Class`, title "Login | 3DEXPERIENCE ID | Dassault+//! Systèmes", owned by SWXDesktopLauncher.exe — WebView2 content, so its fields+//! are reachable through UI Automation, which ab drives (`desktop_ui_tree`,+//! `desktop_ui_set`, `desktop_ui_click`). It appears when the 3DEXPERIENCE+//! platform session has EXPIRED: not on ~15 launches on 09-30/10-01, then on+//! 10-02 after licence checks failed overnight.+//!+//! NOBODY HAS SEEN THAT WINDOW'S ACCESSIBLE TREE YET. So:+//!   * every time a launch reaches the sign-in, its INVENTORY is captured —+//!     control types, names, automation ids, IsPassword, checkbox states, NEVER+//!     a field's value — into the launch state and the reply;+//!   * nothing here assumes field names: the password field is the one edit+//!     whose IsPassword is true, the user field the one other edit, the submit+//!     the one button named like a sign-in; anything else is+//!     `sign_in_window_unrecognized`, with the inventory.+//!+//! THE SECRET (Drew's standing rules): it lives ONLY in the pup vault on his+//! laptop. This bridge runs on that laptop and asks ab for it LOCALLY+//! (`pup_credential_reveal`), types it through UIA, and zeroes its own copy.+//! It never appears in a reply, a log line, the ledger, an error text or a+//! command line: every value that leaves this module is scrubbed of it.+//! A rejected password is NEVER retried (lockout risk).++use serde_json::{json, Value};+use std::time::Duration;++const AB_TIMEOUT: Duration = Duration::from_secs(60);+pub const IMPORT_COMMAND: &str = "pup_import_browser_logins {\"hostFilter\":\"3ds.com\"}";++/// The vault entry asked for. `SWX_SIGN_IN_HOST` overrides.+pub fn vault_host() -> String {+    std::env::var("SWX_SIGN_IN_HOST").ok().filter(|h| !h.trim().is_empty()).unwrap_or_else(|| "3ds.com".into())+}++fn verify_secs() -> u64 {+    std::env::var("SWX_SIGN_IN_VERIFY_SECS").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(30)+}++// ── the secret ──────────────────────────────────────────────────────────────++/// The credential, held only as long as the fill takes, and zeroed on drop.+/// Best effort: the HTTP buffers that carried it are dropped, not wiped.+struct Secret {+    user: Option<Vec<u8>>,+    pass: Vec<u8>,+}++impl Secret {+    fn needles(&self) -> Vec<String> {+        let mut v = vec![String::from_utf8_lossy(&self.pass).to_string()];+        // The username is not a secret, but it is personal: kept out too.+        if let Some(u) = &self.user {+            v.push(String::from_utf8_lossy(u).to_string());+        }+        v.into_iter().filter(|s| !s.is_empty()).collect()+    }+}++fn wipe(b: &mut [u8]) {+    for x in b.iter_mut() {+        // SAFETY: a plain write to memory we own; volatile so it is not elided.+        unsafe { std::ptr::write_volatile(x, 0) };+    }+}++impl Drop for Secret {+    fn drop(&mut self) {+        wipe(&mut self.pass);+        if let Some(u) = self.user.as_mut() {+            wipe(u);+        }+    }+}++/// SCRUB: every string in `v` with every occurrence of every needle replaced,+/// and every key that could carry a credential removed.+pub fn scrub(v: &Value, needles: &[String]) -> Value {+    match v {+        Value::String(s) => {+            let mut s = s.clone();+            for n in needles {+                if !n.is_empty() {+                    s = s.replace(n.as_str(), "[redacted]");+                }+            }+            Value::String(s)+        }+        Value::Array(a) => Value::Array(a.iter().map(|x| scrub(x, needles)).collect()),+        Value::Object(m) => Value::Object(+            m.iter()+                .filter(|(k, _)| {+                    let k = k.to_ascii_lowercase();+                    !matches!(k.as_str(), "password" | "secret" | "value" | "credential" | "username")+                })+                .map(|(k, x)| (k.clone(), scrub(x, needles)))+                .collect(),+        ),+        other => other.clone(),+    }+}++// ── ab replies ──────────────────────────────────────────────────────────────++/// ab answers either with the data inline or as `output` holding a JSON string+/// (the shape the rest of this bridge reads); `data` is unwrapped if present.+fn unwrap_ab(v: &Value) -> Value {+    let mut cur = v.clone();+    if let Some(s) = cur.get("output").and_then(|o| o.as_str()) {+        if let Ok(p) = serde_json::from_str::<Value>(s) {+            cur = p;+        }+    }+    if let Some(d) = cur.get("data").cloned() {+        if !d.is_null() {+            cur = d;+        }+    }+    cur+}++fn ab_ok(v: &Value) -> bool {+    v.get("success").and_then(|s| s.as_bool()) != Some(false)+        && v.get("error").map(|e| e.is_null()).unwrap_or(true)+}++fn ab_error(v: &Value) -> String {+    v.get("error").and_then(|e| e.as_str()).unwrap_or("ab reported a failure").to_string()+}++// ── the inventory ───────────────────────────────────────────────────────────++/// One accessible element — its SHAPE, never its value.+#[derive(Clone, Debug, Default, PartialEq)]+pub struct Element {+    pub control_type: String,+    pub name: String,+    pub automation_id: String,+    pub is_password: Option<bool>,+    pub toggle_state: Option<String>,+    pub enabled: Option<bool>,+}++impl Element {+    pub fn to_json(&self) -> Value {+        json!({+            "controlType": self.control_type,+            "name": self.name,+            "automationId": self.automation_id,+            "isPassword": self.is_password,+            "toggleState": self.toggle_state,+            "enabled": self.enabled,+        })+    }+    fn is(&self, t: &str) -> bool {+        self.control_type == t+    }+}++fn norm_type(t: &str) -> String {+    let t = t.to_ascii_lowercase();+    let t = t.trim_start_matches("controltype.").trim_end_matches("control").trim();+    match t {+        "edit" | "textbox" | "text box" | "input" | "textfield" => "edit".into(),+        "button" | "pushbutton" | "push button" => "button".into(),+        "checkbox" | "check box" => "checkbox".into(),+        other => other.to_string(),+    }+}++fn str_of(m: &serde_json::Map<String, Value>, keys: &[&str]) -> String {+    keys.iter().find_map(|k| m.get(*k).and_then(|v| v.as_str())).unwrap_or("").to_string()+}++/// Every element in ab's tree reply, depth first. Lenient about the reply's+/// shape (it has not been measured) and STRICT about what is copied: type,+/// name, automation id, IsPassword, toggle state, enabled — never a value.+pub fn parse_inventory(reply: &Value) -> Vec<Element> {+    fn walk(v: &Value, out: &mut Vec<Element>) {+        match v {+            Value::Array(a) => a.iter().for_each(|x| walk(x, out)),+            Value::Object(m) => {+                let ty = str_of(m, &["controlType", "control_type", "role", "type", "localizedControlType"]);+                if !ty.is_empty() && (m.contains_key("name") || m.contains_key("automationId") || m.contains_key("automation_id")) {+                    out.push(Element {+                        control_type: norm_type(&ty),+                        name: str_of(m, &["name"]),+                        automation_id: str_of(m, &["automationId", "automation_id", "id"]),+                        is_password: m.get("isPassword").or_else(|| m.get("is_password")).and_then(|x| x.as_bool()),+                        toggle_state: m+                            .get("toggleState")+                            .or_else(|| m.get("toggle_state"))+                            .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()),+                    });+                }+                for (k, x) in m {+                    if matches!(k.as_str(), "children" | "elements" | "nodes" | "tree" | "root" | "items" | "controls") {+                        walk(x, out);+                    }+                }+            }+            _ => {}+        }+    }+    let mut out = Vec::new();+    walk(&unwrap_ab(reply), &mut out);+    out+}++/// 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);+    if !ab_ok(&r) {+        return Err(format!("desktop_ui_tree failed: {}", ab_error(&r)));+    }+    let inv = parse_inventory(&r);+    if inv.is_empty() {+        return Err("desktop_ui_tree answered, but no element with a control type could be read from it".into());+    }+    Ok(inv)+}++pub fn inventory_json(inv: &[Element]) -> Value {+    json!(inv.iter().map(|e| e.to_json()).collect::<Vec<_>>())+}++// ── what the window is asking ───────────────────────────────────────────────++const SECOND_STEP: &[&str] = &[+    "verification code", "security code", "one-time", "one time", "two-step", "2-step", "two-factor",+    "2fa", "authenticator", "captcha", "not a robot", "new device", "verify your identity",+    "confirm your identity", "enter the code",+];+const REJECTED: &[&str] = &[+    "incorrect", "invalid", "wrong password", "does not match", "do not match", "not recognized",+    "authentication failed", "login failed", "sign in failed", "couldn't sign", "could not sign",+];+const SUBMIT_PRIMARY: &[&str] = &["log in", "login", "sign in", "sign-in", "signin", "connect", "log on"];+const SUBMIT_SECONDARY: &[&str] = &["continue", "next", "submit", "ok"];+const REMEMBER: &[&str] = &["remember", "stay signed", "keep me signed", "keep me logged"];++/// The recognized form.+#[derive(Debug, Clone)]+pub struct Form {+    pub user: Option<Element>,+    pub password: Option<Element>,+    pub submit: Element,+    pub remember: Option<Element>,+}++#[derive(Debug, Clone, PartialEq)]+pub enum Reading {+    Form,+    SecondStep(String),+    Rejected(String),+    Unrecognized(String),+}++fn has_marker(inv: &[Element], markers: &[&str]) -> Option<String> {+    inv.iter()+        .map(|e| format!("{} {}", e.name, e.automation_id).to_lowercase())+        .find_map(|s| markers.iter().find(|m| s.contains(*m)).map(|m| m.to_string()))+}++/// What the window is asking for, from its inventory alone.+pub fn recognize(inv: &[Element]) -> (Reading, Option<Form>) {+    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();+    if passwords.len() > 1 {+        return (Reading::Unrecognized(format!("{} password fields", passwords.len())), None);+    }+    if users.len() > 1 {+        return (Reading::Unrecognized(format!("{} non-password text fields (expected at most one: the user name)", users.len())), None);+    }+    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();+    let named = |list: &[&str]| -> Vec<&Element> {+        buttons+            .iter()+            .filter(|b| {+                let n = b.name.trim().trim_end_matches(['.', '…']).to_lowercase();+                list.contains(&n.as_str())+            })+            .copied()+            .collect()+    };+    let primary = named(SUBMIT_PRIMARY);+    let submit = match (primary.len(), named(SUBMIT_SECONDARY)) {+        (1, _) => primary[0].clone(),+        (0, s) if s.len() == 1 => s[0].clone(),+        (0, _) => return (Reading::Unrecognized("no single button named like a sign-in (Log in / Sign in / Connect / Continue / Next)".into()), None),+        (n, _) => return (Reading::Unrecognized(format!("{n} buttons named like a sign-in")), None),+    };+    let remember = inv+        .iter()+        .find(|e| e.is("checkbox") && REMEMBER.iter().any(|r| e.name.to_lowercase().contains(r)))+        .cloned();+    (+        Reading::Form,+        Some(Form {+            user: users.first().map(|e| (*e).clone()),+            password: passwords.first().map(|e| (*e).clone()),+            submit,+            remember,+        }),+    )+}++fn target(e: &Element, role: &str) -> Value {+    json!({+        "automationId": if e.automation_id.is_empty() { Value::Null } else { json!(e.automation_id) },+        "contains": e.name,+        "role": role,+    })+}++fn merge(mut a: Value, b: Value) -> Value {+    if let (Value::Object(x), Value::Object(y)) = (&mut a, b) {+        for (k, v) in y {+            x.insert(k, v);+        }+    }+    a+}++// ── the vault ───────────────────────────────────────────────────────────────++enum Vault {+    Got(Secret, Value),+    Missing(String),+    Denied(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)+            }+        }+    }+}++// ── the rejection record: never retried ─────────────────────────────────────++fn rejected_path() -> std::path::PathBuf {+    crate::scratch::root().join("sign-in-rejected.json")+}++pub fn rejected_before() -> Option<Value> {+    std::fs::read_to_string(rejected_path()).ok().and_then(|t| serde_json::from_str(&t).ok())+}++fn record_rejection(why: &str) {+    let _ = crate::launch::write_atomic(+        &rejected_path(),+        &json!({"atMs": crate::launchos::epoch_ms() as u64, "why": why, "host": vault_host()}).to_string(),+    );+}++// ── the sign-in ─────────────────────────────────────────────────────────────++fn err(code: &str, msg: impl Into<String>, hint: impl Into<String>, extra: Value) -> Value {+    merge(crate::verbs::err_code(code, msg, hint), extra)+}++fn tell_user(what: &str) {+    let _ = crate::caller::ad_callback(+        "notify_user",+        json!({"title": "SOLIDWORKS sign-in needs you", "message": what}),+        Duration::from_secs(10),+    );+}++/// SIGN IN, unattended. `who` is the caller's identity for the ledger.+pub fn sign_in(reason: &str, user_confirmed_new_password: bool, who: Value) -> Value {+    let started = std::time::Instant::now();+    let out = sign_in_inner(reason, user_confirmed_new_password);+    let outcome = out.get("errorCode").and_then(|c| c.as_str()).unwrap_or(if out.get("signedIn") == Some(&json!(true)) { "signedIn" } else { "unknown" }).to_string();+    crate::ledger::record(+        "solidworks_sign_in",+        json!({+            "caller": who,+            "reason": reason,+            "outcome": outcome,+            "elapsedSec": started.elapsed().as_secs(),+            "consent": out.pointer("/vault/consent").cloned().unwrap_or(Value::Null),+            "rememberTicked": out.get("rememberTicked").cloned().unwrap_or(Value::Null),+        }),+    );+    out+}++fn sign_in_inner(reason: &str, user_confirmed_new_password: bool) -> Value {+    let os = crate::launchos::os();+    // ONLY OUR LAUNCH'S WINDOW: a launch this bridge started, its sign-in window+    // owned by a process of OUR chain.+    let Some(a) = crate::launch::attempt() else {+        return err("sign_in_not_waiting", "no launch by this bridge is recorded", "swx_start begins one; swx_sign_in answers only the sign-in window of a launch this bridge started.", json!({}));+    };+    let hwnd = match crate::launch::sign_in_now(os.as_ref()) {+        Some(Some((h, _))) => h,+        Some(None) => {+            return err("sign_in_not_waiting", "no 3DEXPERIENCE sign-in window is open for this bridge's launch", "Nothing to do: swx_status says what the launch is waiting on.", json!({"launchStage": a.stage.name()}))+        }+        None => {+            return err("sign_in_not_waiting", "the windows of this launch's processes could not be read, so no sign-in window can be attributed to it", "Nothing was typed anywhere.", json!({}))+        }+    };+    // NEVER RETRIED after a rejection (lockout risk).+    if a.sign_in_rejected || (rejected_before().is_some() && !user_confirmed_new_password) {+        return err(+            "sign_in_rejected_before",+            "a sign-in with the saved password was REJECTED before, and it is never retried (an account lockout is worse than a manual sign-in)",+            "Ask the user to sign in by hand, or to update the saved 3DEXPERIENCE login in the vault; only after THEY say it has changed may swx_sign_in be called with userConfirmedNewPassword:true.",+            json!({"rejectedBefore": rejected_before()}),+        );+    }+    // 1. THE INVENTORY, captured every time.+    let inv = match inventory(hwnd) {+        Ok(i) => i,+        Err(e) => return err("sign_in_inventory_unreadable", e, "Nothing was typed. The user can sign in by hand.", json!({"hwnd": hwnd})),+    };+    crate::launch::note_sign_in_inventory(inventory_json(&inv));+    let (reading, form) = recognize(&inv);+    let inv_j = inventory_json(&inv);+    let form = match (reading, form) {+        (Reading::Form, Some(f)) => f,+        (Reading::SecondStep(m), _) => {+            tell_user(&format!("The 3DEXPERIENCE sign-in is asking for a second step (\"{m}\"). Please complete it in the sign-in window — the bridge will not."));+            return err("sign_in_needs_user", format!("the sign-in window asks for a second step (\"{m}\") that only the user can answer"), "The user has been notified. Nothing was typed.", json!({"inventory": inv_j}));+        }+        (r, _) => {+            let why = match r { Reading::Unrecognized(w) | Reading::Rejected(w) => w, _ => "unrecognized".into() };+            return err("sign_in_window_unrecognized", format!("the sign-in window does not look like the form this bridge fills: {why}"), "Nothing was typed. The inventory is attached so the form can be recognized next time; the user can sign in by hand.", json!({"inventory": inv_j}));+        }+    };+    // 2. THE SECRET, locally.+    let (secret, vault) = match reveal(reason) {+        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::Unavailable(e) => return err("sign_in_vault_unavailable", format!("the vault could not be asked: {e}"), "Nothing was typed. The user can sign in by hand.", json!({"inventory": inv_j})),+    };+    let needles = secret.needles();+    let fill_reason = "signing in to 3DEXPERIENCE for a SOLIDWORKS launch this bridge started";+    let mut steps: Vec<Value> = Vec::new();+    // 3. FILL — the user name (if the form has one and the vault has one), then+    //    the password.+    let mut fail: Option<(String, String)> = None;+    if let (Some(u_el), Some(u)) = (&form.user, &secret.user) {+        let r = crate::caller::ad_callback(+            "desktop_ui_set",+            merge(target(u_el, "edit"), json!({"hwnd": hwnd, "value": String::from_utf8_lossy(u), "sensitive": true, "reason": fill_reason})),+            AB_TIMEOUT,+        );+        steps.push(json!({"step": "user", "ok": ab_ok(&r)}));+        if !ab_ok(&r) {+            fail = Some(("user".into(), ab_error(&r)));+        }+    }+    if fail.is_none() {+        if let Some(p_el) = &form.password {+            let r = crate::caller::ad_callback(+                "desktop_ui_set",+                merge(target(p_el, "edit"), json!({"hwnd": hwnd, "value": String::from_utf8_lossy(&secret.pass), "sensitive": true, "reason": fill_reason})),+                AB_TIMEOUT,+            );+            steps.push(json!({"step": "password", "ok": ab_ok(&r)}));+            if !ab_ok(&r) {+                fail = Some(("password".into(), ab_error(&r)));+            }+        }+    }+    drop(secret); // ZEROED. Only the needles (for scrubbing) remain, below.+    if let Some((step, e)) = fail {+        let out = err("sign_in_fill_failed", format!("typing the {step} into the sign-in window failed: {e}"), "Nothing was submitted. The user can sign in by hand.", json!({"inventory": inv_j, "steps": steps, "vault": vault}));+        return scrub(&out, &needles);+    }+    // 4. "REMEMBER ME", if the form has one and it is not already on.+    let mut remember_ticked = Value::Null;+    if let Some(rm) = &form.remember {+        let on = rm.toggle_state.as_deref().map(|t| t.to_lowercase()).map(|t| t == "on" || t == "true" || t == "1" || t == "checked");+        if on == Some(true) {+            remember_ticked = json!("alreadyOn");+        } else {+            let r = crate::caller::ad_callback("desktop_ui_click", merge(target(rm, "checkbox"), json!({"hwnd": hwnd, "reason": fill_reason})), AB_TIMEOUT);+            remember_ticked = json!(ab_ok(&r));+        }+    }+    // 5. SUBMIT.+    let r = crate::caller::ad_callback("desktop_ui_click", merge(target(&form.submit, "button"), json!({"hwnd": hwnd, "reason": fill_reason})), AB_TIMEOUT);+    if !ab_ok(&r) {+        let out = err("sign_in_fill_failed", format!("pressing \"{}\" failed: {}", form.submit.name, ab_error(&r)), "The fields may be filled but nothing was submitted. The user can press it.", json!({"inventory": inv_j, "steps": steps, "vault": vault}));+        return scrub(&out, &needles);+    }+    // 6. VERIFY BY EFFECT: the window goes away.+    let deadline = std::time::Instant::now() + Duration::from_secs(verify_secs());+    loop {+        match crate::launch::sign_in_now(os.as_ref()) {+            Some(None) => {+                let out = crate::verbs::ok(json!({+                    "signedIn": true,+                    "verifiedBy": "the sign-in window closed after the submit",+                    "rememberTicked": remember_ticked,+                    "steps": steps,+                    "vault": vault,+                    "inventory": inv_j,+                    "_hint": "Signed in. The launch continues on its own; poll swx_status for `ready`.",+                }));+                return scrub(&out, &needles);+            }+            _ if std::time::Instant::now() >= deadline => break,+            _ => std::thread::sleep(Duration::from_millis(500)),+        }+    }+    // STILL UP: read it again and say what it is asking.+    let after = inventory(hwnd).unwrap_or_default();+    let after_j = inventory_json(&after);+    let out = if let Some(m) = has_marker(&after, REJECTED) {+        crate::launch::note_sign_in_rejected();+        record_rejection(&m);+        tell_user("The saved 3DEXPERIENCE password was rejected. The bridge will not try it again — please sign in by hand, and update the saved login.");+        err("sign_in_rejected", format!("the sign-in window rejected the saved password (\"{m}\"); it is NEVER retried"), "Ask the user to sign in by hand and update the saved login in the vault.", json!({"inventoryAfter": after_j, "steps": steps, "vault": vault, "rememberTicked": remember_ticked}))+    } else if let Some(m) = has_marker(&after, SECOND_STEP) {+        tell_user(&format!("The 3DEXPERIENCE sign-in is asking for a second step (\"{m}\"). Please complete it in the sign-in window."));+        err("sign_in_needs_user", format!("after the password, the sign-in window asks for a second step (\"{m}\")"), "The user has been notified.", json!({"inventoryAfter": after_j, "steps": steps, "vault": vault, "rememberTicked": remember_ticked}))+    } else {+        err("sign_in_unconfirmed", format!("the form was submitted, but the sign-in window is still open after {}s and says nothing this bridge recognizes", verify_secs()), "Not retried. swx_status shows whether the launch advanced; the inventory says what the window shows now.", json!({"inventoryAfter": after_j, "steps": steps, "vault": vault, "rememberTicked": remember_ticked}))+    };+    scrub(&out, &needles)+}++#[cfg(test)]+mod tests {+    use super::*;++    fn el(t: &str, name: &str, pw: Option<bool>) -> Element {+        Element { control_type: t.into(), name: name.into(), is_password: pw, ..Default::default() }+    }++    #[test]+    fn a_plain_form_is_recognized_and_extra_fields_are_not() {+        let inv = vec![el("edit", "Email", Some(false)), el("edit", "Password", Some(true)), el("checkbox", "Remember me", None), el("button", "Log in", None), el("button", "Forgot password?", None)];+        let (r, f) = recognize(&inv);+        assert_eq!(r, Reading::Form);+        let f = f.unwrap();+        assert_eq!(f.submit.name, "Log in");+        assert!(f.remember.is_some());+        let mut extra = inv.clone();+        extra.push(el("edit", "Company", Some(false)));+        assert!(matches!(recognize(&extra).0, Reading::Unrecognized(_)));+        let mut code = inv.clone();+        code.push(el("edit", "Verification code", Some(false)));+        assert!(matches!(recognize(&code).0, Reading::SecondStep(_)));+    }++    #[test]+    fn scrub_removes_the_secret_everywhere() {+        let v = json!({"error": "could not set 'hunter2-xyz'", "value": "hunter2-xyz", "nested": [{"a": "x hunter2-xyz y"}]});+        let s = scrub(&v, &["hunter2-xyz".to_string()]).to_string();+        assert!(!s.contains("hunter2-xyz"), "{s}");+    }++    #[test]+    fn the_inventory_never_copies_a_value() {+        let reply = json!({"success": true, "data": {"controlType": "Window", "name": "Login", "children": [+            {"controlType": "Edit", "name": "Password", "isPassword": true, "value": "SHOULD-NOT-COPY"}]}});+        let inv = parse_inventory(&reply);+        assert_eq!(inv.len(), 2);+        assert!(!inventory_json(&inv).to_string().contains("SHOULD-NOT-COPY"));+    }+}
src/verbs.rs+45−2
@@ -3007,6 +3007,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, 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_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)"),@@ -3041,6 +3042,21 @@ pub const CATALOG: &[(&str, &str, &str)] = &[     ("swx_export_dwg", "write", "export DWG (drawings; refused for a part on this edition)"), ]; +/// `swx_sign_in` — UNATTENDED 3DEXPERIENCE SIGN-IN (0.9.13). See src/signin.rs.+fn swx_sign_in(_ctx: &Ctx, args: &Value) -> Value {+    let Some(reason) = arg_str(args, "reason").filter(|r| !r.trim().is_empty()) else {+        return err_code(+            "reason_required",+            "swx_sign_in needs a reason",+            "Say why the bridge is signing the user in (it is ledgered, with who asked).",+        );+    };+    let who = crate::caller::current()+        .map(|c| json!({"aiThread": c.thread, "containerName": c.container}))+        .unwrap_or(Value::Null);+    crate::signin::sign_in(reason, arg_bool(args, "userConfirmedNewPassword", false), who)+}+ /// `swx_user_presence` — the presence measurement every quit and sweep is /// guarded by, on its own (0.9.9). Read-only towards SOLIDWORKS; positive /// evidence marks the session taken over by the user, exactly as the guard does.@@ -3130,6 +3146,7 @@ pub const STARTUP_EXEMPT: &[(&str, &str)] = &[     ("swx_screenshot", "through ab's generic screenshot; no com host"),     ("swx_set_driving_badge", "the taskbar badge; no com host"),     ("swx_kill", "the last resort for a wedged start (reason + confirm); reads the document count only to say what is lost"),+    ("swx_sign_in", "acts on the LAUNCHER's sign-in window, never through COM — it is how a start gets past the sign-in at all"),     ("swx_user_presence", "a read about the USER, needed most during a start; asks COM for documents only on a verified-ready session"), ]; @@ -3301,6 +3318,7 @@ fn dispatch_after_gate(ctx: &Ctx, command: &str, args: &Value) -> Value {         "swx_shutdown" => crate::verbs3::shutdown,         "swx_recover_list" => crate::verbs3::recover_list,         "swx_user_presence" => swx_user_presence,+        "swx_sign_in" => swx_sign_in,         "swx_docs" => swx_docs,         "swx_find_files" => swx_find_files,         "swx_open" => swx_open,@@ -3375,10 +3393,33 @@ fn dispatch_after_gate(ctx: &Ctx, command: &str, args: &Value) -> Value {     // A SESSION'S HISTORY: whether it has ever held a document decides how the     // quit ladder treats a declined ExitApp (see src/session_history.rs).     crate::session_history::note_reply(ctx, command, &out);+    // UNATTENDED SIGN-IN, ARMED (0.9.13): `signIn:"auto"` — the default is "wait".+    if command == "swx_start" && arg_str(args, "signIn") == Some("auto") {+        crate::launch::arm_auto_sign_in(crate::launchos::os().as_ref());+        if let Value::Object(m) = &mut out {+            m.insert("signInMode".into(), json!("auto"));+        }+    }     // BLOCKED ON THE USER'S SIGN-IN (0.9.12): said plainly, with the window.     if command == "swx_start" || command == "swx_status" {         if let Some(a) = crate::launch::attempt() {-            if let Some(title) = a.sign_in_window.clone().filter(|_| !a.finished) {+            // From a LIVE census whenever the record names the sign-in stage —+            // the monitor that first saw the window may have died with an older+            // bridge process (0.9.13).+            let live = if a.stage.name() == "helperSignIn" {+                match crate::launch::sign_in_now(crate::launchos::os().as_ref()) {+                    Some(Some((_, t))) => Some(t),+                    _ => None,+                }+            } else {+                None+            };+            let title = live.or_else(|| a.sign_in_window.clone().filter(|_| !a.finished));+            if let Some(m) = out.as_object_mut().filter(|_| title.is_some()) {+                m.insert("signInInventory".into(), a.sign_in_inventory.clone().unwrap_or(Value::Null));+                m.insert("signInMode".into(), json!(if a.sign_in_auto { "auto" } else { "wait" }));+            }+            if let Some(title) = title {                 if let Value::Object(m) = &mut out {                     m.insert("waitingOn".into(), json!("userSignIn"));                     m.insert("signInWindow".into(), json!(title));@@ -3386,7 +3427,9 @@ fn dispatch_after_gate(ctx: &Ctx, command: &str, args: &Value) -> Value {                         "_signInHint".into(),                         json!("SOLIDWORKS is waiting for the user to sign in to 3DEXPERIENCE in the \                                window named here. Only the user can answer it; the launch's deadlines \-                               are paused and nothing is killed. Tell the user, then poll swx_status."),+                               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}. Then poll swx_status."),                     );                 }             }
tests/e2e.rs+4
@@ -1180,6 +1180,10 @@ fn every_verb_over_http_against_the_real_binary() {     check!(b, "...with a recovery root", rec["recoveryRoot"].is_string(), s(&rec));     // The user-presence guard's own reading (0.9.9): a simulated SOLIDWORKS is on     // nobody's screen, and the reply says what it measured.+    // Unattended sign-in (0.9.13): with no launch waiting at a sign-in window,+    // it types nothing anywhere and says so.+    let si = b.call("swx_sign_in", json!({"reason": "coverage"}));+    check!(b, "sign_in with nothing waiting: sign_in_not_waiting", si["errorCode"] == "sign_in_not_waiting", s(&si));     let pres = b.call("swx_user_presence", json!({}));     check!(         b,
tests/readiness.rs+1−1
@@ -374,7 +374,7 @@ const EXEMPT: &[&str] = &[     "swx_status", "swx_readiness", "swx_start", "swx_describe", "swx_progress",     "swx_action_ledger", "swx_verb_times", "swx_uninstall", "swx_find_files",     "swx_recover_list", "swx_window_info", "swx_dismiss_blocking_dialogs",-    "swx_close_window", "swx_screenshot", "swx_set_driving_badge", "swx_kill", "swx_user_presence",+    "swx_close_window", "swx_screenshot", "swx_set_driving_badge", "swx_kill", "swx_user_presence", "swx_sign_in", ];  #[test]
tests/signin.rsadded+425
@@ -0,0 +1,425 @@+//! UNATTENDED 3DEXPERIENCE SIGN-IN (0.9.13).+//!+//! The login window (class DSYSysLoginPanelWebView2Class, owned by our launcher+//! chain) is simulated by the launch simulator's `sign-in.json`; its accessible+//! tree, the vault and the UIA actions by an ab stub. THE FIXTURE PASSWORD BELOW+//! IS FAKE, and every test that types it greps every reply, the server log,+//! every file in the data root and every ab request except the one fill that+//! must carry it — and fails if it appears anywhere else.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};+use std::time::{Duration, Instant};++const PW: &str = "Fixture-P4ss!word-7731";+const USER: &str = "[email protected]";++fn s(v: &Value) -> String {+    serde_json::to_string_pretty(v).unwrap_or_default()+}++struct Machine {+    dir: TempDir,+}++impl Machine {+    fn new(tag: &str) -> Machine {+        let dir = TempDir::new(tag);+        // A launch that reaches Run Begin and never attaches while the sign-in is up.+        std::fs::write(dir.join("routes.json"), json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0}}).to_string()).unwrap();+        std::fs::write(dir.join("preexisting.json"), "[]").unwrap();+        std::fs::write(+            dir.join("sign-in.json"),+            json!({"hwnd": 777001, "className": "DSYSysLoginPanelWebView2Class",+                   "title": "Login | 3DEXPERIENCE ID | Dassault Systèmes"})+            .to_string(),+        )+        .unwrap();+        Machine { dir }+    }++    fn boot(&self, scratch: TempDir, ab: &common::AbStub, extra: &[(&str, &str)]) -> Bridge {+        let lnk = format!("{}/SOLIDWORKS Design.lnk", self.dir.path());+        std::fs::write(&lnk, "not really a shortcut").unwrap();+        let mut env: Vec<(&str, &str)> = vec![+            ("SWX_SOLIDWORKS_SHORTCUT", Box::leak(lnk.into_boxed_str())),+            ("SWX_LAUNCH_FAKE_DIR", Box::leak(self.dir.path().into_boxed_str())),+            ("SWX_LAUNCH_RUN_BEGIN_SECS", "2"),+            ("SWX_LAUNCH_SLDWORKS_SECS", "4"),+            ("SWX_LAUNCH_ATTACH_SECS", "8"),+            ("SWX_LAUNCH_POLL_MS", "100"),+            ("SWX_LAUNCH_ATTACH_POLL_MS", "150"),+            ("SWX_LAUNCH_INFLIGHT_SECS", "120"),+            ("SWX_SIGN_IN_VERIFY_SECS", "3"),+            ("ADOM_DIRECT_API_URL", Box::leak(ab.url.clone().into_boxed_str())),+        ];+        env.extend_from_slice(extra);+        Bridge::start_in(scratch, &env)+    }++    fn sign_in_up(&self) -> bool {+        self.dir.join("sign-in.json").exists()+    }+}++/// The login form as a UIA tree — what ab's desktop_ui_tree is assumed to+/// return (the real shape is unmeasured; the parser is lenient).+fn form(extra: Vec<Value>) -> Value {+    let mut kids = vec![+        json!({"controlType": "Edit", "name": "Email or username", "automationId": "username", "isPassword": false}),+        json!({"controlType": "Edit", "name": "Password", "automationId": "password", "isPassword": true}),+        json!({"controlType": "CheckBox", "name": "Remember me", "automationId": "rememberMe", "toggleState": "Off"}),+        json!({"controlType": "Button", "name": "Log in", "automationId": "login-btn"}),+        json!({"controlType": "Hyperlink", "name": "Forgot your password?"}),+    ];+    kids.extend(extra);+    json!({"success": true, "data": {"controlType": "Window", "name": "Login | 3DEXPERIENCE ID", "children": kids}})+}++fn vault_ok() -> Value {+    json!({"success": true, "data": {"username": USER, "password": PW, "consent": "perUse"}})+}++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);+    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}));+    ab+}++fn wait_for_sign_in_stage(b: &Bridge) -> Value {+    let deadline = Instant::now() + Duration::from_secs(10);+    loop {+        let st = b.call("swx_status", json!({}));+        if st["waitingOn"] == "userSignIn" || Instant::now() > deadline {+            return st;+        }+        std::thread::sleep(Duration::from_millis(150));+    }+}++/// Every text the bridge produced or kept, except the fill that must carry it.+fn everything_but_the_fill(b: &Bridge, ab: &common::AbStub, replies: &[Value]) -> String {+    let mut all = String::new();+    for r in replies {+        all.push_str(&r.to_string());+    }+    all.push_str(&b.log());+    fn walk(p: &std::path::Path, out: &mut String) {+        if let Ok(rd) = std::fs::read_dir(p) {+            for e in rd.flatten() {+                let p = e.path();+                if p.is_dir() {+                    walk(&p, out);+                } else if let Ok(bytes) = std::fs::read(&p) {+                    out.push_str(&String::from_utf8_lossy(&bytes));+                }+            }+        }+    }+    walk(std::path::Path::new(&b.scratch.path()), &mut all);+    for c in ab.calls() {+        let is_fill = c["command"] == "desktop_ui_set";+        if !is_fill {+            all.push_str(&c.to_string());+        }+    }+    all+}++/// The user signs in... by the bridge: when the submit is pressed the window+/// closes (in the simulator: sign-in.json goes away).+fn close_window_on_submit(m: &Machine, ab: &common::AbStub) {+    let deadline = Instant::now() + Duration::from_secs(20);+    while Instant::now() < deadline {+        if ab.calls_to("desktop_ui_click").iter().any(|c| c["args"]["role"] == "button") {+            let _ = std::fs::remove_file(m.dir.join("sign-in.json"));+            return;+        }+        std::thread::sleep(Duration::from_millis(50));+    }+}++#[test]+fn the_bridge_signs_in_from_the_vault_and_the_password_appears_nowhere() {+    let m = Machine::new("signin-ok");+    let ab = ab_for(form(vec![]), vault_ok());+    let b = m.boot(TempDir::new("signin-ok-scratch"), &ab, &[]);+    let mut replies = vec![b.call("swx_start", json!({"waitSeconds": 0, "reason": "the user asked for SOLIDWORKS"}))];+    let st = wait_for_sign_in_stage(&b);+    check!(+        b,+        "INVENTORY FIRST: swx_status shows the login window's inventory — types, names, IsPassword, never values",+        st["waitingOn"] == "userSignIn"+            && st["signInInventory"].as_array().map(|a| a.iter().any(|e| e["isPassword"] == true)).unwrap_or(false)+            && st["signInMode"] == "wait",+        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": "Drew asked: sign me in"}))+    });+    check!(+        b,+        "swx_sign_in: signed in, verified by the window closing, Remember me ticked, consent reported",+        r["success"] == true && r["signedIn"] == true && r["rememberTicked"] == true+            && r["vault"]["consent"] == "perUse" && !m.sign_in_up(),+        s(&r)+    );+    replies.push(r);+    // The fill itself carried the secret, to the IsPassword element, marked sensitive.+    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),+        format!("{sets:?}")+    );+    replies.push(b.call("swx_status", json!({})));+    replies.push(b.call("swx_action_ledger", json!({"limit": 200})));+    let all = everything_but_the_fill(&b, &ab, &replies);+    check!(b, "THE PASSWORD APPEARS NOWHERE: no reply, log, ledger, data-root file or other ab request", !all.contains(PW), String::new());+    check!(b, "...and neither does the user name", !all.contains(USER), String::new());+    let ledger = b.call("swx_action_ledger", json!({"limit": 200}));+    check!(+        b,+        "the attempt is ledgered: who, outcome",+        ledger["actions"].as_array().map(|a| a.iter().any(|e| e["action"] == "solidworks_sign_in" && e["detail"]["outcome"] == "signedIn")).unwrap_or(false),+        s(&ledger)+    );+}++#[test]+fn a_rejected_password_is_never_retried_and_never_echoed() {+    let m = Machine::new("signin-rejected");+    let ab = ab_for(form(vec![]), vault_ok());+    let b = m.boot(TempDir::new("signin-rejected-scratch"), &ab, &[]);+    let mut replies = vec![b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}))];+    wait_for_sign_in_stage(&b);+    // After the submit the window stays, now saying the password was wrong.+    let r = std::thread::scope(|sc| {+        sc.spawn(|| {+            let deadline = Instant::now() + Duration::from_secs(20);+            while Instant::now() < deadline {+                if ab.calls_to("desktop_ui_click").iter().any(|c| c["args"]["role"] == "button") {+                    ab.set_response("desktop_ui_tree", form(vec![json!({"controlType": "Text", "name": "Incorrect email or password"})]));+                    return;+                }+                std::thread::sleep(Duration::from_millis(30));+            }+        });+        b.call("swx_sign_in", json!({"reason": "sign in"}))+    });+    check!(b, "sign_in_rejected", r["errorCode"] == "sign_in_rejected", s(&r));+    replies.push(r);+    let again = b.call("swx_sign_in", json!({"reason": "try again"}));+    check!(b, "the next call refuses: never retried", again["errorCode"] == "sign_in_rejected_before", s(&again));+    replies.push(again);+    check!(b, "...the vault was asked exactly once", ab.calls_to("pup_credential_reveal").len() == 1, String::new());+    check!(b, "...and the user was told", !ab.calls_to("notify_user").is_empty(), String::new());+    let all = everything_but_the_fill(&b, &ab, &replies);+    check!(b, "the password appears nowhere", !all.contains(PW), String::new());+}++#[test]+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}));+    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);+    let r = b.call("swx_sign_in", json!({"reason": "sign in"}));+    check!(+        b,+        "sign_in_fill_failed, nothing submitted, and the echoed password is redacted",+        r["errorCode"] == "sign_in_fill_failed" && r["error"].as_str().unwrap_or("").contains("[redacted]")+            && ab.calls_to("desktop_ui_click").iter().all(|c| c["args"]["role"] != "button"),+        s(&r)+    );+    replies.push(r);+    let all = everything_but_the_fill(&b, &ab, &replies);+    check!(b, "the password appears nowhere", !all.contains(PW), String::new());+}++#[test]+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 b = m.boot(TempDir::new("signin-missing-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,+        "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(),+        s(&r)+    );++    // An unknown form (a second password field): the inventory, nothing asked of the vault.+    let m = Machine::new("signin-unknown");+    let ab = ab_for(form(vec![json!({"controlType": "Edit", "name": "Confirm password", "isPassword": true})]), vault_ok());+    let b = m.boot(TempDir::new("signin-unknown-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,+        "unrecognized: the inventory is returned, the vault never asked",+        r["errorCode"] == "sign_in_window_unrecognized" && r["inventory"].is_array()+            && ab.calls_to("pup_credential_reveal").is_empty(),+        s(&r)+    );++    // A second step: the user is told, nothing typed.+    let m = Machine::new("signin-2fa");+    let ab = ab_for(form(vec![json!({"controlType": "Text", "name": "Enter the verification code we sent you"})]), vault_ok());+    let b = m.boot(TempDir::new("signin-2fa-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 second step: sign_in_needs_user, the user toasted, the vault never asked",+        r["errorCode"] == "sign_in_needs_user" && !ab.calls_to("notify_user").is_empty()+            && ab.calls_to("pup_credential_reveal").is_empty(),+        s(&r)+    );+}++#[test]+fn nothing_is_typed_when_no_sign_in_of_ours_is_waiting() {+    let m = Machine::new("signin-none");+    std::fs::remove_file(m.dir.join("sign-in.json")).unwrap();+    let ab = ab_for(form(vec![]), vault_ok());+    let b = m.boot(TempDir::new("signin-none-scratch"), &ab, &[]);+    let r = b.call("swx_sign_in", json!({"reason": "sign in"}));+    check!(+        b,+        "no launch: sign_in_not_waiting, the vault never asked",+        r["errorCode"] == "sign_in_not_waiting" && ab.calls_to("pup_credential_reveal").is_empty(),+        s(&r)+    );+    let r = b.call("swx_sign_in", json!({}));+    check!(b, "a reason is required", r["errorCode"] == "reason_required", s(&r));+}++#[test]+fn sign_in_auto_signs_in_once_by_itself() {+    let m = Machine::new("signin-auto");+    let ab = ab_for(form(vec![]), vault_ok());+    let b = m.boot(TempDir::new("signin-auto-scratch"), &ab, &[]);+    let r = std::thread::scope(|sc| {+        sc.spawn(|| close_window_on_submit(&m, &ab));+        let r = b.call("swx_start", json!({"waitSeconds": 0, "reason": "start", "signIn": "auto"}));+        let deadline = Instant::now() + Duration::from_secs(15);+        while Instant::now() < deadline && m.sign_in_up() {+            std::thread::sleep(Duration::from_millis(100));+        }+        r+    });+    check!(b, "swx_start says the mode", r["signInMode"] == "auto", s(&r));+    check!(b, "the window closed after the bridge's own submit", !m.sign_in_up(), String::new());+    check!(b, "...the vault was asked exactly once", ab.calls_to("pup_credential_reveal").len() == 1, String::new());+    let all = everything_but_the_fill(&b, &ab, &[r]);+    check!(b, "the password appears nowhere", !all.contains(PW), String::new());+}++#[test]+fn a_respawned_bridge_still_sees_the_sign_in_and_reconciles_after_it() {+    let m = Machine::new("signin-respawn");+    let ab = ab_for(form(vec![]), vault_ok());+    let scratch = TempDir::new("signin-respawn-scratch");+    {+        let b = m.boot(TempDir::borrowed(&scratch.0), &ab, &[]);+        b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}));+        wait_for_sign_in_stage(&b);+    }+    // The old bridge (and its monitor) is gone; a new one reads the record.+    let b = m.boot(TempDir::borrowed(&scratch.0), &ab, &[]);+    let st = b.call("swx_status", json!({}));+    check!(+        b,+        "after a respawn mid-launch, waitingOn and the window come from a LIVE census",+        st["waitingOn"] == "userSignIn" && st["signInWindow"].as_str().unwrap_or("").contains("3DEXPERIENCE ID"),+        s(&st)+    );+    // The user signs in by hand; SOLIDWORKS comes up and COM attaches.+    std::fs::remove_file(m.dir.join("sign-in.json")).unwrap();+    std::fs::write(+        m.dir.join("routes.json"),+        json!({"shellExecute": {"chain": true, "runBeginAfterMs": 0, "sldworksAfterMs": 0, "attachAfterMs": 0}}).to_string(),+    )+    .unwrap();+    let deadline = Instant::now() + Duration::from_secs(15);+    let mut last = json!({});+    while Instant::now() < deadline {+        // Only swx_status: no swx_start re-marks the attempt (measured: the+        // record stayed helperSignIn when only status was read).+        last = b.call("swx_status", json!({}));+        if last["com"]["running"] == true && last["launch"]["stage"] == "attached" {+            break;+        }+        std::thread::sleep(Duration::from_millis(250));+    }+    check!(+        b,+        "RECONCILED ON ATTACH: the record no longer says helperSignIn once SOLIDWORKS is attached",+        last["com"]["running"] == true && last["launch"]["stage"] == "attached" && last["waitingOn"].is_null(),+        s(&last)+    );+}++/// RECONCILED ON ATTACH (0.9.13), the measured shape: the record says+/// {stage:"helperSignIn", outcome:"abandoned"} (the bridge was respawned while+/// the user signed in), SOLIDWORKS is up and attached, and re-attribution has+/// no lineage to go on. The STAGE moves to `attached`; the outcome and the+/// attribution keep their evidence; the refusal is kept as supersededByAttach.+#[test]+fn an_abandoned_sign_in_record_is_reconciled_when_solidworks_is_attached() {+    let scratch = TempDir::new("signin-reconcile");+    std::fs::write(+        scratch.join("mock-solidworks-open-docs.json"),+        json!({"running": true, "untitled": 1, "active": Value::Null, "docs": [], "files": {}}).to_string(),+    )+    .unwrap();+    std::fs::write(+        scratch.join("launch-state.json"),+        json!({+            "attempt": {+                "id": 7, "startedAt": 1700000000u64, "shortcut": "x.lnk", "route": "shellExecute",+                "invokedAt": 1700000000u64, "stage": "helperSignIn", "finished": true,+                "outcome": "abandoned", "runBeginSeen": false, "signInSeen": true,+                "signInWindow": "Login | 3DEXPERIENCE ID | Dassault Systèmes"+            },+            "preferredRoute": "shellExecute", "consecutiveFailures": 0+        })+        .to_string(),+    )+    .unwrap();+    let machine = TempDir::new("signin-reconcile-m");+    std::fs::write(machine.join("windows.json"), "[]").unwrap();+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_LAUNCH_FAKE_DIR", &machine.path())]);+    let st = b.call("swx_status", json!({}));+    check!(+        b,+        "the record is reconciled: stage attached, outcome kept, not attributed, the reason kept",+        st["com"]["running"] == true+            && st["launch"]["stage"] == "attached"+            && st["launch"]["outcome"] == "abandoned"+            && st["launch"]["attributed"] != true+            && st["launch"]["supersededByAttach"].is_string()+            && st["waitingOn"].is_null(),+        s(&st)+    );+}
tests/wiring.rs+1−1
@@ -1068,7 +1068,7 @@ fn the_startup_exemption_list_is_pinned_name_by_name() {         "swx_status", "swx_readiness", "swx_start", "swx_describe", "swx_progress",         "swx_action_ledger", "swx_verb_times", "swx_uninstall", "swx_find_files",         "swx_recover_list", "swx_window_info", "swx_dismiss_blocking_dialogs",-        "swx_close_window", "swx_screenshot", "swx_set_driving_badge", "swx_kill", "swx_user_presence",+        "swx_close_window", "swx_screenshot", "swx_set_driving_badge", "swx_kill", "swx_user_presence", "swx_sign_in",     ];     pinned.sort();     assert_eq!(