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.9 source: user-presence guard; adversary pass 45 (unreadable log unverifiable, latch keyed by pid+creation); manifest as published
27 files changed
+1128−27
BRIDGE_VERSION+1−1Cargo.lock+1−1Cargo.toml+6−1README.md+14−1SKILL.md+5bridge.json+2−1docs/ECOSYSTEM-TABLE.md+1docs/LAUNCH-RESILIENCE.md+19docs/QUIT-INVESTIGATION.md+40package.json+1−1references/closing-and-recovery.md+6src/comhost/mock.rs+8src/launchos.rs+88src/lib.rs+1src/perflog.rs+19−3src/presence.rs+338src/readiness.rs+14−7src/session_history.rs+13−6src/verbs.rs+73−1tests/adversary43.rs+143tests/e2e.rs+9tests/presence.rs+217tests/quit.rs+5−4tests/readiness.rs+21tests/wiring.rs+19tools/drain-gate.py+25tools/quit-gate.py+39BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.8+0.9.9
Cargo.lock+1−1@@ -107,7 +107,7 @@ dependencies = [ [[package]] name = "swx-bridge"-version = "0.9.8"+version = "0.9.9" dependencies = [ "serde", "serde_json",
Cargo.toml+6−1@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.9.8"+version = "0.9.9" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"@@ -53,6 +53,11 @@ windows = { version = "0.62", features = [ # answers "is SOLIDWORKS responding?" WITHOUT COM, which is the only time # that question matters. "Win32_UI_WindowsAndMessaging",+ # Input_KeyboardAndMouse + SystemInformation: GetLastInputInfo / GetTickCount,+ # the user-presence guard's "how long since the user last touched the+ # machine" (0.9.9).+ "Win32_UI_Input_KeyboardAndMouse",+ "Win32_System_SystemInformation", # Shell: ShellExecuteExW, the launch route that runs the .lnk with an # explicit SW_SHOWNORMAL. SHELLEXECUTEINFOW carries an HKEY, so the Registry # feature is what makes the struct exist at all.
README.md+14−1@@ -458,7 +458,20 @@ not. ## Roadmap -- v0.9.8 (this): **modality is a persistent measurement.** 0.9.7 was LIVE-PROVEN+- v0.9.9 (this): **never end a session the user is working in.** 0.9.8 was+ measured LIVE on 2026-10-01 (launch 16/16, drain 5/5, quit gate 11/11) — and+ then Drew started using the 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 and sweep (and priming) now measures USER PRESENCE+ first — SOLIDWORKS in the foreground with input in the last 120 s, documents+ the bridge did not open, or an earlier take-over — and refuses+ `user_is_active` with the evidence; unreadable counts as present; positive+ evidence marks the session taken over for good, and only+ `userPresentOverride` + `userAuthorized` + a reason proceeds. The gates+ refuse on presence and never override it. Adversary pass 45: an unreadable+ performance log is unverifiable, never "no log", and the ready latch is keyed+ by pid + creation time. 491 tests.+- v0.9.8: **modality is a persistent measurement.** 0.9.7 was LIVE-PROVEN on 2026-10-01 (launch 16/16, READY 16.7 s with the log read as rewritten-per-start and complete; drain 5/5; quit gate 11/11; primed quit 3.4 s). One new measurement: the 3DEXPERIENCE Connected notice, read at an
SKILL.md+5@@ -76,6 +76,11 @@ BEFORE you do it: 8. **`swx_stop` quits only a session with zero documents open**, and `force` never waives that. Treat a quit as close to a one-way door: relaunching takes minutes and can stop at the sign-in window again.+9. **Never end a session the user is working in.** Every quit and sweep+ refuses `user_is_active` when SOLIDWORKS is in front with recent input, or+ documents appeared that the bridge did not open; the session is then theirs.+ `userPresentOverride` is for the user's explicit request only — never a+ workaround. ## The everyday flow
bridge.json+2−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "solidworks", "displayName": "SOLIDWORKS",- "version": "0.9.8",+ "version": "0.9.9", "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",@@ -84,6 +84,7 @@ "swx_status", "swx_stop", "swx_uninstall",+ "swx_user_presence", "swx_verb_times", "swx_window_info" ],
docs/ECOSYSTEM-TABLE.md+1@@ -112,6 +112,7 @@ templates**, alisamsam **22**. | 44 | **Graceful QUIT that is measured, not assumed** (never a kill, never answers a dialog, proves the exit from the process census) | ✗ | **✓ LIVE-PROVEN 2026-09-17 (rung a)** — `swx_stop {userAuthorized, maxRung:"a"}` quit a bridge-launched session on the FIRST rung (UserControl=false; ExitApp; every COM reference released), verified absent from the process census; `swx_shutdown` then removed a stray empty instance (pid 145188) and `tasklist` showed no `sldworks.exe` at all. **`ExitApp` is NOT declined at zero documents on this edition** — the long-held `host_app_stop_declined` belief was a SAVE-PROMPT STALL misread as a refusal, which is why the ladder was built pinned at rung (a) and why the preserve-then-close sweep is what actually unblocked it. A rung ladder (a `ExitApp` → f Restart Manager *without* `RmForceShutdown`), pinned at rung (a); 7 re-measured preconditions where UNKNOWN refuses and `force` waives the UNSAVED question ONLY; every COM reference released before and after each rung, with outstanding refs named in the report; success = *this pid AND this creation time* absent from the census, never `GetActiveObject`; per-session children reaped while the documented platform daemons are left alone; a dialog stops the ladder permanently and is never answered; nothing disowned until the exit is verified. every precondition re-measured before EVERY rung (a mid-ladder refusal is a real outcome), the exit POLLED to the rung's budget rather than sampled once (a real `ExitApp` returns before the process goes), the dialog census covering the `swCefSubProc` web-view children by parentage, the count cross-checked against the enumeration, and the message rungs re-verifying window ownership before they send. `tools/quit-gate.py` proves the session was bridge-launched, and **its FULL CYCLE PASSED LIVE 2026-09-30 through the bridge ab hosts**: rung (a), process gone 29.5 s, per-session servers reaped, nothing on screen, no crash reporter, every rung reported, and SOLIDWORKS relaunched by the bridge 54.7 s later. The gate now reaches the bridge through ab's verb routing (the side-loaded dev copy on 24695 is terminated by ab 2.1.163 within 30 s) and passes `userAuthorized:true` — the standing authorization — rather than the write-gate pair, which the hosted bridge rightly never enables. **MEASURED 2026-10-01: the decline is about session HISTORY** — rung (a) was declined three times on a session that had never held a document and quit in 3.4 s once one had been created and closed; since 0.9.3 the ladder PRIMES such a session (empty, no dialog, user-authorized only) and asks again — **LIVE-PROVEN 2026-10-01 on 0.9.4**: declined at 130 s, primed, quit in 3.4 s (declines measured from 50 s to 8 min old; such sessions quit unprimed at ~25 s), and `swx_status` exposes the session's document history — and the gate's "no longer running" claim — which had passed vacuously after declines — now requires `running:false` and is self-tested | ✗ | ✗ | ✗ | ✗ | ✗ | | 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. No MCP server has a presence guard 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**; 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+19@@ -1148,6 +1148,25 @@ measurement (QUIT-INVESTIGATION §5.22): the 3DEXPERIENCE Connected notice read with its owner frame DISABLED for an instant while SOLIDWORKS was busy inside a sweep's close — 0.9.8 re-reads the owner before calling such a window a modal. +**0.9.9 (adversary pass 45, the last of this cycle).** V1: `startup_complete`+returned the same "unavailable" for "no log" and "a log that exists but could+not be read" (locked, an ACL, antivirus, a configured path that is missing),+and unavailable + COM true was ready — COM alone, measured true before the+marker. Now a log that exists or a configured path that is missing and cannot+be read is `unreadable`: the session is `unverifiable` (reads after the grace;+document verbs refuse, naming the log). Only a genuinely absent default log+lets COM decide. V2: the ready latch was keyed by pid alone and lived as long as+the server, so a SOLIDWORKS relaunched with a reused pid was born ready; it is+now keyed by (pid, process creation time), like the session history and the+quit ladder, and still survives a com-host restart.++**Open — the single most valuable live measurement left (not built):** poll+`StartupProcessCompleted` once a second against the performance log on warm and+cold starts, with the 3DEXPERIENCE add-in enabled and disabled, to measure the+gap between COM true and the marker. Read-only, no document commands; it waits+until Drew is no longer working in the session (0.9.9's presence guard is what+tells the harness so).+ ## Live proof 4 — 0.9.3 on Drew's machine (2026-10-01) | | Measured |
docs/QUIT-INVESTIGATION.md+40@@ -2111,3 +2111,43 @@ measured, its owner's enablement was not. blocks those). The sweep already censuses before EVERY save and EVERY close, between its own COM calls — the live read came right after one close returned and before the next was issued; the settle gives that instant time to pass.++### 5.23 LIVE 2026-10-01 on 0.9.8 — the USER took over a bridge-launched session; 0.9.9 guards presence++* **Live:** launch gate 16/16 (READY 16.7 s); 2 of 3 sweeps completed; drain+ 5/5; quit gate 11/11.+* **The event that defines 0.9.9.** A "leave the machine as found" `swx_stop`+ on the relaunched, empty-looking session returned `stop_blocked_by_dialog`,+ `rungsTried [{result:"dialogAppeared", 7.9 s}]`. The dialog was SOLIDWORKS's+ own File Open dialog (Options…, Open, Cancel, &Help, Open from 3DEXPERIENCE,+ Re&ferences…, Select Sheets…, a shell file browser inside): DREW HAD STARTED+ USING THE SESSION. Minutes later it held 34 documents — his crate assembly+ ASM-CRATE.SLDASM and its parts under C:/Users/Public/adom-crate/, plus a+ pathless Part1. The retry was correctly refused `open_documents_present`.+ Nothing was closed or lost — but only because his dialog happened to be up:+ an empty-looking session he had just brought to the front would have been+ quit under him, and a sweep a minute later would have closed his clean+ documents in place. The bridge had no notion that the USER had taken over a+ session it launched.+* **0.9.9: the user-presence guard** (`src/presence.rs`). Before swx_stop,+ swx_shutdown, swx_close_all_safely, swx_close_all_documents,+ swx_reset_workspace — and again before priming — presence is MEASURED:+ SOLIDWORKS (its process or a child) is the FOREGROUND window+ (GetForegroundWindow → GetWindowThreadProcessId) AND the last user input+ (GetLastInputInfo) is under 120 s; OR documents are open that were not in the+ set the bridge last looked at (refreshed after every bridge verb that can+ change it); OR the session was already taken over. Present → `user_is_active`+ with the evidence (foreground window title/class/pid, secondsSinceLastInput,+ foreignDocuments count + titles). An unreadable measurement is present (fail+ closed) but never marks a take-over; POSITIVE evidence marks the session+ `takenOverByUser` persistently for its pid + creation time — the bridge then+ never ends or sweeps it without `userPresentOverride:true` ++ `userAuthorized:true` + a reason, even after the user steps away (a sweep+ under the override still preserves first). Verbs that ADD documents still+ run on a taken-over session and report `userPresent`. `swx_user_presence`+ reads the same measurement. The quit and drain gates measure it first,+ refuse on presence, and never pass the override.+* **Not yet explained:** one of the three sweeps stopped on the Connected+ notice with its owner read DISABLED across the whole 0.9.8 settle window (5+ reads, ~2 s). The next sweep closed everything. It cannot be measured further+ today: the user is working in the session.
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "solidworks-bridge",- "version": "0.9.8",+ "version": "0.9.9", "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/closing-and-recovery.md+6@@ -124,6 +124,12 @@ Read this before closing any document, quitting SOLIDWORKS, draining leaked impo one (measured 2026-10-01). `textIsTemplatePlaceholder:true` means the captions read were a template's ("Title", "Description", …), not what the window shows — do not quote them to the user as its message.+- **`user_is_active` — the session is the user's.** Every quit and sweep+ measures presence first (SOLIDWORKS in front with recent input, documents the+ bridge did not open, or an earlier take-over) and refuses with the evidence+ under `userPresence`. Leave the session alone. `swx_user_presence` reads the+ same measurement; `userPresentOverride:true` (with `userAuthorized:true` and+ a reason) is only for the user's own explicit request. - **`swx_shutdown` is idempotent.** SOLIDWORKS not running answers `success:true, alreadyStopped:true`. It is the last thing you do, and the last thing should be safe to repeat.
src/comhost/mock.rs+8@@ -3125,6 +3125,14 @@ impl MockBackend { } "app_state" => {+ // THE USER OPENS A DOCUMENT behind the bridge's back (0.9.9): the+ // presence guard must see a document the bridge did not open.+ if fault("mock-user-opens-document") {+ let path = "C:\\Users\\Public\\adom-crate\\ASM-CRATE.SLDASM".to_string();+ if self.find_by_name(&path).is_none() {+ self.docs.push(MockDoc::opened(path, "assembly", "Default".into()));+ }+ } self.publish_session(); // A com host that cannot answer. The point of the switch is that // the SERVER must treat this as "I do not know" and keep its
src/launchos.rs+88@@ -465,6 +465,18 @@ pub trait LaunchOs: Send + Sync { Err("this host cannot read dialog contents".into()) } + /// THE FOREGROUND WINDOW, for the user-presence guard (0.9.9): `Ok(None)`+ /// when no window has the foreground; `Err` = could not look, which the+ /// guard treats as PRESENT.+ fn foreground_window(&self) -> Result<Option<ForegroundWindow>, String> {+ Err("this host cannot read the foreground window".into())+ }+ /// SECONDS SINCE THE LAST USER INPUT, system-wide (GetLastInputInfo). `Err`+ /// = could not read, which the guard treats as PRESENT.+ fn seconds_since_last_input(&self) -> Result<u64, String> {+ Err("this host cannot read the last-input time".into())+ }+ /// RE-READ ONE WINDOW'S OWNER: is the frame that owns `hwnd` enabled right /// now? `None` = no owner, or the read failed — UNKNOWN, never "enabled". /// The owner-state settle (0.9.8) reads this several times, because a frame@@ -485,6 +497,15 @@ pub trait LaunchOs: Send + Sync { fn kind(&self) -> &'static str; } +/// The window that has the foreground, and the process that owns it.+#[derive(Clone, Debug, Default)]+pub struct ForegroundWindow {+ pub hwnd: i64,+ pub pid: u32,+ pub title: String,+ pub class_name: String,+}+ pub fn epoch_secs() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) }@@ -1159,6 +1180,35 @@ impl LaunchOs for FakeOs { }) } + /// `foreground.json`: `{"hwnd","pid","title","className"}`, or absent / `{}`+ /// for "no window has the foreground". `presence-unreadable` makes both+ /// presence reads fail.+ fn foreground_window(&self) -> Result<Option<ForegroundWindow>, String> {+ if self.dir.join("presence-unreadable").exists() {+ return Err("simulated: the foreground window could not be read".into());+ }+ let v = self.read_json("foreground.json");+ let Some(hwnd) = v.get("hwnd").and_then(|h| h.as_i64()) else { return Ok(None) };+ Ok(Some(ForegroundWindow {+ hwnd,+ pid: v.get("pid").and_then(|p| p.as_u64()).unwrap_or(0) as u32,+ title: v.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string(),+ class_name: v.get("className").and_then(|t| t.as_str()).unwrap_or("").to_string(),+ }))+ }++ /// `last-input-secs` holds the seconds since the last user input; absent =+ /// an idle machine (an hour).+ fn seconds_since_last_input(&self) -> Result<u64, String> {+ if self.dir.join("presence-unreadable").exists() {+ return Err("simulated: the last-input time could not be read".into());+ }+ Ok(std::fs::read_to_string(self.dir.join("last-input-secs"))+ .ok()+ .and_then(|t| t.trim().parse().ok())+ .unwrap_or(3600))+ }+ /// The scenario's row for `hwnd` may carry `ownerEnabledReads: [false, /// false, true]` — one value per re-read, the last repeating; otherwise its /// `ownerEnabled` holds for every read.@@ -1664,6 +1714,44 @@ mod win { Ok(out) } + fn foreground_window(&self) -> Result<Option<ForegroundWindow>, String> {+ use windows::Win32::UI::WindowsAndMessaging::{+ GetClassNameW, GetForegroundWindow, GetWindowTextW, GetWindowThreadProcessId,+ };+ unsafe {+ let h = GetForegroundWindow();+ if h.0.is_null() {+ return Ok(None);+ }+ let mut pid = 0u32;+ if GetWindowThreadProcessId(h, Some(&mut pid)) == 0 || pid == 0 {+ return Err("GetWindowThreadProcessId failed on the foreground window".into());+ }+ let mut class = [0u16; 256];+ let mut title = [0u16; 512];+ let cl = GetClassNameW(h, &mut class);+ let tl = GetWindowTextW(h, &mut title);+ Ok(Some(ForegroundWindow {+ hwnd: h.0 as i64,+ pid,+ title: String::from_utf16_lossy(&title[..tl.max(0) as usize]),+ class_name: String::from_utf16_lossy(&class[..cl.max(0) as usize]),+ }))+ }+ }++ fn seconds_since_last_input(&self) -> Result<u64, String> {+ use windows::Win32::System::SystemInformation::GetTickCount;+ use windows::Win32::UI::Input::KeyboardAndMouse::{GetLastInputInfo, LASTINPUTINFO};+ unsafe {+ let mut info = LASTINPUTINFO { cbSize: std::mem::size_of::<LASTINPUTINFO>() as u32, dwTime: 0 };+ if !GetLastInputInfo(&mut info).as_bool() {+ return Err("GetLastInputInfo failed".into());+ }+ Ok((GetTickCount().wrapping_sub(info.dwTime) / 1000) as u64)+ }+ }+ fn owner_enabled_of(&self, hwnd: i64) -> Option<bool> { use windows::Win32::UI::WindowsAndMessaging::{GetWindow, GetWindowLongPtrW, GWL_STYLE, GW_OWNER}; unsafe {
src/lib.rs+1@@ -38,6 +38,7 @@ pub mod launchos; pub mod ledger; pub mod owned; pub mod perflog;+pub mod presence; pub mod quit; pub mod readiness; pub mod disk;
src/perflog.rs+19−3@@ -74,9 +74,13 @@ pub const ON_DEMAND_QUIET_SECS: u64 = 60; /// What the log says about the RUNNING session's start. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LogVerdict {- /// No log at all, or one that cannot be read: the log cannot judge, and COM- /// decides.+ /// No log at all (the default location holds none): the log cannot judge,+ /// and COM decides. Unavailable,+ /// A log that exists — or a configured path (`SWX_PERF_LOG`) that is+ /// missing — and cannot be read: UNVERIFIABLE. Never treated as absent: COM+ /// alone was measured true before the marker (adversary pass 45, V1).+ Unreadable, /// Nothing ties the log to THIS session: no process creation time, or a log /// last written before the process started (adversary pass 42: a day-old log /// ending in a completed start read "complete" for a session that had not@@ -104,6 +108,7 @@ impl LogVerdict { pub fn name(self) -> &'static str { match self { LogVerdict::Unavailable => "unavailable",+ LogVerdict::Unreadable => "unreadable", LogVerdict::NotThisSession => "notThisSession", LogVerdict::Loading => "loading", LogVerdict::Complete => "complete",@@ -146,7 +151,18 @@ pub fn startup_complete(session_start: Option<u64>) -> (LogVerdict, String) { return (LogVerdict::Unavailable, "no SOLIDWORKS performance log to read".into()); }; let Some(t) = tail() else {- return (LogVerdict::Unavailable, format!("the performance log {} could not be read", path.display()));+ // ABSENT AND UNREADABLE ARE DIFFERENT FACTS (adversary pass 45, V1). A+ // log that exists — or a configured path that is missing — and cannot+ // be read says the edition DOES write one and this bridge did not see+ // it: UNVERIFIABLE, never "no log, let COM decide".+ return (+ LogVerdict::Unreadable,+ if path.exists() {+ format!("the performance log exists but could not be read ({})", path.display())+ } else {+ format!("the configured performance log path is missing ({})", path.display())+ },+ ); }; let t: &str = match t.rfind(LOG_HEADER) { Some(h) => &t[h..],
src/presence.rsadded+338@@ -0,0 +1,338 @@+//! IS THE USER WORKING IN THIS SOLIDWORKS? — the user-presence guard (0.9.9).+//!+//! MEASURED 2026-10-01 on hosted 0.9.8: a "leave the machine as found"+//! `swx_stop` on a bridge-launched, relaunched, EMPTY-LOOKING session returned+//! `stop_blocked_by_dialog` — the dialog was SOLIDWORKS's own File Open dialog,+//! because DREW HAD STARTED USING THE SESSION. Minutes later it held 34+//! documents: his crate assembly and its parts. Nothing was lost only because+//! his dialog happened to be up; a session he had just brought to the front+//! would have been quit under him, and a sweep a minute later would have closed+//! his documents. The bridge had no notion that the USER had taken over a+//! session it launched.+//!+//! So, before every verb that ENDS work — swx_stop, swx_shutdown,+//! swx_close_all_safely, swx_close_all_documents, swx_reset_workspace, and the+//! quit ladder's priming — presence is MEASURED:+//!+//! * is a SOLIDWORKS window (the session's process or one of its children)+//! the FOREGROUND window, and how long since the last user input+//! (system-wide) — foreground AND input within `idle_secs()` (120 s);+//! * have documents appeared that the bridge did not open — any document+//! not in the set the bridge last looked at, which is refreshed after every+//! verb the bridge runs that can add one;+//! * was the session already marked `takenOverByUser`.+//!+//! PRESENT refuses `user_is_active` with the evidence. An unreadable+//! measurement is PRESENT (fail closed) — but only POSITIVE evidence marks the+//! session taken over, persistently for its (pid, creation time): from then on+//! the bridge never ends or sweeps it without `userPresentOverride:true` ++//! `userAuthorized:true` + a reason, even after the user steps away.+//! Attribution of the launch stays; authority to end the session does not.++use serde_json::{json, Value};++/// Input within this many seconds, with SOLIDWORKS in the foreground, is a+/// user at the keyboard. `SWX_PRESENCE_IDLE_SECS` overrides.+pub fn idle_secs() -> u64 {+ std::env::var("SWX_PRESENCE_IDLE_SECS").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(120)+}++/// The verbs that end a user's work, and so are guarded.+pub const GUARDED: &[&str] = &[+ "swx_stop",+ "swx_shutdown",+ "swx_close_all_safely",+ "swx_close_all_documents",+ "swx_reset_workspace",+];++fn path() -> std::path::PathBuf {+ crate::scratch::root().join("presence.json")+}++fn key(pid: u32, created: Option<u64>) -> String {+ format!("{pid}:{}", created.map(|c| c.to_string()).unwrap_or_else(|| "?".into()))+}++fn load() -> serde_json::Map<String, Value> {+ std::fs::read_to_string(path())+ .ok()+ .and_then(|t| serde_json::from_str::<Value>(&t).ok())+ .and_then(|v| v.as_object().cloned())+ .unwrap_or_default()+}++fn save(mut m: serde_json::Map<String, Value>) {+ while m.len() > 20 {+ let oldest = m+ .iter()+ .min_by_key(|(_, v)| v.get("atMs").and_then(|a| a.as_u64()).unwrap_or(0))+ .map(|(k, _)| k.clone());+ match oldest {+ Some(o) => {+ m.remove(&o);+ }+ None => break,+ }+ }+ let _ = crate::launch::write_atomic(&path(), &Value::Object(m).to_string());+}++fn entry(k: &str) -> Value {+ load().get(k).cloned().unwrap_or_else(|| json!({}))+}++fn put(k: &str, v: Value) {+ let mut m = load();+ let mut v = v;+ if let Value::Object(o) = &mut v {+ o.insert("atMs".into(), json!(crate::launchos::epoch_ms() as u64));+ }+ m.insert(k.to_string(), v);+ save(m);+}++/// The running session: (pid, creation time, documents, truncated). `None`+/// when SOLIDWORKS is not running or the host cannot say.+struct Session {+ pid: u32,+ created: Option<u64>,+ docs: Vec<String>,+ count: Option<u64>,+ titles: std::collections::HashMap<String, String>,+}++fn doc_key(d: &Value) -> String {+ let p = d.get("path").and_then(|p| p.as_str()).unwrap_or("");+ if !p.is_empty() {+ p.replace('/', "\\").to_lowercase()+ } else {+ format!("title:{}", d.get("title").and_then(|t| t.as_str()).unwrap_or("").to_lowercase())+ }+}++fn session(ctx: &crate::verbs::Ctx) -> Option<Session> {+ let st = ctx.call("app_state", json!({}), 30).ok()?;+ if st.get("running").and_then(|v| v.as_bool()) == Some(false) {+ return None;+ }+ let pid = st.get("processId").and_then(|v| v.as_u64()).map(|p| p as u32)?;+ let created = crate::launchos::os()+ .processes()+ .ok()+ .and_then(|ps| ps.iter().find(|p| p.pid == pid).and_then(|p| p.created_at))+ .or_else(|| ctx.call("startup_state", json!({}), 20).ok().and_then(|s| s.get("processCreatedAt").and_then(|v| v.as_u64())));+ let truncated = st.get("documentsTruncated").and_then(|v| v.as_bool()).unwrap_or(false);+ let list: Vec<Value> = st.get("documents").and_then(|d| d.as_array()).cloned().unwrap_or_default();+ let mut titles = std::collections::HashMap::new();+ let docs: Vec<String> = if truncated {+ Vec::new()+ } else {+ list.iter()+ .map(|d| {+ let k = doc_key(d);+ titles.insert(k.clone(), d.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string());+ k+ })+ .collect()+ };+ let count = st+ .get("documentCount")+ .and_then(|v| v.as_u64())+ .or_else(|| (!truncated).then_some(docs.len() as u64));+ Some(Session { pid, created, docs, count, titles })+}++/// THE BRIDGE LOOKED: the documents open right now are known to it. Called+/// after every verb the bridge runs that can change the document set, and by a+/// presence check that finds the user absent.+pub fn note_bridge_looked(ctx: &crate::verbs::Ctx) {+ let Some(s) = session(ctx) else { return };+ let k = key(s.pid, s.created);+ let mut e = entry(&k);+ if let Value::Object(o) = &mut e {+ o.insert("baselineDocs".into(), json!(s.docs));+ o.insert("baselineCount".into(), json!(s.count));+ }+ put(&k, e);+}++/// The running session's taken-over record, if it has one — for the reply of a+/// verb that adds a document to it (allowed: it adds, never removes).+pub fn taken_over_now(ctx: &crate::verbs::Ctx) -> Option<Value> {+ let st = ctx.call("startup_state", json!({}), 20).ok()?;+ let pid = st.get("processId").and_then(|v| v.as_u64()).map(|p| p as u32)?;+ let created = crate::launchos::os()+ .processes()+ .ok()+ .and_then(|ps| ps.iter().find(|p| p.pid == pid).and_then(|p| p.created_at))+ .or_else(|| st.get("processCreatedAt").and_then(|v| v.as_u64()));+ taken_over(pid, created).map(|e| {+ json!({"takenOverByUser": true, "evidence": e.get("evidence").cloned().unwrap_or(Value::Null),+ "note": "the user has taken over this session; documents may be added, and nothing \+ will be closed or quit without the user-present override"})+ })+}++/// Is this session marked as taken over by the user?+pub fn taken_over(pid: u32, created: Option<u64>) -> Option<Value> {+ let e = entry(&key(pid, created));+ (e.get("takenOverByUser").and_then(|v| v.as_bool()) == Some(true)).then_some(e)+}++fn mark_taken_over(pid: u32, created: Option<u64>, evidence: &Value) {+ let k = key(pid, created);+ let mut e = entry(&k);+ if e.get("takenOverByUser").and_then(|v| v.as_bool()) == Some(true) {+ return;+ }+ if let Value::Object(o) = &mut e {+ o.insert("takenOverByUser".into(), json!(true));+ o.insert("takenOverAtMs".into(), json!(crate::launchos::epoch_ms() as u64));+ o.insert("evidence".into(), evidence.clone());+ }+ put(&k, e);+ crate::ledger::record("session_taken_over_by_user", json!({"pid": pid, "createdAt": created, "evidence": evidence}));+}++/// MEASURE PRESENCE. `(present, evidence)`.+pub fn measure(ctx: &crate::verbs::Ctx) -> Option<(bool, Value)> {+ let s = session(ctx)?;+ let os = crate::launchos::os();+ let k = key(s.pid, s.created);+ let e = entry(&k);+ let taken = e.get("takenOverByUser").and_then(|v| v.as_bool()) == Some(true);++ // FOREGROUND: the session's process, or one of its children (a sign-in+ // web view is a child process).+ let census = os.processes().ok();+ let children: Vec<u32> = census+ .as_ref()+ .map(|c| c.iter().filter(|p| p.ppid == Some(s.pid)).map(|p| p.pid).collect())+ .unwrap_or_default();+ let fg = os.foreground_window();+ let input = os.seconds_since_last_input();+ let simulator_without_scenario = crate::is_mock() && std::env::var(crate::launchos::ENV_FAKE_DIR).map(|d| d.is_empty()).unwrap_or(true);+ let (fg_json, fg_is_sw, fg_unreadable) = match &fg {+ Ok(Some(w)) => (+ json!({"hwnd": w.hwnd, "pid": w.pid, "title": w.title, "className": w.class_name}),+ w.pid == s.pid || children.contains(&w.pid),+ false,+ ),+ Ok(None) => (Value::Null, false, false),+ Err(e) => (json!({"unreadable": e}), false, true),+ };+ let (input_secs, input_unreadable) = match &input {+ Ok(n) => (Some(*n), false),+ Err(_) => (None, true),+ };+ // A simulated SOLIDWORKS with no presence scenario is on nobody's screen.+ let (fg_unreadable, input_unreadable) = if simulator_without_scenario {+ (false, false)+ } else {+ (fg_unreadable, input_unreadable)+ };+ let at_keyboard = fg_is_sw && input_secs.map(|n| n < idle_secs()).unwrap_or(false);++ // FOREIGN DOCUMENTS: open now, not in the set the bridge last looked at.+ let baseline: Option<Vec<String>> = e+ .get("baselineDocs")+ .and_then(|v| v.as_array())+ .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect());+ let baseline_count = e.get("baselineCount").and_then(|v| v.as_u64());+ let foreign: Vec<String> = match &baseline {+ Some(b) => s.docs.iter().filter(|d| !b.contains(d)).cloned().collect(),+ None => Vec::new(),+ };+ let count_rose = match (baseline_count, s.count) {+ (Some(b), Some(n)) => n > b,+ _ => false,+ };+ let foreign_titles: Vec<String> =+ foreign.iter().map(|k| s.titles.get(k).cloned().unwrap_or_else(|| k.clone())).collect();+ let foreign_seen = !foreign.is_empty() || (count_rose && s.docs.is_empty());++ let unreadable = fg_unreadable || input_unreadable;+ let positive = at_keyboard || foreign_seen;+ let present = positive || unreadable || taken;+ let evidence = json!({+ "present": present,+ "foregroundWindow": fg_json,+ "foregroundIsSolidworks": fg_is_sw,+ "secondsSinceLastInput": input_secs,+ "idleThresholdSecs": idle_secs(),+ "foreignDocuments": {"count": foreign.len(), "titles": foreign_titles,+ "countRoseWithoutTheBridge": count_rose},+ "takenOverByUser": taken || positive,+ "takenOverEarlier": taken,+ "measurementUnreadable": unreadable,+ "processId": s.pid,+ "processCreatedAt": s.created,+ "basis": if taken {+ "this session was taken over by the user earlier; the bridge does not end it without the override"+ } else if at_keyboard {+ "SOLIDWORKS is the foreground window and the user gave input recently"+ } else if foreign_seen {+ "documents appeared that this bridge did not open"+ } else if unreadable {+ "presence could not be measured, which counts as present"+ } else {+ "no sign of the user: SOLIDWORKS is not in the foreground with recent input, and no document appeared that the bridge did not open"+ },+ });+ if positive {+ mark_taken_over(s.pid, s.created, &evidence);+ }+ if !present {+ // The bridge looked, and the user is not here: this is the set it knows.+ let mut e = entry(&k);+ if let Value::Object(o) = &mut e {+ o.insert("baselineDocs".into(), json!(s.docs));+ o.insert("baselineCount".into(), json!(s.count));+ }+ put(&k, e);+ }+ Some((present, evidence))+}++/// THE GUARD, for a verb that ends work. `Ok(evidence)` lets it run (absent,+/// not running, or overridden); `Err(refusal)` is `user_is_active`.+pub fn guard(ctx: &crate::verbs::Ctx, verb: &str, args: &Value) -> Result<Value, Value> {+ let Some((present, evidence)) = measure(ctx) else {+ return Ok(json!({"present": Value::Null, "why": "no running SOLIDWORKS session to measure"}));+ };+ if !present {+ return Ok(evidence);+ }+ let overridden = args.get("userPresentOverride").and_then(|v| v.as_bool()) == Some(true)+ && args.get("userAuthorized").and_then(|v| v.as_bool()) == Some(true)+ && args.get("reason").and_then(|v| v.as_str()).map(|r| !r.trim().is_empty()).unwrap_or(false);+ if overridden {+ crate::ledger::record(+ "user_present_override",+ json!({"verb": verb, "reason": args.get("reason"), "evidence": evidence}),+ );+ let mut e = evidence;+ if let Value::Object(o) = &mut e {+ o.insert("overridden".into(), json!(true));+ }+ return Ok(e);+ }+ let mut out = crate::verbs::err_code(+ "user_is_active",+ format!(+ "{verb} refused: the user is working in this SOLIDWORKS ({}). The session is now the \+ user's — the bridge does not end it or sweep it.",+ evidence.get("basis").and_then(|b| b.as_str()).unwrap_or("present")+ ),+ "Nothing was closed, saved or quit. This session belongs to the person using it: leave \+ it alone. Only if the user has explicitly asked for it to be ended, pass \+ userPresentOverride:true together with userAuthorized:true and their reason — a sweep \+ under the override still preserves every document first.",+ );+ if let Value::Object(m) = &mut out {+ m.insert("userPresence".into(), evidence);+ }+ crate::ledger::record("user_present_refusal", json!({"verb": verb}));+ Err(out)+}
src/readiness.rs+14−7@@ -67,7 +67,11 @@ struct Seen { log_len_at_first_sight: Option<u64>, } -static SESSIONS: Mutex<Option<HashMap<i64, Seen>>> = Mutex::new(None);+/// Keyed by (pid, process creation time) — like the session history and the+/// quit ladder's same-process check — so a SOLIDWORKS relaunched in one bridge+/// lifetime that reuses a pid is a NEW session, never born ready (adversary+/// pass 45, V2). The map lives in the server, so it survives a com-host restart.+static SESSIONS: Mutex<Option<HashMap<(i64, Option<u64>), Seen>>> = Mutex::new(None); /// The readiness of the running session, with what it is based on. `running` /// false (or a host that cannot answer) means there is no session to judge, and@@ -103,7 +107,7 @@ pub fn assess(ctx: &crate::verbs::Ctx) -> Value { use crate::perflog::LogVerdict as L; let mut guard = SESSIONS.lock().unwrap_or_else(|e| e.into_inner()); let map = guard.get_or_insert_with(HashMap::new);- let key = pid.unwrap_or(-1);+ let key = (pid.unwrap_or(-1), created); let shape = crate::perflog::shape(); let len_now = shape.get("lengthNow").and_then(|v| v.as_u64()); let seen = map@@ -131,6 +135,8 @@ pub fn assess(ctx: &crate::verbs::Ctx) -> Value { (L::OnDemandAfterComplete, None) => None, (L::PlatformAddInNotLoaded, Some(true)) => Some(true), (L::PlatformAddInNotLoaded, None) => None,+ // Exists but unreadable: unverifiable, whatever COM says.+ (L::Unreadable, _) => None, (L::Unavailable, Some(true)) => Some(true), (L::Unavailable, None) => None, // Nothing ties the log to this session: COM alone is never enough@@ -194,12 +200,12 @@ pub fn assess(ctx: &crate::verbs::Ctx) -> Value { /// Has this bridge VERIFIED that session `pid` finished starting? Read from the /// per-session cache; no COM.-pub fn known_ready(pid: u32) -> bool {+pub fn known_ready(pid: u32, created: Option<u64>) -> bool { SESSIONS .lock() .unwrap_or_else(|e| e.into_inner()) .as_ref()- .and_then(|m| m.get(&(pid as i64)).map(|s| s.ready))+ .and_then(|m| m.get(&(pid as i64, created)).map(|s| s.ready)) .unwrap_or(false) } @@ -259,9 +265,10 @@ fn refusal_for(r: &Value, verb: &str, touch: Touch) -> Option<Value> { if unverifiable { format!( "{verb} refused: the bridge cannot verify that SOLIDWORKS has finished starting \- (no signal it can read ties a completed start to this session), and a verb that \- touches documents is never sent on the clock alone. Document commands sent while \- SOLIDWORKS was still starting crashed it on 2026-10-01."+ (no signal it can read ties a completed start to this session: {}), and a verb \+ that touches documents is never sent on the clock alone. Document commands sent \+ while SOLIDWORKS was still starting crashed it on 2026-10-01.",+ r.pointer("/performanceLog/why").and_then(|v| v.as_str()).unwrap_or("no performance log") ) } else { format!(
src/session_history.rs+13−6@@ -142,6 +142,17 @@ pub fn ever_had_document(pid: u32, created: Option<u64>) -> Option<bool> { } } +/// The verbs that bring a document into a session.+pub const ADDS_DOCUMENTS: &[&str] = &[+ "swx_new_document",+ "swx_open",+ "swx_import",+ "swx_add_component",+ "swx_show_part",+ "swx_show_assembly",+ "swx_show_drawing",+];+ /// After a verb: if its reply shows a document open in the running session, /// record it. Asks the host which session that is only when there is something /// to record.@@ -149,11 +160,7 @@ pub fn note_reply(ctx: &crate::verbs::Ctx, command: &str, reply: &Value) { if reply.get("success").and_then(|v| v.as_bool()) == Some(false) { return; }- let made_one = matches!(- command,- "swx_new_document" | "swx_open" | "swx_import" | "swx_add_component" | "swx_show_part"- | "swx_show_assembly" | "swx_show_drawing"- );+ let made_one = ADDS_DOCUMENTS.contains(&command); let saw_one = ["documentCount", "count", "openDocuments"] .iter() .any(|k| reply.get(*k).and_then(|v| v.as_u64()).unwrap_or(0) > 0);@@ -169,7 +176,7 @@ pub fn note_reply(ctx: &crate::verbs::Ctx, command: &str, reply: &Value) { .or_else(|| state.get("processCreatedAt").and_then(|v| v.as_u64())); if made_one { mark(pid, created);- } else if crate::readiness::known_ready(pid) {+ } else if crate::readiness::known_ready(pid, created) { // A COUNT IN A REPLY is a poll like any other: evidence only on a started // session, and only once it persists. let n = ["documentCount", "count", "openDocuments"]
src/verbs.rs+73−1@@ -1546,7 +1546,8 @@ fn swx_stop(ctx: &Ctx, args: &Value) -> Value { && attempt.dialog.is_none() && user_authorized {- match prime_session(ctx, os.as_ref(), &id) {+ let presence_overridden = arg_bool(args, "userPresentOverride", false);+ match prime_session(ctx, os.as_ref(), &id, presence_overridden) { Ok(prime) => { crate::ledger::record( "solidworks_quit_rung",@@ -1855,6 +1856,7 @@ fn prime_session( ctx: &Ctx, os: &dyn crate::launchos::LaunchOs, id: &crate::quit::Identity,+ presence_overridden: bool, ) -> Result<String, String> { use crate::quit; // THE SESSION'S HISTORY IS ADVISORY — reported, never a guard (0.9.6, P1).@@ -1864,6 +1866,17 @@ fn prime_session( // rung (a) declined, the session empty (re-checked below), no blocking // dialog, userAuthorized — once per swx_stop call. let ever = crate::session_history::ever_had_document(id.pid, Some(id.created_at));+ // NEVER PRIME A SESSION THE USER IS IN (0.9.9): presence is measured again+ // here — the front gate's reading is a ladder rung old.+ if !presence_overridden {+ if let Some((true, evidence)) = crate::presence::measure(ctx) {+ return Err(format!(+ "the user is working in this session ({}) — the bridge does not create and close a \+ document in it",+ evidence.get("basis").and_then(|b| b.as_str()).unwrap_or("present")+ ));+ }+ } let count = ctx .call("app_state", json!({}), 20) .ok()@@ -2994,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_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)"), ("swx_dismiss_blocking_dialogs", "exec", "census of blocking SOLIDWORKS dialogs; close:true is opt-in, gated, and never touches an unattributed window"),@@ -3027,6 +3041,27 @@ pub const CATALOG: &[(&str, &str, &str)] = &[ ("swx_export_dwg", "write", "export DWG (drawings; refused for a part on this edition)"), ]; +/// `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.+fn swx_user_presence(ctx: &Ctx, _args: &Value) -> Value {+ match crate::presence::measure(ctx) {+ None => ok(json!({"present": Value::Null, "running": false,+ "_hint": "No running SOLIDWORKS session to measure."})),+ Some((present, evidence)) => ok(json!({+ "present": present,+ "evidence": evidence,+ "_hint": if present {+ "The user is working in this SOLIDWORKS: swx_stop, swx_shutdown and every sweep \+ refuse user_is_active. Leave the session alone; it is theirs."+ } else {+ "No sign of the user right now. This is a reading, not a promise — every verb that \+ ends work measures again before it acts."+ },+ })),+ }+}+ fn swx_describe(_ctx: &Ctx, _args: &Value) -> Value { let verbs: Vec<Value> = CATALOG .iter()@@ -3192,10 +3227,46 @@ pub fn dispatch(ctx: &Ctx, command: &str, args: &Value) -> Value { } } let args: &Value = gated_args.as_ref().unwrap_or(args);+ // THE USER-PRESENCE GUARD (0.9.9): a verb that ends work never runs on a+ // session the user is working in — measured, fail-closed. See+ // src/presence.rs.+ let mut presence: Option<Value> = None;+ if crate::presence::GUARDED.contains(&command) {+ match crate::presence::guard(ctx, command, args) {+ Ok(evidence) => presence = Some(evidence),+ Err(refusal) => {+ let mut refusal = refusal;+ if let (Some(summary), Value::Object(m)) = (at_gate.clone(), &mut refusal) {+ m.entry("readiness").or_insert(summary);+ }+ return refusal;+ }+ }+ } let mut out = dispatch_after_gate(ctx, command, args); if let (Some(summary), Value::Object(m)) = (at_gate, &mut out) { m.entry("readiness").or_insert(summary); }+ if let (Some(evidence), Value::Object(m)) = (presence, &mut out) {+ m.entry("userPresence").or_insert(evidence);+ }+ if is_known(command) && startup_touch(command) == crate::readiness::Touch::Mutate+ && !is_startup_exempt(command)+ {+ // A verb that adds documents on a session the user has taken over is+ // allowed — it adds, never removes — and says so.+ if crate::session_history::ADDS_DOCUMENTS.contains(&command) {+ if let Some(t) = crate::presence::taken_over_now(ctx) {+ if let Value::Object(m) = &mut out {+ m.insert("userPresent".into(), t);+ }+ }+ }+ // THE BRIDGE LOOKED: whatever this verb did to the document set is the+ // bridge's own doing, so it is the new baseline. A document that+ // appears between two bridge verbs is the user's.+ crate::presence::note_bridge_looked(ctx);+ } out } @@ -3214,6 +3285,7 @@ fn dispatch_after_gate(ctx: &Ctx, command: &str, args: &Value) -> Value { "swx_close_all_safely" => crate::verbs3::close_all_safely, "swx_shutdown" => crate::verbs3::shutdown, "swx_recover_list" => crate::verbs3::recover_list,+ "swx_user_presence" => swx_user_presence, "swx_docs" => swx_docs, "swx_find_files" => swx_find_files, "swx_open" => swx_open,
tests/adversary43.rsadded+143@@ -0,0 +1,143 @@+//! ADVERSARIAL REVIEW OF 0.9.7 — pass forty-five, the last of this cycle.+//!+//! readiness.rs, perflog.rs, quit.rs and the dispatch gate compile here and were+//! exercised; com.rs and LiveOs are cfg(windows): READ, NOT RUN.+//!+//! WHAT CHECKS OUT+//! * The pass-44 findings pass, and the platform add-in is now keyed on the DLL+//! (`swk3dcompassaddin.dll`), not a word.+//! * Underscore stripping: no handler reads any underscore-prefixed key except+//! the dispatcher's own `_startupNotVerified`, so stripping ab's injected+//! `_callerBudgetMs` changes nothing in the bridge — ab enforces its budget on+//! its own side. Nothing caller-facing is lost.+//! * swx_dismiss_blocking_dialogs on the Connected notice: its words are+//! template placeholders ("Title", "Description", "Button1"…), which+//! `classify_words` maps to `unknown`, which is never answered — refused.+//! * (2) The 60 s quiet window decides only the NO-MARKER paths. A session+//! whose marker is after its last LOAD_ADDIN is `complete` however busy the+//! log is, so DocSave-time <PROCMEM> lines cannot hold a healthy platform+//! start. The one place a session's own activity can hold it is a session+//! that never loaded the platform add-in while somebody works by hand,+//! saving more often than once a minute — and the bridge's own document+//! commands are refused before the first ready, so it cannot keep the log+//! busy itself. Availability, narrow; noted, not tested. Clock skew and+//! coarse mtime shift the 60 s by seconds; they cannot fake a marker.++mod common;+use common::*;+use serde_json::{json, Value};++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++fn session(scratch: &TempDir) {+ std::fs::write(+ scratch.join("mock-solidworks-open-docs.json"),+ json!({"running": true, "untitled": 1, "active": Value::Null, "docs": [], "files": {}}).to_string(),+ )+ .unwrap();+}++const HEADER: &str = "; SolidWorks generated log file";+const COMPASS: &str = r#"<AD2> "LOAD_ADDIN" "SWK3DCompassAddin.dll" "3DEXPERIENCE for WORKS" [2.18] "6.34.0.2027" 14 COM.new 1770885081 13"#;+const TOOLBOX: &str = r#"<AD2> "LOAD_ADDIN" "SWToolbox.dll" "SOLIDWORKS Toolbox Library" [1.0] "34.0" 14 COM.new 1770885070 13"#;+const MARKER: &str = "<3DXLOAD_TIME>24000</3DXLOAD_TIME>";++/// SEVERITY 2 — AN UNREADABLE LOG IS TREATED AS NO LOG, AND NO LOG LETS COM+/// DECIDE ALONE.+///+/// `startup_complete` returns the same `Unavailable` for "no log to read" and+/// for "the performance log … could not be read", and the verdict table maps+/// `(Unavailable, Some(true))` to READY. So a log that EXISTS but cannot be+/// opened — locked, an ACL, an antivirus holding it, a path the process cannot+/// reach — hands the verdict to COM, which was MEASURED reporting complete before+/// the marker on a real start. That is the 10-01 crash window by a different+/// door.+///+/// Absent and unreadable are different facts. "No log" on a machine whose+/// SOLIDWORKS never writes one can defensibly leave COM alone; "a log I could+/// not read" says the edition DOES write one and this bridge simply did not see+/// it. That is `unverifiable` — reads after the grace, mutations refused — the+/// same answer the bridge already gives when nothing ties the log to the+/// session.+#[test]+fn a_log_that_exists_but_cannot_be_read_does_not_let_com_decide_alone() {+ use std::os::unix::fs::PermissionsExt;+ let scratch = TempDir::new("adv43-unreadable");+ session(&scratch);+ let log = scratch.join("SolidWorksPerformance.log");+ std::fs::write(&log, format!("{HEADER}\n{TOOLBOX}\n{COMPASS}\n")).unwrap();+ std::fs::set_permissions(&log, std::fs::Permissions::from_mode(0o000)).unwrap();+ let unreadable = std::fs::read(&log).is_err();+ let logp = log.to_string_lossy().to_string();+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_PERF_LOG", &logp)]);+ b.call("swx_start", json!({}));+ let r = b.call("swx_new_document", json!({"type": "part"}));+ let _ = std::fs::set_permissions(&log, std::fs::Permissions::from_mode(0o644));+ assert!(unreadable, "precondition: this user cannot read the file (not running as root)");++ assert!(+ r["errorCode"] == json!("host_app_starting"),+ "the performance log EXISTS and could not be read — and its contents, had they been \+ readable, say a start is loading (the platform add-in loaded, no marker). The bridge \+ treated \"could not be read\" exactly like \"there is no log\" and let COM — measured true \+ before the marker — decide alone: swx_new_document ran. Unreadable is unverifiable, not \+ absent. {}",+ s(&r)+ );+}++/// SEVERITY 2 — THE READY LATCH IS KEYED BY PID ALONE, SO A NEW SESSION THAT+/// REUSES THE PID IS BORN READY.+///+/// `SESSIONS: HashMap<i64, Seen>` — the key is the process id; `seen.ready`, once+/// true, short-circuits every later assessment (`alreadyReadyThisSession`). The+/// process CREATION TIME is computed two lines earlier and not part of the key.+/// Windows reissues pids, and the map lives as long as the bridge server — so a+/// SOLIDWORKS quit and relaunched in one bridge lifetime that happens to get the+/// same pid skips the whole gate: no log, no COM, no grace. Ready at second 0 —+/// the crash window again. Everything else in this bridge that identifies a+/// session (session history, the quit ladder's `same_process`) keys on pid ++/// creation time; this is the one that does not.+///+/// It survives a com-host respawn (correct — the map is in the server), and a+/// server restart empties it (also correct). The fix is the key: (pid,+/// creation time).+#[test]+fn a_new_session_that_reuses_the_pid_does_not_inherit_ready() {+ let scratch = TempDir::new("adv43-pidreuse");+ session(&scratch);+ let log = scratch.join("SolidWorksPerformance.log");+ // Session one: a completed start, written since the (hour-old) process began.+ std::fs::write(&log, format!("{HEADER}\n{TOOLBOX}\n{COMPASS}\n{MARKER}\n")).unwrap();+ let logp = log.to_string_lossy().to_string();+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_PERF_LOG", &logp)]);+ let first = b.call("swx_start", json!({}));+ let pid1 = first["readiness"]["processId"].clone();+ let made = b.call("swx_new_document", json!({"type": "part"}));+ assert!(made["success"] == json!(true), "precondition: session one became ready: {}", s(&made));++ // Session two: SOLIDWORKS relaunched, the SAME pid reissued, created NOW,+ // and its log (rewritten at start) shows add-ins loading with no marker.+ let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();+ std::fs::write(scratch.join("mock-sldworks-created"), now.to_string()).unwrap();+ std::fs::write(&log, format!("{HEADER}\n{TOOLBOX}\n{COMPASS}\n")).unwrap();+ let st = b.call("swx_status", json!({}));+ let r = b.call("swx_new_document", json!({"type": "part"}));+ let text = s(&st);+ assert!(+ text.contains(&format!("\"processCreatedAt\": {now}")) && pid1.is_number(),+ "precondition: a new process (created {now}) with pid {pid1} — status: {text}"+ );++ assert!(+ r["errorCode"] == json!("host_app_starting"),+ "a NEW SOLIDWORKS process — created just now, its log showing add-ins loading and no marker \+ — reused the pid of a session the bridge had already seen ready, and the latch \+ (`alreadyReadyThisSession`, keyed by pid alone) waved swx_new_document through without \+ reading the log or COM. Key the latch by (pid, creation time), as every other session \+ identity in this bridge is keyed. {}",+ s(&r)+ );+}
tests/e2e.rs+9@@ -1178,6 +1178,15 @@ fn every_verb_over_http_against_the_real_binary() { let rec = b.call("swx_recover_list", json!({})); check!(b, "recover_list answers before anything has been preserved", rec["success"] == true, s(&rec)); 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.+ let pres = b.call("swx_user_presence", json!({}));+ check!(+ b,+ "user_presence answers, with its evidence",+ pres["success"] == true && pres["present"] == false && pres["evidence"]["basis"].is_string(),+ s(&pres)+ ); // ── teardown verbs ────────────────────────────────────────────────────── // The bridge closes its OWN documents freely; the user's need the opt-in
tests/presence.rsadded+217@@ -0,0 +1,217 @@+//! THE USER-PRESENCE GUARD (0.9.9) — never end a session the user is working in.+//!+//! MEASURED 2026-10-01 on hosted 0.9.8: a "leave the machine as found" swx_stop+//! on a bridge-launched, relaunched, empty-looking session hit SOLIDWORKS's own+//! File Open dialog — Drew had started using the session, and minutes later it+//! held 34 of his documents. Nothing was lost only because his dialog happened+//! to be up. Every verb that ends work now measures presence first: SOLIDWORKS+//! in the foreground with recent input, or documents the bridge did not open,+//! or an earlier take-over — and an unreadable measurement counts as present.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++/// A simulated machine with a scripted quit ladder.+struct Sim {+ dir: TempDir,+}++impl Sim {+ fn new(tag: &str) -> Sim {+ let dir = TempDir::new(tag);+ std::fs::write(dir.join("quit-script.json"), json!({"a": "exited"}).to_string()).unwrap();+ std::fs::write(+ dir.join("sldworks.json"),+ json!({"responding": true, "hwnd": 6622700, "privateMB": 900, "handles": 4000}).to_string(),+ )+ .unwrap();+ Sim { dir }+ }++ fn boot(&self) -> Bridge {+ let b = Bridge::start_in(+ TempDir::new("presence-scratch"),+ &[+ ("SWX_LAUNCH_FAKE_DIR", Box::leak(self.dir.path().into_boxed_str())),+ ("SWX_ALLOW_USER_WRITES", "1"),+ ("SWX_QUIT_SETTLE_MS", "80"),+ ("SWX_QUIT_EXIT_WAIT_SECS", "3"),+ ],+ );+ b.call("swx_start", json!({}));+ b+ }++ /// SOLIDWORKS in the foreground (the user brought it to the front): the pid+ /// the session reports.+ fn foreground_solidworks(&self, b: &Bridge) {+ let pid = b.call("swx_get_app_state", json!({}))["processId"].as_u64().expect("a session pid");+ std::fs::write(+ self.dir.join("foreground.json"),+ json!({"hwnd": 6622700, "pid": pid, "title": "SOLIDWORKS Premium 2026 - [ASM-CRATE.SLDASM]",+ "className": "Afx:00007FF6B0000000:8"})+ .to_string(),+ )+ .unwrap();+ }++ fn foreground_other_app(&self) {+ std::fs::write(+ self.dir.join("foreground.json"),+ json!({"hwnd": 777, "pid": 4321, "title": "Inbox - Outlook", "className": "rctrl_renwnd32"}).to_string(),+ )+ .unwrap();+ }++ fn last_input(&self, secs: u64) {+ std::fs::write(self.dir.join("last-input-secs"), secs.to_string()).unwrap();+ }++ fn quit_happened(&self) -> bool {+ self.dir.join("quit.jsonl").exists()+ }+}++fn authorized(max_rung: &str) -> Value {+ json!({"userAuthorized": true, "reason": "leave the machine as found", "maxRung": max_rung})+}++#[test]+fn solidworks_in_front_with_recent_input_refuses_every_verb_that_ends_work() {+ let m = Sim::new("presence-front");+ let b = m.boot();+ m.foreground_solidworks(&b);+ m.last_input(5);+ let r = b.call("swx_stop", authorized("a"));+ check!(+ b,+ "swx_stop: user_is_active, with the evidence",+ r["errorCode"] == "user_is_active"+ && r["userPresence"]["foregroundIsSolidworks"] == true+ && r["userPresence"]["secondsSinceLastInput"] == 5+ && r["userPresence"]["foregroundWindow"]["title"].as_str().unwrap_or("").contains("ASM-CRATE"),+ s(&r)+ );+ check!(b, "...and SOLIDWORKS was not quit", !m.quit_happened(), String::new());+ for (verb, args) in [+ ("swx_shutdown", json!({"reason": "tidy"})),+ ("swx_close_all_safely", json!({"reason": "tidy"})),+ ("swx_close_all_documents", json!({"confirm": true, "reason": "tidy"})),+ ("swx_reset_workspace", json!({})),+ ] {+ let r = b.call(verb, args);+ check!(b, &format!("{verb}: user_is_active"), r["errorCode"] == "user_is_active", s(&r));+ }++ // THE USER STEPS AWAY. The session stays theirs: taken over, persistently.+ m.foreground_other_app();+ m.last_input(3600);+ let r = b.call("swx_stop", authorized("a"));+ check!(+ b,+ "swx_stop after the user stepped away: still refused — the session was taken over",+ r["errorCode"] == "user_is_active" && r["userPresence"]["takenOverEarlier"] == true,+ s(&r)+ );+ let st = b.call("swx_user_presence", json!({}));+ check!(b, "swx_user_presence: present, taken over earlier", st["present"] == true, s(&st));++ // A verb that ADDS is allowed on a taken-over session, and says so.+ let made = b.call("swx_new_document", json!({"type": "part"}));+ check!(+ b,+ "swx_new_document on a taken-over session: allowed, reporting userPresent",+ made["success"] == true && made["userPresent"]["takenOverByUser"] == true,+ s(&made)+ );+ b.call("swx_close_doc", json!({"name": made["created"]["title"].as_str().unwrap_or("")}));++ // ONLY the explicit override, with the user's authorization and a reason.+ let mut half = authorized("a");+ half["userPresentOverride"] = json!(true);+ half["userAuthorized"] = json!(false);+ let r = b.call("swx_stop", half);+ check!(b, "the override without userAuthorized is not an override", r["errorCode"] == "user_is_active", s(&r));+ let mut over = authorized("a");+ over["userPresentOverride"] = json!(true);+ let r = b.call("swx_stop", over);+ check!(+ b,+ "swx_stop with userPresentOverride + userAuthorized + reason: proceeds, and says it was overridden",+ r["stopped"] == true && r["userPresence"]["overridden"] == true,+ s(&r)+ );+}++#[test]+fn solidworks_in_front_but_idle_is_not_presence() {+ let m = Sim::new("presence-idle");+ let b = m.boot();+ m.foreground_solidworks(&b);+ m.last_input(600);+ let r = b.call("swx_stop", authorized("a"));+ check!(b, "swx_stop: SOLIDWORKS in front, no input for 10 min — not present; it quits", r["stopped"] == true, s(&r));+}++#[test]+fn recent_input_in_another_app_is_not_presence() {+ let m = Sim::new("presence-elsewhere");+ let b = m.boot();+ m.foreground_other_app();+ m.last_input(2);+ let r = b.call("swx_stop", authorized("a"));+ check!(b, "swx_stop: the user is busy in Outlook, not in SOLIDWORKS — it quits", r["stopped"] == true, s(&r));+}++#[test]+fn documents_the_bridge_did_not_open_are_the_user_taking_over() {+ let m = Sim::new("presence-foreign");+ let b = m.boot();+ // The bridge's OWN document is not foreign.+ let mine = b.call("swx_new_document", json!({"type": "part"}));+ check!(b, "precondition: the bridge made a part", mine["success"] == true, s(&mine));+ let pres = b.call("swx_user_presence", json!({}));+ check!(b, "the bridge's own document is not the user's", pres["present"] == false, s(&pres));+ // ...then the user opens their crate assembly.+ std::fs::write(b.scratch.join("mock-user-opens-document"), "").unwrap();+ let r = b.call("swx_close_all_safely", json!({"reason": "tidy"}));+ check!(+ b,+ "swx_close_all_safely: user_is_active, naming the document the bridge did not open",+ r["errorCode"] == "user_is_active"+ && r["userPresence"]["foreignDocuments"]["count"] == 1+ && r["userPresence"]["foreignDocuments"]["titles"].to_string().contains("ASM-CRATE"),+ s(&r)+ );+ let docs = b.call("swx_docs", json!({}));+ check!(b, "...and nothing was closed", s(&docs).contains("ASM-CRATE") && docs["count"] == 2, s(&docs));+}++#[test]+fn an_unreadable_presence_is_presence_but_never_a_take_over() {+ let m = Sim::new("presence-unreadable");+ let b = m.boot();+ std::fs::write(m.dir.join("presence-unreadable"), "").unwrap();+ let r = b.call("swx_stop", authorized("a"));+ check!(+ b,+ "swx_stop: presence could not be measured — refused as present",+ r["errorCode"] == "user_is_active" && r["userPresence"]["measurementUnreadable"] == true,+ s(&r)+ );+ check!(b, "...and SOLIDWORKS was not quit", !m.quit_happened(), String::new());+ std::fs::remove_file(m.dir.join("presence-unreadable")).unwrap();+ let r = b.call("swx_stop", authorized("a"));+ check!(+ b,+ "once it can be measured and the user is absent, the quit proceeds — unreadable is not a take-over",+ r["stopped"] == true,+ s(&r)+ );+}
tests/quit.rs+5−4@@ -535,10 +535,11 @@ fn the_session_changing_between_rungs_is_caught_by_the_per_rung_sweep() { .unwrap(); // THE SEQUENCE, counted from the start of the `swx_stop` call (the cursor is // reset immediately before it, below):- // read 1 `status` — the "is anything running" check- // read 2 `app_state` — the FRONT GATE sweep: still the claimed session- // read 3 `app_state` — the PER-RUNG sweep before rung (b): a different one- std::fs::write(scratch.join("mock-com-pid"), "700900 700900 700901").unwrap();+ // read 1 `app_state` — the USER-PRESENCE guard (0.9.9), before the verb+ // read 2 `status` — the "is anything running" check+ // read 3 `app_state` — the FRONT GATE sweep: still the claimed session+ // read 4 `app_state` — the PER-RUNG sweep before rung (b): a different one+ std::fs::write(scratch.join("mock-com-pid"), "700900 700900 700900 700901").unwrap(); let b = Bridge::start_in( TempDir::borrowed(&scratch.0),
tests/readiness.rs+21@@ -865,3 +865,24 @@ fn a_window_with_a_button_is_never_settled() { ); check!(b, "...and its owner was never re-read", !m.join("owner-reads-4329196.cursor").exists(), String::new()); }++/// A CONFIGURED LOG PATH THAT IS MISSING is unreadable, not absent (0.9.9,+/// adversary pass 45 V1): COM alone does not decide, and the refusal says why.+#[test]+fn a_missing_configured_log_is_unverifiable_not_absent() {+ let scratch = TempDir::new("ready-log-missing");+ session(&scratch, json!([]));+ let missing = scratch.join("no-such-dir").join("SolidWorksPerformance.log");+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_PERF_LOG", &missing.to_string_lossy())]);+ b.call("swx_start", json!({}));+ let r = b.call("swx_new_document", json!({"type": "part"}));+ check!(+ b,+ "swx_new_document: refused, unverifiable, saying the configured log is missing",+ r["errorCode"] == "host_app_starting"+ && r["startingUnverifiable"] == true+ && r["readiness"]["performanceLog"]["verdict"] == "unreadable"+ && r["error"].as_str().unwrap_or("").contains("performance log path is missing"),+ s(&r)+ );+}
tests/wiring.rs+19@@ -128,6 +128,10 @@ const GUARDS: &[(&str, &str)] = &[ "the listing and the migration still read every location older bridges wrote to"), ("readiness::gate", "no document command reaches a SOLIDWORKS that has not finished starting (crash measured 2026-10-01)"),+ ("presence::guard",+ "no quit or sweep ends a session the user is working in (measured 2026-10-01: Drew took over a bridge-launched session)"),+ ("presence::measure",+ "priming re-measures the user's presence before it creates and closes a document"), ("is_startup_exempt", "every verb not on the exemption list waits for SOLIDWORKS to finish starting (adversary pass 42)"), ("stop_window_rungs_during_startup",@@ -1080,3 +1084,18 @@ fn the_startup_exemption_list_is_pinned_name_by_name() { let table = src.find("let handler: fn(&Ctx, &Value) -> Value = match command").unwrap(); assert!(gate < table, "the startup gate must come before the handler table"); }++/// THE GATES NEVER END A SESSION THE USER IS IN (0.9.9). Both gates that end+/// or sweep work ask `swx_user_presence` first and refuse on presence, and+/// neither ever passes `userPresentOverride` — a gate is not the person using+/// the session.+#[test]+fn the_gates_refuse_on_user_presence_and_never_override_it() {+ let root = Path::new(env!("CARGO_MANIFEST_DIR"));+ for gate in ["quit-gate.py", "drain-gate.py"] {+ let src = std::fs::read_to_string(root.join("tools").join(gate)).unwrap();+ assert!(src.contains("verb(\"swx_user_presence\""), "{gate} does not measure user presence");+ assert!(src.contains("raise Refused(why_present)"), "{gate} does not refuse on presence");+ assert!(!src.contains("\"userPresentOverride\""), "{gate} passes the user-present override");+ }+}
tools/drain-gate.py+25@@ -640,6 +640,27 @@ def make_fixture(_unused=None): return d.strip().splitlines()[-1].strip() +def presence_verdict(reply):+ """THE USER-PRESENCE GUARD (0.9.9), as a gate reads it. Returns+ ("absent" | "present", message). A gate NEVER passes userPresentOverride:+ the session belongs to the person using it, and a gate is not that person.+ An unreadable reply is "present" — fail closed."""+ if not isinstance(reply, dict) or reply.get("success") is False and reply.get("errorCode") != "user_is_active":+ return ("present", "the bridge could not say whether the user is working in SOLIDWORKS (%s) — "+ "the gate does not run on a guess" % (reply.get("error") if isinstance(reply, dict) else reply))+ if reply.get("errorCode") == "user_is_active":+ ev = reply.get("userPresence") or {}+ return ("present", "the bridge refused: the user is working in this SOLIDWORKS (%s)" % ev.get("basis"))+ if reply.get("present") is False:+ return ("absent", "no sign of the user: %s" % ((reply.get("evidence") or {}).get("basis")))+ ev = reply.get("evidence") or {}+ return ("present", "THE USER IS WORKING IN THIS SOLIDWORKS (%s; foreground=%s, %ss since last input, "+ "%s document(s) the bridge did not open). This gate does not run on a session "+ "somebody is using, and never overrides that — wait until they are done."+ % (ev.get("basis"), (ev.get("foregroundWindow") or {}).get("title"),+ ev.get("secondsSinceLastInput"), (ev.get("foreignDocuments") or {}).get("count")))++ def main(): """Run the gate, and CLEAN UP ON EVERY EXIT PATH. @@ -703,6 +724,10 @@ def _run(): if (st.get("com") or {}).get("running") is not True: raise Refused("SOLIDWORKS is not attached (swx_status.com.running is not true). This gate " "runs on a bridge-launched session; start one with swx_start first.")+ who, why_present = presence_verdict(verb("swx_user_presence", {}))+ print(" user presence: %s — %s" % (who, why_present))+ if who != "absent":+ raise Refused(why_present) launch = st.get("launch") or {} if launch.get("attributed") is not True: NOTES.append("launch.attributed is %r — this bridge cannot prove it started the running "
tools/quit-gate.py+39@@ -358,6 +358,27 @@ def identity_of(rows): return (sw[0]["pid"], sw[0]["created"]) if sw else None +def presence_verdict(reply):+ """THE USER-PRESENCE GUARD (0.9.9), as a gate reads it. Returns+ ("absent" | "present", message). A gate NEVER passes userPresentOverride:+ the session belongs to the person using it, and a gate is not that person.+ An unreadable reply is "present" — fail closed."""+ if not isinstance(reply, dict) or reply.get("success") is False and reply.get("errorCode") != "user_is_active":+ return ("present", "the bridge could not say whether the user is working in SOLIDWORKS (%s) — "+ "the gate does not run on a guess" % (reply.get("error") if isinstance(reply, dict) else reply))+ if reply.get("errorCode") == "user_is_active":+ ev = reply.get("userPresence") or {}+ return ("present", "the bridge refused: the user is working in this SOLIDWORKS (%s)" % ev.get("basis"))+ if reply.get("present") is False:+ return ("absent", "no sign of the user: %s" % ((reply.get("evidence") or {}).get("basis")))+ ev = reply.get("evidence") or {}+ return ("present", "THE USER IS WORKING IN THIS SOLIDWORKS (%s; foreground=%s, %ss since last input, "+ "%s document(s) the bridge did not open). This gate does not run on a session "+ "somebody is using, and never overrides that — wait until they are done."+ % (ev.get("basis"), (ev.get("foregroundWindow") or {}).get("title"),+ ev.get("secondsSinceLastInput"), (ev.get("foreignDocuments") or {}).get("count")))++ def dialog_verdict(judged): """The BRIDGE's verdict decides, never a raw window list. Returns ("clear" | "blocking" | "unknown", message). Only a BLOCKING dialog is handed@@ -649,6 +670,10 @@ def prove_zero_documents(base): if isinstance(listed, list) and len(listed) != 0: raise Refused("the count says 0 but the enumeration lists %d — they DISAGREE, so neither " "is evidence." % len(listed))+ who, why_present = presence_verdict(verb("swx_user_presence", {}, 60))+ print(" user presence: %s — %s" % (who, why_present))+ if who != "absent":+ raise Refused(why_present) claim, why = dialog_verdict(base.get("judged")) if claim == "blocking": raise Refused(why)@@ -688,6 +713,8 @@ def run_ladder(base, max_rung, allow_rm, reason): args["allowRestartManager"] = True t0 = time.time() r = verb("swx_stop", args, 240)+ if r.get("errorCode") == "user_is_active":+ raise Refused(presence_verdict(r)[1]) show("swx_stop returned (recorded; every claim below comes from the machine)", r) # ── the §5.4 measurement set, from the machine, at +2/+5/+10/+30s ───────@@ -816,6 +843,18 @@ def self_test(): ("an unreadable census", {"readable": False, "why": "no process census"}, "unknown"), ("no census at all", None, "unknown"), ]+ presences = [+ ("the user at the keyboard", {"success": True, "present": True,+ "evidence": {"basis": "SOLIDWORKS is the foreground window"}}, "present"),+ ("nobody there", {"success": True, "present": False, "evidence": {"basis": "no sign"}}, "absent"),+ ("the bridge refused user_is_active", {"success": False, "errorCode": "user_is_active",+ "userPresence": {"basis": "documents appeared"}}, "present"),+ ("an unreadable reply", {"success": False, "error": "bridge down"}, "present"),+ ("no reply at all", None, "present"),+ ]+ for label, reply, want in presences:+ if presence_verdict(reply)[0] != want:+ bad.append("presence verdict: " + label) for label, judged, want in verdicts: got, msg = dialog_verdict(judged) if got != want: