Files Branches

Comparing master ← linux-port

Changes on linux-port that are not yet on master (three-dot, from the merge base).

71 files changed, 4078 insertions(+), 164 deletions(-)
CHANGELOG.md+64
@@ -1,5 +1,69 @@ # Changelog — Puppeteer Bridge +## Unreleased: pup on Linux++pup now runs on Linux desktops from the same package as on Windows. The release zip carries both programs,+`adom-pup-bridge.exe` and `adom-pup-bridge` (x86_64, glibc 2.30+), and its `bridge.json` names each in+`spawn.entrypoints`, so Adom Bridge for Linux 2.1.175+ starts pup instead of answering `unsupported_on_this_os`.+Windows behaviour is unchanged.++- **Dock tile breaks between words on Linux (11-09):** a thread name that did not fit two lines at the Windows size floor+  was cut mid-word ("BO / M ch"). On Linux the tile now keeps whole words, on up to four lines, smaller if needed+  (`tile::layout_words`); a name the Windows layout already shows whole is drawn the same. Windows output is unchanged.+- **Linux-only package and wording (11-08):** `tools/rust-package.py <version> - <elf> <zip>` builds a Linux-only zip for the+  manifest's `platforms.linux`, with a Linux card description. On Linux: a saved login in a locked keyring answers+  `keyring_locked` (not "no saved login"), the no-login hints name the routes that exist there (no browser-login import,+  no UAC), `pup_use {install:true}` says plainly that pup does not install Google Chrome, and a new thread's window is+  re-announced to the dock for 30 s so it gets its own icon on a busy machine. Every change is `cfg!(target_os = "linux")`+  or Linux-only code; Windows text and behaviour are byte-for-byte the same (checked on the Windows build).+- **A Linux host crate** (`rust/host-linux`) behind the same `host` traits as Windows: windows through X11/EWMH+  (found by `_NET_WM_PID`, tagged with an X property so a restarted pup re-adopts them, lowered without+  activation), processes through `/proc` with a reaper for exit codes, secrets in the desktop keyring through the+  Secret Service (or a 0600 file when no keyring is unlocked; `credential_set` says which), idle time from GNOME or+  the X screen saver. `tools/check-borders.sh` keeps X11, procfs and D-Bus inside it.+- **Background windows on Wayland**: pup's browser runs under XWayland (`--ozone-platform=x11`) so its windows can+  be kept at the bottom of the stack; the user's own apps stay native. Measured on Ubuntu 24.04 GNOME Wayland: the+  user's active window stayed active through open, navigate, new tab and close.+- **The browser sandbox on Ubuntu 23.10+**, where AppArmor blocks unprivileged user namespaces: Chrome for Testing+  runs under an installed setuid helper (Google Chrome's or the distro Chromium's); `--no-sandbox` only when there+  is none. `pup_readiness` reports `browserSandbox`.+- **A clean environment for the browser**: Adom Bridge for Linux is an AppImage, whose library and GTK paths point+  into its own mount; pup removes them before starting Chrome.+- **No keyring prompts**: `--password-store=basic` on Linux, like `--use-mock-keychain` on macOS.+- **The presented user agent is Linux's** (`X11; Linux x86_64`, platform `Linux`) on the extension lane, matching+  `navigator.platform`. It said Windows before.+- Chrome for Testing linux64 unpacks with `bsdtar`, `tar`, `unzip` or `python3` (exec bits restored);+  `pup_readiness.diskFreeMb` is measured on Linux and macOS (statvfs).+- A browser that fails to start explains itself in `~/.adom/bridge-logs/pup-browser.log`.+- **Lowering a window gives the keyboard back.** After a granted raise, `pup_lower_os_window` restacked the window+  but it kept the keyboard focus, so typing went into a window now behind the user's work. When the lowered window+  holds the focus, pup now hands it to the window on top (EWMH on X11; on Wayland the compositor's own choice).+- **No browser restarts on a desktop without a GPU.** The WebGL watch restarts pup's browser when GPU crashes turned+  3D graphics off. A desktop with no GPU (a VM, a remote session) never had WebGL, and the watch restarted the browser+  every 30 minutes, closing windows mid-task. On Linux only a browser that had WebGL and lost it is restarted.+- **Thread tiles on the Linux dock and window list.** Each AI thread's windows get their own dock icon or window-list+  button showing the thread's name tile, the same tile as on the Windows taskbar. pup writes a hidden `.desktop`+  entry per thread (StartupWMClass = the thread's identity, its tile as the icon, "Open pup dashboard" and "Close ALL+  Adom Pup windows" on right-click), gives the window that WM_CLASS before its first show, and sets the window's own+  icon (`_NET_WM_ICON`) to the tile with the favicon badge. The entry and icon go when the thread's last window closes.+  Measured on Ubuntu 24.04 GNOME (Ubuntu Dock) and Debian 13 Cinnamon. Linux does all of this in-process, with no+  calls to Adom Bridge.+- **Taskbar progress, count and urgency on Linux**: `taskbarActivity: progress`, `pup_progress` and+  `pup_alert_window` drive `com.canonical.Unity.LauncherEntry` (a bar, a count badge and urgency on Ubuntu Dock,+  Dash to Dock, KDE and Plank) and Cinnamon's XApp progress hints (a fill across the button). The dock's count is+  the thread's open tabs when there are two or more. What each state looks like per desktop: [pup on Linux](linux/README.md#taskbar-icons-and-progress).+- **No "… is ready" banner for every new window on GNOME.** A background-born window is denied the focus and marked+  as wanting attention, and GNOME Shell announces it. pup now takes the mark off right after the show+  (`winAttentionFlash: on` keeps the desktop's behaviour).+- The `--jumplist` relaunch mode finds the running pup on Linux (it read only `%LOCALAPPDATA%`), so the dock entry's+  actions reach it.+- **Closing the last window and opening again no longer stalls ~20 s** (shared code): an `/extcmd` long poll that+  was waiting when pup ended the lane's browser re-marked the dead lane as live, and commands still queued for that+  browser kept their callers waiting; now the stale poll leaves the lane dead and pup answers the ended lane's+  outstanding commands at once. Measured on Linux: 21 to 42 s before, 1.3 to 2.0 s after.+- Windows-only on Linux: per-window jump-list tasks, assisted click, importing saved passwords from Chrome or Edge,+  and window recording (it uses Adom Bridge's native recorder). Guide: [pup on Linux](linux/README.md).+ ## 2.0.443 — 2026-09-06 — no window left behind: closes verified against the OS, orphan windows swept on connect  Recreating John's windows across reinstalls left three Chrome windows nobody tracked, wearing old tiles:
README.md+16−5
@@ -2,7 +2,7 @@  **pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you.** It opens real Chrome windows that never jump in front of your work, keeps one shared profile where your logins accumulate, fills saved passwords for the AI, and labels every window on the taskbar with the name of the AI thread that owns it. Ask your AI to "open it in pup" and it does. -pup is one small signed Windows program (about 8 MB) that [Adom Bridge](https://wiki.adom.inc/adom/adom-bridge) installs and keeps up to date for you. This page holds its source, its AI skills, and the guides below. It is open source under the MIT license.+pup is one small program for Windows and Linux (about 8 MB on Windows, signed) that [Adom Bridge](https://wiki.adom.inc/adom/adom-bridge) installs and keeps up to date for you. This page holds its source, its AI skills, and the guides below. It is open source under the MIT license.  ## Watch the two-minute tour @@ -27,9 +27,10 @@ adom-bridge --ai-thread "my-thread" pup_open_window '{"url":"https://example.com adom-bridge --ai-thread "my-thread" pup_screenshot  '{}'                              # the image lands in the container adom-bridge --ai-thread "my-thread" pup_navigate    '{"url":"https://wiki.adom.inc"}' # same window, next page adom-bridge --ai-thread "my-thread" pup_my_windows  '{}'                              # everything this thread has open+adom-bridge --ai-thread "my-thread" pup_upload_file '{"selector":"input[type=file]","filePath":"C:/Users/me/Downloads/gerbers.zip"}'  # a file from this PC onto the page's file input: no OS dialog, any size ``` -Or just say it: "open my app in pup", "screenshot this site in pup", "record a clip of this page in pup".+Or just say it: "open my app in pup", "screenshot this site in pup", "record a clip of this page in pup", "upload these gerbers to the JLCPCB quote form in pup".  ## Every window wears its thread's name @@ -49,6 +50,15 @@ Right-clicking a pup tile opens its jump list. Every window offers Annotate this  The dashboard lists every pup window grouped by the AI thread that owns it, with each tab, whether it is signed in, which profile it surfs on, and a mirror of the taskbar buttons. Every icon carries a "how this icon was chosen" note, so a wrong icon is never a mystery. The Say it panel lists phrases you can paste into any AI thread, and the gear opens Settings. Open it from the dock bar card, the jump list, or by asking your AI for "your dashboard in pup". Full tour: [The dashboard](docs/dashboard.md). +## Linux: pup on Linux desktops++<table>+<tr>+<td width="104"><a href="linux/README.md"><img src="linux/img/pup-linux-icon.png" width="112" alt="pup on Linux"></a><br><sub>&nbsp;&nbsp;&nbsp;&nbsp;Linux&nbsp;&nbsp;&nbsp;&nbsp;</sub></td>+<td>The same pup package runs on Linux desktops, on X11 and on Wayland, started and updated by Adom Bridge for Linux. <strong><a href="linux/README.md">pup on Linux</a></strong> explains how its windows stay behind your work on each kind of session, how the browser is sandboxed, and where logins are kept. Use it to set up a Linux machine for your AI, or to see what differs from Windows.<br><br><a href="linux/README.md">Requirements, behavior and troubleshooting</a></td>+</tr>+</table>+ ## Feature guides  | guide | what it covers |@@ -59,6 +69,7 @@ The dashboard lists every pup window grouped by the AI thread that owns it, with | [Settings](docs/settings.md) | Every setting in the Settings window, its default, and what it changes. | | [Logins, passwords and cards](docs/logins-and-cards.md) | pup's own vault, auto-capture and fill, reason-gated sign-in, importing from Chrome or Edge, Google accounts, Basic-Auth, cards. | | [Screenshots and recording](docs/screenshots-and-recording.md) | Window, element and full-page screenshots, recording with sound, and annotate. |+| [pup on Linux](linux/README.md) | Requirements, background windows on X11 and Wayland, the browser sandbox, logins, and what is Windows-only. | | [Install size and footprint](docs/install-and-footprint.md) | What lands on disk, measured, what cleans itself up, and how to remove pup completely. | | [pup or native browser?](docs/pup-vs-native-browser.md) | When the AI should use pup, when it should drive your own browser, and why both exist. | | [Open source: code and contributing](docs/open-source-and-contributing.md) | How the code is organized, how to build and test it, how to add a feature, and how to send it back. |@@ -80,8 +91,8 @@ The dashboard lists every pup window grouped by the AI thread that owns it, with  | dependency | required? | why | |---|---|---|-| Windows 10 or 11 | yes | pup is a native Windows program. A Mac port is in progress; Macs run the older build for now. |-| Adom Bridge 2.1 or later | yes | It installs, updates and starts pup, and carries every command from your AI to it. Hydrogen includes it. |+| Windows 10 or 11, or Linux (x86_64, glibc 2.30+, X11 or Wayland) | yes | pup is a native program for each. A Mac port is in progress; Macs run the older build for now. Linux details: [pup on Linux](linux/README.md). |+| Adom Bridge 2.1 or later (2.1.175 or later on Linux) | yes | It installs, updates and starts pup, and carries every command from your AI to it. Hydrogen includes it. | | A browser build | automatic | pup downloads its own Chrome build once (about 200 MB, 428 MB unpacked) and uses your installed Chrome or Edge until that finishes. |  ## Skills@@ -103,4 +114,4 @@ The skills teach any AI how to use pup well. The main `pup` skill is the start;  ## Open source -The complete source is in this page's **Files** tab: one Rust workspace of twelve crates, with every Windows call kept in a single crate so the Mac port only implements one interface. [Open source: code and contributing](docs/open-source-and-contributing.md) walks through the layout, the build, the tests and the release gates, and how to send a change back as a pull request on this page. Issues and ideas are welcome in the **Issues** tab.+The complete source is in this page's **Files** tab: one Rust workspace, with every operating-system call kept behind one interface: a Windows crate, a Linux crate and the Mac port each implement it, and the rest of pup is shared. [Open source: code and contributing](docs/open-source-and-contributing.md) walks through the layout, the build, the tests and the release gates, and how to send a change back as a pull request on this page. Issues and ideas are welcome in the **Issues** tab.
SKILL.md+61−15
@@ -26,7 +26,7 @@ adom-bridge-cli --ai-thread "<your thread>" pup_screenshot  '{}' ## Signing in? Offer to import the user's own logins first  pup can import every password saved in the user's own Chrome, Edge or Brave, through `pup_import_browser_logins {}`:-nb drives Chrome's own export, the user answers one Windows Hello, and the whole profile comes over. From then on+nb drives Chrome's own export, the user answers one Windows Hello, and the whole profile comes over (Windows only today; on Linux or a Mac see `pup-import-logins`). From then on pup fills sign-in pages in the background. Most users do not know this exists, so when a login is in the way, offer it in plain words: "I can bring your saved passwords from Chrome or Edge into pup, so I can sign you in here and on other sites@@ -79,14 +79,14 @@ dropdown. Backed by the OS keychain (encrypted at rest); passwords are never log - **pup can read the user's OWN saved browser logins**: `pup_import_browser_logins {}` imports every   password saved in their Chrome/Edge profiles into pup's vault. nb drives Chrome's own consented export   (the user answers one Windows Hello; Chrome does its own decryption, pup never touches the sealed store),-  and no AI ever sees a password. Run it with no `hostFilter` the first time. Reach for it the moment a+  and no AI ever sees a password. Windows only: on Linux it answers plainly and the vault still works. Run it with no `hostFilter` the first time. Reach for it the moment a   login is missing, and say the capability out loud: most users do not know it exists.   It cannot give you the account the BROWSER itself is signed in as; that one needs a single manual   sign-in in the pup window, which pup captures. - **Manage**: `pup_credentials {}` lists saved logins (host + username only); `credential_delete   {host}` removes one; `credential_set {host, username, password}` stores one explicitly. - **Grow + share the vault (never ask the human for a password)**: `pup_import_browser_logins` pulls-  the user's saved Chrome/Edge/Brave logins into the vault (one consent + UAC, local only); `pup_vault_sync+  the user's saved Chrome/Edge/Brave logins into the vault (one consent + UAC, local only; Windows only); `pup_vault_sync   {peer, reason}` merges the adom-you vault with another of the user's desktops BOTH directions   (newest-wins, pup-to-pup, no AI sees a password). The vault exists so you serve logins from it   rather than asking; see the `pup-credentials` skill.@@ -175,7 +175,7 @@ runs on the DESKTOP. Use the container's public proxy URL instead: https://{container-slug}.adom.cloud/proxy/{port}/ ``` -Open it background-first (`foreground:false` + the taskbar flash signals the user), give the window+Open it background-first (`foreground:false` + the taskbar flash, a dock icon alert on Linux, signals the user), give the window your thread's sessionId, and re-use the SAME window on every subsequent "check it now" (navigate / reload, don't spawn new windows). Under Hydrogen (local container) `http://localhost:{port}` IS correct, the trap is cloud-only. pup lints obvious container-local URLs and will warn you.@@ -251,21 +251,22 @@ does NOT attach to the user's running browser and does NOT run headless. It:    OS Chrome/Edge profile. By default every window shares ONE durable profile (`adom-you`), so logins,    cookies and localStorage **accumulate and are shared across all pup windows**, sign into a site once and    every pup window is signed in; app state (localStorage) survives between windows. Sharing a profile does-   NOT merge windows: each window is still its own OS window with its own taskbar identity. For a private,+   NOT merge windows: each window is still its own OS window with its own taskbar identity (on Linux the dock icon is per thread). For a private,    throwaway window (a fresh-login test, or acting as a different user) pass `{isolated:true}`, or pass an    explicit `{profile:"<name>"}` for a separate durable jar. (Need the user's REAL signed-in profile/SSO?    That's the **Adom extension** `nbrowser_*`, see below.) 3. **Renders a REAL window, but keeps it in the BACKGROUND.** It is *not* headless, a real GPU-composited    window, so `pup_screenshot` / recording are true-to-life and canvas/WebGL/media render correctly.    But pup immediately z-orders it to the **bottom** (Windows: Win32 `SetWindowPos` with-   `SWP_NOACTIVATE`; macOS: the browser process is **hidden**, the Cmd+H state, same contract), so it+   `SWP_NOACTIVATE`; macOS: the browser process is **hidden**, the Cmd+H state, same contract; Linux: the+   bottom of the window stack through the window manager, under XWayland on a Wayland session), so it    renders **without stealing focus and without covering the user's active window**. You drive and    screenshot it fully while it stays hidden. `foreground:true` is the ONLY thing that shows it. 4. **Spawn-verifies + falls through.** The effective order is **pinned/cached default first** (CfT,    per policy) **→ installed Chrome → Edge → cached CfT**; pup actually launches each and, if one    won't start (corrupt install), falls to the next, so an open never hard-fails on a broken    browser. The one that launches is **cached as the default** and goes first next time. On a box-   where CfT is not yet cached, `pup_prewarm` fetches it (~150 MB, once) so the first real open is+   where CfT is not yet cached, `pup_prewarm` fetches it (~150 MB, once; about 200 MB on Linux) so the first real open is    instant. 5. **Is addressed by your `--ai-thread` name** (zero-id: omit ids and your thread's window is    implied), and **one window holds many tabs**. An explicit `window` label (legacy alias:@@ -387,7 +388,7 @@ the OS for what was really "the human took their focus back, because nobody aske    change between two events, and a half-typed password in the wrong window is a real harm. 6. **Caption each step** as you go, and caption when you are DONE so they know they can resume. -**`pup_assisted_click` already implements all six.** Use it instead of hand-rolling+**`pup_assisted_click` already implements all six** (Windows only; on Linux follow the six steps yourself). Use it instead of hand-rolling raise-then-click. If you need native typing, follow the same protocol around it.  **Corollaries that cost this project real time:**@@ -406,11 +407,11 @@ NEVER put a window on the user's screen silently. Any time you FOREGROUND a pup pup's auto-toast do it, v1.9.166+) so the user's attention is drawn to what just appeared. A window that pops up with no heads-up reads as the agent hijacking the screen. pup now auto-fires `notify_user` on every granted foreground, but you should still narrate WHY in the reason. For a-background nudge (not a full foreground), use `pup_alert_window` (taskbar flash) instead.+background nudge (not a full foreground), use `pup_alert_window` (taskbar flash; on Linux the dock icon bounces or fills) instead.  **If you foreground in order to CLICK something pup's CDP click can't drive** (a native OS dialog, or a cross-origin FedCM "Sign in with Google" account chooser that rejects in-page clicks), use-**`pup_assisted_click`**, never raise-then-click by hand. It captions the reason + toasts first,+**`pup_assisted_click`** (Windows only), never raise-then-click by hand. It captions the reason + toasts first, foregrounds, then WAITS and detects if the user alt-tabbed away (holding, captioning "Adom is waiting" until they're back, it clicks NOTHING while they're not ready), then does the tight real-cursor hover→click and narrates each step. This is the protocol so pup never foregrounds-and-acts while the@@ -514,6 +515,23 @@ but never the user's own signed-in OS Chrome/Edge profile, so it can't borrow lo there. pup accumulates its OWN logins over time (sign in once inside pup, it sticks). The extension is the counterpart when you need the user's REAL profile/SSO directly. +## Uploading a file to a page: `pup_upload_file`++For any `<input type=file>`: a quote form, a bug report, a CAD upload. No OS file picker, in the background, at any size,+because Chrome reads the file from disk itself. **Never base64 a file through `pup_eval`.**++```bash+adom-bridge --ai-thread "<thread>" pup_upload_file '{"selector":"input[type=file]","filePath":"C:/Users/me/Downloads/gerbers.zip"}'+```++- **The path is on the PC the browser runs on, not in your container** (on Linux a path like `/home/me/Downloads/gerbers.zip`). A file in the container: `adom-bridge send_files '{"filePaths":["/abs/gerbers.zip"]}'` first (it lands in Downloads), then pass that path.+- `selector` can be the input (hidden is fine) or its Browse button or label. Open shadow roots and same-origin frames are searched. With one file input on the page you can omit it; with several you get `ambiguous_input` and a list. `filePaths:[...]` for an input that takes several files.+- **The page creates its input when Browse is clicked?** You get `input_not_found`. Then: `pup_upload_file {armForNextChooser:true, filePath}`, `pup_click {selector: the Browse control}` within 30 s, `pup_upload_file {chooserStatus:true}` to confirm. **Never click Browse without arming:** the OS dialog opens in front of the user and freezes the window.+- `attached:true, verified:true` means the page has the file (read back from the input). It does NOT mean the site has it: many pages upload on change, others need an Upload or Submit click. Screenshot or `pup_wait` for the page's own result, and keep the file in place until then.+- The page gets exactly one `input` and one `change` per call, including a retry with the same file. `clearedByPage:true` is normal: the widget took the file and emptied its input.+- **Refused on purpose, with `errorCode:"path_refused"`:** key, password and browser-login files (`.pem`, `id_rsa`, `.env`, `.kdbx`, `Login Data`, ...), `.ssh`, `.git` and other credential folders, AppData (except Temp) and hidden folders in the home folder, system folders, other people's profiles, and `\\server` paths. There is no override: if the user really means to send one, they copy it to an ordinary folder first. Downloads, Documents, Desktop, Temp, other drives and pup's own screenshots are fine.+- A real signed-in Chrome? `nbrowser_set_file_input {selector, filePaths:[...]}` does the same job through the extension.+ ## Logins & credentials (the durable profile pays off here)  - Sign into a site ONCE in any pup window and the durable profile keeps it, every later window@@ -537,7 +555,7 @@ counterpart when you need the user's REAL profile/SSO directly. | **`pup`** (this file) | The mental model, when-to-use, quick start, the essentials | | **`pup-windows-sessions-tabs`** | sessionIds + naming, **window ownership** (don't steal another AI thread's window), **background vs foreground** rules, **many tabs in one window**, list/switch/close, verifying what you opened | | **`pup-screenshots-recording`** | Screenshots (full-page, full-res), reading the shot, **recording** a window vs the whole desktop, framing a window for a clean shot without foregrounding |-| **`pup-browsers-and-chrome`** | The browser detection/CDP-launch details, `pup_use` to pin a browser, **installing Chrome / Chrome-for-Testing** (incl. the UAC-notify flow), **readiness**, the cold-start error table, Node provisioning, the shell-approval gate |+| **`pup-browsers-and-chrome`** | The browser detection/CDP-launch details, `pup_use` to pin a browser, **installing Chrome / Chrome-for-Testing** (incl. the Windows UAC-notify flow), **readiness**, the cold-start error table, Node provisioning, the shell-approval gate | | **`pup-adom-wiki`** | Driving + verifying **`wiki.adom.inc`** pages in pup (show/screenshot/verify a published page; the login situation, pup views the wiki logged out for now) | | **`pup-why-cft`** | WHY the driving browser is Chrome for Testing, the five dated reasons, the trade-offs, and what a "Google Chrome for Testing" window title means. Read before proposing native-first or answering "what is this browser?" | @@ -582,9 +600,34 @@ Every `pup_*` verb works on macOS; only the OS-presentation layer differs: - **Recording** needs ffmpeg (`brew install ffmpeg`, the error hint says so; re-detects   without a restart). +## Linux differences, same verbs, same package++pup runs on Linux desktops (x86_64, X11 or Wayland) from the same package; Adom Bridge for Linux+2.1.175+ starts pup's Linux binary. Every everyday verb works (open, navigate, eval, click, type,+tabs, screenshots, vault, close, recovery after a restart). What differs:++- **Background = bottom of the window stack, never activated**, as on Windows. On a Wayland+  session pup's browser runs under XWayland so pup can keep its windows behind the user's apps;+  the user's own apps are untouched. `pup_raise_os_window` (with `foregroundReason`) activates.+- **`pup_readiness.browserSandbox`** says how Chrome is sandboxed (`namespaces`, a setuid helper,+  or `off: ...` with the fix). Ubuntu 23.10+ needs an installed Google Chrome or Chromium for its+  setuid helper; pup finds it by itself.+- **Saved passwords** go to the desktop keyring (Secret Service), or to `~/.adom/pup-secrets.json`+  (0600) when no keyring is unlocked; `credential_set` replies with `storage`. A keyring login while the+  keyring is locked answers `keyring_locked` (ask the user to unlock it). Through Adom Bridge, save a login+  with `pup_login {username, password, save:true}`.+- **Taskbar on Linux:** each AI thread gets its own dock icon / window-list button with its name+  tile (GNOME's Ubuntu Dock and Dash to Dock, Cinnamon), with progress, a tab count and urgency on+  it; right-click has the dashboard and close-all. Per-desktop table: `linux/README.md`.+- **Windows-only on Linux:** per-window jump-list tasks, `pup_assisted_click`, `pup_import_browser_logins`.+  Window recording (`pup_record_*`) works: Adom Bridge for Linux has no native recorder yet, so pup records+  the page itself (up to 10 fps, VP9 WebM with ffmpeg, else MJPEG AVI). Screenshots work.+- A failed browser start explains itself in `~/.adom/bridge-logs/pup-browser.log`.+  Full guide: `linux/README.md` on the wiki page.+ ## Taskbar preference: the USER can ask for "combined mode" -> **WINDOWS ONLY.** macOS has no taskbar; `pup_configure` stores the preference and returns+> **WINDOWS AND LINUX** (on Linux, `tiles` and `plain` are the measured modes). macOS has no taskbar; `pup_configure` stores the preference and returns > `taskbarSupported:false`, but nothing changes on screen. Don't propose it to a Mac user.  The DEFAULT is `taskbarMode: tiles`, ONE button per AI thread, wearing the thread's NAME on the@@ -693,7 +736,7 @@ layout, wrong content, an element off-screen). A clean `renderCheck` is not proo  Pass `--ai-thread "<what this conversation is>"` on every `adom-bridge-cli` call (a global flag, like `--target`). A typical user runs ~20 AI threads against one desktop, so pup puts your thread name at the-FRONT of the window title (visible in the taskbar hover-preview header, which truncates ~25-30 chars)+FRONT of the window title (visible in the taskbar hover-preview header, which truncates ~25-30 chars; on Linux the dock tile carries the name) and in the hover tooltip alongside the container, browser and session. That is how the user knows which of their conversations opened a window. Do NOT export `ADOM_AI_THREAD`, threads share one container, so it names the shell rather than you.@@ -744,6 +787,8 @@ Run ALL SEVEN sections, in this order, and report them as a table:      its reason**. A `none` on a favicon-serving page is a regression; a `none` on local PDFs is      legitimate (no favicon exists).    - AUMID keys: `reg query HKCU\Software\Classes\AppUserModelId` filtered to `Adom.Pup*`.+     (Windows. On Linux there are no AUMID keys: expect one hidden `Adom.Pup.*.desktop` entry per live+     thread in `~/.local/share/applications`, tiles in `~/.local/share/adom-pup/icons`.)      In tiles mode (the default) expect the `Adom.Pup` anchor **plus one thread key per live      thread** (`Adom.Pup.thread.<slug>.<gen><tile-token>`; the token hashes the tile's content, v2.0.271). MANY more than live threads = stale keys the janitor missed. The old "exactly 1"      expectation was the minimalTouch-era rule and reads every healthy tiles-mode machine as broken.@@ -824,7 +869,8 @@ When a user asks to record the desktop, point them to Adom Bridge's native recor to MP4, no browser, no picker, and aggressive GPU acceleration, including GPU frame offloading when the machine has a GPU. pup's `pup_screen_record_start` is the fallback: a Google Meet style screen share (getDisplayMedia into WebM) that works on a very broad range of computers, so it is the right answer-when ab's recorder is missing, refuses, or the box has no usable GPU. Recording ONE pup window's page+when ab's recorder is missing, refuses, or the box has no usable GPU. On a Linux Wayland session, `pup_screen_record_*` waits on the desktop's share dialog: nothing+records until the user presses **Share** (`blocked_on_user`, `share_dialog`). Recording ONE pup window's page content is a different thing and stays pup's (`pup_record_start`). Full detail in `pup-screenshots-recording`. @@ -843,7 +889,7 @@ pup runs on somebody's actual laptop and you are spending their RAM: the number that actually moves when you open a page. `avgMbPerTab` prices your next tab before you open it.  **Read the hint and act on it.** Under 25 % free, budget and close what you are done with. Under 10 % free, open-nothing more until you have closed something: that is where Windows starts paging to disk and the user's whole+nothing more until you have closed something: that is where Windows starts paging to disk (Linux swaps) and the user's whole desktop stutters, their touchpad included. A rich page is not cheap: Adom wiki component pages with 3D viewers measured around 200 MB each, so twenty of them is 4 GB that Chrome will not hand back while you hold the tabs open. Close tabs with `pup_close_tab` the moment a task is done, rather than leaving a window to grow.
adom-bridge-puppeteer-manifest-candidate.json+5−5
@@ -1,17 +1,17 @@ {   "manifest_version": 1,   "name": "puppeteer",-  "version": "2.0.739",-  "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.739/adom-bridge-puppeteer-v2.0.739.zip",-  "sha256": "bdc5611c45fa1634d702aa6fecb4970e619b0d1e42e4219f3b509793adceeaac",-  "size": 4112818,+  "version": "2.0.741",+  "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.741/adom-bridge-puppeteer-v2.0.741.zip",+  "sha256": "b903e6eedee21064368be5b74a97b34554f580eb0f547953506b508c33b256ce",+  "size": 4156218,   "verbPrefixes": [     "pup_",     "browser_"   ],   "healthEndpoint": "/status",   "statusVerb": "pup_readiness",-  "released_at": "2026-11-01T18:37:52.490436Z",+  "released_at": "2026-11-05T19:22:21.128273Z",   "shell": "rust",   "tier": "insiders" }\ No newline at end of file
adom-bridge-puppeteer-manifest.json+9−9
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "puppeteer",-  "version": "2.0.723",-  "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.723/adom-bridge-puppeteer-v2.0.723.zip",-  "sha256": "c1240b47cbeb7fd7f4ed1e77dedb4cbf5a8b0a71ff60f281aa99678b0bf1a169",-  "size": 4076991,+  "version": "2.0.741",+  "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.741/adom-bridge-puppeteer-v2.0.741.zip",+  "sha256": "b903e6eedee21064368be5b74a97b34554f580eb0f547953506b508c33b256ce",+  "size": 4156218,   "verbPrefixes": [     "browser_",     "desktop_recorder_",@@ -12,9 +12,9 @@   ],   "healthEndpoint": "/status",   "statusVerb": "browser_readiness",-  "released_at": "2026-10-15T12:48:24.532481Z",-  "insiders_released_at": "2026-10-15T12:22:40.660829Z",-  "promotion_attestation": "promote it",-  "promoted_by": "John Lauer",-  "promoted_at": "2026-10-15T12:48:24.532481Z"+  "released_at": "2026-11-05T19:30:02.661484Z",+  "insiders_released_at": "2026-11-05T19:22:21.128273Z",+  "promotion_attestation": "i guess do 1 but we need to support and make sure nb is working",+  "promoted_by": "Colby Knox",+  "promoted_at": "2026-11-05T19:30:02.661484Z" }
docs/PLAN-rust-port.md+4−3
@@ -425,9 +425,10 @@ warmup. The Rust port must make this whole class of failure impossible by constr   and the pkg publish step are untouched. - `tools/check-borders.sh` (section 4) and `cargo check --target aarch64-apple-darwin -p pup-host-mac`   run every ship, so a Windows leak or a broken mac stub fails the ship, not Kyle's afternoon.-- Manifests are single-zip today (ab wiki #111 caveat), so the exe zip is Windows only. When Kyle's-  crate compiles, the mac build needs either per-platform manifests in ab or a second page. That is-  an ab ask for later; it does not block anything here.+- Manifests were single-zip when this was written (ab wiki #111 caveat), so the exe zip was Windows only.+  ab 2.1.175 added `spawn.entrypoints` (one zip, a binary per OS) and per-OS packages in the wiki+  manifest (`platforms.<os>.url`). The Linux build uses the first: `tools/rust-package.py` puts the+  Windows exe and the Linux binary in one zip. The Mac build can do the same.  ## 8. Risks, stated plainly 
docs/install-and-footprint.md+2
@@ -12,6 +12,8 @@ What pup puts on your machine, measured, what cleans itself up, and how to remov  On a brand-new Windows machine (measured 2026-09-23): pup installed in 1 second, was usable at once on the system Edge, finished the browser download in 6 seconds and unpacking in 3, and opened its first window 24 seconds after a bare install. +On Linux the program is `adom-pup-bridge` in `~/.local/share/Adom Bridge/bridges-cache/puppeteer`, and the browser build, profiles and logs are in the same `~/.cache/puppeteer` and `~/.adom` folders. See [pup on Linux](../linux/README.md).+ ## What grows with use  Measured on a desktop used heavily for a month (2026-10-03):
docs/open-source-and-contributing.md+11−6
@@ -4,7 +4,7 @@ pup is open source under the MIT license. Its complete source is in this page's  ## How the code is organized -pup is one Rust workspace in `rust/`, built into a single Windows program. Twelve crates, each with one job:+pup is one Rust workspace in `rust/`, built into one program for Windows and one for Linux. Thirteen crates, each with one job:  | crate | lines | job | |---|---|---|@@ -19,25 +19,30 @@ pup is one Rust workspace in `rust/`, built into a single Windows program. Twelv | `core` | 390 | Shared types: settings, the reply envelope, caller identity, logging. | | `host` | 150 | The operating-system interface, as traits and nothing else. | | `host-win` | 570 | The Windows implementation of `host`, and the only crate allowed to call Windows directly. |+| `host-linux` | 1,000 | The Linux implementation: X11/XWayland windows (EWMH), `/proc`, the Secret Service, the Chromium sandbox choice. The only crate allowed to name X11, procfs or D-Bus. | | `host-mac` | 50 | The macOS implementation, in progress. | -The rule that keeps the port possible: every Windows call lives in `host-win` behind a trait in `host`. `tools/check-borders.sh` fails the build if a Windows or macOS API name appears anywhere else. The Mac port is one crate: implement `host-mac`.+The rule that keeps the ports possible: every Windows call lives in `host-win` behind a trait in `host`, every Linux call in `host-linux`. `tools/check-borders.sh` fails the build if a Windows, macOS or Linux API name appears anywhere else. `bridge/src/main.rs` picks the host for the target it is compiled for; nothing else knows which OS it runs on. The Mac port is one crate: implement `host-mac`.  The browser extensions that pup loads into its windows live in `src/extension*/` as plain JavaScript and are built into the program, along with the dashboard (`rust/dashboard/assets/`) and the command catalog.  ## Build and test -You need Rust with the Windows target. From Linux or WSL, install mingw-w64 for the linker; on Windows, use the stable toolchain.+Build on Linux or WSL: the Windows program cross-compiles with mingw-w64, the Linux program builds natively. Build the Linux one on a distribution no newer than the oldest you support (the program needs only the glibc it was linked against; glibc 2.30 today).  ```bash-rustup target add x86_64-pc-windows-gnu+rustup target add x86_64-pc-windows-gnu x86_64-unknown-linux-gnu python3 tools/gen-assets.py                      # embed extensions, dashboard and the command catalog-tools/check-borders.sh                           # Windows calls only in host-win+tools/check-borders.sh                           # OS calls only in host-win / host-linux / host-mac cd rust && cargo test --workspace                # unit tests, on any OS cargo build --release --target x86_64-pc-windows-gnu # -> rust/target/x86_64-pc-windows-gnu/release/adom-pup-bridge.exe+cargo build --release --target x86_64-unknown-linux-gnu+# -> rust/target/x86_64-unknown-linux-gnu/release/adom-pup-bridge ``` +`tools/rust-build.sh <version>` does all of this, signs the Windows program, and `tools/rust-package.py` puts both programs in one release zip whose `bridge.json` names each one in `spawn.entrypoints`.+ ## Add a feature  Most features are a new command or a change to one. To add `pup_something`:@@ -45,7 +50,7 @@ Most features are a new command or a change to one. To add `pup_something`: 1. Write the handler in the fitting file under `rust/verbs/src/` (`tabs.rs`, `input.rs`, `extras.rs`, ...): a function of `(ctx, caller, args)` that returns `ok(json!({...}))` or `err(code, message, hint)`. 2. Route it in `dispatch` in `rust/verbs/src/lib.rs` and add its name to `PHASE1_VERBS`. 3. Describe it for AIs: add an entry to `_STUBS` in `tools/gen-assets.py` with a summary, inputs, an example and a hint. The build stops if a routed command has no description, and the same list becomes both `pup_describe` and the manifest Adom Bridge reads, so they cannot drift apart.-4. If it needs the operating system, add a method to a trait in `host`, implement it in `host-win`, and give `host-mac` a stub.+4. If it needs the operating system, add a method to a trait in `host`, implement it in `host-win` and `host-linux`, and give `host-mac` a stub (or give the trait method a default that answers "not on this OS"). 5. Add a test, and run the build and tests above.  Good first changes: a clearer hint on a command that confused your AI, a new jump list task, a Settings row with a better explanation, a faster screenshot path. Open the folder in Claude Code and describe the change you want; the code's comments record why things are the way they are, often with the date and the problem that caused them.
linux/README.mdadded+152
@@ -0,0 +1,152 @@+# pup on Linux++pup runs on Linux desktops and installs the same way as on Windows: Adom Bridge for Linux installs it, starts it and updates it, with no sudo and no Node (pup is one program per OS). Your AI uses the same `pup_*` verbs and the same skills. Windows open in the background, stay behind your work, and are fully drivable: navigate, click, type, eval, screenshot.++![A pup window on Ubuntu 24.04 (GNOME, Wayland), captured with pup_screenshot through Adom Bridge](img/pup-linux-page.png)++## What you need++| requirement | details |+|---|---|+| A 64-bit x86 Linux desktop | glibc 2.30 or newer: Ubuntu 20.04+, Debian 11+, Fedora 32+ and their relatives. |+| An X11 or Wayland session | GNOME, Cinnamon, KDE and other EWMH window managers. |+| Adom Bridge for Linux 2.1.175 or later | It downloads pup's Linux package (the manifest's `platforms.linux`) or picks the Linux program from a two-OS package (`spawn.entrypoints.linux`). |+| A browser build | Automatic. pup downloads Chrome for Testing for linux64 once (about 200 MB, about 420 MB unpacked) into `~/.cache/puppeteer`, at the version of your installed Google Chrome when you have one. |+| A Chromium sandbox | Usually nothing to do; see [The browser sandbox](#the-browser-sandbox). |++`pup_readiness` answers whether pup is ready and which browser it will use, as on Windows. On Linux it also reports `browserSandbox`.++pup does not install Google Chrome on Linux (`pup_use {browser:"chrome", install:true}` answers `chrome_install_unsupported_os`): that needs the package manager and an administrator password, which pup never asks for. Chrome for Testing is the browser pup is built for; if you install Google Chrome yourself, `pup_use {browser:"chrome"}` picks it.++### How the package reaches Linux++The Windows package (signed `adom-pup-bridge.exe`) and the Linux package (`adom-pup-bridge`, built with `tools/rust-package.py <version> - <elf> <zip>`) are separate zips on the same release. The bridge manifest names the Linux one in a `platforms` block, which Adom Bridge for Linux downloads instead of the top-level `url`; Windows reads only the top level, so a Linux package never changes what Windows installs:++```json+"platforms": {"linux": {"url": ".../<version>/adom-bridge-puppeteer-v<version>-linux.zip", "sha256": "...", "size": 0}}+```++Two rules that follow from how Adom Bridge 2.1.186 updates bridges:+- A fresh install reads the **public** manifest (`updateManifestUrl`), because the insiders address is only known once pup's `bridge.json` is on the machine. So the `platforms` block has to be in the public manifest as well as the insiders one.+- Adom Bridge downloads only a **newer** version. A machine that already holds a Windows-only package of the same version keeps it (`supportedOnThisOs: false`) until the next version, or until `bridge_install {manifestUrl, force:true}`.++## How windows stay in the background++pup never activates its own windows. A new window is created unfocused and placed at the bottom of the window stack, behind everything you are working on, and it stays there. Only `pup_raise_os_window` with a `foregroundReason` brings one to the front, with a caption saying why.++- **X11 sessions.** pup lowers its windows through the window manager (EWMH), the same rule as Windows' bottom-of-z-order.+- **When Chrome or the page activates the window.** On Linux the window manager honours activations that Windows refuses. Chrome activates its window when pup clicks in the page, and when a page opens `alert`, `confirm` or `prompt`. pup watches for this: a pup window that comes to the front without a granted raise, and without anyone touching the keyboard or mouse in the last 0.4 seconds, is lowered at once and the keyboard goes back to your window. Measured on Debian 13 (Cinnamon): about 0.1 second. If you click its window-list button or Alt+Tab to it, that counts as you, and pup leaves it in front.+- **Wayland sessions.** Wayland does not let one program list, lower or raise another program's windows. So pup starts its browser under XWayland (`--ozone-platform=x11`); pup's windows are then X11 windows that pup can find, tag and keep at the bottom, while your own apps stay native Wayland and are never touched. To run pup's browser as a native Wayland client instead (at the cost of background placement), set `PUP_OZONE_PLATFORM=wayland` in Adom Bridge's environment.++Measured on Ubuntu 24.04 (GNOME, Wayland): a `pup_open_window` returned in under 2 seconds with the window at the bottom of the stack, and the app the user was working in stayed the active window through the open, a navigate, a new tab and a close.++## The browser sandbox++Chrome sandboxes its renderers with Linux user namespaces. Ubuntu 23.10 and later block those for programs without their own AppArmor profile, which includes a Chrome for Testing in your home folder. pup handles it in this order:++| `browserSandbox` | when |+|---|---|+| `namespaces` | the system allows unprivileged user namespaces (most distributions) |+| `the browser's own setuid helper / AppArmor profile` | the browser is an installed package that brings its own (Google Chrome, distro Chromium) |+| `setuid helper <path>` | pup runs Chrome for Testing under an installed setuid sandbox helper, for example Google Chrome's `/opt/google/chrome/chrome-sandbox` |+| `off: ...` | none of the above: the browser runs with `--no-sandbox`. Installing Google Chrome or your distribution's Chromium package provides a helper and turns the sandbox back on |++## Taskbar icons and progress++Each AI thread's pup windows get their own icon on the dock or window list, showing the thread's name tile, the same+tile as on the Windows taskbar. Right-click it for **Open pup dashboard** and **Close ALL Adom Pup windows**.+On Linux the tile's lines break only between words (up to four lines, the font as small as needed, trailing words+dropped when the name is too long), because GNOME draws dock icons larger than a taskbar button: "BOM check" reads+"BOM / check", not "BO / M ch". The Windows tile keeps its own layout.+pup does this itself, inside your desktop session:++- a hidden `.desktop` entry per thread in `~/.local/share/applications` (never shown in the app menu or search),+  with the tile as its icon, and the tile itself in `~/.local/share/adom-pup/icons`;+- the window's WM_CLASS set to the thread's identity before the window first shows, so the dock files it under+  that entry. A new entry can reach GNOME Shell after the window does (measured: past 4 s on a busy 3 GB Fedora), so+  for the first 30 s pup announces the class again (at 1.5, 4, 8, 15 and 30 s) and the window moves to its own icon;+- the window's own icon set to the tile with the active tab's favicon in the top-right corner, for window lists+  that draw each window's icon.++The entry and its tile are removed when the thread's last pup window closes.++| desktop | icon per thread | progress | count badge | `pup_progress` error, `pup_alert_window` |+|---|---|---|---|---|+| GNOME, Ubuntu Dock or Dash to Dock (X11 or Wayland) | yes, the tile | a bar on the icon | the thread's open tabs, when 2 or more | the icon bounces (urgent); an alert in `progress` mode is a full bar for 2.5 s |+| Cinnamon, grouped window list (X11) | yes, the tile | a green fill across the button | not shown | an alert fills the button for 2.5 s (measured); error and `flash` set the window's attention hint (not yet captured on screen) |+| KDE Plasma, Plank | expected (same entry and LauncherEntry signals) | expected | expected | not tested yet |++How each `pup_progress` mode shows on a Linux dock, which has no red or yellow bar:++| mode | Linux |+|---|---|+| `value` | the bar at that value |+| `indeterminate` | an empty bar on the dock; Cinnamon gets the "busy" pulse hint |+| `paused` | the bar stays where it was |+| `error` | the bar stays where it was and the icon is urgent until the next mode |+| `off` | bar and urgency removed |++Notes:++- `taskbarMode: plain` keeps the browser's own icon and adds no entries; progress then shows only on Cinnamon's+  window list. `tiles` (the default) and `plain` are measured; `aumid` uses the same path with the category icons.+- Cinnamon picks up new entries only if `~/.local/share/applications` existed when you signed in. Installing+  Hydrogen creates it; otherwise pup's windows share one button until your next sign-in.+- The favicon badge is drawn on the window's own icon. GNOME's dock draws the thread tile, not the window icon, so+  the badge does not show there.+- On GNOME a window that opens in the background is marked as wanting attention, and GNOME announces that with an+  "… is ready" banner. pup clears the mark at once, so no banner stays up. `winAttentionFlash: on` keeps the+  desktop's own behaviour.++## Logins and saved passwords++pup's vault keeps passwords in your desktop keyring through the Secret Service (GNOME Keyring, KWallet), with the same attributes keytar uses. pup never asks you to unlock a keyring: when no keyring is unlocked (common on desktops that sign in automatically), it keeps them in `~/.adom/pup-secrets.json` with mode 0600 instead, and `credential_set` says which one it used. pup's browser keeps its own cookies with `--password-store=basic`, so Chrome never raises a keyring prompt either.++| your keyring | saving a login (`pup_login {save:true}`, or captured when you sign in) | signing in from the vault (`pup_login {}`) |+|---|---|---|+| unlocked (you signed in to the desktop with your password) | goes to the keyring (item `adom-bridge-pup/<site>`) | works |+| locked (automatic sign-in, or you locked it) | goes to `~/.adom/pup-secrets.json` (0600), no prompt | a login saved in the keyring answers `keyring_locked` and a desktop notice asks you to unlock it; one saved in the file works |+| no keyring service | goes to `~/.adom/pup-secrets.json` (0600) | works |++What does not work on Linux: importing the passwords saved in your own Chrome or Edge (`pup_import_browser_logins`, Windows-only), and `pup_assisted_click`. Sign in once in the pup window instead (pup captures it), or copy logins from a Windows desktop with `pup_vault_sync`. Through Adom Bridge, store a login with `pup_login {username, password, save:true}`: the bare `credential_set` verb is not routed by Adom Bridge (same on Windows).++## Where things live++| location | what it is |+|---|---|+| `~/.local/share/Adom Bridge/bridges-cache/puppeteer` | the program (`adom-pup-bridge`), put there by Adom Bridge |+| `~/.cache/puppeteer` | the browser build |+| `~/.adom/pup-profiles/adom-you` | the shared profile: cookies, logins and site data (or `<data dir>/profiles` when Adom Bridge gives pup a data directory) |+| `~/.adom/bridge-logs/puppeteer.log`, `pup-browser.log` | pup's log, and the browser's own error output (where a failed start explains itself) |+| `~/.adom/pup-secrets.json` | saved passwords when no keyring is unlocked |+| `~/.local/share/applications/Adom.Pup.*.desktop`, `~/.local/share/adom-pup/icons` | each thread's dock entry and tile, while it has a window open |++## Recording++- **One pup window** (`pup_record_start` / `pup_record_stop`): Adom Bridge for Linux has no native window recorder yet, so pup records the page itself. It captures up to 10 frames a second in the background and saves a VP9 WebM (with ffmpeg; otherwise an MJPEG AVI). The window can stay behind your work.+- **The whole screen** (`pup_screen_record_*`): on Wayland your desktop asks you which screen to share. Nothing records until you press **Share** in that dialog; no app or AI can press it for you. Until then pup answers `blocked_on_user` (`share_dialog`), and a stop with nothing captured answers `nothing_recorded`.+- **If Xwayland restarts** (GNOME starts it on demand), pup picks up its new login cookie by itself. Windows that were open when it restarted are gone, the same as every other X app on the desktop.++## What is Windows-only++These answer plainly on Linux (or store the setting and paint nothing) instead of failing silently:++- **Per-window jump-list tasks** (switch the wiki view, annotate this page). The dock entry is per thread, so it+  carries only the thread-wide tasks (dashboard, close all).+- **Assisted click** (`pup_assisted_click`), which uses the Windows cursor and foreground rules.+- **Importing saved passwords from your own Chrome or Edge** (`pup_import_browser_logins`). Sign-in through pup's vault works.++## Troubleshooting++- **`pup_open_window` says the browser did not start.** Read the end of `~/.adom/bridge-logs/pup-browser.log`. "No usable sandbox" means none of the sandbox options above is available; "Missing X server or $DISPLAY" means Adom Bridge was started without the desktop session's `DISPLAY` and `XAUTHORITY`.+- **Which browser and sandbox will pup use?** `pup_readiness`: `browserKind`, `browserExecutablePath`, `browserSandbox`, `diskFreeMb`.+- **Snap and Flatpak browsers** are not used: their confinement refuses the profile folder pup keeps under `~/.adom`. pup uses Chrome for Testing, or a native Google Chrome, Chromium or Edge package.++## Tested on++| distribution | desktop | result |+|---|---|---|+| Ubuntu 24.04 | GNOME, Wayland | through Adom Bridge 2.1.185: readiness, open, eval, navigate, screenshot, tabs, list, close, vault, bridge restart with window recovery |+| Debian 13 | Cinnamon, X11 | readiness (sandbox: namespaces), open, eval, navigate, screenshot, tabs, raise and lower, two threads' windows, close and reopen (1.3 to 1.9 s), restart with window recovery; the user's focused terminal stayed active and on top throughout |+| Fedora 44, clean, Hydrogen 1.0.712-2 + Adom Bridge 2.1.186 from the insiders manifests (11-08) | GNOME 50, Wayland | installed by Adom Bridge from a manifest with `platforms.linux` (no sudo, no Node); Chrome for Testing 152 fetched into `~/.cache/puppeteer` (sandbox: namespaces); readiness, open (warm 1.6 to 3.0 s), navigate, eval, screenshot, download to `~/Downloads`, four threads with their own dock icons and names, `pup_login` from the keyring, `keyring_locked` with the keyring locked, the 0600 file fallback |+| Fedora 44 | GNOME, Wayland, SELinux enforcing | readiness (sandbox: namespaces), open, eval, navigate, screenshot, tabs, two threads' windows, raise and lower, vault (0600 file, no unlocked keyring), close and reopen (1.8 s), restart with window recovery; the user's terminal stayed on top throughout, no SELinux denials |
linux/img/pup-linux-icon.pngadded
⋯ 1 unchanged line ⋯
linux/img/pup-linux-page.pngadded
⋯ 1 unchanged line ⋯
package.json+17−3
@@ -1,6 +1,6 @@ {   "slug": "pup-bridge",-  "version": "2.0.739",+  "version": "2.0.741",   "type": "app",   "description": "The pup SKILL PACK: the AI skills that teach a cloud/container AI how to drive the Bridge Puppeteer (pup) bridge (pup_* verbs): the everyday `pup` skill plus focused sub-skills for windows/sessions/tabs, screenshots+recording, browsers+Chrome install, and driving the Adom wiki. Installs into ~/.claude/skills + ~/.codex/skills. DOCS ONLY \u2014 this is NOT the bridge runtime. The bridge itself (the Node program that drives the browser) runs on the DESKTOP: Adom Bridge bundles it and auto-installs/updates it, and it's published separately on the Releases tab as adom-bridge-puppeteer-v<ver>.zip. The pkg version is kept in lockstep with the Release version so the page's max version row always carries BOTH a pkg tarball (skills) and a runtime zip (bridge) \u2014 a skills-only or runtime-only bump on the top row breaks constraint installs. TL;DR: pkg install = the skills for your AI; the bridge = the Release zip, which Adom Bridge manages for you.",   "keywords": [@@ -155,7 +155,21 @@     "log in to mouser",     "log in to digikey",     "sign in problem",-    "stuck on the login page"+    "stuck on the login page",+    "upload a file in pup",+    "pup upload",+    "pup upload file",+    "upload a file to a page",+    "upload a file to a website",+    "attach a file to a form",+    "file input",+    "input type file",+    "upload gerbers",+    "upload gerber zip to jlcpcb",+    "fill the jlcpcb quote form",+    "set file input without the file picker",+    "browser file upload without a dialog",+    "large file upload in the browser"   ],   "sample_prompts": [     {@@ -187,5 +201,5 @@       "prompt": "Download the puppeteer-bridge zip from the wiki and walk me through server.js"     }   ],-  "changelog": "Never a bare Chrome taskbar button during recovery (pup-bridge#143): an OS-level health-tick safety net stamps a pup identity on any pup Chrome-for-Testing window that has no session yet (the gap after a restart or worker-reload cycle while the extension channel re-handshakes), so a pup window never falls back to the stock Chrome button; it reads+stamps only and never touches the lane/kill paths, and the real per-thread tile upgrades it once adopt_stray re-adopts the window."+  "changelog": "Login import keeps working where nb cannot run it (pup-bridge#145): pup asks nb first, and only when nb is missing, older than 0.16.45 or has no extension in the user's browser it uses Adom Bridge's older decrypt helper, as 2.0.737 did; nb's own answers (Hello waiting, no session, export failed) now reach the caller instead of nb_call_failed, and profile:\"chrome:<email>\" picks which browser profile nb reads (pup-bridge#140)." }
page.json+17−3
@@ -4,7 +4,7 @@   "slug": "pup-bridge",   "title": "Pup - the AI's own browser",   "brief": "pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.",-  "version": "2.0.739",+  "version": "2.0.741",   "tags": [     "skills",     "pup",@@ -86,7 +86,21 @@     "log in to mouser",     "log in to digikey",     "sign in problem",-    "stuck on the login page"+    "stuck on the login page",+    "upload a file in pup",+    "pup upload",+    "pup upload file",+    "upload a file to a page",+    "upload a file to a website",+    "attach a file to a form",+    "file input",+    "input type file",+    "upload gerbers",+    "upload gerber zip to jlcpcb",+    "fill the jlcpcb quote form",+    "set file input without the file picker",+    "browser file upload without a dialog",+    "large file upload in the browser"   ],   "discovery_pitch": "pup gives your AI its own real browser on your desktop, driven in the background, signed in as you. It can import every password saved in your Chrome or Edge in one approval, then sign you into sites by itself, so the AI never stalls at a login page. Open pages, click, type, screenshot, record, and keep one window per AI thread on your taskbar. Say 'open it in pup' or 'import my saved passwords into pup'.",   "sample_prompts": [@@ -207,5 +221,5 @@     "Kyle Bergstedt <[email protected]>",     "Colby Knox"   ],-  "changelog": "Never a bare Chrome taskbar button during recovery (pup-bridge#143): an OS-level health-tick safety net stamps a pup identity on any pup Chrome-for-Testing window that has no session yet (the gap after a restart or worker-reload cycle while the extension channel re-handshakes), so a pup window never falls back to the stock Chrome button; it reads+stamps only and never touches the lane/kill paths, and the real per-thread tile upgrades it once adopt_stray re-adopts the window."+  "changelog": "Login import keeps working where nb cannot run it (pup-bridge#145): pup asks nb first, and only when nb is missing, older than 0.16.45 or has no extension in the user's browser it uses Adom Bridge's older decrypt helper, as 2.0.737 did; nb's own answers (Hello waiting, no session, export failed) now reach the caller instead of nb_call_failed, and profile:\"chrome:<email>\" picks which browser profile nb reads (pup-bridge#140)." }\ No newline at end of file
rust/Cargo.lock+764−14
@@ -10,13 +10,14 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-pup-bridge"-version = "2.0.739"+version = "2.0.741" dependencies = [  "pup-browser",  "pup-cdp",  "pup-core",  "pup-dashboard",  "pup-host",+ "pup-host-linux",  "pup-host-mac",  "pup-host-win",  "pup-session",@@ -51,6 +52,143 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]]+name = "async-broadcast"+version = "0.7.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"+dependencies = [+ "event-listener",+ "event-listener-strategy",+ "futures-core",+ "pin-project-lite",+]++[[package]]+name = "async-channel"+version = "2.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"+dependencies = [+ "concurrent-queue",+ "event-listener-strategy",+ "futures-core",+ "pin-project-lite",+]++[[package]]+name = "async-executor"+version = "1.14.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"+dependencies = [+ "async-task",+ "concurrent-queue",+ "fastrand",+ "futures-lite",+ "pin-project-lite",+ "slab",+]++[[package]]+name = "async-io"+version = "2.6.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"+dependencies = [+ "autocfg",+ "cfg-if",+ "concurrent-queue",+ "futures-io",+ "futures-lite",+ "parking",+ "polling",+ "rustix",+ "slab",+ "windows-sys 0.61.2",+]++[[package]]+name = "async-lock"+version = "3.4.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"+dependencies = [+ "event-listener",+ "event-listener-strategy",+ "pin-project-lite",+]++[[package]]+name = "async-process"+version = "2.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"+dependencies = [+ "async-channel",+ "async-io",+ "async-lock",+ "async-signal",+ "async-task",+ "blocking",+ "cfg-if",+ "event-listener",+ "futures-lite",+ "rustix",+]++[[package]]+name = "async-recursion"+version = "1.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.5",+]++[[package]]+name = "async-signal"+version = "0.2.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"+dependencies = [+ "async-io",+ "async-lock",+ "atomic-waker",+ "cfg-if",+ "futures-core",+ "futures-io",+ "rustix",+ "signal-hook-registry",+ "slab",+ "windows-sys 0.61.2",+]++[[package]]+name = "async-task"+version = "4.7.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"++[[package]]+name = "async-trait"+version = "0.1.92"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.5",+]++[[package]]+name = "atomic-waker"+version = "1.1.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"+ [[package]] name = "autocfg" version = "1.5.1"@@ -84,6 +222,25 @@ dependencies = [  "generic-array", ] +[[package]]+name = "blocking"+version = "1.7.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"+dependencies = [+ "async-channel",+ "async-task",+ "futures-io",+ "futures-lite",+ "piper",+]++[[package]]+name = "bumpalo"+version = "3.20.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"+ [[package]] name = "bytemuck" version = "1.25.2"@@ -136,6 +293,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]]+name = "concurrent-queue"+version = "2.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"+dependencies = [+ "crossbeam-utils",+]+ [[package]] name = "core-foundation" version = "0.10.1"@@ -203,6 +369,12 @@ dependencies = [  "cfg-if", ] +[[package]]+name = "crossbeam-utils"+version = "0.8.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"+ [[package]] name = "crypto-common" version = "0.1.7"@@ -256,12 +428,49 @@ dependencies = [  "syn 3.0.5", ] +[[package]]+name = "endi"+version = "1.1.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"++[[package]]+name = "enumflags2"+version = "0.7.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"+dependencies = [+ "enumflags2_derive",+ "serde",+]++[[package]]+name = "enumflags2_derive"+version = "0.7.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 2.0.119",+]+ [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]]+name = "errno"+version = "0.3.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"+dependencies = [+ "libc",+ "windows-sys 0.59.0",+]+ [[package]] name = "euclid" version = "0.22.14"@@ -271,6 +480,32 @@ dependencies = [  "num-traits", ] +[[package]]+name = "event-listener"+version = "5.4.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"+dependencies = [+ "parking",+ "pin-project-lite",+]++[[package]]+name = "event-listener-strategy"+version = "0.5.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"+dependencies = [+ "event-listener",+ "pin-project-lite",+]++[[package]]+name = "fastrand"+version = "2.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"+ [[package]] name = "fdeflate" version = "0.3.7"@@ -355,6 +590,49 @@ dependencies = [  "percent-encoding", ] +[[package]]+name = "futures-core"+version = "0.3.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"++[[package]]+name = "futures-io"+version = "0.3.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"++[[package]]+name = "futures-lite"+version = "2.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"+dependencies = [+ "fastrand",+ "futures-core",+ "futures-io",+ "parking",+ "pin-project-lite",+]++[[package]]+name = "futures-task"+version = "0.3.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"++[[package]]+name = "futures-util"+version = "0.3.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"+dependencies = [+ "futures-core",+ "futures-task",+ "pin-project-lite",+ "slab",+]+ [[package]] name = "generic-array" version = "0.14.7"@@ -365,6 +643,16 @@ dependencies = [  "version_check", ] +[[package]]+name = "gethostname"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"+dependencies = [+ "rustix",+ "windows-link",+]+ [[package]] name = "getrandom" version = "0.2.17"@@ -376,6 +664,17 @@ dependencies = [  "wasi", ] +[[package]]+name = "getrandom"+version = "0.4.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"+dependencies = [+ "cfg-if",+ "libc",+ "r-efi",+]+ [[package]] name = "gif" version = "0.14.2"@@ -403,6 +702,18 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]]+name = "hermit-abi"+version = "0.5.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"++[[package]]+name = "hex"+version = "0.4.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"+ [[package]] name = "http" version = "1.5.0"@@ -579,6 +890,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]]+name = "js-sys"+version = "0.3.107"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a"+dependencies = [+ "cfg-if",+ "futures-util",+ "wasm-bindgen",+]+ [[package]] name = "kurbo" version = "0.11.3"@@ -602,6 +924,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]]+name = "linux-raw-sys"+version = "0.12.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"+ [[package]] name = "litemap" version = "0.8.3"@@ -620,6 +948,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]]+name = "memoffset"+version = "0.9.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"+dependencies = [+ "autocfg",+]+ [[package]] name = "miniz_oxide" version = "0.8.9"@@ -714,6 +1051,22 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]]+name = "ordered-stream"+version = "0.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"+dependencies = [+ "futures-core",+ "pin-project-lite",+]++[[package]]+name = "parking"+version = "2.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"+ [[package]] name = "percent-encoding" version = "2.3.2"@@ -726,6 +1079,23 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" +[[package]]+name = "pin-project-lite"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"++[[package]]+name = "piper"+version = "0.2.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"+dependencies = [+ "atomic-waker",+ "fastrand",+ "futures-io",+]+ [[package]] name = "png" version = "0.17.16"@@ -752,6 +1122,20 @@ dependencies = [  "miniz_oxide 0.8.9", ] +[[package]]+name = "polling"+version = "3.11.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"+dependencies = [+ "cfg-if",+ "concurrent-queue",+ "hermit-abi",+ "pin-project-lite",+ "rustix",+ "windows-sys 0.61.2",+]+ [[package]] name = "potential_utf" version = "0.1.6"@@ -770,6 +1154,15 @@ dependencies = [  "zerocopy", ] +[[package]]+name = "proc-macro-crate"+version = "3.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"+dependencies = [+ "toml_edit",+]+ [[package]] name = "proc-macro2" version = "1.0.107"@@ -781,8 +1174,9 @@ dependencies = [  [[package]] name = "pup-browser"-version = "2.0.739"+version = "2.0.741" dependencies = [+ "libc",  "pup-core",  "pup-host",  "serde",@@ -793,7 +1187,7 @@ dependencies = [  [[package]] name = "pup-cdp"-version = "2.0.739"+version = "2.0.741" dependencies = [  "serde_json",  "thiserror",@@ -803,7 +1197,7 @@ dependencies = [  [[package]] name = "pup-core"-version = "2.0.739"+version = "2.0.741" dependencies = [  "serde",  "serde_json",@@ -813,7 +1207,7 @@ dependencies = [  [[package]] name = "pup-dashboard"-version = "2.0.739"+version = "2.0.741" dependencies = [  "pup-cdp",  "pup-core",@@ -826,16 +1220,28 @@ dependencies = [  [[package]] name = "pup-host"-version = "2.0.739"+version = "2.0.741" dependencies = [  "serde",  "serde_json",  "thiserror", ] +[[package]]+name = "pup-host-linux"+version = "2.0.741"+dependencies = [+ "libc",+ "pup-core",+ "pup-host",+ "serde_json",+ "x11rb",+ "zbus",+]+ [[package]] name = "pup-host-mac"-version = "2.0.739"+version = "2.0.741" dependencies = [  "core-foundation",  "core-graphics",@@ -851,7 +1257,7 @@ dependencies = [  [[package]] name = "pup-host-win"-version = "2.0.739"+version = "2.0.741" dependencies = [  "pup-core",  "pup-host",@@ -861,7 +1267,7 @@ dependencies = [  [[package]] name = "pup-identity"-version = "2.0.739"+version = "2.0.741" dependencies = [  "fontdue",  "image",@@ -871,7 +1277,7 @@ dependencies = [  [[package]] name = "pup-session"-version = "2.0.739"+version = "2.0.741" dependencies = [  "pup-core",  "serde",@@ -881,7 +1287,7 @@ dependencies = [  [[package]] name = "pup-vault"-version = "2.0.739"+version = "2.0.741" dependencies = [  "pup-core",  "pup-host",@@ -892,7 +1298,7 @@ dependencies = [  [[package]] name = "pup-verbs"-version = "2.0.739"+version = "2.0.741" dependencies = [  "base64",  "png 0.17.16",@@ -928,6 +1334,12 @@ dependencies = [  "proc-macro2", ] +[[package]]+name = "r-efi"+version = "6.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"+ [[package]] name = "rand" version = "0.8.8"@@ -955,7 +1367,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [- "getrandom",+ "getrandom 0.2.17", ]  [[package]]@@ -989,7 +1401,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [  "cc",  "cfg-if",- "getrandom",+ "getrandom 0.2.17",  "libc",  "untrusted",  "windows-sys 0.52.0",@@ -1001,6 +1413,19 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]]+name = "rustix"+version = "1.1.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"+dependencies = [+ "bitflags 2.13.2",+ "errno",+ "libc",+ "linux-raw-sys",+ "windows-sys 0.59.0",+]+ [[package]] name = "rustls" version = "0.23.43"@@ -1036,6 +1461,12 @@ dependencies = [  "untrusted", ] +[[package]]+name = "rustversion"+version = "1.0.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"+ [[package]] name = "security-framework" version = "3.7.0"@@ -1103,6 +1534,17 @@ dependencies = [  "zmij", ] +[[package]]+name = "serde_repr"+version = "0.1.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.5",+]+ [[package]] name = "sha1" version = "0.10.7"@@ -1120,6 +1562,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]]+name = "signal-hook-registry"+version = "1.4.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"+dependencies = [+ "errno",+ "libc",+]+ [[package]] name = "simd-adler32" version = "0.3.10"@@ -1141,6 +1593,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]]+name = "slab"+version = "0.4.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"+ [[package]] name = "smallvec" version = "1.16.0"@@ -1211,6 +1669,19 @@ dependencies = [  "syn 2.0.119", ] +[[package]]+name = "tempfile"+version = "3.27.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"+dependencies = [+ "fastrand",+ "getrandom 0.4.3",+ "once_cell",+ "rustix",+ "windows-sys 0.59.0",+]+ [[package]] name = "thiserror" version = "1.0.69"@@ -1279,6 +1750,67 @@ dependencies = [  "zerovec", ] +[[package]]+name = "toml_datetime"+version = "1.1.2+spec-1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"+dependencies = [+ "serde_core",+]++[[package]]+name = "toml_edit"+version = "0.25.17+spec-1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"+dependencies = [+ "indexmap",+ "toml_datetime",+ "toml_parser",+ "winnow",+]++[[package]]+name = "toml_parser"+version = "1.1.5+spec-1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"+dependencies = [+ "winnow",+]++[[package]]+name = "tracing"+version = "0.1.44"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"+dependencies = [+ "pin-project-lite",+ "tracing-attributes",+ "tracing-core",+]++[[package]]+name = "tracing-attributes"+version = "0.1.31"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 2.0.119",+]++[[package]]+name = "tracing-core"+version = "0.1.36"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"+dependencies = [+ "once_cell",+]+ [[package]] name = "ttf-parser" version = "0.25.1"@@ -1312,6 +1844,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]]+name = "uds_windows"+version = "1.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"+dependencies = [+ "memoffset",+ "tempfile",+ "windows-sys 0.61.2",+]+ [[package]] name = "unicode-ident" version = "1.0.24"@@ -1388,6 +1931,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]]+name = "uuid"+version = "1.29.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"+dependencies = [+ "js-sys",+ "serde_core",+ "wasm-bindgen",+]+ [[package]] name = "version_check" version = "0.9.5"@@ -1400,6 +1954,50 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]]+name = "wasm-bindgen"+version = "0.2.130"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c"+dependencies = [+ "cfg-if",+ "rustversion",+ "wasm-bindgen-macro",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-macro"+version = "0.2.130"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9"+dependencies = [+ "quote",+ "wasm-bindgen-macro-support",+]++[[package]]+name = "wasm-bindgen-macro-support"+version = "0.2.130"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a"+dependencies = [+ "bumpalo",+ "proc-macro2",+ "quote",+ "syn 3.0.5",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-shared"+version = "0.2.130"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e"+dependencies = [+ "unicode-ident",+]+ [[package]] name = "webpki-roots" version = "0.26.11"@@ -1424,6 +2022,12 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]]+name = "windows-link"+version = "0.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"+ [[package]] name = "windows-sys" version = "0.52.0"@@ -1442,6 +2046,15 @@ dependencies = [  "windows-targets", ] +[[package]]+name = "windows-sys"+version = "0.61.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"+dependencies = [+ "windows-link",+]+ [[package]] name = "windows-targets" version = "0.52.6"@@ -1506,12 +2119,38 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]]+name = "winnow"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"+dependencies = [+ "memchr",+]+ [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]]+name = "x11rb"+version = "0.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"+dependencies = [+ "gethostname",+ "rustix",+ "x11rb-protocol",+]++[[package]]+name = "x11rb-protocol"+version = "0.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"+ [[package]] name = "xmlwriter" version = "0.1.0"@@ -1541,6 +2180,76 @@ dependencies = [  "synstructure", ] +[[package]]+name = "zbus"+version = "5.19.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"+dependencies = [+ "async-broadcast",+ "async-executor",+ "async-io",+ "async-lock",+ "async-process",+ "async-recursion",+ "async-task",+ "async-trait",+ "blocking",+ "enumflags2",+ "event-listener",+ "futures-core",+ "futures-lite",+ "hex",+ "libc",+ "ordered-stream",+ "rustix",+ "serde",+ "serde_repr",+ "tracing",+ "uds_windows",+ "uuid",+ "windows-sys 0.61.2",+ "winnow",+ "zbus_macros",+ "zbus_names",+ "zvariant",+]++[[package]]+name = "zbus_macros"+version = "5.19.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"+dependencies = [+ "proc-macro-crate",+ "proc-macro2",+ "quote",+ "syn 3.0.5",+ "zbus_names",+ "zvariant",+ "zvariant_utils",+]++[[package]]+name = "zbus_names"+version = "4.3.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd"+dependencies = [+ "serde",+ "winnow",+ "zvariant",+]++[[package]]+name = "zcheapstr"+version = "1.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "939f186d9ecebe044315a5cb6d6f78568bbc21152adc954d5a0c2f58bdbc9920"+dependencies = [+ "serde",+]+ [[package]] name = "zerocopy" version = "0.8.56"@@ -1647,3 +2356,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [  "zune-core", ]++[[package]]+name = "zvariant"+version = "5.15.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7"+dependencies = [+ "endi",+ "enumflags2",+ "serde",+ "winnow",+ "zcheapstr",+ "zvariant_derive",+ "zvariant_utils",+]++[[package]]+name = "zvariant_derive"+version = "5.15.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d"+dependencies = [+ "proc-macro-crate",+ "proc-macro2",+ "quote",+ "syn 3.0.5",+ "zvariant_utils",+]++[[package]]+name = "zvariant_utils"+version = "4.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"+dependencies = [+ "proc-macro2",+ "quote",+ "serde",+ "syn 3.0.5",+ "winnow",+]
rust/Cargo.toml+2−2
@@ -1,10 +1,10 @@ [workspace] resolver = "2"-members = ["core", "host", "host-win", "host-mac", "cdp", "browser", "session", "identity", "vault", "verbs", "dashboard", "bridge"]+members = ["core", "host", "host-win", "host-mac", "host-linux", "cdp", "browser", "session", "identity", "vault", "verbs", "dashboard", "bridge"]  # One version for the whole bridge, in lockstep with src/BRIDGE_VERSION (tools/ship.sh bumps both). [workspace.package]-version = "2.0.739"+version = "2.0.741" edition = "2021" license = "MIT" 
rust/bridge/Cargo.toml+4−1
@@ -28,5 +28,8 @@ pup-host-win = { path = "../host-win" } [target.'cfg(target_os = "macos")'.dependencies] pup-host-mac = { path = "../host-mac" } -[target.'cfg(not(any(windows, target_os = "macos")))'.dependencies]+[target.'cfg(target_os = "linux")'.dependencies]+pup-host-linux = { path = "../host-linux" }++[target.'cfg(not(any(windows, target_os = "macos", target_os = "linux")))'.dependencies] pup-host-win = { path = "../host-win" }
rust/bridge/src/main.rs+6−2
@@ -16,7 +16,9 @@ use tiny_http::{Header, Method, Response, Server}; fn host() -> Arc<dyn Host> { Arc::new(pup_host_win::WinHost) } #[cfg(target_os = "macos")] fn host() -> Arc<dyn Host> { Arc::new(pup_host_mac::MacHost) }-#[cfg(not(any(windows, target_os = "macos")))]+#[cfg(target_os = "linux")]+fn host() -> Arc<dyn Host> { Arc::new(pup_host_linux::LinuxHost) }+#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] fn host() -> Arc<dyn Host> { Arc::new(pup_host_win::WinHost) }   // dev box: the stubbed impl, so the exe links and /health answers  fn json_response(status: u16, v: &Value) -> Response<std::io::Cursor<Vec<u8>>> {@@ -37,7 +39,9 @@ fn jumplist_call(verb: &str, b64: &str) -> i32 {     let mut args: serde_json::Value = if b64.is_empty() { serde_json::json!({}) } else { pup_cdp::base64_decode(b64).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_else(|| serde_json::json!({})) };     args["caller"] = serde_json::json!({ "aiThread": "user-taskbar-menu" });     let body = serde_json::json!({ "command": verb, "args": args }).to_string();-    let lad = std::env::var("LOCALAPPDATA").map(std::path::PathBuf::from).unwrap_or_default();+    // Windows: %LOCALAPPDATA%. Elsewhere the port file lives in the state dir (identity::local_app_data), and the+    // dock entry's actions (Linux) call this same mode.+    let lad = if cfg!(windows) { std::env::var("LOCALAPPDATA").map(std::path::PathBuf::from).unwrap_or_default() } else { host().state_dir() };     let port = std::fs::read_to_string(lad.join("Adom Bridge").join("pup-bridge-port.json")).ok().and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok()).and_then(|v| v["port"].as_u64());     if let Some(port) = port {         let r = ureq::AgentBuilder::new().timeout(std::time::Duration::from_secs(60)).build().post(&format!("http://127.0.0.1:{port}/command"))
rust/browser/Cargo.toml+3
@@ -12,3 +12,6 @@ thiserror.workspace = true ureq.workspace = true pup-core = { path = "../core" } pup-host = { path = "../host" }++[target.'cfg(unix)'.dependencies]+libc = "0.2"
rust/browser/src/ext_assets.rs+3
@@ -18,8 +18,11 @@ pub static EXTENSIONS: &[(&str, &str, &[(&str, &[u8])])] = &[         ("nbe-cdp.js", include_bytes!("../../../src/extension/nbe-cdp.js")),         ("popup.html", include_bytes!("../../../src/extension/popup.html")),         ("popup.js", include_bytes!("../../../src/extension/popup.js")),+        ("pup-errors-main.js", include_bytes!("../../../src/extension/pup-errors-main.js")),+        ("pup-errors.js", include_bytes!("../../../src/extension/pup-errors.js")),         ("satoshi-400.woff2", include_bytes!("../../../src/extension/satoshi-400.woff2")),         ("satoshi-500.woff2", include_bytes!("../../../src/extension/satoshi-500.woff2")),+        ("tile.js", include_bytes!("../../../src/extension/tile.js")),         ("worker.js", include_bytes!("../../../src/extension/worker.js")),     ]),     ("extension-identity", "pup-identity-extension", &[
rust/browser/src/lib.rs+54−4
@@ -171,6 +171,7 @@ impl Installer {             "browserKind": best.as_ref().map(|c| c.kind.clone()),             "browserSource": best.as_ref().map(|c| c.source.clone()),             "browserExecutablePath": best.as_ref().map(|c| c.executable_path.to_string_lossy().replace('\\', "/")),+            "browserSandbox": best.as_ref().and_then(|c| host.browser_sandbox(&c.executable_path)),             "candidates": cands.iter().map(|c| c.to_json()).collect::<Vec<_>>(),             "defaultBrowser": read_default(host).map(|d| json!({ "kind": d.get("kind"), "source": d.get("source"), "forced": d.get("forced").and_then(|f| f.as_bool()).unwrap_or(false), "pendingInstall": d.get("pendingInstall").and_then(|f| f.as_bool()).unwrap_or(false) })),             "nodeInstalled": true, "nodeVersion": format!("rust/{}", pup_core::version()),@@ -204,7 +205,15 @@ pub fn disk_free_mb(path: &Path) -> Option<u64> {         let ok = unsafe { GetDiskFreeSpaceExW(w.as_ptr(), &mut a, &mut b, &mut c) } != 0;         return if ok { Some(a / (1024 * 1024)) } else { None };     }-    #[cfg(not(windows))]+    #[cfg(unix)]+    {+        let mut p = path.to_path_buf(); while !p.exists() { if !p.pop() { break; } }+        let c = std::ffi::CString::new(p.as_os_str().to_string_lossy().as_bytes()).ok()?;+        let mut st: libc::statvfs = unsafe { std::mem::zeroed() };+        if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 { return None; }+        return Some((st.f_bavail as u64).saturating_mul(st.f_frsize as u64) / (1024 * 1024));+    }+    #[cfg(not(any(windows, unix)))]     { let _ = path; None } } @@ -339,13 +348,19 @@ pub fn prune_broken_cache_dirs(host: &dyn Host, keep_build: Option<&str>) -> Vec pub fn unpack_supervised(archive: &Path, dest: &Path, state: &Arc<Mutex<InstallState>>) -> Result<String, (String, String)> {     let mut methods: Vec<(&str, PathBuf, Vec<String>)> = vec![("tar", PathBuf::from("tar"), vec!["-xf".into(), archive.to_string_lossy().into(), "-C".into(), dest.to_string_lossy().into()])];     if !cfg!(windows) { methods.push(("unzip", PathBuf::from("unzip"), vec!["-q".into(), "-o".into(), archive.to_string_lossy().into(), "-d".into(), dest.to_string_lossy().into()])); }+    // Linux: GNU tar cannot read a zip and `unzip` is not on every desktop install; bsdtar and python3 are+    // the usual others. python3's zipfile drops the unix mode bits, so `restore_exec_bits` runs after it.+    if cfg!(target_os = "linux") {+        methods.insert(0, ("bsdtar", PathBuf::from("bsdtar"), vec!["-xf".into(), archive.to_string_lossy().into(), "-C".into(), dest.to_string_lossy().into()]));+        methods.push(("python3", PathBuf::from("python3"), vec!["-I".into(), "-c".into(), "import sys,zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])".into(), archive.to_string_lossy().into(), dest.to_string_lossy().into()]));+    }     let mut last = String::new();     for (name, cmd, args) in methods {         let _ = fs::remove_dir_all(dest);         fs::create_dir_all(dest).map_err(|e| ("chrome_install_permission".to_string(), e.to_string()))?;         set(state, |s| { s.unpack_method = Some(name.into()); });         match run_unpack(&cmd, &args, dest, state) {-            Ok(()) => return Ok(name.into()),+            Ok(()) => { restore_exec_bits(archive, dest); return Ok(name.into()); }             Err(e) => { last = format!("unpack via {name} {e}"); pup_core::log(format!("[chrome] {last} — trying the next unpack method")); }         }     }@@ -353,6 +368,28 @@ pub fn unpack_supervised(archive: &Path, dest: &Path, state: &Arc<Mutex<InstallS     Err(("chrome_unpack_failed".into(), last)) } +/// Unix: give every file the zip marks executable (or that is an ELF binary) its exec bits back. A no-op+/// where the unpacker kept them (tar, unzip) and on Windows.+fn restore_exec_bits(archive: &Path, dest: &Path) {+    #[cfg(unix)]+    {+        use std::os::unix::fs::PermissionsExt;+        fn walk(d: &Path, out: &mut Vec<PathBuf>) { if let Ok(rd) = fs::read_dir(d) { for e in rd.flatten() { let p = e.path(); if p.is_dir() { walk(&p, out); } else { out.push(p); } } } }+        let _ = archive;+        let mut files = Vec::new(); walk(dest, &mut files);+        for f in files {+            let Ok(m) = fs::metadata(&f) else { continue };+            if m.permissions().mode() & 0o111 != 0 { continue; }+            let mut head = [0u8; 4];+            let elf = fs::File::open(&f).and_then(|mut h| std::io::Read::read_exact(&mut h, &mut head)).map(|_| head == *b"\x7fELF").unwrap_or(false);+            let script = f.extension().map(|e| e == "sh").unwrap_or(false);+            if elf || script { let mut p = m.permissions(); p.set_mode(p.mode() | 0o755); let _ = fs::set_permissions(&f, p); }+        }+    }+    #[cfg(not(unix))]+    { let _ = (archive, dest); }+}+ fn run_unpack(cmd: &Path, args: &[String], dest: &Path, state: &Arc<Mutex<InstallState>>) -> Result<(), String> {     let mut c = std::process::Command::new(cmd);     c.args(args).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::piped());@@ -530,9 +567,22 @@ pub fn set_stock_flags(on: bool) { STOCK_FLAGS.store(on, Ordering::SeqCst); } /// for Testing wants to use your confidential information…" (Kyle, 2026-10-20, via H). pup's profiles are /// isolated throwaways, so an in-memory key is right; the mock keychain's key is constant across launches, /// so cookies written under it still persist. Cookies encrypted under a real keychain key before this flag-/// are unreadable once (one re-login). Windows/Linux: nothing (DPAPI / basic store prompt nothing).+/// are unreadable once (one re-login). Windows: nothing (DPAPI prompts nothing).+/// Linux: `--password-store=basic`, the same reasoning as macOS: Chromium's OSCrypt otherwise asks GNOME+/// Keyring / KWallet for "Chrome Safe Storage", and an auto-login desktop leaves the login keyring locked, so+/// every launch would raise an "unlock your keyring" dialog. And on a Wayland session `--ozone-platform=x11`:+/// pup's windows run under XWayland, where pup can find, tag, move and lower them (Wayland gives no client a+/// way to restack or even list another client's windows), so a background window stays behind the user's+/// work. The user's own apps stay native Wayland and are never touched. pub fn platform_switches() -> Vec<String> {-    if cfg!(target_os = "macos") { vec!["--use-mock-keychain".into()] } else { vec![] }+    if cfg!(target_os = "macos") { return vec!["--use-mock-keychain".into()]; }+    if cfg!(target_os = "linux") {+        let mut v = vec!["--password-store=basic".to_string()];+        let wayland = std::env::var("WAYLAND_DISPLAY").map(|w| !w.is_empty()).unwrap_or(false) || std::env::var("XDG_SESSION_TYPE").map(|t| t == "wayland").unwrap_or(false);+        if wayland && std::env::var("PUP_OZONE_PLATFORM").map(|p| p != "wayland").unwrap_or(true) { v.push("--ozone-platform=x11".into()); }+        return v;+    }+    vec![] }  /// The switches every pup launch carries (server.js v2.0.293 merged form: ONE --disable-features).
rust/host-linux/Cargo.tomladded+16
@@ -0,0 +1,16 @@+[package]+name = "pup-host-linux"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "The Linux implementation of pup-host: X11/XWayland window list, lower and tag (EWMH), /proc process table, Secret Service secrets, detached launch with the AppImage environment scrubbed."++[dependencies]+pup-host = { path = "../host" }+pup-core = { path = "../core" }+serde_json.workspace = true++[target.'cfg(target_os = "linux")'.dependencies]+libc = "0.2"+x11rb = { version = "0.13", features = ["screensaver"] }+zbus = { version = "5", default-features = false, features = ["blocking-api", "async-io"] }
rust/host-linux/src/dock.rsadded+147
@@ -0,0 +1,147 @@+//! Dock identity and dock signals on Linux desktops: the counterpart of the Windows taskbar identity (AUMID,+//! thread tiles, jump lists) and of `ITaskbarList3` progress, which pup reaches through ab on Windows.+//!+//! - Identity: a `.desktop` entry per pup identity in `$XDG_DATA_HOME/applications`, `StartupWMClass` = the+//!   identity, `NoDisplay=true` so it never shows in the app grid or search. A window whose WM_CLASS is that+//!   identity is shown by the dock (GNOME Shell's Ubuntu Dock and Dash to Dock, Cinnamon, KDE, Plank) under that+//!   entry's name and icon, and right-clicking it lists the entry's actions (the jump-list tasks).+//! - Signals: `com.canonical.Unity.LauncherEntry.Update` on the session bus, keyed to that entry+//!   (`application://<id>.desktop`): progress, a count badge and urgency. Ubuntu Dock, Dash to Dock, KDE's task+//!   manager and Plank draw them. There is no indeterminate, error or paused state in that protocol.++use std::collections::HashMap;+use std::path::PathBuf;+use std::sync::Mutex;+use zbus::zvariant::Value;++fn data_home() -> PathBuf {+    std::env::var("XDG_DATA_HOME").ok().filter(|d| d.starts_with('/')).map(PathBuf::from)+        .unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".into())).join(".local").join("share"))+}+fn apps_dir() -> PathBuf { data_home().join("applications") }+fn icons_dir() -> PathBuf { data_home().join("adom-pup").join("icons") }++/// A desktop-entry id may hold letters, digits, '-', '_' and '.'; anything else becomes '-'.+pub fn safe_id(app_id: &str) -> String { app_id.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' }).collect() }++/// One line of a desktop entry's value: no newlines, and the spec's string escapes.+fn esc(v: &str) -> String { v.replace('\\', "\\\\").replace('\n', " ").replace('\r', " ").replace('\t', " ") }++/// The entry text (pure, for tests).+pub fn entry_text(app_id: &str, display: &str, icon: &str, exec: &str, actions: &[(String, String)]) -> String {+    let mut t = String::from("[Desktop Entry]\nType=Application\n");+    t += &format!("Name={}\nComment=Adom Pup windows of this AI thread\nIcon={}\nExec={}\nStartupWMClass={}\n", esc(display), esc(icon), esc(exec), safe_id(app_id));+    t += "NoDisplay=true\nTerminal=false\nStartupNotify=false\nX-Adom-Pup=true\n";+    if !actions.is_empty() {+        t += &format!("Actions={};\n", (0..actions.len()).map(|i| format!("pup{i}")).collect::<Vec<_>>().join(";"));+        for (i, (name, cmd)) in actions.iter().enumerate() { t += &format!("\n[Desktop Action pup{i}]\nName={}\nExec={}\n", esc(name), esc(cmd)); }+    }+    t+}++/// Write the entry (and a copy of its icon under our own data dir, so the entry never points into /tmp).+/// Unchanged entries are not rewritten, so the desktop's app monitor is not woken for nothing.+pub fn register(app_id: &str, display: &str, icon_png: &str, exec: &str, actions: &[(String, String)]) -> bool {+    let id = safe_id(app_id);+    let icon = if icon_png.is_empty() { String::new() } else {+        let dst = icons_dir().join(format!("{id}.png"));+        let _ = std::fs::create_dir_all(icons_dir());+        let same = std::fs::read(&dst).ok().zip(std::fs::read(icon_png).ok()).map(|(a, b)| a == b).unwrap_or(false);+        if !same && std::fs::copy(icon_png, &dst).is_err() { return false; }+        dst.to_string_lossy().to_string()+    };+    let text = entry_text(&id, display, &icon, exec, actions);+    let path = apps_dir().join(format!("{id}.desktop"));+    if std::fs::read_to_string(&path).map(|t| t == text).unwrap_or(false) { return true; }+    if std::fs::create_dir_all(apps_dir()).is_err() { return false; }+    let tmp = apps_dir().join(format!(".{id}.desktop.tmp"));+    let ok = std::fs::write(&tmp, text).is_ok() && std::fs::rename(&tmp, &path).is_ok();+    if ok { FRESH.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(HashMap::new).insert(id, std::time::Instant::now()); }+    ok+}++/// Entries written in the last few seconds. A dock loads a new entry a moment after it appears on disk; a window+/// that took the identity before that is grouped by process instead (every pup window shares one browser+/// process, so with the first thread's icon) and is not looked at again (GNOME Shell 46, measured 2026-11-03).+static FRESH: Mutex<Option<HashMap<String, std::time::Instant>>> = Mutex::new(None);+pub fn is_fresh(id: &str) -> bool {+    FRESH.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|m| m.get(id)).map(|t| t.elapsed() < std::time::Duration::from_secs(10)).unwrap_or(false)+}++pub fn unregister(app_id: &str) -> bool {+    let id = safe_id(app_id);+    let a = std::fs::remove_file(apps_dir().join(format!("{id}.desktop"))).is_ok();+    let _ = std::fs::remove_file(icons_dir().join(format!("{id}.png")));+    if let Some(m) = STATE.lock().unwrap_or_else(|e| e.into_inner()).as_mut() { m.remove(&id); }+    a+}++/// The ids of pup's own entries (X-Adom-Pup) whose id starts with `prefix`.+pub fn list(prefix: &str) -> Vec<String> {+    let Ok(rd) = std::fs::read_dir(apps_dir()) else { return Vec::new() };+    rd.flatten().filter_map(|e| {+        let name = e.file_name().to_string_lossy().to_string();+        let id = name.strip_suffix(".desktop")?.to_string();+        if !id.starts_with(prefix) { return None; }+        std::fs::read_to_string(e.path()).ok().filter(|t| t.contains("\nX-Adom-Pup=true\n")).map(|_| id)+    }).collect()+}++/// The last state sent per entry, so every update carries the whole picture (a dock that starts later, or+/// one that applies only the keys it is given, still ends up right).+#[derive(Clone, Default)]+struct Entry { progress: f64, progress_visible: bool, count: i64, count_visible: bool, urgent: bool }+static STATE: Mutex<Option<HashMap<String, Entry>>> = Mutex::new(None);++/// Apply a signal (see `HostWindows::taskbar_signal`) to the entry's state; returns the new state.+fn apply(id: &str, sig: &serde_json::Value) -> Entry {+    let mut g = STATE.lock().unwrap_or_else(|e| e.into_inner());+    let e = g.get_or_insert_with(HashMap::new).entry(id.to_string()).or_default();+    if let Some(p) = sig.get("progress") {+        match p["state"].as_str().unwrap_or("normal") {+            "none" => { e.progress_visible = false; e.progress = 0.0; }+            "indeterminate" => { e.progress_visible = true; e.progress = 0.0; }+            "error" | "paused" => { e.progress_visible = true; if let Some(v) = p["value"].as_f64() { e.progress = v.clamp(0.0, 1.0); } }+            _ => { e.progress_visible = true; e.progress = p["value"].as_f64().unwrap_or(e.progress).clamp(0.0, 1.0); }+        }+    }+    if let Some(c) = sig.get("count") { match c.as_i64() { Some(n) if n > 0 => { e.count = n; e.count_visible = true; } _ => { e.count_visible = false; } } }+    if let Some(u) = sig.get("urgent").and_then(|u| u.as_bool()) { e.urgent = u; }+    e.clone()+}++/// Emit `Update(app_uri, properties)` from an object path of our own. Docks subscribe to the signal by interface+/// and member, from any sender.+pub fn launcher_update(app_id: &str, sig: &serde_json::Value) -> bool {+    let id = safe_id(app_id);+    let e = apply(&id, sig);+    let Some(bus) = crate::secrets::session_bus() else { return false };+    let mut h: u64 = 1469598103934665603; for b in id.as_bytes() { h ^= *b as u64; h = h.wrapping_mul(1099511628211); }+    let path = format!("/com/canonical/unity/launcherentry/{h}");+    let props: HashMap<&str, Value> = HashMap::from([+        ("progress", Value::from(e.progress)), ("progress-visible", Value::from(e.progress_visible)),+        ("count", Value::from(e.count)), ("count-visible", Value::from(e.count_visible)), ("urgent", Value::from(e.urgent)),+    ]);+    bus.emit_signal(None::<()>, path.as_str(), "com.canonical.Unity.LauncherEntry", "Update", &(format!("application://{id}.desktop"), props)).is_ok()+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn entry_text_matches_wm_class_and_lists_actions() {+        let t = entry_text("Adom.Pup.thread.pup bridge.t11ab", "pup bridge", "/x/y.png", "\"/a b/adom-pup-bridge\" --jumplist s1", &[("Open pup dashboard".into(), "\"/a b/p\" --jumplist pup_dashboard e30=".into())]);+        assert!(t.contains("StartupWMClass=Adom.Pup.thread.pup-bridge.t11ab\n"));+        assert!(t.contains("NoDisplay=true\n") && t.contains("Actions=pup0;\n") && t.contains("[Desktop Action pup0]\nName=Open pup dashboard\n"));+        assert!(t.contains("Exec=\"/a b/adom-pup-bridge\" --jumplist s1\n"));+    }+    #[test]+    fn launcher_state_merges() {+        let a = apply("t1", &serde_json::json!({ "progress": { "state": "normal", "value": 0.4 } }));+        assert!(a.progress_visible && (a.progress - 0.4).abs() < 1e-9 && !a.urgent);+        let b = apply("t1", &serde_json::json!({ "urgent": true, "count": 3 }));+        assert!(b.progress_visible && b.urgent && b.count == 3 && b.count_visible);+        let c = apply("t1", &serde_json::json!({ "progress": { "state": "none" }, "count": null }));+        assert!(!c.progress_visible && !c.count_visible && c.urgent);+    }+}
rust/host-linux/src/lib.rsadded+660
@@ -0,0 +1,660 @@+//! pup-host-linux: the Linux host. Same traits as pup-host-win and pup-host-mac; each method names the+//! Windows counterpart it mirrors. The ONLY crate in the workspace that may name X11, EWMH, /proc or the+//! Secret Service (tools/check-borders.sh).+//!+//! The Linux model:+//!   - a "window handle" is the browser's X11 client window id. On an X11 session that is the real+//!     window; on a Wayland session pup launches its browser with `--ozone-platform=x11`+//!     (pup-browser's `platform_switches`), so pup's windows are XWayland windows that this crate can+//!     enumerate, tag, move and lower through the X server, while the user's own Wayland apps are+//!     never visible to it and never touched. Rows come from EWMH `_NET_CLIENT_LIST` and are owned+//!     by `_NET_WM_PID`.+//!   - "bottom of the z-order, never activated" = EWMH `_NET_RESTACK_WINDOW` (source: pager, detail:+//!     Below), plus a ConfigureWindow stack-mode Below for window managers without it. Nothing here+//!     ever sends `_NET_ACTIVE_WINDOW` except `raise_window`, which the verbs gate on a reason.+//!   - window tags: an X property `_ADOM_PUP_TAGS` on the window itself (Windows: SetProp), so a+//!     restarted bridge recognises its windows exactly as on Windows.+//!   - processes: /proc. A launched browser is our child (in its own session, so it outlives the+//!     bridge); a reaper thread waits on it so its exit code is readable and it never lingers as a zombie.+//!   - the browser inherits a CLEAN environment: Adom Bridge runs as an AppImage, whose LD_LIBRARY_PATH,+//!     GTK_*, GIO_*, GST_* and PATH entries point into its own mount; Chrome must not load those.+//!   - secrets: the freedesktop Secret Service (GNOME Keyring, KWallet) in keytar's attribute layout,+//!     never prompting; when there is no unlocked keyring (auto-login desktops leave the login keyring+//!     locked) a 0600 file under ~/.adom holds them instead.+//!   - taskbar-shaped work (identity, badges, jump lists) is ab's job through `AbClient`; nothing here+//!     invents a dock equivalent.++#![cfg(target_os = "linux")]++use pup_host::{HostPaths, HostProcess, HostSecrets, HostWindow, HostWindows};+use std::collections::HashMap;+use std::path::{Path, PathBuf};+use std::sync::Mutex;+use std::time::{Duration, Instant};++mod x11;+mod secrets;+mod dock;++pub struct LinuxHost;++/// The Windows class name the verbs crate asks for; here it means "any Chromium-family window".+const CHROME_CLASS: &str = "Chrome_WidgetWin_1";++fn is_chromium_class(class: &str) -> bool {+    let c = class.to_ascii_lowercase();+    // "adom.pup.": a pup window pup itself re-classed onto its thread's dock identity (`stamp_identity`).+    c.contains("chrome") || c.contains("chromium") || c.contains("edge") || c.starts_with("adom.pup.")+}++fn class_matches(class: &str, class_filter: Option<&str>) -> bool {+    match class_filter {+        None => true,+        Some(CHROME_CLASS) => is_chromium_class(class),+        Some(f) => class.eq_ignore_ascii_case(f) || (is_chromium_class(class) && is_chromium_class(f)),+    }+}++// ───────────────────────── /proc ─────────────────────────++/// (pid, ppid, rss KB, state char) for every process.+fn proc_table() -> Vec<(u32, u32, u64, char)> {+    let page_kb = (unsafe { libc::sysconf(libc::_SC_PAGESIZE) }.max(4096) as u64) / 1024;+    let Ok(rd) = std::fs::read_dir("/proc") else { return Vec::new() };+    rd.flatten().filter_map(|e| {+        let pid: u32 = e.file_name().to_str()?.parse().ok()?;+        let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;+        let (state, ppid, rss_pages) = parse_stat(&stat)?;+        Some((pid, ppid, rss_pages * page_kb, state))+    }).collect()+}++/// /proc/<pid>/stat: "pid (comm) S ppid ... rss(24th field)". comm may hold spaces and parens, so split+/// after the LAST ')'.+fn parse_stat(stat: &str) -> Option<(char, u32, u64)> {+    let rest = &stat[stat.rfind(')')? + 1..];+    let f: Vec<&str> = rest.split_whitespace().collect();+    let state = f.first()?.chars().next()?;+    let ppid = f.get(1)?.parse().ok()?;+    let rss = f.get(21)?.parse::<i64>().ok()?.max(0) as u64;+    Some((state, ppid, rss))+}++fn cmdline(pid: u32) -> Vec<String> {+    std::fs::read(format!("/proc/{pid}/cmdline")).map(|b| b.split(|c| *c == 0).filter(|s| !s.is_empty()).map(|s| String::from_utf8_lossy(s).to_string()).collect()).unwrap_or_default()+}++fn exe_of(pid: u32) -> Option<PathBuf> { std::fs::read_link(format!("/proc/{pid}/exe")).ok() }++fn all_pids() -> Vec<u32> {+    std::fs::read_dir("/proc").map(|rd| rd.flatten().filter_map(|e| e.file_name().to_str()?.parse().ok()).collect()).unwrap_or_default()+}++/// The profile folder name in a `--user-data-dir=` argument that points into one of pup's profile roots:+/// the legacy `~/.adom/pup-profiles/<name>`, or `<ADOM_BRIDGE_DATA_DIR>/profiles/<name>` when ab hands pup a+/// data dir (pup-bridge#98).+fn pup_profile_of(args: &[String]) -> Option<String> {+    let data_profiles = pup_core::data_dir().map(|d| format!("{}/", d.join("profiles").to_string_lossy().trim_end_matches('/')));+    pup_profile_in(args, data_profiles.as_deref())+}++/// Chrome on Linux rewrites its own process title, so a browser's /proc cmdline is often ONE space-joined+/// string ("/path/chrome --user-data-dir=/x/profiles/adom-you --no-first-run ...") rather than separate+/// arguments; read the value out of either shape. Helper processes (`--type=`: zygote, renderer, GPU, and the+/// setuid sandbox wrapper that execs the zygote) are not browsers: ending the browser ends them.+fn pup_profile_in(args: &[String], data_profiles: Option<&str>) -> Option<String> {+    if args.iter().any(|a| a.starts_with("--type=") || a.contains(" --type=")) { return None; }+    const K: &str = "--user-data-dir=";+    let dir: &str = if let Some(a) = args.iter().find(|a| a.starts_with(K)) { &a[K.len()..] } else {+        let joined = args.iter().find(|a| a.contains(&format!(" {K}")))?;+        let v = &joined[joined.find(&format!(" {K}"))? + 1 + K.len()..];+        // the value runs to the next " --flag" (a profile path never contains that), or to the end+        match v.find(" --") { Some(i) => &v[..i], None => v }+    };+    let rest = if let Some(i) = dir.find("pup-profiles/") { &dir[i + "pup-profiles/".len()..] } else { dir.strip_prefix(data_profiles?)? };+    let name: String = rest.chars().take_while(|c| !matches!(c, '/' | '"' | '\'')).collect();+    if name.is_empty() { None } else { Some(name) }+}++// ───────────────────────── launched children ─────────────────────────++/// Pids pup itself ended through `Host::kill`, so recovery can tell a deliberate end from a crash or the+/// user closing the browser (Windows: the same KILLED list).+fn killed() -> &'static Mutex<Vec<u32>> {+    static K: std::sync::OnceLock<Mutex<Vec<u32>>> = std::sync::OnceLock::new();+    K.get_or_init(|| Mutex::new(Vec::new()))+}++/// Exit codes of children pup launched, recorded by their reaper thread. A signal death is 128 + signal,+/// the shell convention, so a crash (SIGSEGV = 139) never reads as a clean exit 0.+fn exits() -> &'static Mutex<HashMap<u32, u32>> {+    static E: std::sync::OnceLock<Mutex<HashMap<u32, u32>>> = std::sync::OnceLock::new();+    E.get_or_init(|| Mutex::new(HashMap::new()))+}++fn reap_in_background(mut child: std::process::Child) {+    let pid = child.id();+    let _ = std::thread::Builder::new().name(format!("reap-{pid}")).spawn(move || {+        if let Ok(st) = child.wait() {+            use std::os::unix::process::ExitStatusExt;+            let code = st.code().map(|c| c as u32).or_else(|| st.signal().map(|s| 128 + s as u32)).unwrap_or(1);+            exits().lock().unwrap().insert(pid, code);+        }+    });+}++/// Env vars an AppImage parent (Adom Bridge) points into its own mount. The browser gets them with every+/// entry under `$APPDIR` removed (and the var dropped when nothing is left), so Chrome loads the system's+/// GTK, GIO and libraries, not the bridge's.+const APPIMAGE_LIST_VARS: &[&str] = &["LD_LIBRARY_PATH", "PATH", "XDG_DATA_DIRS", "GST_PLUGIN_SYSTEM_PATH", "GST_PLUGIN_SYSTEM_PATH_1_0", "GST_PLUGIN_PATH", "PYTHONPATH", "PERLLIB", "QT_PLUGIN_PATH", "GSETTINGS_SCHEMA_DIR"];+const APPIMAGE_DROP_VARS: &[&str] = &["APPDIR", "APPIMAGE", "ARGV0", "OWD", "GTK_THEME", "GDK_PIXBUF_MODULE_FILE", "GDK_PIXBUF_MODULEDIR", "GTK_DATA_PREFIX", "GTK_EXE_PREFIX", "GTK_PATH", "GTK_IM_MODULE_FILE", "GIO_MODULE_DIR", "GIO_EXTRA_MODULES", "WEBKIT_EXEC_PATH", "WEBKIT_DISABLE_COMPOSITING_MODE", "LIBGL_DRIVERS_PATH", "__EGL_VENDOR_LIBRARY_DIRS", "ADOM_BRIDGE_EGL_ACTIVE"];++/// The environment changes (key, Some(new value) | None = remove) that scrub an AppImage parent's mount+/// out of a child's environment. Pure, so it is unit-tested.+fn appimage_scrub(env: &HashMap<String, String>) -> Vec<(String, Option<String>)> {+    let mut out = Vec::new();+    let Some(appdir) = env.get("APPDIR").filter(|d| !d.is_empty()).map(|d| d.trim_end_matches('/').to_string()) else { return out };+    for k in APPIMAGE_LIST_VARS {+        if let Some(v) = env.get(*k) {+            let kept: Vec<&str> = v.split(':').filter(|e| !e.is_empty() && !e.starts_with(&appdir)).collect();+            if kept.is_empty() { out.push((k.to_string(), None)); } else if kept.join(":") != *v { out.push((k.to_string(), Some(kept.join(":")))); }+        }+    }+    for k in APPIMAGE_DROP_VARS { if env.contains_key(*k) { out.push((k.to_string(), None)); } }+    // Anything else still pointing into the mount (a var we do not know by name).+    for (k, v) in env { if v.contains(&appdir) && !out.iter().any(|(o, _)| o == k) { out.push((k.clone(), None)); } }+    out+}++/// Chrome on Linux reports a failed start (no display, a missing library, a sandbox refusal) only on stderr,+/// so launched children append to `pup-browser.log` beside pup's own log (started fresh past 4 MB).+fn browser_stderr() -> std::process::Stdio {+    let dir = std::env::var("ADOM_BRIDGE_DATA_DIR").ok().filter(|d| !d.is_empty()).map(|d| PathBuf::from(d).join("logs"))+        .unwrap_or_else(|| LinuxHost.state_dir().join("bridge-logs"));+    let _ = std::fs::create_dir_all(&dir);+    let path = dir.join("pup-browser.log");+    if std::fs::metadata(&path).map(|m| m.len() > 4 << 20).unwrap_or(false) { let _ = std::fs::remove_file(&path); }+    match std::fs::OpenOptions::new().create(true).append(true).open(&path) { Ok(f) => f.into(), Err(_) => std::process::Stdio::null() }+}++// ───────────────────────── the Chromium sandbox ─────────────────────────++fn is_chromium_exe(exe: &Path) -> bool { matches!(exe.file_name().and_then(|f| f.to_str()), Some("chrome" | "chromium" | "chromium-browser" | "msedge" | "chrome-headless-shell")) }++/// Setuid-root sandbox helpers that system browser packages install. The helper protocol is Chromium's own+/// and stable, so any of them sandboxes any Chromium build, Chrome for Testing included.+const SANDBOX_HELPERS: &[&str] = &["/opt/google/chrome/chrome-sandbox", "/opt/google/chrome-beta/chrome-sandbox", "/opt/google/chrome-unstable/chrome-sandbox", "/usr/lib/chromium/chrome-sandbox", "/usr/lib64/chromium-browser/chrome-sandbox", "/usr/lib/chromium-browser/chrome-sandbox", "/opt/microsoft/msedge/msedge-sandbox"];++fn setuid_root(p: &Path) -> bool {+    use std::os::unix::fs::{MetadataExt, PermissionsExt};+    std::fs::metadata(p).map(|m| m.is_file() && m.uid() == 0 && m.permissions().mode() & 0o4000 != 0).unwrap_or(false)+}++#[derive(Debug, PartialEq)]+enum Sandbox { Namespaces, OwnHelper, Helper(PathBuf), Off(String) }++/// Can an unprivileged process make the user namespaces Chromium's default sandbox needs? Ubuntu 23.10++/// says no through AppArmor (kernel.apparmor_restrict_unprivileged_userns=1) for any binary without its own+/// AppArmor profile, which is every Chrome for Testing unpacked under a home folder; older Debian kernels+/// say no through kernel.unprivileged_userns_clone=0.+fn userns_allowed() -> bool {+    let read = |p: &str| std::fs::read_to_string(p).ok().map(|s| s.trim().to_string());+    if read("/proc/sys/kernel/apparmor_restrict_unprivileged_userns").as_deref() == Some("1") { return false; }+    if read("/proc/sys/kernel/unprivileged_userns_clone").as_deref() == Some("0") { return false; }+    if read("/proc/sys/user/max_user_namespaces").as_deref() == Some("0") { return false; }+    true+}++/// The exe's own packaged helper (`chrome-sandbox` beside it, setuid root), or an AppArmor profile that names+/// it (the distro's /etc/apparmor.d/chrome covers /opt/google/chrome/chrome), lets it sandbox itself.+fn self_sandboxed(exe: &Path) -> bool {+    let dir = exe.parent().unwrap_or(Path::new("/"));+    if setuid_root(&dir.join("chrome-sandbox")) || setuid_root(&dir.join("msedge-sandbox")) { return true; }+    let real = std::fs::canonicalize(exe).unwrap_or_else(|_| exe.to_path_buf());+    let real = real.to_string_lossy();+    std::fs::read_dir("/etc/apparmor.d").map(|rd| rd.flatten().any(|e| e.path().is_file() && std::fs::read_to_string(e.path()).map(|t| t.contains(real.as_ref()) && t.contains("userns")).unwrap_or(false))).unwrap_or(false)+}++fn sandbox_plan(exe: &Path) -> Sandbox {+    if userns_allowed() { return Sandbox::Namespaces; }+    if self_sandboxed(exe) { return Sandbox::OwnHelper; }+    if let Some(h) = SANDBOX_HELPERS.iter().map(PathBuf::from).find(|h| setuid_root(h)) { return Sandbox::Helper(h); }+    Sandbox::Off("this system blocks unprivileged user namespaces and no setuid Chromium sandbox helper is installed (installing Google Chrome or the distro's Chromium package provides one)".into())+}++// ───────────────────────── HostWindows ─────────────────────────++impl HostWindows for LinuxHost {+    // Windows: EnumWindows + GetWindowThreadProcessId, class Chrome_WidgetWin_1. Here: EWMH's managed client+    // list, owner = _NET_WM_PID, class = WM_CLASS. Hidden (minimized) windows are included, flagged !visible.+    fn windows_for_pids(&self, pids: &[u32], class_filter: Option<&str>) -> Vec<HostWindow> {+        x11::client_rows().into_iter()+            .filter(|r| pids.is_empty() || pids.contains(&r.pid))+            .filter(|r| class_matches(&r.class, class_filter))+            .filter(|r| r.rect.2 - r.rect.0 > 50 && r.rect.3 - r.rect.1 > 50)+            .map(|r| HostWindow { hwnd: r.id, pid: r.pid, title: r.title, class: r.class, rect: r.rect, visible: r.viewable && !r.hidden })+            .collect()+    }+    // Windows: every unowned captioned frame, visible or not. Here: the managed clients plus the root's+    // direct children that carry _NET_WM_PID (a newborn the window manager has not managed yet).+    fn frame_windows_all(&self, class_filter: Option<&str>) -> Vec<HostWindow> {+        x11::all_rows().into_iter()+            .filter(|r| class_matches(&r.class, class_filter))+            .map(|r| HostWindow { hwnd: r.id, pid: r.pid, title: r.title, class: r.class, rect: r.rect, visible: r.viewable && !r.hidden })+            .collect()+    }+    // Windows: IsWindow.+    // An X window outlives its process briefly: the server destroys it only when the client's connection is torn+    // down, so right after pup ended a browser its windows still "existed" and recovery skipped the relaunch as+    // "alive on glass" (pup_restart_browser on the extension lane: windows never came back; hl-debian, Ralph test,+    // 2026-11-03). A window whose owning process is gone is dead, whatever the server still says.+    fn is_alive(&self, hwnd: i64) -> bool {+        if hwnd <= 0 || !x11::exists(hwnd) { return false; }+        let pid = x11::wm_pid(hwnd);+        pid == 0 || self.alive(pid)+    }+    // Windows: SetProp "AdomPupSid=<sid>". Here: the `_ADOM_PUP_TAGS` property, one tag per line.+    fn tag(&self, hwnd: i64, tag: &str) -> bool { hwnd > 0 && x11::add_tag(hwnd, tag) }+    fn has_tag(&self, hwnd: i64, tag: &str) -> bool { hwnd > 0 && x11::tags(hwnd).iter().any(|t| t == tag) }+    // Windows: powershell -WindowStyle Hidden -File <script> <tail>. Here: /bin/sh <script> <tail>.+    fn script_launch(&self, script: &Path, tail: &str, quote_for_relaunch: bool) -> (String, String) {+        let q = if quote_for_relaunch { "\\\"" } else { "\"" };+        ("/bin/sh".into(), format!("{q}{}{q} {tail}", script.display()))+    }+    // Windows: SetWindowPos HWND_BOTTOM | SWP_NOACTIVATE. Here: _NET_RESTACK_WINDOW Below (+ ConfigureWindow+    // Below). Never activates. The user's own windows (Wayland or X11) all end up above it.+    fn send_to_bottom_no_activate(&self, hwnd: i64) -> bool { hwnd > 0 && x11::lower(hwnd) }+    // Windows: ShowWindow(SW_HIDE / SW_SHOWNOACTIVATE). Unmapping another client's window is not a thing a+    // well-behaved X client does (Chrome would read it as closed), so hide is refused and show = lower.+    fn set_visible_no_activate(&self, hwnd: i64, visible: bool) -> bool { if visible { self.send_to_bottom_no_activate(hwnd) } else { false } }+    fn show_at_bottom_no_activate(&self, hwnd: i64) -> bool { self.send_to_bottom_no_activate(hwnd) }+    // Windows: a sub-ms spin that bottoms the window the instant it first shows. Here: wait for the map+    // (Viewable), lower it at once, and keep it lowered for 300 ms unless the user made it active.+    fn park_on_first_show(&self, hwnd: i64, timeout_ms: u64) -> Option<u64> {+        let t0 = Instant::now();+        loop {+            if x11::viewable(hwnd) { break; }+            if t0.elapsed().as_millis() as u64 > timeout_ms || !x11::exists(hwnd) { return None; }+            std::thread::sleep(Duration::from_millis(2));+        }+        let shown = t0.elapsed().as_millis() as u64;+        let hold = Instant::now();+        while hold.elapsed() < Duration::from_millis(300) {+            if x11::active_window() == hwnd { break; }+            x11::lower(hwnd);+            std::thread::sleep(Duration::from_millis(40));+        }+        Some(shown)+    }+    // Windows: ITaskbarList DeleteTab/AddTab. No equivalent a client may drive.+    fn taskbar_tab(&self, _hwnd: i64, _present: bool) -> bool { false }+    // Windows: SetWindowPos SWP_NOACTIVATE | SWP_NOZORDER. Here: ConfigureWindow x/y/w/h, no stack mode.+    fn set_bounds_no_activate(&self, hwnd: i64, left: i32, top: i32, width: i32, height: i32) -> bool { hwnd > 0 && x11::set_bounds(hwnd, left, top, width, height) }+    // Windows: GetForegroundWindow. Here: _NET_ACTIVE_WINDOW. On Wayland that is the active XWayland window,+    // or 0 while the user is in a native Wayland app (which is never a pup window).+    fn foreground(&self) -> i64 { x11::active_window() }+    // Windows: FlashWindowEx(FLASHW_STOP). Here: clear _NET_WM_STATE_DEMANDS_ATTENTION.+    fn stop_flash(&self, hwnd: i64) -> bool { hwnd > 0 && x11::clear_attention(hwnd) }++    // Dock identity: the window's WM_CLASS becomes the identity, which the `.desktop` entry written by+    // register_app_identity claims through StartupWMClass. `icon` and `relaunch` live in that entry.+    fn stamp_identity(&self, hwnd: i64, app_id: &str, _display: &str, _icon: &str, _relaunch: &str) -> bool {+        if hwnd <= 0 { return false; }+        let id = dock::safe_id(app_id);+        let ok = x11::wm_class(hwnd).as_deref() == Some(id.as_str()) || x11::set_wm_class(hwnd, &id);+        // The entry is new: the dock may not have loaded it yet, so announce the class again once it has. A change+        // of WM_CLASS makes the dock look the window up again (measured: GNOME Shell regroups it at once).+        // How long the shell takes to load a new entry depends on the machine: under 4 s on hl-ubuntu, but on a+        // clean 3 GB Fedora 44 during Hydrogen's first setup the second thread's window stayed grouped with the+        // first thread's icon past +4 s, and one re-announce minutes later fixed it. So keep announcing until+        // +30 s (at +1.5, 4, 8, 15 and 30 s); each one is a 60 ms class flip on this window only.+        if ok && dock::is_fresh(&id) {+            std::thread::Builder::new().name("dock-regroup".into()).spawn(move || {+                for wait in [1500u64, 2500, 4000, 7000, 15000] {+                    std::thread::sleep(Duration::from_millis(wait));+                    if x11::wm_class(hwnd).as_deref() != Some(id.as_str()) { return; }   // re-classed or gone meanwhile+                    x11::set_wm_class(hwnd, &format!("{id}.regroup"));+                    std::thread::sleep(Duration::from_millis(60));+                    x11::set_wm_class(hwnd, &id);+                }+            }).ok();+        }+        ok+    }+    fn register_app_identity(&self, app_id: &str, display: &str, icon_png: &str, exec: &str, actions: &[(String, String)]) -> bool { dock::register(app_id, display, icon_png, exec, actions) }+    fn unregister_app_identity(&self, app_id: &str) -> bool { dock::unregister(app_id) }+    fn list_aumids(&self, prefix: &str) -> Vec<String> { dock::list(prefix) }+    fn window_app_id(&self, hwnd: i64) -> Option<String> { if hwnd > 0 { x11::wm_class(hwnd) } else { None } }+    fn set_window_icon(&self, hwnd: i64, icons: &[(u32, Vec<u8>)]) -> bool { hwnd > 0 && x11::set_icon(hwnd, icons) }+    fn clear_window_identity(&self, hwnd: i64) -> bool { hwnd > 0 && x11::restore_original(hwnd) }+    fn taskbar_signal(&self, hwnd: i64, app_id: Option<&str>, signal: &serde_json::Value) -> bool {+        let mut any = false;+        if hwnd > 0 {+            if let Some(p) = signal.get("progress") {+                any |= match p["state"].as_str().unwrap_or("normal") {+                    "none" => x11::set_xapp_progress(hwnd, None, false),+                    "indeterminate" => x11::set_xapp_progress(hwnd, Some(0), true),+                    _ => x11::set_xapp_progress(hwnd, Some((p["value"].as_f64().unwrap_or(0.0).clamp(0.0, 1.0) * 100.0).round() as u32), false),+                };+            }+            if let Some(u) = signal.get("urgent").and_then(|u| u.as_bool()) { any |= if u { x11::set_attention(hwnd) } else { x11::clear_attention(hwnd) }; }+        }+        if let Some(id) = app_id { any |= dock::launcher_update(id, signal); }+        any+    }+    // A GRANTED raise (the verbs gate it on foregroundReason): _NET_ACTIVE_WINDOW from a pager source,+    // which EWMH window managers honour.+    fn raise_window(&self, hwnd: i64) -> bool { hwnd > 0 && x11::activate(hwnd) }+    // Windows: GetLastInputInfo. Here: GNOME's IdleMonitor over D-Bus (covers Wayland), else the X+    // screen-saver extension (X11 sessions).+    fn idle_ms(&self) -> Option<u64> { secrets::gnome_idle_ms().or_else(x11::idle_ms) }+    // Windows: SPI_GETWORKAREA. Here: _NET_WORKAREA of the current desktop, else the root window.+    fn work_area(&self) -> Option<(i32, i32, i32, i32)> { x11::work_area().filter(|(_, _, w, h)| *w > 400 && *h > 300) }+    // Chrome under X11 reports CSS px at its own device scale; pup's rects come from CDP. Nothing to undo.+    fn dpi_scale(&self, _hwnd: i64) -> f64 { 1.0 }+}++// ───────────────────────── HostProcess ─────────────────────────++impl HostProcess for LinuxHost {+    // Windows: OpenProcess + GetExitCodeProcess == STILL_ACTIVE. A zombie (exited, not reaped) is not alive.+    fn alive(&self, pid: u32) -> bool {+        if pid == 0 { return false; }+        if exits().lock().unwrap().contains_key(&pid) { return false; }+        let r = unsafe { libc::kill(pid as i32, 0) };+        if !(r == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)) { return false; }+        match std::fs::read_to_string(format!("/proc/{pid}/stat")).ok().and_then(|s| parse_stat(&s)) { Some(('Z', _, _)) | Some(('X', _, _)) => false, _ => true }+    }+    // Windows: GlobalMemoryStatusEx. Here: /proc/meminfo MemTotal / MemAvailable.+    fn memory_status(&self) -> Option<(u64, u64)> {+        let m = std::fs::read_to_string("/proc/meminfo").ok()?;+        let kb = |key: &str| m.lines().find(|l| l.starts_with(key)).and_then(|l| l.split_whitespace().nth(1)).and_then(|v| v.parse::<u64>().ok());+        Some((kb("MemTotal:")? / 1024, kb("MemAvailable:").or_else(|| kb("MemFree:"))? / 1024))+    }+    // Windows: Toolhelp parent walk + GetProcessMemoryInfo. Here: /proc rss over pid and its descendants.+    fn process_tree_rss_mb(&self, pid: u32) -> Option<u64> {+        if pid == 0 { return None; }+        let table = proc_table();+        if !table.iter().any(|(p, ..)| *p == pid) { return None; }+        let (mut want, mut sum_kb, mut seen) = (vec![pid], 0u64, std::collections::HashSet::new());+        while let Some(p) = want.pop() {+            if !seen.insert(p) { continue; }+            for (cp, pp, rss, _) in &table { if *cp == p { sum_kb += rss; } if *pp == p { want.push(*cp); } }+        }+        Some(sum_kb / 1024)+    }+    // Windows: DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP. Here: its own session (setsid) so it outlives the+    // bridge, the AppImage environment scrubbed, a display guaranteed, and a reaper thread for the exit code.+    fn spawn_detached(&self, exe: &Path, args: &[String], cwd: Option<&Path>) -> std::io::Result<u32> {+        use std::os::unix::process::CommandExt;+        let mut c = std::process::Command::new(exe);+        c.args(args).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(browser_stderr());+        if let Some(d) = cwd { c.current_dir(d); }+        let env: HashMap<String, String> = std::env::vars().collect();+        for (k, v) in appimage_scrub(&env) { match v { Some(v) => { c.env(k, v); } None => { c.env_remove(k); } } }+        if env.get("DISPLAY").map(|d| d.is_empty()).unwrap_or(true) { c.env("DISPLAY", x11::display_name()); }+        if let Some(a) = x11::refresh_xauthority() { c.env("XAUTHORITY", a); }   // the CURRENT cookie (Xwayland restarts change it)+        if is_chromium_exe(exe) && !args.iter().any(|a| a == "--no-sandbox") {+            match sandbox_plan(exe) {+                Sandbox::Namespaces | Sandbox::OwnHelper => {}+                Sandbox::Helper(h) => { c.env("CHROME_DEVEL_SANDBOX", h); }+                Sandbox::Off(why) => { pup_core::log(format!("[sandbox] {}: {why}; launching with --no-sandbox", exe.display())); c.arg("--no-sandbox"); }+            }+        }+        unsafe { c.pre_exec(|| { libc::setsid(); Ok(()) }); }+        let child = c.spawn()?;+        let pid = child.id();+        reap_in_background(child);+        Ok(pid)+    }+    // Windows: TerminateProcess. The pid is remembered so recovery reads this as pup's own, deliberate end.+    fn kill(&self, pid: u32) -> bool {+        if pid == 0 || pid == std::process::id() { return false; }+        let ok = unsafe { libc::kill(pid as i32, libc::SIGKILL) == 0 };+        if ok { let mut k = killed().lock().unwrap(); if !k.contains(&pid) { k.push(pid); } }+        ok+    }+    // Every launched child is already watched (spawn_detached's reaper).+    fn watch_process(&self, _pid: u32) {}+    fn exit_code(&self, pid: u32) -> Option<u32> { exits().lock().unwrap().get(&pid).copied() }+    fn ended_by_pup(&self, pid: u32) -> bool { killed().lock().unwrap().contains(&pid) }+    // Windows: powershell -Command. Here: /bin/sh -c in its own process group, so a timeout ends the pipeline.+    fn run_shell_script(&self, script: &str, timeout: Duration) -> String {+        use std::os::unix::process::CommandExt;+        let mut child = match std::process::Command::new("/bin/sh").arg("-c").arg(script).process_group(0)+            .stdin(std::process::Stdio::null()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()).spawn() { Ok(c) => c, Err(_) => return String::new() };+        // Read on a thread so a chatty script cannot block on a full pipe while we wait.+        let mut so = child.stdout.take();+        let reader = std::thread::spawn(move || { let mut out = String::new(); if let Some(s) = so.as_mut() { use std::io::Read; let _ = s.read_to_string(&mut out); } out });+        let deadline = Instant::now() + timeout;+        loop {+            match child.try_wait() {+                Ok(Some(_)) => break,+                Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)),+                _ => { unsafe { libc::kill(-(child.id() as i32), libc::SIGKILL); } let _ = child.kill(); let _ = child.wait(); break; }+            }+        }+        reader.join().unwrap_or_default()+    }+    // Windows: HKCU/HKLM ...\Chrome\Extensions. Linux Chrome reads external extensions from JSON files named+    // <id>.json in these folders.+    fn external_extension_ids(&self) -> Vec<String> {+        let mut out = Vec::new();+        for d in ["/opt/google/chrome/extensions", "/usr/share/google-chrome/extensions", "/usr/share/chromium/extensions", "/opt/microsoft/msedge/extensions", "/usr/share/microsoft-edge/extensions"] {+            if let Ok(rd) = std::fs::read_dir(d) { for e in rd.flatten() {+                let n = e.file_name().to_string_lossy().to_string();+                if let Some(id) = n.strip_suffix(".json") { if id.len() == 32 && id.chars().all(|c| ('a'..='p').contains(&c)) && !out.contains(&id.to_string()) { out.push(id.to_string()); } }+            } }+        }+        out+    }+    // Windows: the App Paths registry key. Here: `<google chrome> --version`.+    fn installed_chrome_version(&self) -> Option<String> {+        let exe = self.system_browsers().into_iter().find(|(k, p)| k == "chrome" && p.to_string_lossy().contains("google"))?.1;+        let out = std::process::Command::new(&exe).arg("--version").stdin(std::process::Stdio::null()).stderr(std::process::Stdio::null()).output().ok()?;+        String::from_utf8_lossy(&out.stdout).split_whitespace().find(|w| w.split('.').count() == 4 && w.split('.').all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))).map(|s| s.to_string())+    }+    // Windows: Toolhelp snapshot by image name. Here: the executable's file name ("chrome", "msedge"), read+    // from /proc/<pid>/exe (".exe" in the request is ignored, so callers can pass the Windows name).+    fn pids_for_image(&self, image: &str) -> Vec<u32> {+        let want = image.trim_end_matches(".exe").to_string();+        if want.is_empty() { return Vec::new(); }+        all_pids().into_iter().filter(|p| exe_of(*p).and_then(|e| e.file_name().map(|f| f.to_string_lossy().to_string())).map(|n| n == want).unwrap_or(false)).collect()+    }+    fn download_file(&self, url: &str, dest: &Path) -> Result<(), String> { pup_core::http_download(url, dest) }+    // Windows: Win32_Process command lines. Here: /proc/<pid>/cmdline naming `pup-profiles/<name>`.+    fn pup_browser_processes(&self) -> Vec<(u32, String)> {+        all_pids().into_iter().filter_map(|p| pup_profile_of(&cmdline(p)).map(|n| (p, n))).collect()+    }+    fn browser_sandbox(&self, exe: &Path) -> Option<String> {+        Some(match sandbox_plan(exe) {+            Sandbox::Namespaces => "namespaces".into(),+            Sandbox::OwnHelper => "the browser's own setuid helper / AppArmor profile".into(),+            Sandbox::Helper(h) => format!("setuid helper {}", h.display()),+            Sandbox::Off(why) => format!("off: {why}"),+        })+    }+    fn running_browser_executables(&self) -> Vec<PathBuf> {+        let mut out: Vec<PathBuf> = all_pids().into_iter().filter_map(exe_of)+            .filter(|e| matches!(e.file_name().and_then(|f| f.to_str()), Some("chrome" | "chromium" | "chromium-browser" | "msedge" | "chrome-headless-shell")))+            .collect();+        out.sort(); out.dedup(); out+    }+}++// ───────────────────────── HostPaths ─────────────────────────++/// Native (not snap, not Flatpak) Chromium-family installs, in preference order. Snap and Flatpak+/// browsers are skipped: their confinement refuses a `--user-data-dir` under a hidden home folder and the+/// extension files pup stages, so pup's profile cannot live where it must.+const CHROME_PATHS: &[&str] = &["/opt/google/chrome/chrome", "/opt/google/chrome-beta/chrome", "/usr/lib/chromium/chromium", "/usr/lib64/chromium-browser/chromium-browser", "/usr/lib/chromium-browser/chromium-browser"];+const EDGE_PATHS: &[&str] = &["/opt/microsoft/msedge/msedge"];++fn is_real_binary(p: &Path) -> bool {+    use std::os::unix::fs::PermissionsExt;+    let Ok(real) = std::fs::canonicalize(p) else { return false };+    if real.starts_with("/snap") || real.to_string_lossy().contains("/flatpak/") { return false; }+    std::fs::metadata(&real).map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0).unwrap_or(false)+}++impl HostPaths for LinuxHost {+    fn home(&self) -> PathBuf { PathBuf::from(std::env::var("HOME").ok().filter(|h| !h.is_empty()).unwrap_or_else(|| "/tmp".into())) }+    fn browser_roots(&self) -> Vec<PathBuf> { vec![PathBuf::from("/opt/google"), PathBuf::from("/opt/microsoft"), PathBuf::from("/usr/lib"), PathBuf::from("/usr/lib64")] }+    // Windows: Program Files / LocalAppData. Here: Google Chrome (deb/rpm), else the distro's native+    // Chromium (Debian, Fedora), reported as kind "chrome"; Microsoft Edge as "edge".+    fn system_browsers(&self) -> Vec<(String, PathBuf)> {+        let mut out = Vec::new();+        if let Some(p) = CHROME_PATHS.iter().map(PathBuf::from).find(|p| is_real_binary(p)) { out.push(("chrome".to_string(), p)); }+        if let Some(p) = EDGE_PATHS.iter().map(PathBuf::from).find(|p| is_real_binary(p)) { out.push(("edge".to_string(), p)); }+        out+    }+    fn cft_platform(&self) -> &'static str { "linux64" }+    fn cft_exe_relative(&self) -> PathBuf { PathBuf::from("chrome-linux64").join("chrome") }+}++// ───────────────────────── HostSecrets ─────────────────────────++impl HostSecrets for LinuxHost {+    // Windows: CredReadW "service/account". Here: Secret Service items with keytar's attributes+    // {service, account}, or the 0600 fallback file.+    fn secret_get(&self, service: &str, account: &str) -> Option<String> { secrets::get(service, account) }+    fn secret_set(&self, service: &str, account: &str, secret: &str) -> Result<(), String> { secrets::set(self.state_dir(), service, account, secret) }+    fn secret_delete(&self, service: &str, account: &str) -> bool { secrets::delete(self.state_dir(), service, account) }+    fn secret_locked(&self, service: &str, account: &str) -> bool { secrets::locked(&self.state_dir(), service, account) }+    fn secret_store(&self) -> String {+        match secrets::store_kind() {+            "secret-service" => "the desktop keyring (Secret Service)".into(),+            _ => format!("{} (mode 0600; this desktop has no unlocked keyring)", self.state_dir().join("pup-secrets.json").display()),+        }+    }+}++/// Where the secrets of this box live right now: "secret-service" or "file" (for pup_readiness/status).+pub fn secret_store_kind() -> &'static str { secrets::store_kind() }++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn class_filter_maps_the_windows_chrome_class_to_any_chromium() {+        assert!(class_matches("Google-chrome-for-testing", Some(CHROME_CLASS)));+        assert!(class_matches("Chromium", Some(CHROME_CLASS)));+        assert!(class_matches("Microsoft-edge", Some(CHROME_CLASS)));+        assert!(!class_matches("org.gnome.Nautilus", Some(CHROME_CLASS)));+        assert!(class_matches("anything", None));+    }++    #[test]+    fn stat_parses_comm_with_spaces_and_parens() {+        let s = "1234 (Web Content (x)) S 1000 1234 1234 0 -1 4194560 100 0 0 0 1 2 0 0 20 0 10 0 500 123456789 2048 18446744073709551615";+        assert_eq!(parse_stat(s), Some(('S', 1000, 2048)));+    }++    #[test]+    fn profile_name_from_user_data_dir() {+        let a = vec!["/x/chrome".to_string(), "--user-data-dir=/home/u/.adom/pup-profiles/adom-you".to_string(), "--no-first-run".to_string()];+        assert_eq!(pup_profile_of(&a).as_deref(), Some("adom-you"));+        assert_eq!(pup_profile_in(&["--user-data-dir=/home/u/other".to_string()], None), None);+        let d = vec!["--user-data-dir=/home/u/.local/share/x/puppeteer/profiles/iso-12".to_string()];+        assert_eq!(pup_profile_in(&d, Some("/home/u/.local/share/x/puppeteer/profiles/")).as_deref(), Some("iso-12"));+        assert_eq!(pup_profile_in(&d, Some("/elsewhere/profiles/")), None);+        // Chrome's rewritten process title: one space-joined string.+        let t = vec!["/home/u/.cache/puppeteer/chrome/linux64-156/chrome-linux64/chrome --user-data-dir=/home/u/.adom/pup-profiles/adom-you --no-first-run".to_string()];+        assert_eq!(pup_profile_in(&t, None).as_deref(), Some("adom-you"));+        // Helpers never count as the browser, the setuid sandbox wrapper included.+        let h = vec!["/opt/google/chrome/chrome-sandbox".to_string(), "/x/chrome".to_string(), "--type=zygote".to_string(), "--user-data-dir=/home/u/.adom/pup-profiles/adom-you".to_string()];+        assert_eq!(pup_profile_in(&h, None), None);+        let r = vec!["/x/chrome --type=renderer --user-data-dir=/home/u/.adom/pup-profiles/adom-you".to_string()];+        assert_eq!(pup_profile_in(&r, None), None);+    }++    #[test]+    fn appimage_environment_is_scrubbed() {+        let mut env = HashMap::new();+        env.insert("APPDIR".into(), "/tmp/.mount_adom-X".into());+        env.insert("LD_LIBRARY_PATH".into(), "/tmp/.mount_adom-X/usr/lib/:/tmp/.mount_adom-X/lib/:".into());+        env.insert("PATH".into(), "/tmp/.mount_adom-X/usr/bin/:/usr/bin:/bin".into());+        env.insert("GTK_PATH".into(), "/tmp/.mount_adom-X//usr/lib/gtk-3.0".into());+        env.insert("HOME".into(), "/home/u".into());+        env.insert("SOMETHING_NEW".into(), "/tmp/.mount_adom-X/share".into());+        let ch: HashMap<String, Option<String>> = appimage_scrub(&env).into_iter().collect();+        assert_eq!(ch.get("LD_LIBRARY_PATH"), Some(&None));+        assert_eq!(ch.get("PATH"), Some(&Some("/usr/bin:/bin".to_string())));+        assert_eq!(ch.get("GTK_PATH"), Some(&None));+        assert_eq!(ch.get("APPDIR"), Some(&None));+        assert_eq!(ch.get("SOMETHING_NEW"), Some(&None));+        assert!(!ch.contains_key("HOME"));+        // No AppImage parent: nothing changes.+        let plain: HashMap<String, String> = [("PATH".to_string(), "/usr/bin".to_string())].into();+        assert!(appimage_scrub(&plain).is_empty());+    }++    #[test]+    fn process_alive_kill_and_exit_codes() {+        let h = LinuxHost;+        assert!(h.alive(std::process::id()));+        let pid = h.spawn_detached(Path::new("/bin/sleep"), &["30".into()], None).unwrap();+        assert!(h.alive(pid));+        assert!(!h.ended_by_pup(pid));+        assert!(h.kill(pid));+        assert!(h.ended_by_pup(pid));+        let t = Instant::now();+        while h.alive(pid) && t.elapsed() < Duration::from_secs(3) { std::thread::sleep(Duration::from_millis(20)); }+        assert!(!h.alive(pid), "a killed child is reaped, never a live-looking zombie");+        assert_eq!(h.exit_code(pid), Some(128 + 9));+        let ok = h.spawn_detached(Path::new("/bin/true"), &[], None).unwrap();+        let t = Instant::now();+        while h.exit_code(ok).is_none() && t.elapsed() < Duration::from_secs(3) { std::thread::sleep(Duration::from_millis(20)); }+        assert_eq!(h.exit_code(ok), Some(0));+        assert!(!h.kill(0));+    }++    #[test]+    fn shell_script_runs_with_timeout() {+        let h = LinuxHost;+        assert_eq!(h.run_shell_script("echo hi", Duration::from_secs(5)).trim(), "hi");+        let t = Instant::now();+        let _ = h.run_shell_script("sleep 5 | cat", Duration::from_millis(300));+        assert!(t.elapsed() < Duration::from_secs(3));+    }++    #[test]+    fn proc_probes_answer() {+        let h = LinuxHost;+        let (total, free) = h.memory_status().expect("meminfo");+        assert!(total > 256 && free <= total);+        assert!(h.process_tree_rss_mb(std::process::id()).is_some());+        let me = exe_of(std::process::id()).unwrap();+        let name = me.file_name().unwrap().to_string_lossy().to_string();+        assert!(h.pids_for_image(&name).contains(&std::process::id()));+        assert!(h.pids_for_image(&format!("{name}.exe")).contains(&std::process::id()));+        for (_, p) in h.system_browsers() { assert!(is_real_binary(&p)); }+        assert_eq!(h.cft_exe_relative(), PathBuf::from("chrome-linux64/chrome"));+    }++    #[test]+    fn file_secret_store_round_trip() {+        let dir = std::env::temp_dir().join(format!("pup-secrets-test-{}", std::process::id()));+        secrets::file_set(&dir, "adom-pup-test", "unit@test", "s3cret").unwrap();+        assert_eq!(secrets::file_get(&dir, "adom-pup-test", "unit@test").as_deref(), Some("s3cret"));+        secrets::file_set(&dir, "adom-pup-test", "unit@test", "changed").unwrap();+        assert_eq!(secrets::file_get(&dir, "adom-pup-test", "unit@test").as_deref(), Some("changed"));+        use std::os::unix::fs::PermissionsExt;+        assert_eq!(std::fs::metadata(dir.join("pup-secrets.json")).unwrap().permissions().mode() & 0o777, 0o600);+        assert!(secrets::file_delete(&dir, "adom-pup-test", "unit@test"));+        assert!(secrets::file_get(&dir, "adom-pup-test", "unit@test").is_none());+        let _ = std::fs::remove_dir_all(&dir);+    }++    #[test]+    fn window_probes_do_not_panic_without_a_display() {+        // CI and WSL often have no X server: every probe answers empty instead of failing.+        let h = LinuxHost;+        let _ = h.windows_for_pids(&[], Some(CHROME_CLASS));+        let _ = h.frame_windows_all(None);+        let _ = h.foreground();+        let _ = h.work_area();+        assert!(!h.is_alive(0));+    }+}
rust/host-linux/src/secrets.rsadded+169
@@ -0,0 +1,169 @@+//! Secrets and the other session-bus probes.+//!+//! Secrets go to the freedesktop Secret Service (GNOME Keyring, KWallet's provider) with keytar's+//! attributes {service, account}, through a "plain" session (the bus is the user's own, local). pup never+//! asks the user to unlock a keyring: a write goes to the Secret Service only when its default collection+//! is already unlocked. Otherwise (no keyring daemon, or the auto-login desktop that leaves the login+//! keyring locked) it goes to `~/.adom/pup-secrets.json`, mode 0600, the same protection a Windows+//! Credential Manager entry has from other processes of the same user. Reads look in both.++use std::collections::HashMap;+use std::path::{Path, PathBuf};+use zbus::blocking::{Connection, Proxy};+use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};++const DEST: &str = "org.freedesktop.secrets";+const SCHEMA: &str = "org.freedesktop.Secret.Generic";++/// The user's session bus, opened once (also used by `dock` for the launcher signal).+pub(crate) fn session_bus() -> Option<Connection> { bus() }++fn bus() -> Option<Connection> {+    static BUS: std::sync::OnceLock<Option<Connection>> = std::sync::OnceLock::new();+    BUS.get_or_init(|| if std::env::var("DBUS_SESSION_BUS_ADDRESS").is_ok() || std::env::var("XDG_RUNTIME_DIR").is_ok() { Connection::session().ok() } else { None }).clone()+}++fn svc_proxy(c: &Connection) -> Option<Proxy<'static>> {+    let p = Proxy::new(c, DEST, "/org/freedesktop/secrets", "org.freedesktop.Secret.Service").ok()?;+    Some(p)+}++fn open_session(svc: &Proxy) -> Option<OwnedObjectPath> {+    let (_out, path): (OwnedValue, OwnedObjectPath) = svc.call("OpenSession", &("plain", Value::from(""))).ok()?;+    Some(path)+}++fn attrs<'a>(service: &'a str, account: &'a str) -> HashMap<&'a str, &'a str> {+    HashMap::from([("service", service), ("account", account), ("xdg:schema", SCHEMA)])+}++/// Unlocked items matching keytar's attributes (locked ones are never unlocked by pup).+fn find_unlocked(svc: &Proxy, service: &str, account: &str) -> Vec<OwnedObjectPath> {+    let mut a = attrs(service, account);+    a.remove("xdg:schema");   // keytar-written items may lack it; match on service + account+    let r: Result<(Vec<OwnedObjectPath>, Vec<OwnedObjectPath>), _> = svc.call("SearchItems", &(a,));+    r.map(|(unlocked, _locked)| unlocked).unwrap_or_default()+}++fn default_collection_unlocked(c: &Connection, svc: &Proxy) -> Option<OwnedObjectPath> {+    let path: OwnedObjectPath = svc.call("ReadAlias", &("default",)).ok()?;+    if path.as_str() == "/" { return None; }+    let coll = Proxy::new(c, DEST, path.clone(), "org.freedesktop.Secret.Collection").ok()?;+    let locked: bool = coll.get_property("Locked").ok()?;+    if locked { None } else { Some(path) }+}++fn ss_get(service: &str, account: &str) -> Option<String> {+    let c = bus()?; let svc = svc_proxy(&c)?;+    let item = find_unlocked(&svc, service, account).into_iter().next()?;+    let session = open_session(&svc)?;+    let ip = Proxy::new(&c, DEST, item, "org.freedesktop.Secret.Item").ok()?;+    let (_s, _params, value, _ct): (OwnedObjectPath, Vec<u8>, Vec<u8>, String) = ip.call("GetSecret", &(session,)).ok()?;+    String::from_utf8(value).ok()+}++fn ss_set(service: &str, account: &str, secret: &str) -> bool {+    let Some(c) = bus() else { return false }; let Some(svc) = svc_proxy(&c) else { return false };+    let Some(coll_path) = default_collection_unlocked(&c, &svc) else { return false };+    let Some(session) = open_session(&svc) else { return false };+    let Ok(coll) = Proxy::new(&c, DEST, coll_path, "org.freedesktop.Secret.Collection") else { return false };+    let label = format!("{service}/{account}");+    let mut props: HashMap<&str, Value> = HashMap::new();+    props.insert("org.freedesktop.Secret.Item.Label", Value::from(label.as_str()));+    props.insert("org.freedesktop.Secret.Item.Attributes", Value::from(attrs(service, account)));+    let secret_struct = (session, Vec::<u8>::new(), secret.as_bytes().to_vec(), "text/plain; charset=utf8");+    let r: Result<(OwnedObjectPath, OwnedObjectPath), _> = coll.call("CreateItem", &(props, secret_struct, true));+    // A prompt path other than "/" means the service wants the user: pup does not prompt, so treat as failed.+    matches!(r, Ok((item, prompt)) if item.as_str() != "/" && prompt.as_str() == "/")+}++fn ss_delete(service: &str, account: &str) -> bool {+    let Some(c) = bus() else { return false }; let Some(svc) = svc_proxy(&c) else { return false };+    let mut any = false;+    for item in find_unlocked(&svc, service, account) {+        if let Ok(ip) = Proxy::new(&c, DEST, item, "org.freedesktop.Secret.Item") {+            let r: Result<OwnedObjectPath, _> = ip.call("Delete", &());+            if matches!(r, Ok(p) if p.as_str() == "/") { any = true; }+        }+    }+    any+}++// ── the 0600 file ──++fn file_path(dir: &Path) -> PathBuf { dir.join("pup-secrets.json") }++fn file_load(dir: &Path) -> HashMap<String, String> {+    std::fs::read_to_string(file_path(dir)).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()+}++fn file_store(dir: &Path, m: &HashMap<String, String>) -> Result<(), String> {+    use std::io::Write;+    use std::os::unix::fs::OpenOptionsExt;+    std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;+    let tmp = dir.join(format!("pup-secrets.json.{}.tmp", std::process::id()));+    let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&tmp).map_err(|e| e.to_string())?;+    f.write_all(serde_json::to_string(m).map_err(|e| e.to_string())?.as_bytes()).map_err(|e| e.to_string())?;+    f.sync_all().map_err(|e| e.to_string())?;+    std::fs::rename(&tmp, file_path(dir)).map_err(|e| e.to_string())+}++fn key(service: &str, account: &str) -> String { format!("{service}/{account}") }++static FILE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());++pub fn file_get(dir: &Path, service: &str, account: &str) -> Option<String> { file_load(dir).get(&key(service, account)).cloned() }+pub fn file_set(dir: &Path, service: &str, account: &str, secret: &str) -> Result<(), String> {+    let _g = FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());+    let mut m = file_load(dir); m.insert(key(service, account), secret.to_string()); file_store(dir, &m)+}+pub fn file_delete(dir: &Path, service: &str, account: &str) -> bool {+    let _g = FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());+    let mut m = file_load(dir);+    if m.remove(&key(service, account)).is_none() { return false; }+    file_store(dir, &m).is_ok()+}++fn state_dir() -> PathBuf { PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".into())).join(".adom") }++// ── what the host calls ──++pub fn get(service: &str, account: &str) -> Option<String> { ss_get(service, account).or_else(|| file_get(&state_dir(), service, account)) }++pub fn set(dir: PathBuf, service: &str, account: &str, secret: &str) -> Result<(), String> {+    if ss_set(service, account, secret) {+        // One home per secret: a stale copy in the file would win after the keyring locks again.+        let _ = file_delete(&dir, service, account);+        return Ok(());+    }+    file_set(&dir, service, account, secret)+}++pub fn delete(dir: PathBuf, service: &str, account: &str) -> bool {+    let a = ss_delete(service, account);+    let b = file_delete(&dir, service, account);+    a || b+}++/// The item exists only in a LOCKED collection (and not in pup's 0600 file), so it cannot be read until the+/// user unlocks their keyring. pup never prompts for that.+pub fn locked(dir: &std::path::Path, service: &str, account: &str) -> bool {+    if file_get(dir, service, account).is_some() { return false; }+    let Some(c) = bus() else { return false };+    let Some(svc) = svc_proxy(&c) else { return false };+    let a = attrs(service, account);+    let mut a = a; a.remove("xdg:schema");+    let r: Result<(Vec<OwnedObjectPath>, Vec<OwnedObjectPath>), _> = svc.call("SearchItems", &(a,));+    r.map(|(unlocked, locked)| unlocked.is_empty() && !locked.is_empty()).unwrap_or(false)+}++pub fn store_kind() -> &'static str {+    match bus().and_then(|c| svc_proxy(&c).and_then(|s| default_collection_unlocked(&c, &s))) { Some(_) => "secret-service", None => "file" }+}++/// GNOME (Mutter) idle time over D-Bus: covers Wayland sessions, where the X server sees no native input.+pub fn gnome_idle_ms() -> Option<u64> {+    let c = bus()?;+    let p = Proxy::new(&c, "org.gnome.Mutter.IdleMonitor", "/org/gnome/Mutter/IdleMonitor/Core", "org.gnome.Mutter.IdleMonitor").ok()?;+    p.call::<_, _, u64>("GetIdletime", &()).ok()+}
rust/host-linux/src/x11.rsadded+372
@@ -0,0 +1,372 @@+//! X11 / EWMH through x11rb (pure Rust, no libX11). One lazily opened connection, reopened when it breaks.+//! On a Wayland session this is the XWayland server, which holds exactly pup's own browser windows+//! (pup launches them with --ozone-platform=x11) and whatever other X11 apps the user runs.++use std::sync::Mutex;+use x11rb::connection::Connection;+use x11rb::errors::ReplyError;+use x11rb::protocol::xproto::*;+use x11rb::rust_connection::RustConnection;+use x11rb::wrapper::ConnectionExt as _;++x11rb::atom_manager! {+    pub Atoms: AtomsCookie {+        _NET_CLIENT_LIST,+        _NET_CLIENT_LIST_STACKING,+        _NET_ACTIVE_WINDOW,+        _NET_WORKAREA,+        _NET_CURRENT_DESKTOP,+        _NET_WM_PID,+        _NET_WM_NAME,+        _NET_WM_STATE,+        _NET_WM_STATE_HIDDEN,+        _NET_WM_STATE_DEMANDS_ATTENTION,+        _NET_WM_ICON,+        _NET_WM_XAPP_PROGRESS,+        _NET_WM_XAPP_PROGRESS_PULSE,+        _NET_RESTACK_WINDOW,+        _NET_MOVERESIZE_WINDOW,+        UTF8_STRING,+        _ADOM_PUP_TAGS,+        _ADOM_PUP_ORIG_CLASS,+        _ADOM_PUP_ORIG_ICON,+    }+}++struct X { conn: RustConnection, root: Window, atoms: Atoms }++static CONN: Mutex<Option<X>> = Mutex::new(None);++/// The X authority file to use NOW. GNOME (46+) runs Xwayland on demand and gives every Xwayland start a new,+/// randomly named cookie file (/run/user/<uid>/.mutter-Xwaylandauth.XXXXXX). A pup that started before an Xwayland+/// restart kept the old path in XAUTHORITY, so its own X connection and every browser it launched failed with+/// "Authorization required ... Missing X server" until pup restarted (hl-ubuntu, Ralph test, 2026-11-03 23:12 CT).+/// Order: XAUTHORITY while that file exists; else the newest mutter Xwayland cookie; else ~/.Xauthority. When the+/// answer changes it is written back to this process's XAUTHORITY so x11rb and child processes use it.+pub fn refresh_xauthority() -> Option<String> {+    let cur = std::env::var("XAUTHORITY").ok().filter(|p| !p.is_empty());+    if let Some(p) = cur.as_deref() { if std::path::Path::new(p).exists() { return cur; } }+    let dir = format!("/run/user/{}", unsafe { libc::getuid() });+    let newest = std::fs::read_dir(&dir).ok().and_then(|rd| rd.flatten()+        .filter(|e| e.file_name().to_string_lossy().starts_with(".mutter-Xwaylandauth."))+        .filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()).map(|t| (t, e.path())))+        .max_by_key(|(t, _)| *t).map(|(_, p)| p.to_string_lossy().to_string()));+    let home_auth = std::env::var("HOME").ok().map(|h| format!("{h}/.Xauthority")).filter(|p| std::path::Path::new(p).exists());+    let pick = newest.or(home_auth)?;+    if cur.as_deref() != Some(pick.as_str()) {+        pup_core::log(format!("[x11] XAUTHORITY {} -> {pick} (the X server's cookie file changed; Xwayland restarted?)", cur.as_deref().unwrap_or("(unset)")));+        std::env::set_var("XAUTHORITY", &pick);+    }+    Some(pick)+}++pub fn display_name() -> String { std::env::var("DISPLAY").ok().filter(|d| !d.is_empty()).unwrap_or_else(|| ":0".into()) }++/// When the last connect failed: probes do not retry for a few seconds, so a box with no X server answers+/// every window probe at once instead of paying a connect each time.+static LAST_FAIL: Mutex<Option<std::time::Instant>> = Mutex::new(None);++fn open() -> Option<X> {+    if LAST_FAIL.lock().unwrap_or_else(|e| e.into_inner()).map(|t| t.elapsed() < std::time::Duration::from_secs(5)).unwrap_or(false) { return None; }+    let r = open_now();+    if r.is_none() { *LAST_FAIL.lock().unwrap_or_else(|e| e.into_inner()) = Some(std::time::Instant::now()); }+    r+}++fn open_now() -> Option<X> {+    // No DISPLAY: use the local :0 socket only when it exists. Guessing ":0" lets x11rb fall back to TCP+    // localhost:6000, which can hang for minutes where a firewall drops it.+    let name = match std::env::var("DISPLAY").ok().filter(|d| !d.is_empty()) {+        Some(d) => d,+        None if std::path::Path::new("/tmp/.X11-unix/X0").exists() => ":0".to_string(),+        None => return None,+    };+    refresh_xauthority();+    let (conn, screen) = x11rb::connect(Some(&name)).ok()?;+    let root = conn.setup().roots.get(screen)?.root;+    let atoms = Atoms::new(&conn).ok()?.reply().ok()?;+    Some(X { conn, root, atoms })+}++/// Run `f` on the shared connection. A broken connection (the X server restarted, XWayland was stopped+/// for idleness) is dropped and the next call reconnects.+fn with<T>(f: impl FnOnce(&X) -> Result<T, ReplyError>) -> Option<T> {+    let mut g = CONN.lock().unwrap_or_else(|e| e.into_inner());+    if g.is_none() { *g = open(); }+    let x = g.as_ref()?;+    match f(x) {+        Ok(v) => Some(v),+        Err(ReplyError::ConnectionError(_)) => { *g = None; None }+        Err(_) => None,+    }+}++fn prop32(x: &X, w: Window, atom: Atom) -> Result<Vec<u32>, ReplyError> {+    let r = x.conn.get_property(false, w, atom, AtomEnum::ANY, 0, 4096)?.reply()?;+    Ok(r.value32().map(|it| it.collect()).unwrap_or_default())+}++fn prop_bytes(x: &X, w: Window, atom: Atom) -> Result<Vec<u8>, ReplyError> {+    Ok(x.conn.get_property(false, w, atom, AtomEnum::ANY, 0, 16384)?.reply()?.value)+}++pub struct Row { pub id: i64, pub pid: u32, pub title: String, pub class: String, pub rect: (i32, i32, i32, i32), pub viewable: bool, pub hidden: bool }++fn row(x: &X, w: Window) -> Result<Row, ReplyError> {+    let pid = prop32(x, w, x.atoms._NET_WM_PID)?.first().copied().unwrap_or(0);+    let mut title = String::from_utf8_lossy(&prop_bytes(x, w, x.atoms._NET_WM_NAME)?).to_string();+    if title.is_empty() { title = String::from_utf8_lossy(&prop_bytes(x, w, AtomEnum::WM_NAME.into())?).to_string(); }+    // WM_CLASS = "instance\0Class\0"; the Class half is what docks group by.+    let wc = prop_bytes(x, w, AtomEnum::WM_CLASS.into())?;+    let parts: Vec<String> = wc.split(|b| *b == 0).filter(|s| !s.is_empty()).map(|s| String::from_utf8_lossy(s).to_string()).collect();+    let class = parts.get(1).or(parts.first()).cloned().unwrap_or_default();+    let g = x.conn.get_geometry(w)?.reply()?;+    let t = x.conn.translate_coordinates(w, x.root, 0, 0)?.reply()?;+    let a = x.conn.get_window_attributes(w)?.reply()?;+    let state = prop32(x, w, x.atoms._NET_WM_STATE)?;+    let (l, tp) = (t.dst_x as i32, t.dst_y as i32);+    Ok(Row { id: w as i64, pid, title, class, rect: (l, tp, l + g.width as i32, tp + g.height as i32), viewable: a.map_state == MapState::VIEWABLE, hidden: state.contains(&x.atoms._NET_WM_STATE_HIDDEN) })+}++fn client_ids(x: &X) -> Result<Vec<Window>, ReplyError> {+    let list = prop32(x, x.root, x.atoms._NET_CLIENT_LIST)?;+    if !list.is_empty() { return Ok(list); }+    // No EWMH window manager: the root's mapped children are the top-levels.+    Ok(x.conn.query_tree(x.root)?.reply()?.children)+}++/// The window manager's managed top-level windows.+pub fn client_rows() -> Vec<Row> {+    with(|x| { let ids = client_ids(x)?; Ok(ids.into_iter().filter_map(|w| row(x, w).ok()).collect()) }).unwrap_or_default()+}++/// Managed windows plus any direct child of the root that carries _NET_WM_PID (a newborn the window+/// manager has not managed yet), each once.+pub fn all_rows() -> Vec<Row> {+    with(|x| {+        let mut ids = client_ids(x)?;+        for c in x.conn.query_tree(x.root)?.reply()?.children { if !ids.contains(&c) { ids.push(c); } }+        Ok(ids.into_iter().filter_map(|w| row(x, w).ok()).filter(|r| r.pid > 0).collect())+    }).unwrap_or_default()+}++/// The process that owns a window (_NET_WM_PID), 0 when it does not say.+pub fn wm_pid(w: i64) -> u32 { with(|x| Ok(prop32(x, w as Window, x.atoms._NET_WM_PID)?.first().copied().unwrap_or(0))).unwrap_or(0) }++pub fn exists(w: i64) -> bool { with(|x| x.conn.get_window_attributes(w as Window)?.reply().map(|_| true)).unwrap_or(false) }++pub fn viewable(w: i64) -> bool { with(|x| Ok(x.conn.get_window_attributes(w as Window)?.reply()?.map_state == MapState::VIEWABLE)).unwrap_or(false) }++pub fn active_window() -> i64 {+    with(|x| Ok(prop32(x, x.root, x.atoms._NET_ACTIVE_WINDOW)?.first().copied().unwrap_or(0) as i64)).unwrap_or(0)+}++fn client_message(x: &X, w: Window, kind: Atom, data: [u32; 5]) -> Result<(), ReplyError> {+    let ev = ClientMessageEvent::new(32, w, kind, data);+    x.conn.send_event(false, x.root, EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, ev)?;+    Ok(())+}++/// Bottom of the stack, never activated: EWMH restack request from a pager (source 2) with detail Below and+/// no sibling, then the plain ICCCM ConfigureWindow Below for window managers that ignore the former.+pub fn lower(w: i64) -> bool {+    with(|x| {+        let w = w as Window;+        x.conn.get_window_attributes(w)?.reply()?;+        client_message(x, w, x.atoms._NET_RESTACK_WINDOW, [2, 0, u32::from(StackMode::BELOW), 0, 0])?;+        x.conn.configure_window(w, &ConfigureWindowAux::new().stack_mode(StackMode::BELOW))?;+        x.conn.flush()?;+        Ok(true)+    }).unwrap_or(false) && { hand_back_focus(w); true }+}++/// Restacking does not move the keyboard: a pup window the user was shown (a granted raise) kept the focus after+/// `pup_lower_os_window`, so typing went into a window now hidden behind their work (GNOME Wayland and Cinnamon+/// X11, 2026-11-02). When the lowered window holds the focus, give it to the window now on top. On X11 that is the+/// topmost other viewable client, activated through EWMH. Under Wayland the user's own apps are not X windows, so+/// focus goes to PointerRoot and the compositor focuses its default window (the top one). Never runs when the+/// lowered window was not focused, so a background lower still never touches the focus.+fn hand_back_focus(lowered: i64) {+    if active_window() != lowered { return; }+    let wayland = std::env::var("WAYLAND_DISPLAY").map(|v| !v.is_empty()).unwrap_or(false)+        || std::env::var("XDG_SESSION_TYPE").map(|v| v == "wayland").unwrap_or(false);+    let _ = with(|x| {+        if !wayland {+            let stack = prop32(x, x.root, x.atoms._NET_CLIENT_LIST_STACKING)?;+            let next = stack.iter().rev().copied().filter(|&c| c as i64 != lowered).find(|&c| {+                x.conn.get_window_attributes(c).ok().and_then(|r| r.reply().ok()).map(|a| a.map_state == MapState::VIEWABLE).unwrap_or(false)+                    && !prop32(x, c, x.atoms._NET_WM_STATE).unwrap_or_default().contains(&x.atoms._NET_WM_STATE_HIDDEN)+            });+            if let Some(c) = next {+                client_message(x, c, x.atoms._NET_ACTIVE_WINDOW, [2, x11rb::CURRENT_TIME, 0, 0, 0])?;+                x.conn.flush()?;+                return Ok(());+            }+        }+        const POINTER_ROOT: Window = 1;   // the X protocol's PointerRoot focus value+        x.conn.set_input_focus(InputFocus::POINTER_ROOT, POINTER_ROOT, x11rb::CURRENT_TIME)?;+        x.conn.flush()?;+        Ok(())+    });+}++/// A granted raise: _NET_ACTIVE_WINDOW from a pager (source 2), which window managers honour.+pub fn activate(w: i64) -> bool {+    with(|x| {+        let w = w as Window;+        x.conn.get_window_attributes(w)?.reply()?;+        client_message(x, w, x.atoms._NET_ACTIVE_WINDOW, [2, x11rb::CURRENT_TIME, 0, 0, 0])?;+        x.conn.configure_window(w, &ConfigureWindowAux::new().stack_mode(StackMode::ABOVE))?;+        x.conn.flush()?;+        Ok(true)+    }).unwrap_or(false)+}++pub fn set_bounds(w: i64, l: i32, t: i32, wd: i32, ht: i32) -> bool {+    with(|x| {+        x.conn.configure_window(w as Window, &ConfigureWindowAux::new().x(l).y(t).width(wd.max(1) as u32).height(ht.max(1) as u32))?;+        x.conn.flush()?;+        Ok(true)+    }).unwrap_or(false)+}++/// _NET_WM_STATE remove DEMANDS_ATTENTION (action 0 = remove).+pub fn clear_attention(w: i64) -> bool {+    with(|x| {+        client_message(x, w as Window, x.atoms._NET_WM_STATE, [0, x.atoms._NET_WM_STATE_DEMANDS_ATTENTION, 0, 1, 0])?;+        x.conn.flush()?;+        Ok(true)+    }).unwrap_or(false)+}++/// _NET_WM_STATE add DEMANDS_ATTENTION (action 1 = add): the taskbar highlights the window without raising it.+pub fn set_attention(w: i64) -> bool {+    with(|x| {+        client_message(x, w as Window, x.atoms._NET_WM_STATE, [1, x.atoms._NET_WM_STATE_DEMANDS_ATTENTION, 0, 1, 0])?;+        x.conn.flush()?;+        Ok(true)+    }).unwrap_or(false)+}++/// WM_CLASS ("instance\0Class\0"), both halves set to `id`. Docks and window lists group windows by it and match it+/// against a `.desktop` entry's StartupWMClass (GNOME Shell, Cinnamon, KDE), so a per-thread class gives each AI+/// thread its own dock icon. The window manager re-reads it on the PropertyNotify.+pub fn set_wm_class(w: i64, id: &str) -> bool {+    let body = format!("{id}\0{id}\0");+    with(|x| {+        save_original(x, w as Window)?;+        x.conn.change_property8(PropMode::REPLACE, w as Window, AtomEnum::WM_CLASS, AtomEnum::STRING, body.as_bytes())?;+        x.conn.flush()?;+        Ok(true)+    }).unwrap_or(false)+}++/// Before pup's first change to a window, keep the browser's own WM_CLASS and icon on the window itself, so+/// `restore_original` can put them back (taskbarMode plain) even after a pup restart.+fn save_original(x: &X, w: Window) -> Result<(), ReplyError> {+    if x.conn.get_property(false, w, x.atoms._ADOM_PUP_ORIG_CLASS, AtomEnum::ANY, 0, 1)?.reply()?.type_ != x11rb::NONE { return Ok(()); }+    let class = prop_bytes(x, w, AtomEnum::WM_CLASS.into())?;+    let icon = x.conn.get_property(false, w, x.atoms._NET_WM_ICON, AtomEnum::CARDINAL, 0, u32::MAX / 4)?.reply()?;+    x.conn.change_property8(PropMode::REPLACE, w, x.atoms._ADOM_PUP_ORIG_CLASS, AtomEnum::STRING, &class)?;+    if let Some(v) = icon.value32() { let v: Vec<u32> = v.collect(); if !v.is_empty() { x.conn.change_property32(PropMode::REPLACE, w, x.atoms._ADOM_PUP_ORIG_ICON, AtomEnum::CARDINAL, &v)?; } }+    Ok(())+}++/// Put back the class and icon `save_original` kept. False when pup never changed this window.+pub fn restore_original(w: i64) -> bool {+    with(|x| {+        let w = w as Window;+        let class = x.conn.get_property(false, w, x.atoms._ADOM_PUP_ORIG_CLASS, AtomEnum::ANY, 0, 4096)?.reply()?;+        if class.type_ == x11rb::NONE { return Ok(false); }+        let icon = x.conn.get_property(false, w, x.atoms._ADOM_PUP_ORIG_ICON, AtomEnum::CARDINAL, 0, u32::MAX / 4)?.reply()?;+        x.conn.change_property8(PropMode::REPLACE, w, AtomEnum::WM_CLASS, AtomEnum::STRING, &class.value)?;+        match icon.value32().map(|v| v.collect::<Vec<u32>>()).filter(|v| !v.is_empty()) {+            Some(v) => { x.conn.change_property32(PropMode::REPLACE, w, x.atoms._NET_WM_ICON, AtomEnum::CARDINAL, &v)?; }+            None => { x.conn.delete_property(w, x.atoms._NET_WM_ICON)?; }+        }+        x.conn.delete_property(w, x.atoms._ADOM_PUP_ORIG_CLASS)?;+        x.conn.delete_property(w, x.atoms._ADOM_PUP_ORIG_ICON)?;+        x.conn.flush()?;+        Ok(true)+    }).unwrap_or(false)+}++/// The class half of WM_CLASS.+pub fn wm_class(w: i64) -> Option<String> {+    with(|x| {+        let wc = prop_bytes(x, w as Window, AtomEnum::WM_CLASS.into())?;+        let parts: Vec<String> = wc.split(|b| *b == 0).filter(|s| !s.is_empty()).map(|s| String::from_utf8_lossy(s).to_string()).collect();+        Ok(parts.get(1).or(parts.first()).cloned())+    }).flatten()+}++/// _NET_WM_ICON: for each entry width, height, then width*height ARGB pixels (CARDINAL/32).+pub fn set_icon(w: i64, icons: &[(u32, Vec<u8>)]) -> bool {+    let mut data: Vec<u32> = Vec::new();+    for (sz, rgba) in icons {+        let n = (*sz as usize) * (*sz as usize);+        if *sz == 0 || rgba.len() < n * 4 { continue; }+        data.push(*sz); data.push(*sz);+        data.extend(rgba.chunks_exact(4).take(n).map(|p| (p[3] as u32) << 24 | (p[0] as u32) << 16 | (p[1] as u32) << 8 | p[2] as u32));+    }+    if data.is_empty() { return false; }+    with(|x| {+        save_original(x, w as Window)?;+        x.conn.change_property32(PropMode::REPLACE, w as Window, x.atoms._NET_WM_ICON, AtomEnum::CARDINAL, &data)?;+        x.conn.flush()?;+        Ok(true)+    }).unwrap_or(false)+}++/// The XApp progress hints Cinnamon's window list draws on a window's button: _NET_WM_XAPP_PROGRESS (0-100) and+/// _NET_WM_XAPP_PROGRESS_PULSE (1 = busy, amount unknown). None removes both.+pub fn set_xapp_progress(w: i64, pct: Option<u32>, pulse: bool) -> bool {+    with(|x| {+        let w = w as Window;+        match pct {+            None => { x.conn.delete_property(w, x.atoms._NET_WM_XAPP_PROGRESS)?; x.conn.delete_property(w, x.atoms._NET_WM_XAPP_PROGRESS_PULSE)?; }+            Some(p) => {+                x.conn.change_property32(PropMode::REPLACE, w, x.atoms._NET_WM_XAPP_PROGRESS, AtomEnum::CARDINAL, &[p.min(100)])?;+                x.conn.change_property32(PropMode::REPLACE, w, x.atoms._NET_WM_XAPP_PROGRESS_PULSE, AtomEnum::CARDINAL, &[pulse as u32])?;+            }+        }+        x.conn.flush()?;+        Ok(true)+    }).unwrap_or(false)+}++pub fn tags(w: i64) -> Vec<String> {+    with(|x| Ok(String::from_utf8_lossy(&prop_bytes(x, w as Window, x.atoms._ADOM_PUP_TAGS)?).lines().map(|s| s.to_string()).filter(|s| !s.is_empty()).collect())).unwrap_or_default()+}++pub fn add_tag(w: i64, tag: &str) -> bool {+    let mut cur = tags(w);+    if cur.iter().any(|t| t == tag) { return true; }+    // One sid tag per window: a re-tag (the window was adopted by another session) replaces the old sid.+    if let Some(k) = tag.split_once('=').map(|(k, _)| k.to_string()) { cur.retain(|t| !t.starts_with(&format!("{k}="))); }+    cur.push(tag.to_string());+    let body = cur.join("\n");+    with(|x| {+        x.conn.change_property8(PropMode::REPLACE, w as Window, x.atoms._ADOM_PUP_TAGS, x.atoms.UTF8_STRING, body.as_bytes())?;+        x.conn.flush()?;+        Ok(true)+    }).unwrap_or(false)+}++/// _NET_WORKAREA for the current desktop (x, y, w, h), else the root window's size.+pub fn work_area() -> Option<(i32, i32, i32, i32)> {+    with(|x| {+        let wa = prop32(x, x.root, x.atoms._NET_WORKAREA)?;+        let d = prop32(x, x.root, x.atoms._NET_CURRENT_DESKTOP)?.first().copied().unwrap_or(0) as usize;+        if let Some(c) = wa.chunks(4).nth(d).or(wa.chunks(4).next()).filter(|c| c.len() == 4) { return Ok(Some((c[0] as i32, c[1] as i32, c[2] as i32, c[3] as i32))); }+        let g = x.conn.get_geometry(x.root)?.reply()?;+        Ok(Some((0, 0, g.width as i32, g.height as i32)))+    }).flatten()+}++/// Milliseconds since the last X input (MIT-SCREEN-SAVER). On Wayland this sees only X11 input, so the+/// GNOME D-Bus probe is asked first.+pub fn idle_ms() -> Option<u64> {+    use x11rb::protocol::screensaver::ConnectionExt as _;+    with(|x| Ok(x.conn.screensaver_query_info(x.root)?.reply()?.ms_since_user_input as u64))+}
rust/host/src/lib.rs+29
@@ -76,6 +76,24 @@ pub trait HostWindows: Send + Sync {     /// (pup's own identities), so a boot sweep can unregister the ones no live window holds. John found     /// 116 dead thread identities on arav-rog on 2026-09-20 ("are you still leaving aumid icon residue?").     fn list_aumids(&self, prefix: &str) -> Vec<String> { let _ = prefix; Vec::new() }+    /// Linux: register a dock identity before any window carries it (a `.desktop` entry whose `StartupWMClass` is+    /// `app_id`, named `display`, with `icon_png` as its icon, `exec` as its launch line and `actions` (name, exec)+    /// as its right-click menu). Windows does this through ab (`desktop_register_app_identity`), so false elsewhere.+    fn register_app_identity(&self, app_id: &str, display: &str, icon_png: &str, exec: &str, actions: &[(String, String)]) -> bool { let _ = (app_id, display, icon_png, exec, actions); false }+    /// Linux: remove what `register_app_identity` wrote. Windows unregisters through ab.+    fn unregister_app_identity(&self, app_id: &str) -> bool { let _ = app_id; false }+    /// The identity a window carries now, read back from the OS (Linux: its WM_CLASS). Windows reads it through ab.+    fn window_app_id(&self, hwnd: i64) -> Option<String> { let _ = hwnd; None }+    /// Linux: the window's own icon (`_NET_WM_ICON`), one (size, RGBA) entry per size. The taskbar of an X11 desktop+    /// (Cinnamon, KDE, XFCE) draws it per window. Windows paints icons through ab.+    fn set_window_icon(&self, hwnd: i64, icons: &[(u32, Vec<u8>)]) -> bool { let _ = (hwnd, icons); false }+    /// Linux: give the window back the WM_CLASS and icon it had before pup's first stamp (`taskbarMode: plain`).+    fn clear_window_identity(&self, hwnd: i64) -> bool { let _ = hwnd; false }+    /// Linux: the taskbar/dock signal for a window, the counterpart of ab's `desktop_taskbar` on Windows.+    /// `signal` keys, each optional: `progress` {state: none|normal|indeterminate|error|paused, value 0..1},+    /// `count` (integer, or null to hide), `urgent` (bool). Painted as the window's XApp progress hints and+    /// attention state, and as a `com.canonical.Unity.LauncherEntry` update for `app_id` when it has one.+    fn taskbar_signal(&self, hwnd: i64, app_id: Option<&str>, signal: &serde_json::Value) -> bool { let _ = (hwnd, app_id, signal); false }     /// Milliseconds since the last keyboard/mouse input (GetLastInputInfo); None where unknown.     fn idle_ms(&self) -> Option<u64> { None }     /// The primary monitor's work area (x, y, w, h), excluding the taskbar.@@ -149,6 +167,10 @@ pub trait HostProcess: Send + Sync {             match (it.next().and_then(|x| x.parse::<u32>().ok()), it.next()) { (Some(pid), Some(prof)) => Some((pid, prof.to_string())), _ => None }         }).collect()     }+    /// How a Chromium browser at `exe` is sandboxed when this host launches it, for pup_readiness. None where+    /// the OS sandbox always works (Windows, macOS). Linux: "namespaces", "setuid helper <path>", or+    /// "off: <why>" when neither is available and the browser runs with --no-sandbox.+    fn browser_sandbox(&self, exe: &Path) -> Option<String> { let _ = exe; None }     fn running_browser_executables(&self) -> Vec<PathBuf> {         self.run_shell_script("Get-CimInstance Win32_Process -Filter \"Name='chrome.exe' OR Name='chrome-headless-shell.exe'\" | ForEach-Object { $_.ExecutablePath }", std::time::Duration::from_secs(20))             .lines().map(|l| l.trim()).filter(|l| !l.is_empty()).map(PathBuf::from).collect()@@ -191,6 +213,13 @@ pub trait HostSecrets: Send + Sync {     fn secret_get(&self, service: &str, account: &str) -> Option<String>;     fn secret_set(&self, service: &str, account: &str, secret: &str) -> Result<(), String>;     fn secret_delete(&self, service: &str, account: &str) -> bool;+    /// Where a secret written now is kept, in words for a verb's reply ("the OS keychain" on Windows and macOS).+    /// Linux names the Secret Service keyring or pup's 0600 file, which it uses when no keyring is unlocked.+    fn secret_store(&self) -> String { "the OS keychain".into() }+    /// True when the secret exists but its store is locked, so `secret_get` cannot read it now (Linux: the+    /// item is in a locked Secret Service collection, e.g. GNOME's login keyring after an auto-login). pup never+    /// unlocks a store itself. Windows and macOS keep the default: false.+    fn secret_locked(&self, _service: &str, _account: &str) -> bool { false } }  /// The whole host, as the binary hands it to every crate.
rust/identity/src/badge.rs+34
@@ -151,3 +151,37 @@ mod tests {         std::fs::write(dir.join("web-100.png"), compose(&generic(false), 100, false)).unwrap();     } }++/// Linux window icons (`_NET_WM_ICON`) and dock icons are plain images, not an ICO plus an overlay the shell+/// composites. These turn the same art into what the Linux desktop takes: the largest picture of an ICO (a thread+/// tile or a category icon) as RGBA at each size in `sizes` plus a 256 px PNG, with the favicon badge (the PNG from+/// `compose`, a 48 px canvas with the card pinned north-east) laid over the top-right half when `badge` is given.+pub const LINUX_ICON_SIZES: [u32; 5] = [16, 32, 48, 64, 128];+pub fn linux_icons(ico: &[u8], badge: Option<&[u8]>, sizes: &[u32]) -> Option<(Vec<(u32, Vec<u8>)>, Vec<u8>)> {+    let base = image::load_from_memory(ico).ok()?.to_rgba8();+    let badge = badge.and_then(|b| image::load_from_memory(b).ok()).map(|b| b.to_rgba8());+    let at = |s: u32| -> RgbaImage {+        let mut im = imageops::resize(&base, s, s, imageops::FilterType::Lanczos3);+        if let Some(b) = &badge { let h = (s / 2).max(8); let small = imageops::resize(b, h, h, imageops::FilterType::Lanczos3); imageops::overlay(&mut im, &small, (s - h) as i64, 0); }+        im+    };+    let icons = sizes.iter().map(|&s| (s, at(s).into_raw())).collect();+    let png = crate::png_encode_rgba(&at(256).into_raw(), 256, 256);+    Some((icons, png))+}++#[cfg(test)]+mod linux_icon_tests {+    #[test]+    fn tile_ico_becomes_rgba_sizes_and_a_png_with_the_badge_top_right() {+        let ico = crate::tile::render_ico("pup bridge Linux").expect("tile");+        let badge = super::compose(&super::generic(false), 75, false);+        let (icons, png) = super::linux_icons(&ico, Some(&badge), &super::LINUX_ICON_SIZES).expect("icons");+        assert_eq!(icons.iter().map(|(s, _)| *s).collect::<Vec<_>>(), vec![16, 32, 48, 64, 128]);+        for (s, px) in &icons { assert_eq!(px.len(), (*s * *s * 4) as usize); }+        assert_eq!(crate::png_size(&png), Some((256, 256)));+        let (s, px) = &icons[4]; let s = *s as usize;+        let at = |x: usize, y: usize| px[(y * s + x) * 4 + 3];+        assert!(at(s * 3 / 4, s / 8) > 200, "badge card opaque at the top right");+    }+}
rust/identity/src/tile.rs+77−1
@@ -94,6 +94,57 @@ pub fn layout(f: &Font, nm: &str) -> (Vec<String>, f32) {     (best.unwrap_or_else(|| vec![nm.to_string()]), bf) } +fn tokens(nm: &str) -> Vec<String> { nm.split_whitespace().flat_map(split_after_delims).filter(|t| !t.is_empty()).collect() }++/// True when `lines` hold every token of the name, whole and in order (no word cut, none dropped).+fn whole_words(lines: &[String], toks: &[String]) -> bool {+    lines.iter().flat_map(|l| tokens(l)).collect::<Vec<_>>() == toks+}++/// Linux (DIR-082353-15860): the same tile, but lines break only between words. `layout` above is the+/// Windows layout, kept byte for byte; when the name does not fit two lines at its size floor it falls+/// back to cutting characters ("BOM check" became "BO" / "M ch" in the GNOME dock). GNOME draws dock icons+/// at 48 to 64 px, larger than a Windows taskbar button, so here the font may go below that floor and a+/// name may take three lines. When `layout` already keeps whole words its answer is used unchanged.+pub fn layout_words(f: &Font, nm: &str) -> (Vec<String>, f32) {+    let (wl, wbf) = layout(f, nm);+    let mut toks = tokens(nm);+    if toks.is_empty() || whole_words(&wl, &toks) { return (wl, wbf); }+    let min = BOX * 0.20;+    toks.truncate(9);                                // a tile shows a name, not a sentence+    // Every split of the words into 1 to 4 lines; fewer lines win unless more lines buy 4% more size.+    fn parts(n: usize, k: usize, from: usize, cur: &mut Vec<usize>, out: &mut Vec<Vec<usize>>) {+        if cur.len() + 1 == k { out.push(cur.clone()); return; }+        for i in from..n { cur.push(i); parts(n, k, i + 1, cur, out); cur.pop(); }+    }+    let best_for = |toks: &[String]| -> (Vec<String>, f32) {+        let n = toks.len(); let mut best = vec![glue(toks)]; let mut bf = fit_size(f, &best);+        for k in 2..=4.min(n) {+            let mut cuts = Vec::new(); parts(n, k, 1, &mut Vec::new(), &mut cuts);+            for c in cuts {+                let mut lines = Vec::new(); let mut a = 0;+                for &e in c.iter().chain(std::iter::once(&n)) { lines.push(glue(&toks[a..e])); a = e; }+                let fs = fit_size(f, &lines); if fs > bf * 1.04 { bf = fs; best = lines; }+            }+        }+        (best, bf)+    };+    // Too many or too long words for `min`: drop trailing WHOLE words until the rest fits.+    for keep in (1..=toks.len()).rev() {+        let (lines, bf) = best_for(&toks[..keep]);+        if bf >= min { return (lines, bf); }+    }+    // A single first word wider than the tile at `min`: clip it on its own line (never carried over).+    let chars: Vec<char> = toks[0].chars().collect();+    let mut k = chars.len(); while k > 1 && width(f, &take_chars(&chars, k), min) > w_for(0, 1) { k -= 1; }+    (vec![take_chars(&chars, k)], min)+}++/// The tile layout for this OS: whole words on Linux, the browser-era layout elsewhere.+pub fn layout_for_os(f: &Font, nm: &str) -> (Vec<String>, f32) {+    if cfg!(target_os = "linux") { layout_words(f, nm) } else { layout(f, nm) }+}+ /// One RGBA tile at `s` px: anti-aliased rounded plate, the name bottom-left, glyphs rasterised at this size. pub fn draw(f: &Font, lines: &[String], bf: f32, s: u32) -> Vec<u8> {     let sf = s as f32; let k = sf / BOX; let r = sf / 5.5;@@ -166,7 +217,7 @@ pub fn font_handle() -> Option<Font> { font() } /// The finished .ico for a thread name: every TILE_SIZES entry drawn at its own size. pub fn render_ico(name: &str) -> Option<Vec<u8>> {     let f = font()?;-    let (lines, bf) = layout(&f, name);+    let (lines, bf) = layout_for_os(&f, name);     let mut raw: Vec<(u32, Vec<u8>)> = Vec::new(); let mut png256: Vec<u8> = Vec::new();     for &sz in crate::TILE_SIZES.iter() {         let px = if sz >= 256 { draw(&f, &lines, bf, sz) } else { draw_smooth(&f, &lines, bf, sz) };@@ -197,4 +248,29 @@ mod tests {             eprintln!("{name}: {lines:?} at {bf:.1}px");         }     }++    #[test]+    fn linux_layout_breaks_only_between_words() {+        let f = font().expect("font");+        let dir = std::env::temp_dir().join("pup-tile-samples"); let _ = std::fs::create_dir_all(&dir);+        for name in ["BOM check", "Scope capture", "pup bridge Linux", "Linux pup v2", "Logic gate molecule family",+                     "kicad-bridge-esc-routing", "pup-bridge", "bt556", "sxsw", "Director", "a very long name for one AI thread here",+                     "supercalifragilistic expialidocious", "pneumonoultramicroscopicsilicovolcanoconiosis thing"] {+            let (lines, bf) = layout_words(&f, name);+            let toks = tokens(name);+            assert!(!lines.is_empty() && lines.len() <= 4 && bf > 0.0, "{name}: {lines:?}");+            // whole words, in order, a prefix of the name (trailing words may be dropped, never cut)+            let flat: Vec<String> = lines.iter().flat_map(|l| tokens(l)).collect();+            assert!(flat.len() <= toks.len() && flat[..] == toks[..flat.len()] || (flat.len() == 1 && toks[0].starts_with(&flat[0])),+                    "{name}: {lines:?} is not whole words of {toks:?}");+            // the Windows layout is untouched, and reused whenever it already keeps whole words+            let (wl, wbf) = layout(&f, name);+            if whole_words(&wl, &toks) { assert_eq!((lines.clone(), bf), (wl, wbf), "{name}"); }+            let px = draw_smooth(&f, &lines, bf, 64);+            std::fs::write(dir.join(format!("linux-{}-64.png", crate::thread_slug(name))), crate::png_encode_rgba(&px, 64, 64)).unwrap();+            let px = draw(&f, &lines, bf, 256);+            std::fs::write(dir.join(format!("linux-{}-256.png", crate::thread_slug(name))), crate::png_encode_rgba(&px, 256, 256)).unwrap();+            eprintln!("linux {name}: {lines:?} at {bf:.1}px (windows {:?} at {wbf:.1}px)", layout(&f, name).0);+        }+    } }
rust/vault/src/lib.rs+10
@@ -137,6 +137,16 @@ impl Vault {         Some(Credential { host: best["host"].as_str().unwrap_or("").into(), username: best["username"].as_str().unwrap_or("").into(), password, source_account: best["sourceAccount"].as_str().map(|s| s.into()) })     } +    /// The host pattern of a saved login for this URL whose password is in a locked store right now (see+    /// HostSecrets::secret_locked; only Linux answers anything but None).+    pub fn locked_for_url(&self, host: &dyn HostSecrets, url: &str) -> Option<String> {+        let h = host_of(url)?;+        self.read_index().into_iter()+            .filter(|e| score_pattern(e["host"].as_str().unwrap_or(""), &h) >= 0)+            .find(|e| host.secret_locked(CRED_SERVICE, &key_of(e)))+            .map(|e| e["host"].as_str().unwrap_or("").to_string())+    }+     pub fn list(&self) -> Vec<Value> {         self.read_index().into_iter().map(|e| json!({ "host": e["host"], "username": e["username"], "sourceAccount": e["sourceAccount"], "sourceBrowser": e["sourceBrowser"], "sourceProfile": e["sourceProfile"], "imported": e["imported"].as_bool().unwrap_or(false), "addedAt": e["addedAt"], "updatedAt": e["updatedAt"] })).collect()     }
rust/verbs/assets/verb-catalog.json+48−5
@@ -836,12 +836,13 @@    "input": {     "action": "accept | dismiss (default accept)",     "promptText": "optional str: the answer typed into a prompt()",+    "policy": "optional \"manual\" | \"auto\": on the extension lane (the default) Chrome closes a page dialog the moment it opens while pup drives the tab, so \"manual\" PRE-ANSWERS the next dialogs with action/promptText and \"auto\" disarms and lists what was answered",     "window": "optional: the pup window (default the calling thread's)"    },    "example": {     "action": "accept"    },-   "hint": "A dialog blocks every other verb on that tab until it is answered. pup_errors and the verb that tripped it name the open dialog.",+   "hint": "A dialog blocks every other verb on that tab until it is answered. pup_errors and the verb that tripped it name the open dialog. On the extension lane a dialog is usually already handled (alert accepted, confirm/prompt dismissed); pup_dialog reports that as alreadyHandled. To answer one yourself: pup_dialog {policy:\"manual\", action:\"accept\"} BEFORE the action, then {policy:\"auto\"}.",    "related": [     "pup_errors",     "pup_click"@@ -1854,6 +1855,44 @@    "summary": "Download a file THROUGH THE BROWSER, exactly as the user clicking it would \u2014 real session, real cookies, real anti-bot cookies \u2014 and Chrome saves it to the Downloads folder. This is the right verb for vendor files (datasheets, KiCad/Altium libraries, STEP 3D models) that a server-side fetch/curl can never get past the site's bot wall. Pass selector to click the actual download button (authentic), or url to force-download via an in-page a[download]. Confirms completion via Chrome's download events and returns the ACTUAL saved path + byte count + file magic. For a gated file, log in on the window first (pup_login).",    "timeoutSeconds": 100   },+  {+   "example": {+    "selector": "input[type=file]",+    "filePath": "C:/Users/me/Downloads/gerbers.zip"+   },+   "hint": "Chrome reads the file itself, so a 5 GB file costs the same as a small one: never base64 a file through pup_eval again. The path is on the PC, not in your container: adom-bridge send_files first if the file is in the container. The verb reads the input back and fires no extra events, so a page that uploads on change sends the file once. attached:true means the page has the file, not that the site has it: screenshot for the page's own progress. Key, password and browser-login files, AppData, hidden folders in the home folder, system folders and network paths are refused (errorCode path_refused).",+   "input": {+    "filePath": "required str unless filePaths: absolute path on the PC the browser runs on",+    "filePaths": "optional array of paths, for an input that takes several files",+    "selector": "optional str (the input, or its Browse button or label; hidden inputs, open shadow roots and same-origin frames are searched)",+    "text": "optional str (visible text of the Browse control, instead of selector)",+    "armForNextChooser": "optional bool (the page creates its input when Browse is clicked: arm, then pup_click the Browse control)",+    "chooserStatus": "optional bool (did the armed chooser get answered?)",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+   },+   "longRunning": false,+   "name": "pup_upload_file",+   "output": {+    "attached": "bool",+    "verified": "bool (read back from the page)",+    "files": "[{name, sizeBytes}]",+    "clearedByPage": "bool (the page took the file and cleared the input)",+    "acceptWarning": "optional str"+   },+   "pitfalls": [+    "Do not click a Browse button without arming first: the OS dialog opens in front of the user and freezes the window.",+    "A missing path, two files on a single-file input and a disabled input are refused here because Chrome would accept them silently."+   ],+   "related": [+    "pup_click",+    "pup_screenshot",+    "pup_wait",+    "pup_download"+   ],+   "statusVerb": null,+   "summary": "Upload a file from this PC to a page: put it on the page's <input type=file> with no OS file picker, in the background, at any size.",+   "timeoutSeconds": 20+  },   {    "example": {},    "hint": "",@@ -2056,10 +2095,14 @@      "digikey"     ]    },-   "hint": "Seed pup's vault from the user's OWN saved Chrome/Edge/Brave passwords, so pup can sign in as them on sites they already use (vendor CAD portals, etc). Runs the SYSTEM-elevated decrypt LOCALLY \u2014 the user approves ONE consent dialog + ONE Windows UAC prompt on the desktop (a cloud/relay caller cannot do this; only pup, a local bridge, can ask). Merges the identity-tagged rows into the keychain vault and DROPS the plaintext. Returns COUNTS ONLY. hostFilter narrows the import to matching hosts (surgical, e.g. [\"snapeda\",\"digikey\"]); omit to import everything saved. Pair with pup_import_scan (read-only preview of how many logins exist).",+   "hint": "Answers consent_pending while the user approves; call again with the SAME arguments to collect the result (no second prompt). The answer says via:\"nb\" or via:\"ab-helper\", and which profile was read. no_data means that profile has no saved passwords: pass profile:\"<key from nbrowser_profiles>\" for another.",    "input": {-    "browsers": "optional str[] subset of [\"chrome\",\"edge\",\"brave\"] (default: all found)",-    "hostFilter": "optional str or str[] \u2014 only import logins whose host contains one of these (surgical import)"+    "hostFilter": "optional str or str[]: only import logins whose host contains one of these",+    "sessionFor": "optional str: one site (e.g. \"x.com\") whose signed-in session to bring over instead of passwords",+    "profile": "optional str: the browser profile nb reads, as nbrowser_profiles lists it (e.g. \"chrome:[email protected]\"); default the active one",+    "browsers": "optional str[] (helper fallback only): subset of [\"chrome\",\"edge\",\"brave\"]",+    "sourceProfile": "optional str (helper fallback, sessionFor): which \"<browser> / <profile>\" to take when several have a session",+    "reason": "optional str: shown to the user with the prompt"    },    "longRunning": false,    "name": "pup_import_browser_logins",@@ -2069,7 +2112,7 @@    "pitfalls": [],    "related": [],    "statusVerb": null,-   "summary": "Seed pup's vault from the user's OWN saved Chrome/Edge/Brave passwords, so pup can sign in as them on sites they already use (vendor CAD portals, etc). Runs the SYSTEM-elevated decrypt LOCALLY \u2014 the user approves ONE consent dialog + ONE Windows UAC prompt on the desktop (a cloud/relay caller cannot do this; only pup, a local bridge, can ask). Merges the identity-tagged rows into the keychain vault and DROPS the plaintext. Returns COUNTS ONLY. hostFilter narrows the import to matching hosts (surgical, e.g. [\"snapeda\",\"digikey\"]); omit to import everything saved. Pair with pup_import_scan (read-only preview of how many logins exist).",+   "summary": "Seed pup's vault from the user's OWN saved browser passwords (or, with sessionFor, one site's signed-in session), so pup can sign in as them on sites they already use. Through nb, the browser exports its own store and the user answers one Windows Hello; where nb cannot run (not installed, too old, no extension connected) pup falls back to Adom Bridge's decrypt helper (a consent and a Windows admin prompt; Edge yes, Chrome 156+ no). Returns COUNTS ONLY; values are never logged or returned.",    "timeoutSeconds": 190   },   {
rust/verbs/src/activity.rs+15−2
@@ -49,6 +49,7 @@ fn record(ctx: &Ctx, op: &str, ms: u64) {  fn push(ctx: &Ctx, hwnd: i64, value: Option<u64>) {     let p = match value { Some(v) => json!({ "state": "normal", "value": (v.min(100) as f64) / 100.0 }), None => json!({ "state": "none" }) };+    if cfg!(target_os = "linux") { crate::identity::linux_taskbar(ctx, hwnd, &json!({ "progress": p })); return; }     let _ = ctx.ab.call("desktop_taskbar", json!({ "hwnd": hwnd, "progress": p }), &crate::identity::internal_caller(), Duration::from_secs(3)); } @@ -69,7 +70,7 @@ pub fn progress_on_hwnd<T>(ctx: &Ctx, hwnd: i64, op: &str, work: impl FnOnce() -     // 2.0.699: minimalTouch no longer gates the bar. It is on by default, so this line made the default     // "progress" activity paint nothing for anyone (John, 2026-10-05: "when do you animate the progress bar now?").     // taskbarActivity alone decides, as the Settings doc always said the per-feature settings do.-    if !cfg!(windows) || mode(ctx) != "progress" { return work(); }+    if !(cfg!(windows) || cfg!(target_os = "linux")) || mode(ctx) != "progress" { return work(); }     let Some(arc) = ctx.arc() else { return work() };     let est = estimate_ms(ctx, op).max(200); let started = std::time::Instant::now();     let done = Arc::new(std::sync::atomic::AtomicBool::new(false)); let d2 = done.clone();@@ -94,7 +95,7 @@ pub fn progress_on_hwnd<T>(ctx: &Ctx, hwnd: i64, op: &str, work: impl FnOnce() - /// Node startIconApplyProgress: from open (earliest) until the taskbar icon actually lands (latest), /// timed to the running average of past icon applies; resolves the hwnd once it exists. pub fn start_icon_apply(ctx: Arc<Ctx>, sid: String) {-    if !cfg!(windows) || mode(&ctx) != "progress" { return; }   // 2.0.699: not gated by minimalTouch (see progress_on_hwnd)+    if !(cfg!(windows) || cfg!(target_os = "linux")) || mode(&ctx) != "progress" { return; }   // 2.0.699: not gated by minimalTouch (see progress_on_hwnd)     let est = estimate_ms(&ctx, "icon-apply").max(200);     std::thread::Builder::new().name("icon-apply-progress".into()).spawn(move || {         let started = std::time::Instant::now(); let mut hwnd: Option<i64> = None; let mut landed = false;@@ -138,6 +139,18 @@ pub fn clear_attention(ctx: &Ctx, hwnd: i64, caller: &Caller) -> String { /// for 40-80 ms about a second after the jump list committed, which the foreground gate caught one run in three. /// Let the pulses show, then stop the flash natively; a window the user has foregrounded is left alone. pub fn clear_attention_later(ctx: &Ctx, hwnd: i64) {+    if cfg!(target_os = "linux") {+        // A window the window manager denied the focus to (every background-born pup window) is marked as wanting+        // attention, and GNOME Shell announces it with a "<app> is ready" banner that stays until the mark is gone.+        // pup did not ask for attention, so take the mark off at once and again after the show settles; the banner+        // goes with it. winAttentionFlash on keeps the desktop's own behaviour.+        if ctx.settings.get("winAttentionFlash", "off") == "on" { return; }+        let Some(a) = ctx.arc() else { return };+        std::thread::Builder::new().name("attention-clear".into()).spawn(move || {+            for wait in [0u64, 400, 1200] { std::thread::sleep(Duration::from_millis(wait)); if a.host.foreground() != hwnd { a.host.stop_flash(hwnd); } }+        }).ok();+        return;+    }     if !cfg!(windows) { return; }     let Some(a) = ctx.arc() else { return };     std::thread::Builder::new().name("flash-stop".into()).spawn(move || {
rust/verbs/src/annotate.rs+14
@@ -24,6 +24,20 @@ pub fn start(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let s = match crate::resolve(ctx, caller, args) { Ok(s) => s, Err(e) => { pup_core::log(format!("[annotate] could not start (by {}): {} (tab {:?}, url {:?})", caller.thread, e["error"].as_str().unwrap_or("?"), args.get("_tabId"), args.get("_tabUrl").and_then(|u| u.as_str()).map(|u| u.chars().take(80).collect::<String>()))); return e } };     let Some(t) = s.active().cloned() else { return err("no_tab", "the window has no active tab", None) };     let lane = match crate::transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };+    // {action:"cancel"} was in the schema but fell through to a fresh start, so it RE-ARMED the overlay. It now+    // closes the toolbar and drops the drawings exactly as the X button does; the watcher sees done:false and+    // captures nothing. A page with no overlay (never armed, or navigated away) answers cancelled:false.+    if args["action"].as_str() == Some("cancel") {+        let r = match crate::transport::call_window_fn(&lane, &t, "__pupCancelAnnotate", &[]) {+            Ok(v) => v.as_str().unwrap_or("none").to_string(),+            Err(e) if e["errorCode"] == json!("call_missing") || e["error"].as_str().map(|m| m.contains("not defined")).unwrap_or(false) => "none".into(),+            Err(e) => return e,+        };+        let cancelled = r == "cancelled";+        pup_core::log(format!("[annotate] \"{}\" cancel by {}: {}", s.id, caller.thread, if cancelled { "toolbar closed" } else { "no overlay was open" }));+        return ok(json!({ "cancelled": cancelled, "sessionId": s.id, "window": s.id, "aiThread": s.owner,+            "_hint": if cancelled { "Annotation overlay closed; nothing was captured." } else { "No annotation overlay was open on this window's active tab." } }));+    }     let tool = args["tool"].as_str().unwrap_or("pen").to_string();     let mode = args["mode"].as_str().filter(|m| *m == "multi" || *m == "auto").map(|m| m.to_string());     // 2.0.569: both lanes. The overlay is one file in the extension folder: file-injected on the extension
rust/verbs/src/extdrive.rs+23−1
@@ -104,6 +104,12 @@ pub fn take_command_for(lane: &str, removed: &[i64], wait_ms: u64) -> Value {         g = QUEUED.1.wait_timeout(g, deadline - now).unwrap().0;     }     drop(g);+    // A lane ended while this request waited (kill_extdrive clears the token, mark_dead zeroes last_poll) must+    // stay dead: the request belongs to the browser that was just ended, and answering it as a live poll stamped+    // last_poll, so the next open took the warm path into a dead channel and stalled ~20 s before relaunching+    // (measured on Linux: close the last window, open again within 10 s). It also handed a queued command to a+    // client that would never run it.+    if *l.last_poll.lock().unwrap() == 0 || *l.token.lock().unwrap() != lane { return json!({}); }     take_command_on(&l) } /// Rung by run() whenever a command is queued, so a waiting /extcmd long poll returns at once.@@ -341,6 +347,20 @@ pub fn adopt_tokenless(ctx: &Ctx) {     pup_core::log(format!("[extdrive] adopted a token-less extension browser: minted lane {lane}, stamped it into port.json, {} Chrome for Testing process(es) learned from its windows{}", pids.len(), if had.is_empty() { String::new() } else { format!(" (dropping the stale token {had}, whose browser is gone)") })); } +/// The lane's browser was ended by pup: answer every command still queued for it or handed to it, at once, so+/// their callers return now instead of waiting out their timeouts. A caller blocked in run() counts as+/// IN_FLIGHT, which alive() reads as a live lane, so a leftover command made the next open take the warm path+/// into a dead channel and stall ~20 s (measured on Windows and Linux: close the last window, open again).+pub(crate) fn fail_outstanding(l: &LaneState, why: &str) {+    let mut ids: Vec<u64> = l.pending.lock().unwrap().drain(..).filter_map(|c| c["id"].as_u64()).collect();+    if let Some(m) = l.taken.lock().unwrap().as_mut() { ids.extend(m.drain().map(|(id, _)| id)); }+    if ids.is_empty() { return; }+    let mut r = l.results.lock().unwrap();+    let m = r.get_or_insert_with(Default::default);+    for id in &ids { m.insert(*id, json!({ "id": id, "ok": false, "error": why })); }+    pup_core::log(format!("[extdrive] answered {} outstanding command(s) of the ended {} lane at once", ids.len(), l.profile));+}+ /// Queue one command and block until the extension answers it (or the deadline passes). pub(crate) fn run(cmd: Value, wait: Duration) -> Result<Value, Value> {     IN_FLIGHT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);@@ -622,7 +642,9 @@ pub fn rebind_unbound(ctx: &Ctx) -> usize {     // a valid window"), re-adopted by recovery after a bridge restart with the right hwnd but NO stamp,     // or never bound at all. One test covers them: unbound, bound to a dead hwnd, or bound and unstamped.     let unbound: Vec<String> = ctx.sessions.all().into_iter()-        .filter(|s| crate::transport::is_ext(s) && (s.hwnd.is_none() || s.app_id.is_none() || !s.hwnd.map(|h| ctx.host.is_alive(h)).unwrap_or(false)))+        // Linux: windows never get a taskbar identity (app_id stays None), so "unstamped" is not a sign of a bad+        // binding there; counting it re-bound every window on every health tick.+        .filter(|s| crate::transport::is_ext(s) && (s.hwnd.is_none() || (s.app_id.is_none() && !cfg!(target_os = "linux")) || !s.hwnd.map(|h| ctx.host.is_alive(h)).unwrap_or(false)))         .map(|s| s.id).collect();     if unbound.is_empty() { return 0; }     let caller = crate::identity::internal_caller();
rust/verbs/src/google.rs+4−1
@@ -442,7 +442,10 @@ fn signin_inner(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {         toast_waiting(ctx, caller, &sid, "pup needs you to sign in once",             &format!("Sign in as {account} in the pup window (click here to open it). pup saves the password and will not ask again."));         return pup_core::merge(err("google_password_missing", format!("{account} is not signed into pup and pup has no saved password for it"),-            Some(&format!("{} Get the password into pup's vault once, then call pup_google_signin again: pup_import_browser_logins {{hostFilter:\"google.com\"}} with NO hostFilter reads the user's own saved Chrome/Edge/Brave logins from EVERY profile into pup's vault: tell the user to approve the Adom Bridge consent (inside Hydrogen it arrives as a notification and a card, not a window) and then a Windows admin prompt; no one sees the password, and if the call answers consent_pending, call it again to collect the result. Know the limit before you promise it: a browser does NOT save the password of the account it is itself signed in as, so a user's everyday Google account is usually absent however wide the import (measured on John's laptop: 828 logins imported, 240 of them under [email protected], and not one for google.com). For those accounts the only route is the user signing in once in this pup window, which pup captures on submit, or the user signs in once in window \"{sid}\" and pup captures it. Never ask the user to type the password to you.", if found_in.is_empty() { String::new() } else { format!("The account is signed into {} on this desktop, so its browser most likely has the password saved.", found_in.join(", ")) }))),+            Some(&if cfg!(target_os = "linux") {+                // Linux: browser-login import is Windows-only, so name the routes that exist here.+                format!("Get the password into pup's vault once: the user signs in as {account} once in pup window \"{sid}\" (the desktop notice opens it) and pup captures it on submit, or pup_vault_sync {{peer, hostFilter:\"google.com\"}} copies it from another of their desktops that has it. Then call pup_google_signin again. Browser-login import (pup_import_browser_logins) is Windows-only. Never ask the user to type the password to you.")+            } else { format!("{} Get the password into pup's vault once, then call pup_google_signin again: pup_import_browser_logins {{hostFilter:\"google.com\"}} with NO hostFilter reads the user's own saved Chrome/Edge/Brave logins from EVERY profile into pup's vault: tell the user to approve the Adom Bridge consent (inside Hydrogen it arrives as a notification and a card, not a window) and then a Windows admin prompt; no one sees the password, and if the call answers consent_pending, call it again to collect the result. Know the limit before you promise it: a browser does NOT save the password of the account it is itself signed in as, so a user's everyday Google account is usually absent however wide the import (measured on John's laptop: 828 logins imported, 240 of them under [email protected], and not one for google.com). For those accounts the only route is the user signing in once in this pup window, which pup captures on submit, or the user signs in once in window \"{sid}\" and pup captures it. Never ask the user to type the password to you.", if found_in.is_empty() { String::new() } else { format!("The account is signed into {} on this desktop, so its browser most likely has the password saved.", found_in.join(", ")) }) })),             json!({ "account": account, "window": sid, "foundIn": found_in }));     };     if args["submitReason"].as_str().map(|r| r.trim().len() >= 10) != Some(true) {
rust/verbs/src/identity.rs+96−3
@@ -52,6 +52,7 @@ pub fn boot(ctx: &Ctx) {     let _ = std::fs::remove_file(id::launcher_path(&lad));     let _ = std::fs::create_dir_all(id::port_file_path(&lad).parent().unwrap());     let _ = std::fs::write(id::port_file_path(&lad), json!({ "port": ctx.port, "pid": std::process::id(), "version": pup_core::version(), "host": "127.0.0.1", "assignedPort": ctx.port, "shell": "rust" }).to_string());+    if cfg!(target_os = "linux") { return; }   // Linux: each thread's dock entry is written when its first window opens (linux_register)     match ctx.ab.call("desktop_register_app_identity", register_args(ctx, id::APP_ID, id::DISPLAY_NAME, &icons_dir(ctx).join("adom-pup.ico").to_string_lossy()), &internal_caller(), AB_T) {         Ok(_) => pup_core::log("[identity] base AUMID registered (Adom.Pup)"),         Err(e) => pup_core::log(format!("[identity] base AUMID register skipped: {e}")),@@ -69,6 +70,7 @@ pub fn sweep_dead_identities(ctx: &Ctx, why: &str) -> Value {     let mut removed = Vec::new(); let mut failed = 0usize;     for k in ctx.host.list_aumids(&format!("{}.", id::APP_ID)) {         if live.contains(&k) || !mine.iter().any(|p| k.starts_with(p)) { continue; }+        if cfg!(target_os = "linux") { if ctx.host.unregister_app_identity(&k) { removed.push(k) } else { failed += 1 } continue; }         match ctx.ab.call("desktop_unregister_app_identity", json!({ "appId": k, "anchorFolder": ANCHOR_FOLDER }), &internal_caller(), AB_T) { Ok(_) => removed.push(k), Err(_) => failed += 1 }     }     // tile / icon generations: keep the current one and any an active identity still points at@@ -108,10 +110,14 @@ pub fn sweep_dead_identities(ctx: &Ctx, why: &str) -> Value { /// AUMIDs are shared; thread AUMIDs are per thread and other windows of the thread hold the same one). pub fn release_identity(ctx: &Ctx, app_id: &str) {     if app_id == id::APP_ID || ctx.sessions.all().iter().any(|s| s.app_id.as_deref() == Some(app_id)) { return; }+    if cfg!(target_os = "linux") { ctx.host.unregister_app_identity(app_id); return; }     let _ = ctx.ab.call("desktop_unregister_app_identity", json!({ "appId": app_id, "anchorFolder": ANCHOR_FOLDER }), &internal_caller(), AB_T); }  pub fn mode(ctx: &Ctx) -> String {+    // Linux paints the same modes itself (dock entry + WM_CLASS + window icon, see linux_register / linux_paint);+    // macOS has no per-window dock identity, so it stays plain there.+    if cfg!(target_os = "macos") { return "plain".into(); }     ctx.settings.all().get("taskbarMode").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| if ctx.settings.get("aumidIcons", "on") != "on" { "plain".into() } else if ctx.settings.get("threadTiles", "on") == "on" { "tiles".into() } else { "aumid".into() }) } @@ -176,6 +182,11 @@ pub fn preregister(ctx: &Ctx, caller: &Caller, app_id: &str, display: &str, icon     static DONE: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> = std::sync::OnceLock::new();     let done = DONE.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));     if done.lock().unwrap().contains(app_id) { return true; }+    if cfg!(target_os = "linux") {+        let ok = linux_register(ctx, app_id, display, icon);+        if ok { done.lock().unwrap().insert(app_id.to_string()); } else { pup_core::log(format!("[identity] dock entry for {app_id} not written")); }+        return ok;+    }     match ctx.ab.call("desktop_register_app_identity", register_args(ctx, app_id, display, &icon.to_string_lossy()), caller, AB_T) {         Ok(_) => { done.lock().unwrap().insert(app_id.to_string()); true }         Err(e) => { pup_core::log(format!("[identity] preregister {app_id} failed: {e}")); false }@@ -189,6 +200,11 @@ pub fn relaunch_for(sid: &str) -> String { let launch = exe_launch(); id::relaun pub fn stamp_hwnd(ctx: &Ctx, caller: &Caller, sid_like: &str, name: &str, hwnd: i64) {     let Some((app_id, display, icon)) = plan(ctx, sid_like, name, "") else { return };     if !preregister(ctx, caller, &app_id, &display, &icon) { return; }+    if cfg!(target_os = "linux") {+        let ok = ctx.host.stamp_identity(hwnd, &app_id, &display, "", "") && linux_icon(&icon, None).map(|(i, _)| ctx.host.set_window_icon(hwnd, &i)).unwrap_or(false);+        pup_core::log(format!("[identity] hwnd {hwnd} {} {app_id} for \"{name}\"", if ok { "stamped" } else { "NOT stamped" }));+        return;+    }     let launch = exe_launch();     match ctx.ab.call("desktop_set_window_identity", json!({ "hwnd": hwnd, "appId": app_id, "displayName": display, "iconPath": icon.to_string_lossy(), "relaunchCommand": id::relaunch_command(&launch, sid_like), "relaunchDisplayName": display, "relaunchIconResource": icon.to_string_lossy() }), caller, AB_T) {         Ok(_) => pup_core::log(format!("[identity] hwnd {hwnd} stamped {app_id} for \"{name}\"")),@@ -202,10 +218,26 @@ pub fn stamp_opts(ctx: &Ctx, sid: &str, caller: &Caller, full: bool, with_overla     let Some(s) = ctx.sessions.get(sid) else { return };     let Some(hwnd) = s.hwnd else { return };     let m = mode(ctx);-    if m == "plain" { return; }+    if m == "plain" {+        // Linux: a window pup re-classed goes back to the browser's own class and icon, and its thread's entry goes+        // with the last window that held it (Windows converges onto the browser's AUMID through ab instead).+        if cfg!(target_os = "linux") { if let Some(old) = s.app_id.clone() { ctx.host.clear_window_identity(hwnd); ctx.sessions.with_mut(sid, |x| x.app_id = None); ctx.host.taskbar_signal(0, Some(&old), &json!({ "progress": { "state": "none" }, "count": null, "urgent": false })); release_identity(ctx, &old); } }+        return;+    }     let url = s.active().map(|t| t.url.clone()).unwrap_or_default();     let name = s.owner.clone().unwrap_or_else(|| s.id.clone());     let Some((app_id, display, icon)) = plan(ctx, sid, &name, &url) else { return };+    if cfg!(target_os = "linux") {+        if full || s.app_id.as_deref() != Some(app_id.as_str()) {+            if !linux_register(ctx, &app_id, &display, &icon) { pup_core::log(format!("[identity] \"{sid}\" dock entry {app_id} not written")); return; }+            if !ctx.host.stamp_identity(hwnd, &app_id, &display, &icon.to_string_lossy(), "") { pup_core::log(format!("[identity] \"{sid}\" WM_CLASS not set")); return; }+            pup_core::log(format!("[identity] \"{sid}\" stamped {app_id} ({m}, dock entry + WM_CLASS)"));+            ctx.sessions.with_mut(sid, |x| x.app_id = Some(app_id.clone())); crate::activity::mark_icon_applied(ctx, sid);+            if let Some(x) = ctx.sessions.get(sid) { ctx.sessions.save_file(&x); }+        }+        if with_overlay { overlay(ctx, sid, caller); } else { let _ = linux_paint(ctx, sid, None); }+        return;+    }     if full || s.app_id.as_deref() != Some(app_id.as_str()) {         if let Err(e) = ctx.ab.call("desktop_register_app_identity", register_args(ctx, &app_id, &display, &icon.to_string_lossy()), caller, AB_T) { pup_core::log(format!("[identity] \"{sid}\" register {app_id} failed: {e}")); return; }         let launch = exe_launch();@@ -281,7 +313,9 @@ fn source_favicon(ctx: &Ctx, lane: &crate::transport::Lane, t: &pup_session::Tab pub fn overlay(ctx: &Ctx, sid: &str, caller: &Caller) {     let Some(s) = ctx.sessions.get(sid) else { return };     let (Some(hwnd), Some(t)) = (s.hwnd, s.active().cloned()) else { return };+    if cfg!(target_os = "linux") && mode(ctx) == "plain" { return; }   // Linux: the badge rides on pup's own tile; plain has none     if ctx.settings.get("overlayBadges", "on") == "off" {+        if cfg!(target_os = "linux") { if s.last_paint_sig.as_deref() != Some("cleared") && linux_paint(ctx, sid, None).is_ok() { ctx.sessions.with_mut(sid, |x| { x.last_paint_sig = Some("cleared".into()); x.overlay_led = Some("amber".into()); x.overlay_desc = Some("off (overlayBadges)".into()); }); } return; }         if s.last_paint_sig.as_deref() != Some("cleared") { let _ = ctx.ab.call("desktop_taskbar", json!({ "hwnd": hwnd, "overlay": { "badge": "none" } }), caller, AB_T); ctx.sessions.with_mut(sid, |x| { x.last_paint_sig = Some("cleared".into()); x.overlay_led = Some("amber".into()); x.overlay_desc = Some("off (overlayBadges)".into()); }); }         return;     }@@ -331,14 +365,15 @@ pub fn overlay(ctx: &Ctx, sid: &str, caller: &Caller) {     if s.last_paint_sig.as_deref() == Some(sig.as_str()) { return; }     if let Ok(png) = pup_cdp::base64_decode(&b64) { let dir = ctx.host.temp_dir().join("pup-badge-debug"); let _ = std::fs::create_dir_all(&dir); let _ = std::fs::write(dir.join(format!("{}.png", pup_session::sanitize_sid(sid))), png); }     let fav = json!({ "kind": kind, "format": r["format"], "mode": "default", "transparent": r["transparent"], "darkDominant": r["darkDominant"], "luminance": r["luminance"], "via": via, "href": r["href"] });-    match ctx.ab.call("desktop_taskbar", json!({ "hwnd": hwnd, "overlay": { "icon": b64, "tooltip": tooltip } }), caller, AB_T) {+    let painted: Result<(), String> = if cfg!(target_os = "linux") { pup_cdp::base64_decode(&b64).map_err(|e| e.to_string()).and_then(|png| linux_paint(ctx, sid, Some(&png))) } else { ctx.ab.call("desktop_taskbar", json!({ "hwnd": hwnd, "overlay": { "icon": b64, "tooltip": tooltip } }), caller, AB_T).map(|_| ()).map_err(|e| e.to_string()) };+    match painted {         Ok(_) => {             pup_core::log(format!("[overlay] \"{sid}\" badge painted ({kind}, {via})"));             let led = if kind == "page-favicon" { "green" } else { "amber" };             ctx.sessions.with_mut(sid, |x| {                 x.last_paint_sig = Some(sig.clone()); x.overlay_led = Some(led.into());                 x.overlay_desc = Some(format!("{kind} via {via}{}", r["href"].as_str().map(|h| format!(" ({h})")).unwrap_or_default()));-                x.overlay_why = Some(json!({ "at": pup_core::now_ms(), "source": kind, "tabId": t.tab_id, "tabPickedBy": "active tab", "fetchedVia": via, "paintedBy": "ab desktop_taskbar (overlay.icon)" }));+                x.overlay_why = Some(json!({ "at": pup_core::now_ms(), "source": kind, "tabId": t.tab_id, "tabPickedBy": "active tab", "fetchedVia": via, "paintedBy": if cfg!(target_os = "linux") { "the host: window icon, tile + badge" } else { "ab desktop_taskbar (overlay.icon)" } }));                 if let Some(tt) = x.tabs.iter_mut().find(|tt| tt.tab_id == t.tab_id) { tt.fav = Some(fav.clone()); }             });             // Node v2.0.199: generic is not a terminal state — one re-resolve 30 s out if the URL is unchanged@@ -381,6 +416,7 @@ pub fn audit(ctx: &Ctx) -> Value {     // attempted on a dead hwnd leaves the field empty while the real window is fine (or vice versa).     let caller = internal_caller();     let windows_appid = |hwnd: i64| -> Option<String> {+        if cfg!(target_os = "linux") { return ctx.host.window_app_id(hwnd); }         let r = ctx.ab.call("desktop_get_window_identity", json!({ "hwnd": hwnd }), &caller, AB_T).ok()?;         let o = r.get("output").and_then(|o| o.as_str()).and_then(|t| serde_json::from_str::<Value>(t).ok()).unwrap_or(r);         o["data"]["windows"][0]["appId"].as_str().map(|x| x.to_string())@@ -447,3 +483,60 @@ pub fn stamp_stray_cft_windows(ctx: &Ctx) {         }     } }+++// ───────────────────────── Linux: the dock identity and the window icon ─────────────────────────+//+// Windows asks ab to register an AUMID and stamp it on the window; the shell then draws the button. A Linux+// desktop has no such registry. The same identity becomes a `.desktop` entry (StartupWMClass = the identity,+// icon = the tile, right-click actions = the thread-wide jump-list tasks) plus the window's WM_CLASS, which docks+// match to that entry, plus the window's own `_NET_WM_ICON` (tile, with the favicon badge when overlay badges are+// on), which X11 window lists draw per window. All of it goes through the host (`pup-host-linux`).++/// (RGBA sizes for `_NET_WM_ICON`, path of the 256 px PNG) for an ICO, the badge laid over when given. The PNG of the+/// plain art is cached next to the ICO; a badged set is rendered per paint.+fn linux_icon(ico: &std::path::Path, badge: Option<&[u8]>) -> Option<(Vec<(u32, Vec<u8>)>, PathBuf)> {+    let bytes = std::fs::read(ico).ok()?;+    let (icons, png) = id::badge::linux_icons(&bytes, badge, &id::badge::LINUX_ICON_SIZES)?;+    let png_path = ico.with_extension("png");+    if badge.is_none() && !std::fs::metadata(&png_path).map(|m| m.len() > 100).unwrap_or(false) { std::fs::write(&png_path, &png).ok()?; }+    if badge.is_some() && !png_path.is_file() { let (_, plain) = id::badge::linux_icons(&bytes, None, &[16])?; std::fs::write(&png_path, plain).ok()?; }+    Some((icons, png_path))+}++/// The dock entry for an identity: its tile as the icon, its name, the dashboard as its launch line, and the+/// thread-wide jump-list tasks as its right-click menu (per-window tasks need a window and stay on Windows).+fn linux_register(ctx: &Ctx, app_id: &str, display: &str, icon: &std::path::Path) -> bool {+    let Some((_, png)) = linux_icon(icon, None) else { return false };+    let launch = exe_launch();+    let cmd = |verb: &str, args: Value| { let (t, a) = launch(&format!("{verb} {}", id::base64_encode(args.to_string().as_bytes())), true); format!("{t} {a}") };+    let mut actions = Vec::new();+    if ctx.settings.get("jumpLists", "on") != "off" {+        if ctx.settings.get("jlDashboard", "on") != "off" { actions.push(("Open pup dashboard".to_string(), cmd("pup_dashboard", json!({})))); }+        if ctx.settings.get("jlCloseAll", "on") != "off" { actions.push(("Close ALL Adom Pup windows".to_string(), cmd("pup_close", json!({ "all": true, "everyone": true })))); }+    }+    ctx.host.register_app_identity(app_id, display, &png.to_string_lossy(), &cmd("pup_dashboard", json!({})), &actions)+}++/// Paint a window's icon (its identity's art, the badge over it when given) and the dock's count badge (the+/// thread's open tabs, when more than one and overlay badges are on).+fn linux_paint(ctx: &Ctx, sid: &str, badge: Option<&[u8]>) -> Result<(), String> {+    let s = ctx.sessions.get(sid).ok_or("no such window")?;+    let hwnd = s.hwnd.ok_or("no OS window yet")?;+    let url = s.active().map(|t| t.url.clone()).unwrap_or_default();+    let name = s.owner.clone().unwrap_or_else(|| s.id.clone());+    let (app_id, _, icon) = plan(ctx, sid, &name, &url).ok_or("plain mode")?;+    let (icons, _) = linux_icon(&icon, badge).ok_or("icon not rendered")?;+    if !ctx.host.set_window_icon(hwnd, &icons) { return Err("window icon not set".into()); }+    let tabs: usize = ctx.sessions.all().iter().filter(|x| x.app_id.as_deref() == Some(app_id.as_str())).map(|x| x.tabs.len()).sum();+    let count = if badge.is_some() && tabs > 1 { json!(tabs) } else { Value::Null };+    ctx.host.taskbar_signal(0, Some(&app_id), &json!({ "count": count }));+    Ok(())+}++/// The Linux counterpart of ab's `desktop_taskbar {hwnd, progress}`: the window's XApp progress hints, and the+/// LauncherEntry of the identity the window carries (if pup stamped one).+pub fn linux_taskbar(ctx: &Ctx, hwnd: i64, signal: &Value) -> bool {+    let app_id = ctx.sessions.all().into_iter().find(|s| s.hwnd == Some(hwnd)).and_then(|s| s.app_id).or_else(|| ctx.host.window_app_id(hwnd).filter(|a| a.starts_with(id::APP_ID)));+    ctx.host.taskbar_signal(hwnd, app_id.as_deref(), signal)+}
rust/verbs/src/import.rs+197−19
@@ -10,6 +10,12 @@ //! Both hand pup a machine-local `handoffPath` plus counts, never a value over the relay. pup is a local //! bridge on the same desktop, so it reads the handoff file directly, imports it, then drops the handoff. //! Credentials are used to fill the keychain / set cookies and are never logged.+//!+//! pup-bridge#145 (Colby, 2026-11-05): nb is the path, and pup falls back to ab's decrypt helper ONLY when nb+//! cannot run the export at all: nb missing, nb older than these verbs (public nb was 0.16.33), or no nb+//! extension connected. Without the fallback, promoting pup broke login import on every public desktop, where+//! the helper still serves Edge (Chrome 156+ is beyond it, adom/adom-bridge#254). A user's own answer from nb+//! (Hello declined, no session, export failed) is never retried through the helper.  use crate::Ctx; use pup_core::{err, ok, Caller};@@ -69,22 +75,71 @@ pub fn import_browser_logins(ctx: &Ctx, caller: &Caller, args: &Value) -> Value     let passwords = session_for(args).is_none();     if passwords {         let _ = ctx.ab.call("notify_user", json!({ "title": "pup is waiting for your approval",-            "body": "A Windows Hello prompt is opening so Chrome can export your saved logins into pup. Approve it; pup uses the logins to sign you in without asking for passwords.",+            "body": "Approve the prompt on your screen (Windows Hello, or on older setups an Adom Bridge consent and then a Windows admin prompt) so pup can import your saved logins. pup uses them to sign you in without asking for passwords.",             "level": "warning", "focus": { "host": true } }), caller, Duration::from_secs(8));     }     let other = IMPORT_JOB.lock().unwrap().as_ref().map(|j| j.key != key).unwrap_or(false);     let detail = if passwords {-        "nb is driving Chrome's own password export on the desktop; the user answers a Windows Hello prompt (Chrome decrypts its own store, pup never touches the sealed store). Tell the user to approve Hello, then call pup_import_browser_logins again with the same arguments: it re-attaches to this request and returns the result, it does not raise a second prompt."+        "The import is waiting on the user. With nb it is Chrome's own password export and one Windows Hello prompt; where nb cannot run, Adom Bridge's consent and then a Windows admin prompt (inside Hydrogen the consent arrives as a notification and a card). Tell the user to approve what is on screen, then call pup_import_browser_logins again with the same arguments: it re-attaches to this request and returns the result, it does not raise a second prompt."     } else {-        "nb is reading the site's session from the user's own browser. Call pup_import_browser_logins again with the same arguments to collect the result; it re-attaches to the same request."+        "The site's session is being read from the user's own browser (where nb cannot run, after an Adom Bridge consent and a Windows admin prompt). Call pup_import_browser_logins again with the same arguments to collect the result; it re-attaches to the same request."     };     pup_core::merge(err("consent_pending", "Waiting for the import to finish on the user's desktop", Some(&format!("{}{}", if other { "An import with different arguments is already waiting; this call attached to it. " } else { "" }, detail))),         json!({ "pending": true, "output": json!({ "ok": false, "pending": true }).to_string() })) }  fn import_now(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {-    if let Some(site) = session_for(args) { return import_session_via_nb(ctx, caller, args, &site); }-    import_passwords_via_nb(ctx, caller, args)+    let site = session_for(args);+    let r = match &site { Some(s) => import_session_via_nb(ctx, caller, args, s), None => import_passwords_via_nb(ctx, caller, args) };+    let Some(why) = r.get("_nbUnavailable").and_then(|w| w.as_str()).map(|w| w.to_string()) else { return pup_core::merge(r, json!({ "via": "nb" })) };+    if !cfg!(windows) { return err("nb_unavailable", format!("nb could not run the import: {why}"), Some("Install or update the native-browser bridge (nb) with its extension in the user's browser.")); }+    pup_core::log(format!("[import] nb cannot run the export ({why}); falling back to ab's decrypt helper"));+    let h = import_via_helper(ctx, caller, args);+    let next = "The supported path is nb: install or update the native-browser bridge with its extension in the user's browser (nbrowser_readiness). Chrome 156 and later can only be imported that way.";+    if h.get("success") == Some(&json!(false)) {+        let msg = format!("{} (nb could not run it first: {why})", h["error"].as_str().unwrap_or("the import failed"));+        let hint = format!("{} {next}", h["_hint"].as_str().unwrap_or(""));+        return pup_core::merge(h, json!({ "error": msg, "_hint": hint.trim(), "nbUnavailable": why, "via": "ab-helper" }));+    }+    let hint = format!("{} Imported through Adom Bridge's older decrypt helper because nb could not run it ({why}). {next}", h["_hint"].as_str().unwrap_or(""));+    pup_core::merge(h, json!({ "via": "ab-helper", "nbUnavailable": why, "_hint": hint.trim() }))+}++/// Ask nb for an export. Err(Ok(why)) = nb cannot run it here (the helper may); Err(Err(resp)) = a real answer to+/// return as it is (the user declined, no session, ...). Before 2.0.741 every nb refusal surfaced as nb_call_failed+/// and nb's own codes (awaiting_user, no_session) never reached the caller.+fn nb_export(ctx: &Ctx, caller: &Caller, verb: &str, args: Value, what: &str) -> Result<(Value, Value), Result<String, Value>> {+    let resp = match ctx.ab.call(verb, args, caller, Duration::from_secs(180)) {+        Ok(v) => v,+        Err(pup_core::AbError::Refused { code, message, .. }) => {+            if let Some(why) = nb_cannot_run(&code, &message) { return Err(Ok(why)); }+            let p = json!({ "error": message });+            return Err(Err(relay_nb_error(&code, &p, what).unwrap_or_else(|| err(if code == "error" { "nb_refused" } else { code.as_str() }, format!("nb refused to {what}: {message}"), None))));+        }+        Err(e) => return Err(Err(err("nb_call_failed", format!("Could not reach the native-browser bridge (nb) to {what}: {e}"), Some("Adom Bridge did not answer pup's call. Retry once; if it keeps failing, report it on adom/pup-bridge with the time.")))),+    };+    let payload = unwrap_payload(&resp);+    if payload["ok"] == json!(false) && payload["handoffPath"].is_null() {+        let msg = payload["error"].as_str().unwrap_or("").to_string();+        if let Some(why) = nb_cannot_run(payload["errorCode"].as_str().unwrap_or(""), &msg) { return Err(Ok(why)); }+    }+    if let Some(ec) = error_code(&resp, &payload) { if let Some(e) = relay_nb_error(&ec, &payload, what) { return Err(Err(e)); } }+    Ok((resp, payload))+}++/// nb cannot run the export on this desktop at all, as opposed to answering it. Matched on what ab and nb+/// actually send: ab's routing codes when no bridge serves the verb, an older nb's "verb not implemented yet"+/// (nb 0.16.33, measured 2026-11-05), and "native browser extension not connected".+fn nb_cannot_run(code: &str, message: &str) -> Option<String> {+    let m = message.to_lowercase();+    let codes = ["unknown_verb", "unknown_command", "no_bridge", "bridge_not_installed", "bridge_not_found", "bridge_unavailable", "bridge_spawn_failed", "unsupported_on_this_os"];+    if codes.contains(&code) || m.contains("not implemented") || m.contains("unknown verb") || m.contains("unknown command") || m.contains("no bridge") {+        return Some("nb is not installed here, or is older than the export verbs (it needs nb 0.16.45 or newer)".into());+    }+    if m.contains("extension not connected") || m.contains("not connected") {+        return Some("no nb extension is connected in the user's browser".into());+    }+    None }  /// Unwrap a bridge response that may carry its real JSON in an `output` string (ab's passthrough shape).@@ -107,7 +162,7 @@ fn relay_nb_error(ec: &str, payload: &Value, what: &str) -> Option<Value> {         "no_session" => (format!("The user's browser has no signed-in session for this site.{suffix}"), Some("The user may be signed in under a different browser profile. Check nbrowser_profiles / nbrowser_login_state, or ask the user to sign in to the site once in their own browser, then try again.")),         "awaiting_user" => (format!("The Windows Hello prompt is waiting to be answered on the desktop.{suffix}"), Some("Tell the user to approve the Hello prompt, then call pup_import_browser_logins again with the same arguments.")),         "export_control_not_found" => (format!("Chrome moved its password-manager UI, so nb could not find the Export control.{suffix}"), Some("nb returned a screenshot in its own response. File it on adom/nb-bridge; do not retry blindly.")),-        "unsupported_platform" | "mac_unsupported" => (format!("This import is Windows-only today.{suffix}"), Some("On a Mac, sign in once in the pup window (pup saves it), or sync logins from a Windows desktop with pup_vault_sync.")),+        "unsupported_platform" | "mac_unsupported" => (format!("This import is Windows-only today.{suffix}"), Some(if cfg!(target_os = "linux") { "On Linux, sign in once in the pup window (pup saves it), or sync logins from a Windows desktop with pup_vault_sync." } else { "On a Mac, sign in once in the pup window (pup saves it), or sync logins from a Windows desktop with pup_vault_sync." })),         _ => return None,     };     Some(err(ec, msg, hint))@@ -125,12 +180,13 @@ fn session_for(args: &Value) -> Option<String> { fn import_session_via_nb(ctx: &Ctx, caller: &Caller, args: &Value, site: &str) -> Value {     let reason = args["reason"].as_str().map(|s| s.to_string()).unwrap_or_else(|| format!("Bring your {site} session from your own browser into pup so it can act as you there."));     pup_core::log(format!("[import] session for {site}: asking nb (nbrowser_export_site_session)"));-    let resp = match ctx.ab.call("nbrowser_export_site_session", json!({ "domain": site, "confirm": true, "reason": reason }), caller, Duration::from_secs(180)) {-        Ok(v) => v,-        Err(e) => return err("nb_call_failed", format!("Could not reach the native-browser bridge (nb) to export the {site} session: {e}"), Some("Is Adom Bridge's native-browser bridge installed with its extension loaded? nbrowser_profiles should list the user's real browser.")),+    let mut a = json!({ "domain": site, "confirm": true, "reason": reason });+    if let Some(p) = nb_profile(args) { a["profile"] = json!(p); }+    let (resp, payload) = match nb_export(ctx, caller, "nbrowser_export_site_session", a, "export a site session") {+        Ok(x) => x,+        Err(Ok(why)) => return json!({ "success": false, "_nbUnavailable": why }),+        Err(Err(e)) => return e,     };-    let payload = unwrap_payload(&resp);-    if let Some(ec) = error_code(&resp, &payload) { if let Some(e) = relay_nb_error(&ec, &payload, "export a site session") { return e; } }      let handoff_id = payload["handoffId"].as_str().or_else(|| resp["handoffId"].as_str()).map(|s| s.to_string());     let Some(path) = payload["handoffPath"].as_str().or_else(|| resp["handoffPath"].as_str()).map(|s| s.to_string()) else {@@ -140,7 +196,8 @@ fn import_session_via_nb(ctx: &Ctx, caller: &Caller, args: &Value, site: &str) -     let file: Value = serde_json::from_str(&data).unwrap_or(Value::Null);     let cookies: Vec<Value> = file["cookies"].as_array().cloned().unwrap_or_default(); -    let result = set_session_cookies(ctx, caller, site, &cookies);+    let mut result = set_session_cookies(ctx, caller, site, &cookies);+    if let Some(p) = payload["profile"].as_str() { result["profile"] = json!(p); }     if let Some(id) = &handoff_id { let _ = ctx.ab.call("nbrowser_drop_handoff", json!({ "handoffId": id }), caller, Duration::from_secs(10)); }     result }@@ -189,12 +246,14 @@ fn import_passwords_via_nb(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     // nb exports the whole profile on one Windows Hello (nb#55): never pass `sites`, which would re-prompt per     // site. pup narrows to hostFilter client-side after reading the CSV.     pup_core::log("[import] passwords: asking nb (nbrowser_export_saved_logins) — Chrome's own export, one Windows Hello".to_string());-    let resp = match ctx.ab.call("nbrowser_export_saved_logins", json!({ "confirm": true, "reason": reason }), caller, Duration::from_secs(180)) {-        Ok(v) => v,-        Err(e) => return err("nb_call_failed", format!("Could not reach the native-browser bridge (nb) to export saved logins: {e}"), Some("Is Adom Bridge's native-browser bridge installed with its extension loaded? It must be nb >=0.16.45 for nbrowser_export_saved_logins.")),+    let mut a = json!({ "confirm": true, "reason": reason });+    if let Some(p) = nb_profile(args) { a["profile"] = json!(p); }+    let (resp, payload) = match nb_export(ctx, caller, "nbrowser_export_saved_logins", a, "export saved logins") {+        Ok(x) => x,+        Err(Ok(why)) => return json!({ "success": false, "_nbUnavailable": why }),+        Err(Err(e)) => return e,     };-    let payload = unwrap_payload(&resp);-    if let Some(ec) = error_code(&resp, &payload) { if let Some(e) = relay_nb_error(&ec, &payload, "export saved logins") { return e; } }+    let read_from = payload["profile"].as_str().map(|s| s.to_string());      let handoff_id = payload["handoffId"].as_str().or_else(|| resp["handoffId"].as_str()).map(|s| s.to_string());     let Some(path) = payload["handoffPath"].as_str().or_else(|| resp["handoffPath"].as_str()).map(|s| s.to_string()) else {@@ -204,7 +263,7 @@ fn import_passwords_via_nb(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let rows = csv_to_login_rows(&csv);     if rows.is_empty() {         if let Some(id) = &handoff_id { let _ = ctx.ab.call("nbrowser_drop_handoff", json!({ "handoffId": id }), caller, Duration::from_secs(10)); }-        return err("no_data", "nb's export contained no logins.", Some("The exported profile may have no saved passwords. Check nbrowser_profiles for the right profile."));+        return pup_core::merge(err("no_data", format!("nb's export{} contained no logins.", read_from.as_deref().map(|p| format!(" of {p}")).unwrap_or_default()), Some("That browser profile may have no saved passwords. nbrowser_profiles lists the user's profiles: call again with profile:\"<its key, e.g. chrome:[email protected]>\" to read another one.")), json!({ "profile": read_from }));     }     let filters: Vec<String> = match &args["hostFilter"] { Value::String(s) => vec![s.to_lowercase()], Value::Array(a) => a.iter().filter_map(|v| v.as_str().map(|s| s.to_lowercase())).collect(), _ => vec![] };     let filtered: Vec<Value> = if filters.is_empty() { rows.clone() } else { rows.iter().filter(|r| { let h = r["url"].as_str().or(r["host"].as_str()).unwrap_or("").to_lowercase(); filters.iter().any(|f| h.contains(f)) }).cloned().collect() };@@ -215,10 +274,115 @@ fn import_passwords_via_nb(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     pup_core::log(format!("[import] merge done: +{added} new, {updated} updated, {skipped} skipped{}", if filters.is_empty() { String::new() } else { format!(" (filtered to: {})", filters.join(", ")) }));     ok(json!({ "success": true, "output": json!({ "ok": true, "added": added, "updated": updated, "skipped": skipped,         "hostFilter": if filters.is_empty() { Value::Null } else { json!(filters) },-        "stats": { "exported": rows.len(), "imported": added + updated },+        "stats": { "exported": rows.len(), "imported": added + updated }, "profile": read_from,         "_hint": format!("Imported {} login(s) into the adom-you vault ({added} new, {updated} updated{}). They auto-fill on the matching sites; pup NEVER auto-submits.", added + updated, if skipped > 0 { format!(", {skipped} skipped") } else { String::new() }) }).to_string() })) } +/// nb's browser profile key for the export (pup-bridge#140: the wrong Chrome profile was read and there was no way+/// to name another). `profile:"chrome:[email protected]"`, as nbrowser_profiles lists them.+fn nb_profile(args: &Value) -> Option<String> {+    args["profile"].as_str().map(|s| s.trim().to_string()).filter(|s| s.contains(':'))+}++// ── the fallback: ab's decrypt helper, exactly as 2.0.737 called it ─────────────────────────────────────────────+// Used only when nb cannot run the export (see import_now). Consent + a Windows admin prompt on the desktop.++fn import_via_helper(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    if !cfg!(windows) { return err("unsupported_platform", "Browser-login import is Windows-only (Chromium ABE/DPAPI).", None); }+    let scan = import_scan(ctx);+    let all: Vec<Value> = scan["profiles"].as_array().cloned().unwrap_or_default();+    let want: Vec<String> = match args["browsers"].as_array() {+        Some(a) if !a.is_empty() => a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect(),+        _ => { let mut v: Vec<String> = all.iter().filter_map(|p| p["browser"].as_str().map(|s| s.to_string())).collect(); v.sort(); v.dedup(); v }+    };+    if want.is_empty() { return err("no_profiles", "No Chromium browser profiles with saved passwords were found to import from.", None); }+    pup_core::log(format!("[import] decrypt requested for browsers=[{}] — ab helper, consent + UAC prompt on the desktop", want.join(", ")));+    // ab-core's signed helper: try the two known verb names (older builds).+    let mut resp = Value::Null;+    for name in ["desktop_decrypt_browser_credentials", "desktop_decrypt_pup_credentials"] {+        let include = if session_for(args).is_some() { json!(["cookies"]) } else { json!(["passwords"]) };+        let r = ctx.ab.call(name, json!({ "browsers": want, "include": include, "reason": args["reason"].as_str().unwrap_or("Import your saved browser logins into the pup adom-you vault so pup can sign you in.") }), caller, Duration::from_secs(180));+        match r { Ok(v) => { if v.get("errorCode").and_then(|e| e.as_str()) == Some("unknown_verb") { continue; } resp = v; break; } Err(e) => { resp = json!({ "errorCode": "decrypt_call_failed", "error": e.to_string() }); break; } }+    }+    let payload = if resp.get("output").and_then(|v| v.as_str()).map(|s| s.starts_with('{')).unwrap_or(false) { serde_json::from_str(resp["output"].as_str().unwrap()).unwrap_or(resp.clone()) } else { resp.clone() };+    if let Some(ec) = payload.get("errorCode").and_then(|e| e.as_str()).or(resp.get("errorCode").and_then(|e| e.as_str())) {+        let msg = match ec {+            "relay_forbidden" => "ab refused the decrypt as a relayed call — this should not happen from pup (a local bridge). Please report it.",+            "consent_declined" => "You declined the Adom Bridge consent dialog on the desktop. Nothing was read.",+            "elevation_declined" => "You cancelled the Windows admin (UAC) prompt. Nothing was decrypted.",+            "helper_missing" => "The signed decrypt helper is not installed — update/repair Adom Bridge.",+            "decrypt_call_failed" => "The decrypt helper call failed.",+            _ => "Decrypt failed.",+        };+        return err(ec, format!("{msg}{}", payload.get("error").and_then(|e| e.as_str()).map(|e| format!(" ({e})")).unwrap_or_default()), None);+    }+    if let Some(site) = session_for(args) { return import_session_from_helper(ctx, caller, args, &payload, &site); }+    let rows: Vec<Value> = payload["passwords"].as_array().cloned().unwrap_or_default();+    if payload.get("ok") != Some(&json!(true)) && rows.is_empty() { return err("no_data", format!("decrypt helper returned no data{}", payload.get("error").and_then(|e| e.as_str()).map(|e| format!(": {e}")).unwrap_or_default()), None); }+    let filters: Vec<String> = match &args["hostFilter"] { Value::String(s) => vec![s.to_lowercase()], Value::Array(a) => a.iter().filter_map(|v| v.as_str().map(|s| s.to_lowercase())).collect(), _ => vec![] };+    let filtered: Vec<Value> = if filters.is_empty() { rows.clone() } else { rows.iter().filter(|r| { let h = r["host"].as_str().or(r["url"].as_str()).or(r["origin"].as_str()).unwrap_or("").to_lowercase(); filters.iter().any(|f| h.contains(f)) }).cloned().collect() };+    let (added, updated, skipped) = ctx.vault.merge_imported(&*ctx.host, &filtered);+    // 2.0.733: remembered so the first-window import tip (vault::import_tip_once) stops for good after an import.+    let _ = std::fs::write(ctx.host.state_dir().join("pup-logins-imported.json"), json!({ "at": pup_core::now_ms(), "added": added, "updated": updated }).to_string());+    let st = &payload["stats"];+    pup_core::log(format!("[import] merge done: +{added} new, {updated} updated, {skipped} skipped{}", if filters.is_empty() { String::new() } else { format!(" (filtered to: {})", filters.join(", ")) }));+    ok(json!({ "success": true, "output": json!({ "ok": true, "added": added, "updated": updated, "skipped": skipped,+        "hostFilter": if filters.is_empty() { Value::Null } else { json!(filters) },+        "stats": { "profilesRead": st["profilesRead"], "passwordsOk": st["passwordsOk"], "passwordsFailed": st["passwordsFailed"] },+        "_hint": format!("Imported {} login(s) into the adom-you vault ({added} new, {updated} updated{}). They auto-fill on the matching sites; pup NEVER auto-submits.", added + updated, if skipped > 0 { format!(", {skipped} skipped") } else { String::new() }) }).to_string() }))+}++/// The helper's cookies for one site, set into adom-you (2.0.735, pup-bridge#136).+fn import_session_from_helper(ctx: &Ctx, caller: &Caller, args: &Value, payload: &Value, site: &str) -> Value {+    let all: Vec<Value> = payload["cookies"].as_array().cloned().unwrap_or_default();+    let host_of = |c: &Value| c["host"].as_str().or(c["host_key"].as_str()).or(c["domain"].as_str()).unwrap_or("").to_lowercase();+    let matches = |h: &str| { let h = h.trim_start_matches('.'); h == site || h.ends_with(&format!(".{site}")) };+    let mut rows: Vec<Value> = all.into_iter().filter(|c| matches(&host_of(c))).collect();+    if rows.is_empty() {+        // 2.0.736: say what came back, so "no session" is never a mystery (John's laptop, 2026-10-28: X is signed in+        // in his Chrome, yet nothing matched). Counts and field names only; never a cookie value.+        let all2: Vec<Value> = payload["cookies"].as_array().cloned().unwrap_or_default();+        let mut per: std::collections::BTreeMap<String, usize> = Default::default();+        for c in &all2 { *per.entry(format!("{} / {}", c["sourceBrowser"].as_str().or(c["source_browser"].as_str()).unwrap_or("?"), c["sourceProfile"].as_str().or(c["source_profile"].as_str()).unwrap_or("?"))).or_default() += 1; }+        let near: usize = all2.iter().filter(|c| { let h = host_of(c); h.contains("x.com") || h.contains("twitter") }).count();+        let keys: Vec<String> = all2.first().and_then(|c| c.as_object()).map(|o| o.keys().cloned().collect()).unwrap_or_default();+        pup_core::log(format!("[import] session for {site}: no match among {} cookie(s); per profile {:?}; near-matches {near}; stats {}; row keys {:?}", all2.len(), per, payload["stats"], keys));+        return pup_core::merge(err("no_session", format!("None of the cookies the user's browsers returned are for {site} ({} cookies came back in all).", all2.len()), Some("Check cookiesByProfile: if the profile the user is signed into is missing or shows 0, its cookies could not be read. Ask the user to sign in to that site once in this pup window instead.")),+            json!({ "cookiesReturned": all2.len(), "cookiesByProfile": per, "nearMatches": near, "decryptStats": payload["stats"], "cookieFields": keys }));+    }+    let src = |c: &Value| format!("{} / {}", c["sourceBrowser"].as_str().or(c["source_browser"].as_str()).unwrap_or("?"), c["sourceProfile"].as_str().or(c["source_profile"].as_str()).unwrap_or("?"));+    let mut sources: Vec<String> = rows.iter().map(|c| src(c)).collect(); sources.sort(); sources.dedup();+    if let Some(want) = args["sourceProfile"].as_str() { rows.retain(|c| src(c).to_lowercase().contains(&want.to_lowercase())); }+    else if sources.len() > 1 {+        return pup_core::merge(err("several_sessions", format!("{} browser profiles have a session for {site}: {}", sources.len(), sources.join("; ")), Some("Ask the user which account they mean, then call again with sourceProfile:\"<profile name from the list>\".")), json!({ "sources": sources }));+    }+    if rows.is_empty() { return err("no_session", format!("No cookies for {site} in the profile you named."), Some("Use one of the names from the sources list.")); }+    // a window of the caller on adom-you, else any adom-you extension-lane window, carries the DevTools calls+    let sessions = ctx.sessions.for_profile("adom-you");+    let Some(s) = sessions.iter().find(|s| crate::transport::is_ext(s) && s.owner.as_deref() == Some(caller.thread.as_str())).or_else(|| sessions.iter().find(|s| crate::transport::is_ext(s))) else {+        return err("no_window", "Open a pup window first (any page); the session is loaded through it.", Some("pup_open_window {url:\"https://example.com\"}, then call again."));+    };+    let Some(t) = s.active().cloned() else { return err("no_tab", "that pup window has no tab", None) };+    let lane = match crate::transport::lane_for(ctx, s) { Ok(l) => l, Err(e) => return e };+    let now_s = (pup_core::now_ms() / 1000) as f64;+    let (mut set, mut failed) = (0u32, 0u32);+    for c in &rows {+        let host = host_of(c); let name = c["name"].as_str().unwrap_or(""); let value = c["value"].as_str().unwrap_or("");+        if name.is_empty() { continue; }+        let secure = c["secure"].as_bool().or(c["isSecure"].as_bool()).or(c["is_secure"].as_bool()).unwrap_or(true);+        let http_only = c["httpOnly"].as_bool().or(c["http_only"].as_bool()).or(c["isHttpOnly"].as_bool()).or(c["is_httponly"].as_bool()).unwrap_or(false);+        let expires = c["expires"].as_f64().or(c["expiresUtc"].as_f64()).filter(|e| *e > now_s).unwrap_or(now_s + 30.0 * 86400.0);+        let mut p = json!({ "name": name, "value": value, "path": c["path"].as_str().unwrap_or("/"), "secure": secure, "httpOnly": http_only, "expires": expires });+        if name.starts_with("__Host-") { p["url"] = json!(format!("https://{}/", host.trim_start_matches('.'))); }+        else { p["domain"] = json!(host); }+        match crate::transport::cdp_raw(&lane, &t, "Network.setCookie", p, 10) { Ok(v) if v["success"] != json!(false) => set += 1, _ => failed += 1 }+    }+    pup_core::log(format!("[import] session for {site}: {set} cookie(s) set in adom-you, {failed} refused (from {}; values never logged)", sources.join(", ")));+    ok(json!({ "success": true, "site": site, "cookiesSet": set, "cookiesRefused": failed, "source": if args["sourceProfile"].is_string() { json!(args["sourceProfile"]) } else { json!(sources.first()) },+        "output": json!({ "ok": true, "site": site, "cookiesSet": set, "cookiesRefused": failed }).to_string(),+        "_hint": format!("Brought the user's signed-in {site} session from their own browser into pup ({set} cookies). Reload or open {site} in a pup window: it should be signed in. Nothing was printed; pup never shows cookie values.") }))+}+ /// Parse a Chrome/Edge password-export CSV (header row names the columns; `note` already stripped by nb) into /// vault rows {url, username, password}. RFC4180: quoted fields may contain commas, quotes ("" escape) and newlines. fn csv_to_login_rows(csv: &str) -> Vec<Value> {@@ -263,6 +427,20 @@ fn parse_csv(s: &str) -> Vec<Vec<String>> { mod tests {     use super::*; +    #[test]+    fn nb_cannot_run_only_when_nb_cannot_serve() {+        // what ab and nb really send when nb cannot run the export (nb 0.16.33 measured 2026-11-05)+        assert!(super::nb_cannot_run("error", "native browser extension not connected").is_some());+        assert!(super::nb_cannot_run("", "verb not implemented yet: nbrowser_export_saved_logins").is_some());+        // nb-bridge#57: the same answer now arrives as a refusal with no errorCode (ab reads it as code "error")+        assert!(super::nb_cannot_run("error", "verb not implemented yet: nbrowser_export_site_session").is_some());+        assert!(super::nb_cannot_run("unknown_verb", "").is_some());+        assert!(super::nb_cannot_run("bridge_not_installed", "").is_some());+        // a real answer from nb or the user is never retried through the helper+        for c in ["awaiting_user", "no_session", "export_failed", "consent_declined", "browser_elevated", "export_control_not_found", "profile_mismatch"] {+            assert!(super::nb_cannot_run(c, "the user said no").is_none(), "{c}");+        }+    }     #[test]     fn csv_handles_quotes_commas_and_note_column() {         // Chrome export header order, note column present but blanked (nb strips it).
rust/verbs/src/input.rs+38
@@ -342,12 +342,50 @@ pub fn set_viewport(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { pub fn errors(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let (s, t, _b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };     let clear = args.get("clear").and_then(|c| c.as_bool()).unwrap_or(true);+    // The extension lane (the default) has no CDP event pump, so tabstate stayed empty and this answered count:0+    // whatever the page did (Ralph test, 2026-11-03, Linux and Windows). Its errors now come from the extension.+    if crate::transport::is_ext(&s) {+        if let Err(e) = crate::transport::lane_for(ctx, &s) { return e; }+        let (list, capture) = match crate::transport::ext_errors(&t, clear) { Ok(x) => x, Err(e) => return e };+        return ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "count": list.len(), "errors": list, "cleared": clear, "lane": "ext",+            "pageCapture": capture,+            "_hint": if capture { "Exceptions, unhandled rejections, failed loads and HTTP error responses since the page loaded (or since the last clear); console.error lines while pup is driving the tab." } else { "This page started before pup's error capture was installed (or is not an http(s) page): only console errors seen while pup drives the tab are listed. Reload the tab to capture everything." } }));+    }     let list = { let mut st = ctx.tabstate.lock().unwrap(); let e = st.entry(t.cdp_session.clone()).or_default(); let v = e.errors.clone(); if clear { e.errors.clear(); } v };     ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "count": list.len(), "errors": list, "cleared": clear })) }  pub fn dialog(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    // The extension lane (the default) never filled tabstate.dialog, so this always answered "no dialog" and could not+    // answer one (Ralph test, 2026-11-03, Linux and Windows). Chrome cancels a page dialog the moment it opens while+    // pup's DevTools driver is attached, so the extension lane answers a dialog that is still open, reports the one+    // Chrome already handled, and can PRE-ANSWER the next ones (policy:"manual") and disarm (policy:"auto").+    if crate::transport::is_ext(&s) {+        if let Err(e) = crate::transport::lane_for(ctx, &s) { return e; }+        let action = args.get("action").and_then(|a| a.as_str()).unwrap_or("");+        let policy = args.get("policy").and_then(|a| a.as_str()).filter(|p| *p == "manual" || *p == "auto");+        if !action.is_empty() && action != "accept" && action != "dismiss" { return err("dialog_action_required", "action must be \"accept\" or \"dismiss\"", None); }+        let prompt_text = args.get("promptText").and_then(|x| x.as_str());+        let r = match crate::transport::ext_dialog(&t, !action.is_empty(), action != "dismiss", prompt_text, policy) {+            Ok(r) => r,+            // "no dialog to handle": nothing was open and nothing was auto-handled on this tab.+            Err(e) if e["error"].as_str().map(|m| m.contains("no dialog")).unwrap_or(false) => json!({ "dialog": Value::Null }),+            Err(e) => return e,+        };+        let mut out = json!({ "sessionId": s.id, "window": s.id, "tabId": t.tab_id, "lane": "ext" });+        for k in ["handled", "accepted", "was", "alreadyHandled", "policy", "armed", "disarmed", "seen", "preAnswer", "open", "last", "dialog"] { if let Some(v) = r.get(k) { out[k] = v.clone(); } }+        out["_hint"] = json!(if policy == Some("manual") {+            "Pre-answer ARMED on this tab: the next dialogs are answered as asked the moment they open. Do the action now, then pup_dialog {policy:\"auto\"} to disarm and list what was answered. Re-arm after a navigation."+        } else if r.get("alreadyHandled") == Some(&json!(true)) {+            "No dialog was open: Chrome handled it the moment it opened (alert accepted, confirm and prompt dismissed) so the page never froze. To answer the next one yourself: pup_dialog {policy:\"manual\", action:\"accept\"|\"dismiss\", promptText?} BEFORE the action."+        } else if r.get("handled") == Some(&json!(true)) {+            "Dialog answered; the page is unblocked."+        } else {+            "No dialog is open on this tab. While pup drives a tab Chrome closes page dialogs at once; to answer one, arm first: pup_dialog {policy:\"manual\", action:\"accept\"}."+        });+        return ok(out);+    }     let pending = ctx.tabstate.lock().unwrap().get(&t.cdp_session).and_then(|x| x.dialog.clone());     let Some(d) = pending else { return ok(json!({ "dialog": Value::Null, "sessionId": s.id, "window": s.id, "_hint": "No dialog is open on this window, so there was nothing to answer. If a verb is timing out, the cause is something else." })) };     let action = args.get("action").and_then(|a| a.as_str()).unwrap_or("");
rust/verbs/src/lib.rs+22−3
@@ -35,6 +35,7 @@ pub mod annotate; pub mod record; pub mod highfps; pub mod extras;+pub mod upload; pub mod lint; pub mod assist; pub mod import;@@ -133,6 +134,7 @@ pub fn kill_extdrive(ctx: &Ctx) -> u32 {     lane.pids.lock().unwrap().clear();     lane.token.lock().unwrap().clear();     *lane.last_poll.lock().unwrap() = 0;+    if n > 0 { extdrive::fail_outstanding(&lane, "the browser for this lane was ended by pup before it answered"); }     lane.worker.lock().unwrap().clear();   // 2.0.647: the next hello is the truth, not the last lane's     let _ = std::fs::remove_file(lane_path);     if n > 0 { pup_core::log(format!("[extdrive] closed {n} browser process(es)")); }@@ -373,6 +375,14 @@ pub fn launch_extdrive(ctx: &Ctx, url: &str) -> Result<u32, Value> { pub fn grease_for(major: &str) -> (&'static str, &'static str) {     match major { "152" => ("Not?A_Brand", "24"), _ => ("Not/A)Brand", "99") } }+/// The OS part of a reduced Chrome user agent, as real Chrome on this OS sends it. Linux: Chrome's reduced UA+/// says "X11; Linux x86_64" on X11 and Wayland alike; presenting a Windows UA from a Linux box contradicts+/// navigator.platform and the client hints, which is exactly what bot walls score. macOS keeps the Windows+/// token it always had (unchanged here; that build has its own owner).+pub fn ua_os_token() -> &'static str {+    if cfg!(target_os = "linux") { "X11; Linux x86_64" } else { "Windows NT 10.0; Win64; x64" }+}+ /// The identity the lane presents: the INSTALLED Chrome's version, in real Chrome's brand order. pub fn consumer_identity_strings(ctx: &Ctx) -> Option<Value> {     set_target_chrome_from_host(&*ctx.host);@@ -380,7 +390,7 @@ pub fn consumer_identity_strings(ctx: &Ctx) -> Option<Value> {     if full.is_empty() { return None; }     let major = full.split('.').next().unwrap_or("").to_string();     let (gb, gv) = grease_for(&major);-    let ua = format!("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{major}.0.0.0 Safari/537.36");+    let ua = format!("Mozilla/5.0 ({}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{major}.0.0.0 Safari/537.36", ua_os_token());     let pinned = ctx.settings.get("consumerBrandsHeader", "");     let chua = if pinned.is_empty() { format!("\"Chromium\";v=\"{major}\", \"{gb}\";v=\"{gv}\", \"Google Chrome\";v=\"{major}\"") } else { pinned };     let chua_full = format!("\"Chromium\";v=\"{full}\", \"{gb}\";v=\"{gv}.0.0.0\", \"Google Chrome\";v=\"{full}\"");@@ -402,7 +412,7 @@ pub fn consumer_ua(cdp: &Cdp) -> Option<(String, Value)> {     // Modern Chrome uses a REDUCED UA (major only, minor/build/patch zeroed); mirror that in the UA header.     if !cft_full.is_empty() { ua = ua.replace(&format!("Chrome/{cft_full}"), &format!("Chrome/{major}.0.0.0")); }     let is_win = ua.contains("Windows");-    let (platform, platform_version) = if is_win { ("Windows", "15.0.0") } else { ("macOS", "14.6.0") };+    let (platform, platform_version) = if is_win { ("Windows", "15.0.0") } else if ua.contains("Linux") { ("Linux", "") } else { ("macOS", "14.6.0") };     let (gb, gv) = grease_for(&major);   // 2.0.543: real Chrome's order and GREASE (measured on the installed Chrome)     let meta = json!({         "brands": [ {"brand":"Chromium","version":major}, {"brand":gb,"version":gv}, {"brand":"Google Chrome","version":major} ],@@ -604,11 +614,13 @@ pub const PHASE1_VERBS: &[&str] = &["pup_describe", "pup_readiness", "pup_prewar     "pup_raise_os_window", "pup_focus_window", "pup_lower_os_window", "pup_alert_window", "pup_progress", "pup_configure", "pup_use", "pup_clear_cookies", "pup_clear_site_data", "pup_quit_idle_browsers", "pup_rescan", "pup_restart_browser", "pup_set_identity", "pup_audit_icons", "pup_dashboard",     "pup_credentials", "credential_list", "credential_set", "credential_delete", "pup_login", "pup_google_accounts", "pup_google_signin", "pup_credential_reveal", "pup_card_set", "pup_cards", "pup_card_delete", "pup_fill_card", "pup_wiki_set_view", "pup_annotate", "pup_floaty_click",     "pup_record_start", "pup_record_stop", "pup_record_status", "pup_record_list", "pup_recorder_open", "pup_recorder_close", "pup_screen_record_start", "pup_screen_record_stop", "pup_screen_record_status", "pup_screen_record_list", "pup_highfps_extend",-    "pup_screenshot_element", "pup_screenshot_full_res", "pup_download", "pup_launch", "pup_deps", "pup_install_toolbar", "pup_reap_profiles", "pup_cache_prune", "pup_sweep_identities", "pup_uninstall", "pup_move_to_workspace", "pup_assisted_click", "pup_import_scan", "pup_import_browser_logins", "pup_vault_sync", "pup_vault_export", "pup_vault_import"];+    "pup_screenshot_element", "pup_screenshot_full_res", "pup_download", "pup_upload_file", "pup_launch", "pup_deps", "pup_install_toolbar", "pup_reap_profiles", "pup_cache_prune", "pup_sweep_identities", "pup_uninstall", "pup_move_to_workspace", "pup_assisted_click", "pup_import_scan", "pup_import_browser_logins", "pup_vault_sync", "pup_vault_export", "pup_vault_import"];  pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value {     let t0 = Instant::now();     extdrive::leave_profile();   // 2.0.659: every request starts on the default lane; lane_for / open enter another+    if cfg!(target_os = "linux") { park::start_self_raise_watch(ctx); }+    let focus_guard = if cfg!(target_os = "linux") && park::FOCUS_GUARD_VERBS.contains(&verb) { Some((ctx.host.foreground(), pup_core::now_ms())) } else { None };     let mut r = match verb {         "pup_describe" => describe(ctx),         "pup_readiness" => readiness(ctx),@@ -701,6 +713,7 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value {         "pup_screenshot_element" => extras::screenshot_element(ctx, caller, args),         "pup_screenshot_full_res" => extras::screenshot_full_res(ctx, caller, args),         "pup_download" => extras::download(ctx, caller, args),+        "pup_upload_file" => upload::upload_file(ctx, caller, args),         "pup_launch" => extras::launch(ctx, caller, args),         "pup_deps" => extras::deps(ctx),         "pup_install_toolbar" => extras::install_toolbar(ctx),@@ -726,6 +739,12 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value {     // Read it without a second call. pup answered with filePath only, so nothing was pulled. Every image reply now     // carries both keys (filePath stays for older callers). The image pup writes is already the shrunk copy when needed.     image_delivery(&mut r);+    if let Some((fg_before, started)) = focus_guard {+        // the handler's fields may still sit in the `output` JSON string here (they are lifted below)+        let sid = r.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string())+            .or_else(|| r.get("output").and_then(|v| v.as_str()).and_then(|s| serde_json::from_str::<Value>(s).ok()).and_then(|o| o["sessionId"].as_str().map(|s| s.to_string())));+        if let Some(sid) = sid { park::guard_input_focus(ctx, &sid, fg_before, started); }+    }     // lastAgentUpdate {tabId, verb, note, ts}: every driving verb that names a window (Node: touch sites)     if !PROBE_VERBS.contains(&verb) && !PASSIVE_VERBS.contains(&verb) {         if let Some(sid) = r.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string()) {
rust/verbs/src/park.rs+68
@@ -58,3 +58,71 @@ pub fn bottom_unless_foreground(ctx: &Ctx, sid: &str, force: bool) -> &'static s     ctx.host.send_to_bottom_no_activate(h);     "bottomed" }++/// The verbs that send input into the page (a mouse press or keys) through DevTools. On Linux each runs under the+/// focus guard below.+pub const FOCUS_GUARD_VERBS: &[&str] = &["pup_click", "pup_input_dispatch", "pup_select", "pup_hover", "pup_press_key", "pup_type", "pup_fill", "pup_scroll", "pup_login", "pup_fill_card", "pup_download"];++/// Linux only (Ralph test, 2026-11-03): a DevTools mouse press makes Chrome focus its own web contents, and the window+/// activation that follows is honoured by the X11 window manager (Cinnamon; GNOME through XWayland). A background+/// pup_click therefore brought the window in FRONT of the user's work (hl-debian: active-window log, step 076; the same+/// on hl-ubuntu over Hydrogen at 20:22). Windows' foreground lock refuses that request, so Windows never needed this.+/// The guard: if the window was not the foreground before the verb, becomes it within ~600 ms, and nobody has touched+/// the machine since the verb began, pup lowers it again, which hands the keyboard back (x11::lower). A window the user+/// had in front, or raised meanwhile, is theirs and is left alone.+pub fn guard_input_focus(ctx: &Ctx, sid: &str, fg_before: i64, started_ms: u64) {+    if !cfg!(target_os = "linux") { return; }+    let Some(s) = ctx.sessions.get(sid) else { return };+    let Some(h) = s.hwnd else { return };+    if fg_before == h || h == 0 { return; }+    let Some(a) = ctx.arc() else { return };+    let sid = sid.to_string();+    std::thread::Builder::new().name("focus-guard".into()).spawn(move || {+        for _ in 0..10 {+            if a.host.foreground() == h {+                let idle = a.host.idle_ms().unwrap_or(u64::MAX);+                let since = pup_core::now_ms().saturating_sub(started_ms);+                if idle < since { pup_core::log(format!("[focus-guard] \"{sid}\" came to the front with input {idle} ms ago: a person, hands off")); return; }+                a.host.send_to_bottom_no_activate(h);+                pup_core::log(format!("[focus-guard] \"{sid}\" was activated by Chrome on a page input, not by a person: lowered, keyboard handed back"));+                return;+            }+            std::thread::sleep(std::time::Duration::from_millis(60));+        }+    }).ok();+}++/// Linux only: the page itself can activate its window too. A page `confirm()` made Chrome activate the pup window and+/// it stayed in front of the user's terminal for 21 s (hl-debian active-window log, 2026-11-03 21:46), long after the+/// verb that ran the script had returned, so the per-verb guard above cannot see it. This watcher looks at the active+/// window every 150 ms. When a pup window becomes active that pup did not raise (no granted raise), the user is not+/// using, and nobody touched keyboard or mouse in the last 400 ms (a click on its taskbar button or Alt+Tab IS input:+/// that is a person and it stays), the window is lowered and the keyboard handed back. Started once per process.+pub fn start_self_raise_watch(ctx: &Ctx) {+    if !cfg!(target_os = "linux") { return; }+    static STARTED: std::sync::Once = std::sync::Once::new();+    let Some(a) = ctx.arc() else { return };+    STARTED.call_once(move || {+        std::thread::Builder::new().name("self-raise-watch".into()).spawn(move || {+            let mut last = a.host.foreground();+            loop {+                std::thread::sleep(std::time::Duration::from_millis(150));+                let fg = a.host.foreground();+                if fg == last { continue; }+                last = fg;+                if fg == 0 { continue; }+                let Some(s) = a.sessions.all().into_iter().find(|s| s.hwnd == Some(fg)) else { continue };+                if s.foreground || user_is_using(&s) || in_birth_grace(&s) { continue; }   // a granted raise, the user's window, or a newborn (park's birth re-assert owns those)+                let idle = a.host.idle_ms().unwrap_or(u64::MAX);+                if idle < 400 {+                    a.sessions.with_mut(&s.id, |x| x.user_active_at_ms = pup_core::now_ms());+                    pup_core::log(format!("[self-raise] \"{}\" came to the front with input {idle} ms ago: a person, hands off", s.id));+                    continue;+                }+                a.host.send_to_bottom_no_activate(fg);+                last = a.host.foreground();+                pup_core::log(format!("[self-raise] \"{}\" came to the front by itself (a page dialog or Chrome), no input for {idle} ms: lowered, keyboard handed back", s.id));+            }+        }).ok();+    });+}
rust/verbs/src/record.rs+100−1
@@ -83,6 +83,7 @@ pub fn start(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {             Err(e) => pup_core::log(format!("[record] ab recorder unavailable ({e}); falling back to the CDP screencast")),         }     }+    if crate::transport::is_ext(&s0) && cfg!(target_os = "linux") { return start_ext_poll(ctx, caller, args, &s0); }     if crate::transport::is_ext(&s0) {         return err("recorder_unavailable", "this window's recording needs ab's native recorder (desktop_record_window_start), which did not answer", Some("Update Adom Bridge to 2.1.35+ (the native recorder). Until then, take pup_screenshot frames, or ask the user to update Adom Bridge."));     }@@ -110,6 +111,75 @@ pub fn start(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {         "_hint": "Recording this tab's page content (background is fine). Drive the page, then pup_record_stop {} returns the video path. The window rides a highFps lease while recording." })) } +/// Width and height from a JPEG's SOF header (the AVI header needs them; DevTools screenshots carry no metadata).+fn jpeg_dims(b: &[u8]) -> Option<(u32, u32)> {+    let mut i = 2;+    while i + 9 < b.len() {+        if b[i] != 0xFF { return None; }+        let m = b[i + 1];+        let len = u16::from_be_bytes([b[i + 2], b[i + 3]]) as usize;+        if (0xC0..=0xCF).contains(&m) && m != 0xC4 && m != 0xC8 && m != 0xCC {+            return Some((u16::from_be_bytes([b[i + 7], b[i + 8]]) as u32, u16::from_be_bytes([b[i + 5], b[i + 6]]) as u32));+        }+        i += 2 + len;+    }+    None+}++/// Linux, extension lane: ab for Linux has no window recorder (desktop_record_window_* is Windows Graphics Capture),+/// and the extension lane has no screencast event stream, so pup_record_start answered "update Adom Bridge" and could+/// not record at all (Ralph test, 2026-11-03). Here frames are polled: Page.captureScreenshot (JPEG) through the+/// extension's DevTools passthrough at up to 10 fps, written into the same frames folder with real timestamps, and+/// pup_record_stop encodes them exactly as for the CDP screencast (ffmpeg VP9 WebM, else MJPEG AVI). The poller+/// stops when the recording leaves tab_recs (pup_record_stop) or the window goes away. Background-safe: the capture+/// forces a compositor frame, so a covered window still records.+fn start_ext_poll(ctx: &Ctx, _caller: &Caller, args: &Value, s: &pup_session::Session) -> Value {+    let Some(t) = s.active().cloned() else { return err("no_tab", "the window has no active tab", None) };+    if let Err(e) = crate::transport::lane_for(ctx, s) { return e; }+    let fps = args["fps"].as_u64().unwrap_or(10).clamp(1, 10) as u32;+    let quality = args["quality"].as_u64().unwrap_or(80).clamp(10, 100);+    let max_ms = args["maxDurationMs"].as_u64().unwrap_or(args["minutes"].as_u64().unwrap_or(20).clamp(1, 120) * 60_000);+    let ts = pup_core::now_ms();+    let file = recordings_dir(ctx).join(format!("rec-tab-{}-{}-{ts}.webm", pup_session::sanitize_sid(&s.id), t.tab_id));+    let frames_dir = recordings_dir(ctx).join(format!("rec-tab-{}-{}-{ts}.frames", pup_session::sanitize_sid(&s.id), t.tab_id));+    let _ = std::fs::create_dir_all(&frames_dir);+    let id = format!("rec-{}", ts % 100000);+    ctx.tab_recs.lock().unwrap().insert(s.id.clone(), TabRec { id: id.clone(), session: s.id.clone(), tab_id: t.tab_id.clone(), cdp_session: t.cdp_session.clone(), frames_dir, file: file.clone(), fps, started: Instant::now(), frames: 0, stamps: Vec::new(), sizes: Vec::new() });+    let Some(a) = ctx.arc() else { ctx.tab_recs.lock().unwrap().remove(&s.id); return err("record_start_failed", "pup is shutting down", None) };+    let sid = s.id.clone(); let id2 = id.clone(); let tab_id = t.tab_id.clone();+    std::thread::Builder::new().name("record-poll".into()).spawn(move || {+        let period = Duration::from_millis(1000 / fps as u64);+        let t0 = Instant::now();+        let mut fails = 0;+        loop {+            let tick = Instant::now();+            if t0.elapsed().as_millis() as u64 > max_ms { pup_core::log(format!("[{id2}] reached maxDurationMs; frames stop (pup_record_stop encodes them)")); return; }+            if !a.tab_recs.lock().unwrap().contains_key(&sid) { return; }   // stopped+            let Some(s) = a.sessions.get(&sid) else { return };+            let Some(tab) = s.tabs.iter().find(|x| x.tab_id == tab_id).cloned() else { return };+            let shot = crate::transport::lane_for(&a, &s).and_then(|lane| crate::transport::cdp_raw(&lane, &tab, "Page.captureScreenshot", json!({ "format": "jpeg", "quality": quality }), 10));+            match shot.ok().and_then(|r| r["data"].as_str().and_then(|d| pup_cdp::base64_decode(d).ok())) {+                Some(bytes) => {+                    fails = 0;+                    let mut m = a.tab_recs.lock().unwrap();+                    let Some(r) = m.get_mut(&sid) else { return };   // stopped while capturing: drop the frame+                    r.frames += 1;+                    if r.frames == 1 { pup_core::log(format!("[{}] FIRST polled frame ({} bytes, extension lane)", r.id, bytes.len())); }+                    let _ = std::fs::write(r.frames_dir.join(format!("f{:06}.jpg", r.frames)), &bytes);+                    r.stamps.push(r.started.elapsed().as_secs_f64());+                    if let Some(d) = jpeg_dims(&bytes) { r.sizes.push(d); }+                }+                None => { fails += 1; if fails == 20 { pup_core::log(format!("[{id2}] 20 captures in a row failed; still trying")); } }+            }+            if let Some(rest) = period.checked_sub(tick.elapsed()) { std::thread::sleep(rest); }+        }+    }).ok();+    pup_core::log(format!("[{id}] polled recording (extension lane, Linux) @ {fps}fps on \"{}\" -> {}", s.id, file.display()));+    ok(json!({ "success": true, "output": json!({ "ok": true, "recordingId": id, "sessionId": s.id, "tabId": t.tab_id, "fps": fps, "filePath": file.to_string_lossy(), "engine": "polled DevTools screenshots (Linux extension lane)", "encoder": if ffmpeg_path().is_some() { "ffmpeg (VP9 WebM)" } else { "built-in (MJPEG AVI, ffmpeg not found)" } }).to_string(),+        "sessionId": s.id, "window": s.id, "aiThread": s.owner, "recordingId": id, "fps": fps, "engine": "poll",+        "_hint": "Recording this tab's page (background is fine) as polled frames, up to 10 fps. Drive the page, then pup_record_stop {} returns the video path." }))+}+ /// 2.0.649 (pup-bridge#105): the encode is a JOB. A VP9 mux that outlives the caller's timeout used to vanish /// from status ("recording:false, current:null") while ffmpeg was still writing, and an early pull got a /// truncated file that looked ready. Now stop returns at once with artifactReady:false when the mux is slow,@@ -324,6 +394,27 @@ pub fn screen_start(ctx: &Ctx, args: &Value) -> Value {     r.current = Some((id.clone(), path.clone(), f, Instant::now(), 0));     let opts = json!({ "fps": args["fps"].as_u64().unwrap_or(30), "audio": args["audio"].as_bool().unwrap_or(false), "videoBitsPerSecond": args["videoBitsPerSecond"].as_u64().unwrap_or(4_000_000), "reason": r.reason, "maxDurationMs": args["maxDurationMs"].as_u64().unwrap_or(600_000) });     if let Err(e) = r.cdp.evaluate(&r.cdp_session, &format!("window.startRecording && window.startRecording({opts})"), Duration::from_secs(10)) { r.current = None; return err("recorder_start_failed", e.to_string(), None); }+    // Linux (Ralph test, 2026-11-03, GNOME Wayland): Chrome's screen capture goes through the desktop's own "Share+    // Screen" dialog (xdg-desktop-portal), which no flag can answer, so the capture waits for a PERSON to press Share.+    // startRecording is async and was not awaited: pup answered ok, the HUD showed "REC 0 KB", and stop handed back a+    // 0-byte file. Wait briefly for the stream; if it is not live, say the dialog is waiting on the user.+    if cfg!(target_os = "linux") {+        drop(st);+        // Live means bytes reach the file (the recorder delivers a chunk every second). The page's stream object is+        // no signal: Chrome reported it active while the Share Screen dialog was still waiting (hl-ubuntu, measured).+        let t0 = Instant::now();+        let mut live = false;+        while t0.elapsed() < Duration::from_millis(4000) {+            std::thread::sleep(Duration::from_millis(250));+            if std::fs::metadata(&path).map(|m| m.len() > 0).unwrap_or(false) { live = true; break; }+        }+        if !live {+            pup_core::log(format!("[recorder] {id}: nothing captured after 4 s: the desktop's Share Screen dialog is waiting for the user"));+            return pup_core::blocked_on_user(true, "share_dialog", "The desktop's own Share Screen dialog is open on the user's screen (Wayland: an app cannot pick the screen for them). Nothing records until they choose a screen and press Share. Tell the user, then pup_screen_record_status shows the recording once it is live; pup_screen_record_stop before that returns nothing_recorded.",+                json!({ "recordingId": id, "filePath": path.to_string_lossy().replace('\\', "/"), "phase": "waiting_for_share_dialog" }));+        }+        return ok(json!({ "success": true, "_hint": SCREEN_HINT, "output": json!({ "ok": true, "recordingId": id, "filePath": path.to_string_lossy().replace('\\', "/"), "fps": opts["fps"], "audio": opts["audio"], "live": true }).to_string(), "recordingId": id }));+    }     ok(json!({ "success": true, "_hint": SCREEN_HINT, "output": json!({ "ok": true, "recordingId": id, "filePath": path.to_string_lossy().replace('\\', "/"), "fps": opts["fps"], "audio": opts["audio"] }).to_string(), "recordingId": id })) } @@ -331,7 +422,15 @@ pub fn screen_stop(ctx: &Ctx) -> Value {     let (cdp, sid, id) = { let st = ctx.screen_rec.lock().unwrap(); let Some(r) = st.as_ref() else { return err("no_recorder", "the recorder HUD is not open", None) }; let Some((id, ..)) = r.current.as_ref() else { return err("no_recording", "no screen recording is running", None) }; (r.cdp.clone(), r.cdp_session.clone(), id.clone()) };     let _ = cdp.evaluate(&sid, "window.stopRecording && window.stopRecording()", Duration::from_secs(10));     let t0 = Instant::now();-    loop { { let st = ctx.screen_rec.lock().unwrap(); if let Some(r) = st.as_ref() { if r.current.is_none() { let clip = r.clips.last().cloned().unwrap_or(json!({})); return ok(json!({ "success": true, "_hint": SCREEN_HINT, "output": pup_core::merge(json!({ "ok": true, "recordingId": id }), clip.clone()).to_string(), "filePath": clip["filePath"] })); } } else { break; } } if t0.elapsed() > Duration::from_secs(15) { break; } std::thread::sleep(Duration::from_millis(200)); }+    loop { { let st = ctx.screen_rec.lock().unwrap(); if let Some(r) = st.as_ref() { if r.current.is_none() { let clip = r.clips.last().cloned().unwrap_or(json!({}));+        if cfg!(target_os = "linux") {+            // see screen_start: an unanswered Share Screen dialog leaves an empty file; say so instead of "ok"+            let fp = clip["filePath"].as_str().unwrap_or("").to_string();+            if !fp.is_empty() && std::fs::metadata(&fp).map(|m| m.len()).unwrap_or(0) == 0 {+                let _ = std::fs::remove_file(&fp); let _ = std::fs::remove_file(std::path::Path::new(&fp).with_extension("json"));+                return err("nothing_recorded", "the screen recording captured nothing: the desktop's Share Screen dialog was never answered", Some("On Wayland the user must choose a screen and press Share in the system dialog. pup_screen_record_start again and ask them to press Share. For one pup window, pup_record_start needs no dialog."));+            }+        } return ok(json!({ "success": true, "_hint": SCREEN_HINT, "output": pup_core::merge(json!({ "ok": true, "recordingId": id }), clip.clone()).to_string(), "filePath": clip["filePath"] })); } } else { break; } } if t0.elapsed() > Duration::from_secs(15) { break; } std::thread::sleep(Duration::from_millis(200)); }     err("recorder_stop_timeout", "the recorder did not confirm the stop within 15s", None) } 
rust/verbs/src/recovery.rs+14
@@ -722,6 +722,20 @@ pub fn webgl_watch(ctx: &Ctx) {         r["result"]["value"].as_bool().map(|v| (v, s.id.clone()))     });     let Some((has_webgl, sid)) = probe else { return };+    // Linux: a desktop with no GPU (a VM, a remote session) gives pup's browser no WebGL from its first page, and+    // current Chrome has no software fallback. A restart cannot bring back what this browser never had, and on such a+    // desktop the watch restarted the browser every 30 minutes, closing windows in the middle of an AI's work+    // (Linux test VM, 2026-11-02). So on Linux only a browser that HAD WebGL and lost it is restarted; the lane token+    // names the browser instance. Windows keeps the original rule.+    static SEEN: std::sync::Mutex<(String, String)> = std::sync::Mutex::new((String::new(), String::new()));   // token that had WebGL, token logged as never+    let token = crate::extdrive::default_lane().token.lock().unwrap().clone();+    if has_webgl { SEEN.lock().unwrap().0 = token.clone(); }+    if !has_webgl && cfg!(target_os = "linux") && SEEN.lock().unwrap().0 != token {+        let mut g = SEEN.lock().unwrap();+        if g.1 != token { g.1 = token.clone(); pup_core::log(format!("[webgl] no 3D graphics in \"{sid}\" since this browser started (no GPU on this desktop?): not a GPU crash, so pup does not restart the browser for it")); }+        STATE.lock().unwrap().1 = 0;+        return;+    }     if has_webgl { if fails > 0 { pup_core::log(format!("[webgl] back in \"{sid}\"")); } STATE.lock().unwrap().1 = 0; return; }     let fails = { let mut g = STATE.lock().unwrap(); g.1 += 1; g.1 };     pup_core::log(format!("[webgl] OFF in \"{sid}\" (probe {fails}): the browser has stopped giving pages 3D graphics, most likely after GPU-process crashes"));
rust/verbs/src/settings.rs+6−1
@@ -59,7 +59,7 @@ pub fn configure(ctx: &Ctx, args: &Value) -> Value {         if changed.iter().any(|k| RESTAMP.contains(&k.as_str())) { crate::identity::reapply_all(a, true); }         else if changed.iter().any(|k| k == "overlayBadges" || k == "overlaySize") { crate::identity::reapply_all(a, false); }     }-    let out = json!({ "ok": true, "settings": snapshot(ctx), "changed": changed, "rejected": rejected, "platform": if cfg!(windows) { "win32" } else if cfg!(target_os = "macos") { "darwin" } else { "linux" }, "taskbarSupported": cfg!(windows), "_hint": if changed.is_empty() { "Current pup preferences (persisted in ~/.adom/pup-settings.json). Pass {key:value} to change one; every key applies live, no restart." } else { "Saved. Live windows pick the change up on their next identity pass; toolbarExtension changes apply to the NEXT browser launch." } });+    let out = json!({ "ok": true, "settings": snapshot(ctx), "changed": changed, "rejected": rejected, "platform": if cfg!(windows) { "win32" } else if cfg!(target_os = "macos") { "darwin" } else { "linux" }, "taskbarSupported": cfg!(windows) || cfg!(target_os = "linux"), "_hint": if changed.is_empty() { "Current pup preferences (persisted in ~/.adom/pup-settings.json). Pass {key:value} to change one; every key applies live, no restart." } else { "Saved. Live windows pick the change up on their next identity pass; toolbarExtension changes apply to the NEXT browser launch." } });     ok(json!({ "success": true, "output": out.to_string(), "settings": out["settings"], "changed": out["changed"], "rejected": out["rejected"] })) } @@ -75,6 +75,11 @@ pub fn use_browser(ctx: &Ctx, args: &Value) -> Value {         "cft" | "chrome-for-testing" => {             match pup_browser::detect_cft(&*ctx.host) { Some(c) => c, None => { ctx.installer.ensure_background(ctx.host.clone()); return pup_core::merge(err("chrome_for_testing_installing", "Chrome for Testing is not cached yet; fetching it now", Some("Poll pup_readiness until chromeForTestingInstalled:true, then pup_use {browser:\"cft\"} again (or just open a window: it converges on its own).")), json!({ "installing": true, "stillRunning": true, "statusVerb": "pup_readiness" })); } }         }+        "chrome" if cfg!(target_os = "linux") && args.get("install").and_then(|b| b.as_bool()) == Some(true) && cands.iter().all(|c| c.kind != "chrome") => {+            // Linux: installing Google Chrome needs the package manager and sudo, which pup never uses.+            return err("chrome_install_unsupported_os", "pup does not install Google Chrome on Linux: that needs the system package manager and an administrator password, which pup never asks for.",+                Some("pup already runs Chrome for Testing (downloaded into the user's folder, no sudo), which is the right browser for pup. If the user wants Google Chrome itself, they install it from google.com/chrome or their distribution's package; then pup_use {browser:\"chrome\"} picks it."));+        }         "chrome" if args.get("install").and_then(|b| b.as_bool()) == Some(true) && cands.iter().all(|c| c.kind != "chrome") => {             let elevate = match args.get("elevate") { Some(Value::Bool(b)) => Some(*b), Some(Value::String(s)) if s == "false" => Some(false), Some(Value::String(s)) if s == "true" => Some(true), _ => None };             if let Some(a) = ctx.arc() { pup_browser::chrome_stable::install_background(a.host.clone(), elevate); }
rust/verbs/src/transport.rs+28
@@ -280,6 +280,34 @@ pub fn inject_script(lane: &Lane, t: &Tab, file: &str, text: &str, guard: &str)         Lane::Ext => run(json!({ "action": "inject", "file": file, "tabId": ext_id(t), "world": "MAIN" }), 20).map(|_| ()),     } }+/// pup_dialog on the extension lane (see the worker's "dialog" action).+pub fn ext_dialog(t: &Tab, answer: bool, accept: bool, prompt_text: Option<&str>, policy: Option<&str>) -> Result<Value, Value> {+    run(json!({ "action": "dialog", "tabId": ext_id(t), "answer": answer, "accept": accept, "promptText": prompt_text, "policy": policy }), 15)+}+/// pup_errors on the extension lane: the tab's page buffer (exceptions, failed loads, HTTP errors) plus the console+/// errors pup's DevTools driver saw, oldest first.+pub fn ext_errors(t: &Tab, clear: bool) -> Result<(Vec<Value>, bool), Value> {+    let r = run(json!({ "action": "errors", "tabId": ext_id(t), "clear": clear }), 10)?;+    let capture = r["pageCapture"].as_bool().unwrap_or(false);+    // One line per error: the page capture and the DevTools driver both see an exception (the driver as+    // "Error: x", the page as "Unhandled rejection: Error: x" for a rejection), and the driver's "Failed to load+    // resource" log line says less than the page's own "HTTP 404 <url>" entry.+    let key = |e: &Value| { let s = e["text"].as_str().unwrap_or(""); s.trim_start_matches("Unhandled rejection: ").lines().next().unwrap_or("").trim().to_string() };+    let mut all: Vec<Value> = Vec::new();+    let mut seen = std::collections::HashSet::new();+    for e in r["page"].as_array().cloned().unwrap_or_default() {+        if seen.insert((e["type"].as_str().unwrap_or("").to_string(), e["text"].as_str().unwrap_or("").to_string())) { all.push(e); }+    }+    let page_exc: std::collections::HashSet<String> = all.iter().filter(|e| e["type"] == json!("exception")).map(key).collect();+    for e in r["driver"].as_array().cloned().unwrap_or_default() {+        let ty = e["type"].as_str().unwrap_or("");+        if ty == "exception" && page_exc.contains(&key(&e)) { continue; }+        if capture && ty == "log" && e["text"].as_str().map(|s| s.starts_with("Failed to load resource")).unwrap_or(false) { continue; }+        all.push(e);+    }+    all.sort_by_key(|e| e["ts"].as_u64().unwrap_or(0));+    Ok((all, capture))+} /// window[name](...args) in the page. pub fn call_window_fn(lane: &Lane, t: &Tab, name: &str, args: &[Value]) -> Result<Value, Value> {     match lane {
rust/verbs/src/upload.rsadded+326
@@ -0,0 +1,326 @@+//! pup_upload_file (pup-bridge#145): put a file from this PC on a page's <input type=file>, with no OS picker,+//! in the background, through DOM.setFileInputFiles (what Puppeteer's uploadFile and Playwright's setInputFiles+//! use). Chrome reads the file itself when the page reads it, so the size costs nothing here.+//!+//! Measured on the extension lane, 2026-11-05 (Chrome for Testing 157), which is why the checks below exist:+//!   - a path that does not exist still "succeeds": the input holds a phantom File with that name, the page's+//!     change fires, and the upload fails later or sends nothing. So every path is checked here first.+//!   - two files on an input without `multiple` silently keep only the first. So that is refused.+//!   - Chrome fires input+change itself, once. Firing them again makes a page that uploads on change send the+//!     file twice, so this verb never does.+//!   - Chrome fires change only when the file list CHANGES, so a retry with the same file would do nothing and still+//!     look fine. The input is emptied first (no events), so every call gives the page exactly one change.+//!   - many upload widgets clear the input in their change handler, so the read-back alone would report a false+//!     failure. A one-shot capture listener records what the page was handed.+use crate::input::resolve_tab;+use crate::{touch, Ctx};+use pup_core::{err, ok, Caller};+use serde_json::{json, Value};+use std::time::Duration;++const T: Duration = Duration::from_secs(10);+fn js_str(s: &str) -> String { serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into()) }++// ── which paths may leave the PC ─────────────────────────────────────────────────────────────────++/// A file handed to a page is a file a website can read, and the AI driving the page may be following what a page+/// told it. Secrets live INSIDE the user's profile, so "stay under the user's folders" would not protect them: the+/// rule is this PC's local files, minus the system folders, other people's profiles and the places secrets are kept.+/// There is deliberately no override argument: an AI could just pass it. A person who really wants one of these+/// sent copies it somewhere ordinary first.+#[derive(Clone, Debug)]+pub struct PathRules { windows: bool, home: String, always_ok: Vec<String>, system: Vec<String> }++const SECRET_DIRS: &[&str] = &[".ssh", ".gnupg", ".aws", ".azure", ".kube", ".docker", ".git", "$recycle.bin", "system volume information"];+const SECRET_NAMES: &[&str] = &[".env", ".netrc", "_netrc", ".git-credentials", ".npmrc", ".pypirc", "login data", "login data for account", "cookies", "web data", "local state", "ntuser.dat", "sam", "security"];+const SECRET_PREFIXES: &[&str] = &["id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", ".env."];+const SECRET_EXTS: &[&str] = &["pem", "ppk", "key", "pfx", "p12", "kdbx", "keystore"];++fn norm(p: &str) -> String { let n = p.replace('\\', "/").to_lowercase(); let n = n.strip_prefix("//?/").unwrap_or(&n).to_string(); n.trim_end_matches('/').to_string() }+fn under(path: &str, root: &str) -> bool { !root.is_empty() && (path == root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))) }++impl PathRules {+    pub fn from_env(extra_ok: &[String]) -> PathRules {+        let env = |k: &str| std::env::var(k).unwrap_or_default();+        let windows = cfg!(windows);+        let home = norm(&if windows { env("USERPROFILE") } else { env("HOME") });+        let mut always_ok: Vec<String> = extra_ok.iter().map(|p| norm(p)).collect();+        for k in ["TEMP", "TMP"] { let t = norm(&env(k)); if !t.is_empty() { always_ok.push(t); } }+        let system = if windows { ["SystemRoot", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432", "ProgramData"].iter().map(|k| norm(&env(k))).filter(|s| !s.is_empty()).collect() }+            else { ["/etc", "/var", "/usr", "/bin", "/sbin", "/boot", "/root", "/proc", "/sys", "/system", "/library", "/private"].iter().map(|s| s.to_string()).collect() };+        PathRules { windows, home, always_ok, system }+    }++    /// Before the disk is touched: is this even a plain absolute local path? A \\server\share path is refused here+    /// because just looking at it makes Windows authenticate to that server with the user's credentials.+    pub fn precheck(&self, raw: &str) -> Result<(), (&'static str, String, String)> {+        let send = "Files in your container are not on this PC: send it first with adom-bridge send_files {filePaths:[...]}, then pass the path it lands at (Downloads by default).";+        let p = raw.trim();+        if p.is_empty() { return Err(("file_path_required", "the file path is empty".into(), "Pass filePath: an absolute path on this PC, e.g. C:/Users/me/Downloads/gerbers.zip.".into())); }+        if p.starts_with("\\\\") || p.starts_with("//") { return Err(("path_refused", format!("{raw} is a network or device path"), "Only files on this PC's own drives can be uploaded: opening a \\\\server path would make Windows sign in to that server as the user. Copy the file to a local folder first.".into())); }+        let bytes = p.as_bytes();+        let abs = if self.windows { bytes.len() > 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && (bytes[2] == b'\\' || bytes[2] == b'/') } else { bytes[0] == b'/' };+        if !abs {+            let looks_container = p.starts_with('/') || p.starts_with('~');+            return Err(("path_not_absolute", format!("{raw} is not an absolute path on this PC"), if looks_container { send.to_string() } else { format!("Pass the full path, e.g. C:/Users/me/Downloads/gerbers.zip. {send}") }));+        }+        let rest = if self.windows { &p[2..] } else { p };+        if rest.contains(':') { return Err(("path_refused", format!("{raw} names an alternate data stream"), "Pass the plain file path.".into())); }+        if rest.split(['/', '\\']).any(|c| c == "..") { return Err(("path_refused", format!("{raw} contains .."), "Pass the file's direct path, without .. in it.".into())); }+        Ok(())+    }++    /// On the REAL path (links and 8.3 names resolved): why this file may not be sent, if it may not.+    pub fn refusal(&self, real: &str) -> Option<String> {+        let path = norm(real);+        let comps: Vec<&str> = path.split('/').collect();+        let name = comps.last().copied().unwrap_or("");+        let ext = name.rsplit_once('.').map(|(_, e)| e).unwrap_or("");+        if SECRET_NAMES.contains(&name) || SECRET_PREFIXES.iter().any(|x| name.starts_with(x)) || SECRET_EXTS.contains(&ext) { return Some(format!("{name} looks like a key, password or login store")); }+        if let Some(d) = comps[..comps.len().saturating_sub(1)].iter().find(|c| SECRET_DIRS.contains(c)) { return Some(format!("it is inside {d}, where keys and credentials are kept")); }+        if self.always_ok.iter().any(|r| under(&path, r)) { return None; }+        if let Some(s) = self.system.iter().find(|s| under(&path, s)) { return Some(format!("it is inside a system folder ({s})")); }+        if under(&path, &self.home) {+            let first = path[self.home.len()..].trim_start_matches('/').split('/').next().unwrap_or("");+            if first == "appdata" || first == "library" { return Some("it is inside AppData, where browsers and apps keep logins, tokens and settings".to_string()); }+            if first.starts_with('.') { return Some(format!("it is inside {first}, a hidden settings folder in the home folder where tools keep tokens")); }+            return None;+        }+        if let Some((users, _)) = self.home.rsplit_once('/') {+            if !users.is_empty() && under(&path, users) && !under(&path, &format!("{users}/public")) && !under(&path, &format!("{users}/shared")) { return Some("it is inside another person's profile".to_string()); }+        }+        None+    }+}++#[derive(Debug, Clone)]+struct Local { path: String, name: String, size: u64 }++/// Check one path; the error is a full verb response.+fn check_file(rules: &PathRules, raw: &str) -> Result<Local, Value> {+    let copy = "Copy the file to Downloads or a project folder first if the user really means to send it.";+    let raw = raw.trim();+    rules.precheck(raw).map_err(|(code, msg, hint)| pup_core::merge(err(code, msg, Some(&hint)), json!({ "filePath": raw })))?;+    let not_found = || pup_core::merge(err("file_not_found", format!("{raw} does not exist on this PC"), Some("The path is read on the PC the browser runs on, not in your container. Send the file with adom-bridge send_files {filePaths:[...]} and pass the path it lands at. pup checks first because Chrome would accept a missing path and hand the page a phantom empty file.")), json!({ "filePath": raw }));+    let real = std::fs::canonicalize(raw).map_err(|e| if e.kind() == std::io::ErrorKind::NotFound { not_found() } else { pup_core::merge(err("file_unreadable", format!("cannot open {raw}: {e}"), Some("Check the file's permissions, and whether another app holds it open.")), json!({ "filePath": raw })) })?;+    let real_s = real.to_string_lossy().to_string();+    if let Some(why) = rules.refusal(&real_s) { return Err(pup_core::merge(err("path_refused", format!("pup will not hand {raw} to a web page: {why}"), Some(copy)), json!({ "filePath": raw, "resolvedPath": real_s.replace('\\', "/") }))); }+    let meta = std::fs::metadata(&real).map_err(|e| err("file_unreadable", format!("cannot read {raw}: {e}"), None))?;+    if !meta.is_file() { return Err(pup_core::merge(err("not_a_file", format!("{raw} is a folder, not a file"), Some("Pass a file. To send a folder, zip it first; a site that wants a whole folder needs a file list per file.")), json!({ "filePath": raw }))); }+    if let Err(e) = std::fs::File::open(&real) { return Err(pup_core::merge(err("file_unreadable", format!("cannot open {raw}: {e}"), Some("Another app may hold it open exclusively, or this user may not be allowed to read it.")), json!({ "filePath": raw }))); }+    let path = if cfg!(windows) { raw.replace('/', "\\") } else { raw.to_string() };+    let name = raw.rsplit(['/', '\\']).next().unwrap_or(raw).to_string();+    Ok(Local { path, name, size: meta.len() })+}++/// `accept` is advice to the OS picker; DevTools does not enforce it, so a page may reject the file afterwards.+fn accept_warning(accept: &str, files: &[Local]) -> Option<String> {+    let tokens: Vec<String> = accept.split(',').map(|t| t.trim().to_lowercase()).filter(|t| !t.is_empty()).collect();+    if tokens.is_empty() || tokens.iter().any(|t| !t.starts_with('.')) { return None; }   // a MIME type: only the page can judge it+    let off: Vec<&str> = files.iter().filter(|f| !tokens.iter().any(|t| f.name.to_lowercase().ends_with(t.as_str()))).map(|f| f.name.as_str()).collect();+    if off.is_empty() { None } else { Some(format!("This input only offers {} in its picker and {} is not one; the page may reject it.", tokens.join(", "), off.join(", "))) }+}++// ── the page side ─────────────────────────────────────────────────────────────────────────────────++const WALK: &str = r#"const roots = []; let xframes = 0;+      const walk = (r) => { roots.push(r); r.querySelectorAll('*').forEach((e) => { if (e.shadowRoot) walk(e.shadowRoot);+        if (e.tagName === 'IFRAME' || e.tagName === 'FRAME') { let d = null; try { d = e.contentDocument; } catch (_) {} if (d) walk(d); else xframes++; } }); };+      walk(document);+      const inputs = () => roots.flatMap((r) => [...r.querySelectorAll('input[type=file]')]);"#;++fn finder(sel: Option<&str>, text: Option<&str>) -> String {+    format!(r#"(() => {{ {WALK}+      const sel = {s}, txt = {t};+      const near = (el) => {{+        if (el.matches && el.matches('input[type=file]')) return el;+        const lab = el.tagName === 'LABEL' ? el : (el.closest && el.closest('label'));+        if (lab && lab.control && lab.control.type === 'file') return lab.control;+        if (el.querySelector) {{ const i = el.querySelector('input[type=file]'); if (i) return i; }}+        let p = el.parentElement; for (let d = 0; p && d < 4; d++, p = p.parentElement) {{ const f = p.querySelectorAll('input[type=file]'); if (f.length === 1) return f[0]; if (f.length > 1) return null; }}+        return null; }};+      if (sel) {{ for (const r of roots) {{ const m = r.querySelector(sel); if (m) return near(m); }} return null; }}+      if (txt) {{ const w = txt.trim().toLowerCase(); let best = null, bestLen = 1e9;+        for (const r of roots) for (const n of r.querySelectorAll('button,a,label,input,[role=button],span,div')) {{ const own = (n.innerText || n.value || n.getAttribute('aria-label') || '').trim().toLowerCase(); if (own && own.includes(w) && own.length < bestLen) {{ best = n; bestLen = own.length; }} }}+        return best ? near(best) : null; }}+      const a = inputs(); return a.length === 1 ? a[0] : null; }})()"#, s = sel.map(js_str).unwrap_or("null".into()), t = text.map(js_str).unwrap_or("null".into()))+}++fn diag(sel: Option<&str>) -> String {+    format!(r#"(() => {{ {WALK} const sel = {s}; let matched = null, bad = null;+      if (sel) {{ try {{ matched = roots.some((r) => !!r.querySelector(sel)); }} catch (e) {{ bad = e.message; }} }}+      return {{ matched, badSelector: bad, crossOriginFrames: xframes, fileInputs: inputs().slice(0, 12).map((i) => ({{ id: i.id || null, name: i.name || null, accept: i.accept || null, multiple: i.multiple, disabled: i.disabled, visible: !!(i.offsetWidth || i.offsetHeight) }})) }}; }})()"#, s = sel.map(js_str).unwrap_or("null".into()))+}++fn call_on(b: &crate::BrowserHandle, sid: &str, oid: &str, f: &str) -> Result<Value, pup_cdp::CdpError> {+    b.cdp.send(Some(sid), "Runtime.callFunctionOn", json!({ "objectId": oid, "functionDeclaration": f, "returnByValue": true, "awaitPromise": false }), T).map(|r| r["result"]["value"].clone())+}++// ── a file chooser the page opens itself (the input is born in the Browse button's click handler) ───++struct ChooserState { armed: bool, served: Option<(Vec<String>, u64)> }+static CHOOSERS: std::sync::Mutex<Option<std::collections::HashMap<String, ChooserState>>> = std::sync::Mutex::new(None);+fn chooser_set(sid: &str, st: ChooserState) { CHOOSERS.lock().unwrap().get_or_insert_with(Default::default).insert(sid.to_string(), st); }+const ARM_SECS: u64 = 30;+/// A warm browser keeps the extension it started with, so right after an update it does not know the action yet.+fn old_worker(e: &Value) -> Option<Value> {+    if !e["error"].as_str().unwrap_or("").contains("unsupported action") { return None; }+    Some(err("browser_needs_restart", "this browser is still running the pup extension from before the last update", Some("pup_restart_browser brings every window back at its page with the new extension. pup_upload_file with a selector works without restarting.")))+}++/// CDP lane: the events are ours to wait on. (The extension lane streams none, so its worker runs nbe's arm.)+fn arm_cdp(b: std::sync::Arc<crate::BrowserHandle>, sid: String, files: Vec<Local>) -> Result<(), String> {+    let _ = b.cdp.send(Some(&sid), "Page.enable", json!({}), T);+    let rx = b.cdp.subscribe(Some(&sid), Some("Page.fileChooserOpened"));+    b.cdp.send(Some(&sid), "Page.setInterceptFileChooserDialog", json!({ "enabled": true }), T).map_err(|e| e.to_string())?;+    chooser_set(&sid, ChooserState { armed: true, served: None });+    std::thread::Builder::new().name("pup-chooser".into()).spawn(move || {+        let ev = pup_cdp::Cdp::wait_event(&rx, Duration::from_secs(ARM_SECS));+        let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();+        let served = ev.and_then(|e| b.cdp.send(Some(&sid), "DOM.setFileInputFiles", json!({ "files": paths, "backendNodeId": e.params["backendNodeId"] }), T).ok());+        let _ = b.cdp.send(Some(&sid), "Page.setInterceptFileChooserDialog", json!({ "enabled": false }), T);+        chooser_set(&sid, ChooserState { armed: false, served: served.map(|_| (files.iter().map(|f| f.name.clone()).collect(), pup_core::now_ms())) });+    }).map_err(|e| e.to_string())?;+    Ok(())+}++// ── the verb ──────────────────────────────────────────────────────────────────────────────────────++pub fn upload_file(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let base = json!({ "success": true, "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "windowEtiquette": "background" });+    let ext = crate::transport::is_ext(&s);+    let ext_tab = crate::transport::ext_id(&t);++    if args["chooserStatus"] == json!(true) {+        let (armed, served) = if ext {+            match crate::extdrive::run(json!({ "action": "fileChooser", "status": true, "tabId": ext_tab }), T) { Ok(r) => (r["armed"] == json!(true), if r["served"] == json!(true) { Some(r["files"].clone()) } else { None }), Err(e) => return old_worker(&e).unwrap_or(e) }+        } else {+            let g = CHOOSERS.lock().unwrap();+            let st = g.as_ref().and_then(|m| m.get(&t.cdp_session));+            (st.map(|x| x.armed).unwrap_or(false), st.and_then(|x| x.served.as_ref().map(|(f, _)| json!(f))))+        };+        return ok(pup_core::merge(base, json!({ "armed": armed, "served": served.is_some(), "files": served, "_hint": if served.is_some() { "The page's file chooser was answered with your files. Check the page shows them; many sites need an Upload or Submit click next." } else if armed { "Still armed: pup_click the Browse or Upload control now." } else { "Not armed and nothing was served. The arm lasts 30 s; arm again and click straight after. A chooser opened by a script with no click behind it is refused by Chrome: use pup_click, not domClick." } })));+    }++    let raws: Vec<String> = match (args.get("filePaths").and_then(|v| v.as_array()), args.get("filePath").and_then(|v| v.as_str())) {+        (Some(a), _) if !a.is_empty() => a.iter().filter_map(|v| v.as_str().map(|x| x.to_string())).collect(),+        (_, Some(p)) => vec![p.to_string()],+        _ => return err("file_path_required", "pup_upload_file needs {filePath} (or {filePaths:[...]}), an absolute path on the PC the browser runs on", Some("e.g. pup_upload_file {selector:\"input[type=file]\", filePath:\"C:/Users/me/Downloads/gerbers.zip\"}. A file in your container: adom-bridge send_files first, then pass where it lands.")),+    };+    let rules = PathRules::from_env(&[ctx.shots_dir().to_string_lossy().to_string()]);   // pup's own screenshots may go into a bug-report form+    let mut files = Vec::new();+    for r in &raws { match check_file(&rules, r) { Ok(f) => files.push(f), Err(e) => return e } }+    let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();+    let listed: Vec<Value> = files.iter().map(|f| json!({ "name": f.name, "sizeBytes": f.size })).collect();+    let total: u64 = files.iter().map(|f| f.size).sum();++    if args["armForNextChooser"] == json!(true) {+        let armed = if ext {+                match crate::extdrive::run(json!({ "action": "fileChooser", "arm": true, "filePaths": paths, "tabId": ext_tab }), T) {+                    Ok(_) => Ok(()),+                    Err(e) => { if let Some(o) = old_worker(&e) { return o; } Err(e["error"].as_str().unwrap_or("the extension did not arm").to_string()) }+                }+            }+            else { arm_cdp(b.clone(), t.cdp_session.clone(), files.clone()) };+        if let Err(e) = armed { return err("arm_failed", format!("could not arm the file chooser: {e}"), Some("pup_upload_file with a selector works when the input already exists in the page.")); }+        touch(ctx, &s.id, None);+        pup_core::log(format!("[upload] armed window {} for {} file(s), {} bytes", s.id, files.len(), total));+        return ok(pup_core::merge(base, json!({ "armed": true, "armedForSec": ARM_SECS, "files": listed,+            "_hint": "ARMED for 30 s: now pup_click the Browse or Upload control. The page's file chooser is answered with these files and no OS dialog opens. Confirm with pup_upload_file {chooserStatus:true}.",+            "_next": ["pup_click {selector: the Browse control}", "pup_upload_file {chooserStatus:true}", "pup_screenshot"] })));+    }++    // direct: find the input as a live object+    let (sel, text) = (args.get("selector").and_then(|v| v.as_str()), args.get("text").and_then(|v| v.as_str()));+    let sid = t.cdp_session.as_str();+    let found = b.cdp.send(Some(sid), "Runtime.evaluate", json!({ "expression": finder(sel, text), "returnByValue": false }), T);+    let found = match found { Ok(r) => r, Err(e) => return err("eval_failed", format!("could not look for the file input: {e}"), None) };+    let oid = found["result"]["objectId"].as_str().filter(|_| found["result"]["subtype"] == json!("node")).map(|x| x.to_string());+    let Some(oid) = oid else {+        let d = b.cdp.evaluate(sid, &diag(sel), T).unwrap_or(json!({}));+        let n = d["fileInputs"].as_array().map(|a| a.len()).unwrap_or(0);+        let xf = d["crossOriginFrames"].as_u64().unwrap_or(0);+        let (code, hint) = if d["badSelector"].is_string() { ("bad_selector", format!("That selector is not valid CSS: {}", d["badSelector"].as_str().unwrap_or(""))) }+            else if sel.is_none() && text.is_none() && n > 1 { ("ambiguous_input", "This page has several file inputs. Pick one with {selector}: fileInputs below lists them.".to_string()) }+            else if d["matched"] == json!(true) { ("not_a_file_input", "The selector matched, but neither that element nor anything near it is a file input. fileInputs below lists the ones on the page.".to_string()) }+            else if n > 0 { ("input_not_found", "Nothing matched, but the page has the file inputs listed in fileInputs below: use one of those, hidden or not.".to_string()) }+            else if xf > 0 { ("input_not_found", format!("No file input in this document, and {xf} cross-origin frame(s) pup cannot look inside from here. If the input is in one, open that frame's address in its own tab; otherwise arm for the chooser: pup_upload_file {{armForNextChooser:true, filePath}}, then pup_click the Browse control.")) }+            else { ("input_not_found", "No file input on the page right now. Sites often create it when Browse is clicked: pup_upload_file {armForNextChooser:true, filePath}, then pup_click the Browse control. Do NOT click Browse without arming: the OS dialog opens, comes to the front and freezes the window.".to_string()) };+        return pup_core::merge(err(code, format!("no file input resolved from {}", sel.map(|x| format!("selector {x}")).or(text.map(|x| format!("text \"{x}\""))).unwrap_or("the page".into())), Some(&hint)), json!({ "fileInputs": d["fileInputs"], "crossOriginFrames": xf }));+    };++    let facts = call_on(&b, sid, &oid, "function(){ return { multiple: this.multiple, disabled: this.disabled, accept: this.accept || '', id: this.id || null, name: this.name || null }; }").unwrap_or(json!({}));+    let release = |o: &str| { let _ = b.cdp.send(Some(sid), "Runtime.releaseObject", json!({ "objectId": o }), T); };+    if facts["disabled"] == json!(true) { release(&oid); return err("input_disabled", "that file input is disabled", Some("Pages usually enable it once an earlier step is done. Finish that step, then retry.")); }+    if files.len() > 1 && facts["multiple"] != json!(true) { release(&oid); return pup_core::merge(err("not_multiple", format!("that input takes one file and {} were given", files.len()), Some("Chrome would silently keep only the first. Zip them, or send one at a time if the page lists each upload.")), json!({ "input": facts })); }++    // One capture listener, so a page that clears the input in its own change handler still counts as handed the file.+    let _ = call_on(&b, sid, &oid, "function(){ const el = this; if (el.files && el.files.length) { el.value = ''; } el.addEventListener('change', function(){ el.__pupSeen = Array.from(el.files || []).map(function(f){ return { name: f.name, size: f.size }; }); }, { capture: true, once: true }); return true; }");+    if let Err(e) = b.cdp.send(Some(sid), "DOM.setFileInputFiles", json!({ "files": paths, "objectId": oid }), T) {+        release(&oid);+        return err("upload_failed", format!("Chrome refused the file: {e}"), Some("Check the element is a real input[type=file] still in the page (a re-render replaces it: call this verb again)."));+    }+    std::thread::sleep(Duration::from_millis(60));   // change is dispatched on Chrome's side of the call+    let back = call_on(&b, sid, &oid, "function(){ const seen = this.__pupSeen || null; try { delete this.__pupSeen; } catch (_) {} return { seen: seen, now: Array.from(this.files || []).map(function(f){ return { name: f.name, size: f.size }; }), connected: this.isConnected }; }").unwrap_or(json!({}));+    release(&oid);+    touch(ctx, &s.id, None);+    let want: Vec<(String, u64)> = files.iter().map(|f| (f.name.clone(), f.size)).collect();+    let as_list = |v: &Value| -> Vec<(String, u64)> { v.as_array().map(|a| a.iter().map(|x| (x["name"].as_str().unwrap_or("").to_string(), x["size"].as_u64().unwrap_or(0))).collect()).unwrap_or_default() };+    let (now, seen) = (as_list(&back["now"]), as_list(&back["seen"]));+    let on_input = now == want; let handed = seen == want;+    if !on_input && !handed {+        return pup_core::merge(err("attach_unverified", "Chrome took the call, but the page does not show these files on the input", Some("The page may have replaced the input while this ran. Screenshot it, then call this verb again.")), json!({ "wanted": listed, "onInput": back["now"], "seenAtChange": back["seen"] }));+    }+    let cleared = handed && now.is_empty();+    let origin = t.url.split('/').take(3).collect::<Vec<_>>().join("/");+    pup_core::log(format!("[upload] window {} put {} file(s), {} bytes, on an input at {}", s.id, files.len(), total, origin));+    let warn = accept_warning(facts["accept"].as_str().unwrap_or(""), &files);+    let mut v = pup_core::merge(base, json!({ "attached": true, "verified": true, "files": listed, "totalBytes": total, "input": facts, "clearedByPage": cleared, "pageOrigin": origin,+        "_hint": if cleared { "The page received the file and cleared the input, which is what upload widgets do. The file has NOT necessarily reached the site yet: screenshot or poll the page for its own progress or error." } else { "The file is on the input and the page got its normal input and change events. It has NOT necessarily reached the site yet: many pages upload on change, others need an Upload or Submit click. Screenshot to see which." },+        "_next": ["pup_screenshot", "pup_wait {selector: what the page shows when the upload finishes}"],+        "pitfalls": ["Keep the file in place until the page has finished uploading: Chrome reads it from disk only then, and a changed or moved file fails the upload."] }));+    if let Some(w) = warn { v["acceptWarning"] = json!(w); }+    ok(v)+}++#[cfg(test)]+mod tests {+    use super::*;+    fn win() -> PathRules { PathRules { windows: true, home: "c:/users/me".into(), always_ok: vec!["c:/users/me/appdata/local/temp".into(), "c:/users/me/appdata/local/adom bridge/bridge-data/puppeteer/screenshots".into()], system: vec!["c:/windows".into(), "c:/program files".into(), "c:/program files (x86)".into(), "c:/programdata".into()] } }+    #[test] fn ordinary_files_pass() {+        let r = win();+        for p in ["C:\\Users\\me\\Downloads\\gerbers.zip", "C:/Users/me/Documents/board/fab.zip", "C:\\Users\\me\\Desktop\\a.step", "D:\\boards\\v2\\gerbers.zip", "C:\\Projects\\x.zip", "C:\\Users\\Public\\x.zip",+                  "C:\\Users\\me\\AppData\\Local\\Temp\\staged\\x.zip", "C:\\Users\\me\\AppData\\Local\\Adom Bridge\\bridge-data\\puppeteer\\screenshots\\s.png", "\\\\?\\C:\\Users\\me\\Downloads\\y.zip", "C:\\Users\\me\\Downloads\\keyboard.zip", "C:\\Users\\me\\Downloads\\cookies.txt"] {+            assert_eq!(r.refusal(p), None, "{p}");+        }+    }+    #[test] fn secrets_and_system_refused() {+        let r = win();+        for p in ["C:\\Users\\me\\.ssh\\config", "C:\\Users\\me\\.adom\\config.json", "C:\\Users\\me\\.claude\\x.json", "C:\\Users\\me\\.gitconfig", "C:\\Users\\me\\AppData\\Roaming\\x.db",+                  "C:\\Users\\me\\AppData\\Local\\Google\\Chrome\\User Data\\Default\\Network\\Cookies", "C:\\Users\\me\\Downloads\\id_rsa", "C:\\Users\\me\\Documents\\id_ed25519.pub", "D:\\backup\\.ssh\\k", "D:\\x\\server.pem", "C:\\Users\\me\\vault.kdbx", "D:\\repo\\.env", "D:\\repo\\.env.local", "D:\\repo\\.git\\config", "C:\\Users\\me\\AppData\\Local\\Temp\\login data",+                  "C:\\Windows\\System32\\config\\SAM", "C:\\Windows\\win.ini", "C:\\Program Files\\x\\y.dll", "C:\\ProgramData\\x", "C:\\Users\\other\\Documents\\a.zip", "\\\\?\\C:\\Users\\me\\.ssh\\k"] {+            assert!(r.refusal(p).is_some(), "{p}");+        }+    }+    #[test] fn prefix_is_a_folder_not_a_string() { assert_eq!(win().refusal("C:\\Windows2\\a.zip"), None); assert_eq!(win().refusal("C:\\Users\\me2\\a.zip").is_some(), true); }+    #[test] fn precheck_shapes() {+        let r = win();+        assert!(r.precheck("C:\\Users\\me\\x.zip").is_ok()); assert!(r.precheck("D:/a/b.zip").is_ok());+        assert_eq!(r.precheck("\\\\evil\\share\\x").unwrap_err().0, "path_refused");+        assert_eq!(r.precheck("//evil/share/x").unwrap_err().0, "path_refused");+        assert_eq!(r.precheck("\\\\wsl$\\Ubuntu\\x").unwrap_err().0, "path_refused");+        assert_eq!(r.precheck("/home/adom/x.zip").unwrap_err().0, "path_not_absolute");+        assert_eq!(r.precheck("~/x.zip").unwrap_err().0, "path_not_absolute");+        assert_eq!(r.precheck("x.zip").unwrap_err().0, "path_not_absolute");+        assert_eq!(r.precheck("").unwrap_err().0, "file_path_required");+        assert_eq!(r.precheck("C:\\x.txt:secret").unwrap_err().0, "path_refused");+        assert_eq!(r.precheck("C:\\Users\\me\\..\\other\\x").unwrap_err().0, "path_refused");+        assert!(r.precheck("C:\\Users\\me\\a..b.zip").is_ok());+    }+    #[test] fn accept_warning_only_for_extension_lists() {+        let f = vec![Local { path: "x".into(), name: "G.ZIP".into(), size: 1 }];+        assert_eq!(accept_warning("", &f), None); assert_eq!(accept_warning(".zip,.rar", &f), None); assert_eq!(accept_warning("application/zip", &f), None);+        assert!(accept_warning(".png", &f).is_some());+    }+}
rust/verbs/src/vault.rs+31−3
@@ -65,6 +65,11 @@ pub fn unanswered_challenge(tab: i64) -> Option<(String, bool)> { /// The typed answer to an unanswered challenge: which host asked, and the three ways to supply it. pub fn no_credential_error(host: &str, rejected: bool) -> Value {     let what = if rejected { format!("{host} asked for HTTP authentication and REJECTED the stored vault credential (stale password).") } else { format!("{host} asked for HTTP authentication (Basic/Digest) and the vault has no entry for that domain.") };+    if cfg!(target_os = "linux") {+        // Linux: no browser-login import (Windows-only) and no UAC; the routes that exist there.+        return crate::err("no_credential", format!("{what} pup cancelled the challenge so no native dialog blocks the window; the page is an error page until a credential exists."),+            Some(&format!("Two routes: pup_vault_sync {{peer, hostFilter:\"{d}\"}} from another of the user's desktops that has it, or credential_set {{host:\"{d}\", username, password}} (a plain domain entry matches every subdomain). Then navigate again. {LINUX_NO_CREDENTIAL_HINT}", d = pup_vault::registrable_domain(host))));+    }     crate::err("no_credential", format!("{what} pup cancelled the challenge so no native dialog blocks the window; the page is an error page until a credential exists."),         Some(&format!("Three routes, in order: pup_import_browser_logins {{}} (the user's own Chrome/Edge saved logins, one consent + one UAC), pup_vault_sync {{peer, hostFilter:\"{d}\"}} from a box that has it, or credential_set {{host:\"{d}\", username, password}} (a plain domain entry matches every subdomain). Then navigate again. {IMPORT_HINT}", d = pup_vault::registrable_domain(host)))) }@@ -93,6 +98,21 @@ pub fn on_capture(ctx: &Ctx, sid_cdp: &str, payload: &str) { /// to discover that the user's OWN saved browser logins can be pulled in with one consent. pub const IMPORT_HINT: &str = "pup can READ THE USER'S OWN SAVED BROWSER LOGINS and use them: pup_import_browser_logins {} pulls every password saved in their Chrome/Edge/Brave profiles into pup's keychain vault (Adom Bridge asks them for one consent plus one Windows admin prompt; no AI ever sees a password). Run it with NO hostFilter the first time: a filter hides logins saved under a different domain, and the import is one prompt either way. Then call this verb again. Other routes: pup_vault_sync {peer} merges another of the user's desktops' logins, or they sign in once in the pup window and pup captures it on submit. Passing {username, password} yourself is the LAST resort, and never ask the user to type a password into the chat."; +/// Linux: browser-login import is Windows-only, so the "no saved login" hint names the routes that work there.+pub const LINUX_NO_CREDENTIAL_HINT: &str = "No saved login for this site in pup's vault. On Linux: ask the user to sign in once in the pup window (pup saves the login when they submit it, into the desktop keyring when it is unlocked, else ~/.adom/pup-secrets.json, mode 0600), or merge another of their desktops' logins with pup_vault_sync {peer}. Passing {username, password} yourself is the last resort; never ask the user to type a password into the chat. pup_import_browser_logins (reading their Chrome/Edge saved passwords) is Windows-only.";++/// The hint for a missing login, per OS (Windows and macOS keep IMPORT_HINT byte for byte).+pub fn no_credential_hint() -> &'static str { if cfg!(target_os = "linux") { LINUX_NO_CREDENTIAL_HINT } else { IMPORT_HINT } }++/// Linux: the vault has a login for this URL but the desktop keyring holding its password is locked (an+/// auto-login desktop, or the user locked it). Say that instead of "no saved login": pup never unlocks a keyring.+pub fn keyring_locked_error(ctx: &Ctx, url: &str) -> Option<(String, Value)> {+    if !cfg!(target_os = "linux") { return None; }+    let saved = ctx.vault.locked_for_url(&*ctx.host, url)?;+    Some((saved.clone(), err("keyring_locked", format!("pup has a saved login for {saved}, but it is in the desktop keyring, which is locked right now"),+        Some("Ask the user to unlock their keyring (it unlocks when they sign in to the desktop with their password; or open Passwords and Keys and unlock \"Login\"), then call pup_login again. pup never shows an unlock prompt itself. Logins pup saves while the keyring is locked go to ~/.adom/pup-secrets.json (mode 0600)."))))+}+ pub fn credentials(ctx: &Ctx) -> Value {     let list = ctx.vault.list();     ok(json!({ "success": true, "count": list.len(), "credentials": list, "output": json!({ "ok": true, "credentials": list }).to_string(), "_hint": "Host + username only; passwords stay in the OS keychain. pup_login {submit:true, submitReason} signs a window in; pup_google_signin {account} does Google. EMPTY or missing the site you need? pup_import_browser_logins {} pulls the user's OWN saved Chrome/Edge/Brave logins into this vault behind one Adom Bridge consent plus one Windows prompt (no hostFilter the first time: a filter hides logins saved under another domain)." }))@@ -100,7 +120,7 @@ pub fn credentials(ctx: &Ctx) -> Value {  pub fn credential_set(ctx: &Ctx, args: &Value) -> Value {     let (h, u, p) = (args["host"].as_str().unwrap_or("").trim(), args["username"].as_str().unwrap_or("").trim(), args["password"].as_str().unwrap_or(""));-    match ctx.vault.set(&*ctx.host, h, u, p) { Ok(_) => ok(json!({ "success": true, "host": h, "username": u, "output": json!({ "ok": true, "host": h, "username": u }).to_string(), "_hint": "Stored in the OS keychain. The password was not echoed and will not be." })), Err(e) => err("credential_set_failed", e, Some("host, username and password are all required; host may be a pattern like *.example.com.")) }+    match ctx.vault.set(&*ctx.host, h, u, p) { Ok(_) => ok(json!({ "success": true, "host": h, "username": u, "output": json!({ "ok": true, "host": h, "username": u }).to_string(), "storage": ctx.host.secret_store(), "_hint": format!("Stored in {}. The password was not echoed and will not be.", ctx.host.secret_store()) })), Err(e) => err("credential_set_failed", e, Some("host, username and password are all required; host may be a pattern like *.example.com.")) } }  pub fn credential_delete(ctx: &Ctx, args: &Value) -> Value {@@ -132,7 +152,10 @@ pub fn login(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {         }         _ => match ctx.vault.get_for_url(&*ctx.host, &cur_url, args["preferAccount"].as_str()) {             Some(c) => (c.username, c.password, format!("vault:{}", c.host)),-            None => return pup_core::merge(err("no_credential", format!("no saved login matches {live_host}"), Some(IMPORT_HINT)), json!({ "output": json!({ "ok": false, "url": cur_url }).to_string() })),+            None => {+                if let Some((_, e)) = keyring_locked_error(ctx, &cur_url) { return pup_core::merge(e, json!({ "output": json!({ "ok": false, "url": cur_url }).to_string() })); }+                return pup_core::merge(err("no_credential", format!("no saved login matches {live_host}"), Some(no_credential_hint())), json!({ "output": json!({ "ok": false, "url": cur_url }).to_string() }));+            }         },     };     let submit = args["submit"].as_bool() == Some(true);@@ -379,9 +402,14 @@ fn login_ext(ctx: &Ctx, caller: &Caller, args: &Value, s: Session) -> Value {         _ => match ctx.vault.get_for_url(&*ctx.host, &cur_url, args["preferAccount"].as_str()) {             Some(c) => (c.username, c.password, format!("vault:{}", c.host)),             None => {+                if let Some((saved, e)) = keyring_locked_error(ctx, &cur_url) {+                    crate::google::toast_waiting(ctx, caller, &s.id, "pup's saved login is in your locked keyring",+                        &format!("pup has your login for {saved}, but the desktop keyring is locked. Unlock it (Passwords and Keys, \"Login\") and pup can sign in."));+                    return e;+                }                 crate::google::toast_waiting(ctx, caller, &s.id, "pup needs a login it does not have",                     &format!("Sign in to {live_host} once in the pup window (click here to open it) and pup will remember it."));-                return err("no_credential", format!("no saved login matches {live_host}"), Some(IMPORT_HINT));+                return err("no_credential", format!("no saved login matches {live_host}"), Some(no_credential_hint()));             }         },     };
rust/verbs/src/window.rs+29−3
@@ -46,13 +46,18 @@ pub fn raise(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {         let _ = ctx.ab.call("notify_user", json!({ "title": format!("pup: {thread} needs this window on your screen"), "body": reason, "focus": { "hwnd": h }, "level": "warning" }), caller, AB_T);         std::thread::sleep(Duration::from_millis(1200));     }+    // The grant is recorded BEFORE the window moves. Recorded after, a park re-assert running in between (a window+    // inside its 8 s birth grace, e.g. just relaunched by pup_restart_browser) saw an un-granted self-raise and+    // bottomed the granted raise 0.8 s later (hl-ubuntu Ralph test, 2026-11-03).+    ctx.sessions.with_mut(&s.id, |x| { x.foreground = true; x.raise_granted = true; x.user_active_at_ms = pup_core::now_ms(); });     // macOS raises natively (ab cannot target another app's window by handle there); Windows goes through ab.     let applied = if ctx.host.raise_window(h) { true } else {         let r = ctx.ab.call("desktop_bring_to_front", json!({ "hwnd": h }), caller, AB_T);         if r.is_err() { let _ = ctx.ab.call("desktop_window_show", json!({ "hwnd": h }), caller, AB_T); }         r.is_ok()     };-    ctx.sessions.with_mut(&s.id, |x| { x.foreground = true; x.raise_granted = true; x.user_active_at_ms = pup_core::now_ms(); }); touch(ctx, &s.id, Some(reason));+    if !applied { ctx.sessions.with_mut(&s.id, |x| x.foreground = false); }+    touch(ctx, &s.id, Some(reason));     if let Some(x) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&x); }     ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "focused": applied, "applied": applied, "placement": "foreground", "windowEtiquette": "foreground", "reason": reason, "captioned": true, "toasted": !human, "_hint": "Raised onto the user's screen with a caption and a toast naming why. Send it back with pup_lower_os_window when the demo is over.", "_next": ["pup_lower_os_window", "pup_screenshot"] })) }@@ -66,12 +71,13 @@ pub fn raise_for_user(ctx: &Ctx, s: &pup_session::Session, caller: &Caller, capt         let ms: u64 = ctx.settings.get("fgCaptionMs", "3500").parse().unwrap_or(3500);         let _ = ctx.ab.call("desktop_caption", json!({ "text": caption, "duration": ms, "expiresInMs": ms, "position": "bottom", "size": "medium", "id": format!("pup-fg-{}", s.id) }), caller, AB_T);     }+    ctx.sessions.with_mut(&s.id, |x| { x.foreground = true; x.raise_granted = true; x.user_active_at_ms = pup_core::now_ms(); });   // before the raise: see raise()     let applied = if ctx.host.foreground() == h { true } else if ctx.host.raise_window(h) { true } else {         let r = ctx.ab.call("desktop_bring_to_front", json!({ "hwnd": h }), caller, AB_T);         if r.is_err() { let _ = ctx.ab.call("desktop_window_show", json!({ "hwnd": h }), caller, AB_T); }         r.is_ok()     };-    ctx.sessions.with_mut(&s.id, |x| { x.foreground = true; x.raise_granted = true; x.user_active_at_ms = pup_core::now_ms(); });+    if !applied { ctx.sessions.with_mut(&s.id, |x| x.foreground = false); }     if let Some(x) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&x); }     applied }@@ -91,6 +97,12 @@ pub fn alert(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let Some(h) = hwnd_of(ctx, &s) else { return err("no_window_handle", "pup has no OS window handle for this window yet", None) };     let stop = args.get("stop").and_then(|b| b.as_bool()).unwrap_or(false);     let mode = crate::activity::mode(ctx);+    if stop && cfg!(target_os = "linux") {+        // Linux: the window's attention hint off, the dock's urgency off. Nothing tints a button that needs activating.+        let cleared = ctx.host.stop_flash(h);+        crate::identity::linux_taskbar(ctx, h, &json!({ "urgent": false }));+        return ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "alerted": false, "cleared": cleared, "applied": cleared, "clearMethod": "window attention hint removed, dock urgency off", "_hint": "Attention cleared on the window and its dock icon." }));+    }     if stop {         // Node v1.8.45: FLASHW_STOP only stops the pulse; the Win11 tint needs the activate-and-restore.         let r = ctx.ab.call("desktop_flash_window", json!({ "hwnd": h, "stop": true }), caller, AB_T);@@ -101,11 +113,24 @@ pub fn alert(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     if mode == "progress" {         // Node v2.0.304 (John: "i thought our new setting was for you to not do the orange flash anymore"):         // in progress mode the nudge is a short full-bar pulse, never orange.+        if cfg!(target_os = "linux") {+            crate::identity::linux_taskbar(ctx, h, &json!({ "progress": { "state": "normal", "value": 1.0 } }));+            if let Some(a) = ctx.arc() { std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(2500)); crate::identity::linux_taskbar(&a, h, &json!({ "progress": { "state": "none" } })); }); }+        } else {         let _ = ctx.ab.call("desktop_taskbar", json!({ "hwnd": h, "progress": { "state": "normal", "value": 1.0 } }), caller, AB_T);         if let Some(a) = ctx.arc() { std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(2500)); let _ = a.ab.call("desktop_taskbar", json!({ "hwnd": h, "progress": { "state": "none" } }), &crate::identity::internal_caller(), AB_T); }); }+        }         return ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "alerted": true, "cleared": false, "applied": true, "method": "progress-pulse", "_hint": "taskbarActivity is \"progress\", so the nudge was a short full progress pulse on the button (no orange). pup_configure {taskbarActivity:\"flash\"} brings the orange flash back." }));     }     if mode == "off" { return ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "alerted": false, "applied": false, "_hint": "taskbarActivity is \"off\": no taskbar signal is painted." })); }+    if cfg!(target_os = "linux") {+        // Linux "flash": the window asks for attention (window lists highlight it until it is focused) and its dock+        // icon is urgent for 5 s, the length of the Windows four-count flash. Never raised, never focused.+        let applied = crate::identity::linux_taskbar(ctx, h, &json!({ "urgent": true }));+        if let Some(a) = ctx.arc() { std::thread::spawn(move || { std::thread::sleep(Duration::from_secs(5)); if let Some(id) = a.sessions.all().into_iter().find(|x| x.hwnd == Some(h)).and_then(|x| x.app_id) { a.host.taskbar_signal(0, Some(&id), &json!({ "urgent": false })); } }); }+        ctx.sessions.with_mut(&s.id, |x| x.flash_count += 1);+        return ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "alerted": applied, "cleared": false, "applied": applied, "_hint": "The window asks for attention (no raise, no focus) and its dock icon is urgent for 5 s. Pass {stop:true} to clear." }));+    }     let r = ctx.ab.call("desktop_flash_window", json!({ "hwnd": h, "mode": "count", "count": 4 }), caller, AB_T);     let applied = r.is_ok(); ctx.sessions.with_mut(&s.id, |x| x.flash_count += 1);     ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "alerted": applied, "cleared": false, "applied": applied, "abError": r.as_ref().err().map(|e| e.to_string()), "_hint": "Flashed the taskbar (finite 4-count) via ab, no focus steal. Pass {stop:true} to clear." }))@@ -124,7 +149,8 @@ pub fn progress(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     };     let mut p = json!({ "hwnd": h, "progress": progress });     if let Some(note) = args.get("note").and_then(|n| n.as_str()) { p["overlay"] = json!({ "tooltip": format!("Adom Pup — {note}") }); }-    let applied = ctx.ab.call("desktop_taskbar", p, caller, AB_T).is_ok();+    // Linux: no red or yellow bar exists on a dock, so error also makes the icon urgent; anything else clears that.+    let applied = if cfg!(target_os = "linux") { let mut sig = json!({ "progress": p["progress"].clone() }); sig["urgent"] = json!(mode == "error"); crate::identity::linux_taskbar(ctx, h, &sig) } else { ctx.ab.call("desktop_taskbar", p, caller, AB_T).is_ok() };     touch(ctx, &s.id, args.get("note").and_then(|n| n.as_str()));     ok(json!({ "success": true, "output": json!({ "ok": true, "sessionId": s.id, "mode": mode, "painted": applied, "note": args.get("note").and_then(|n| n.as_str()) }).to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner, "mode": mode, "applied": applied, "_hint": "Taskbar progress painted on this window's button. Clear it with mode:\"off\" when the long task ends." })) }
skills/pup-adom-wiki/SKILL.md+2−1
@@ -82,7 +82,8 @@ view" on an authed one. Clicking it relaunches THAT window under the other cooki on-screen (no thread involvement — it's a pure user affordance). So if the user asks "how do I see the logged-in version," you can tell them to right-click the pup taskbar button and pick "Switch to logged-in view" (or just reopen with `wikiView:"authed"` yourself). This requires AD ≥1.9.139; on-older AD the menu simply isn't there.+older AD the menu simply isn't there. Windows only: on Linux the dock menu has only the thread-wide+tasks, so reopen with `wikiView` yourself.  ## Historical note — pup WAS logged-out only (this is no longer true; see "Two views" above) 
skills/pup-browsers-and-chrome/SKILL.md+24−1
@@ -20,7 +20,8 @@ missing or still-downloading CfT never blocks an open. On every open pup builds and **spawn-verifies** it:  1. **Pinned/cached default first (CfT, per policy) → installed Chrome → Edge → cached CfT.** Every-   Windows PC ships Edge, so there is always an interim candidate while CfT downloads.+   Windows PC ships Edge, so there is always an interim candidate while CfT downloads. A Linux box has+   one only if a native Chrome, Chromium or Edge package is installed (see On Linux below). 2. It **launches a fresh process** of that browser binary with an **isolated `--user-data-dir` + a CDP    `--remote-debugging-port`** — a clean empty profile driven over the DevTools Protocol, never the user's    signed-in profile, and a **real rendered window** (not headless).@@ -45,6 +46,26 @@ adom-bridge-cli pup_readiness '{}' - **`pup_status` is NOT a readiness check** — it only lists open sessions. - AD-core **`bridge_readiness`** is the platform-level "is this bridge up?" probe. +## On Linux++- The browser build is Chrome for Testing **linux64** in `~/.cache/puppeteer` (same layout and the same+  installed-Chrome version match as on Windows). The download is unpacked with `bsdtar`, `tar`, `unzip` or+  `python3`, whichever the box has.+- System browsers pup can see: native Google Chrome (`/opt/google/chrome`), the distribution's Chromium+  (`/usr/lib/chromium`, `/usr/lib64/chromium-browser`) reported as kind `chrome`, and Microsoft Edge+  (`/opt/microsoft/msedge`). Snap and Flatpak browsers are skipped (their confinement refuses pup's+  profile folder under `~/.adom`). The extension lane needs Chrome for Testing, as on Windows.+- **`browserSandbox`** in `pup_readiness`: `namespaces`; `the browser's own setuid helper / AppArmor+  profile`; `setuid helper <path>` (Chrome for Testing run under an installed Chrome or Chromium+  `chrome-sandbox`, which is what Ubuntu 23.10+ needs because AppArmor blocks unprivileged user+  namespaces); or `off: <why>`, where the browser runs with `--no-sandbox` until a Chrome or Chromium+  package is installed.+- A browser that will not start explains itself in `~/.adom/bridge-logs/pup-browser.log` (the browser's+  own stderr): "No usable sandbox", "Missing X server or $DISPLAY", a missing library.+- Launch switches added on Linux: `--password-store=basic` (no keyring prompt), and on a Wayland session+  `--ozone-platform=x11` so pup's windows live under XWayland where pup can keep them in the background+  (`PUP_OZONE_PLATFORM=wayland` in Adom Bridge's environment turns that off).+ ## Pinning a browser — `pup_use`  pup auto-picks, but you can override (persists across restarts; launch still spawn-verifies + falls back):@@ -61,6 +82,8 @@ Every `pup_open_window` response also carries `browser` (what was driven), `avai  ## Installing real Google Chrome on an Edge-only box +(Windows. There is no UAC on Linux.)+ If the user specifically wants Chrome:  ```bash
skills/pup-credentials/SKILL.md+9−1
@@ -16,6 +16,7 @@ find yourself about to ask "what's your Google password?" — STOP. You have ver    (one consent click). 2. **Not saved on THIS box? Import the user's own browser logins.** `pup_import_browser_logins    {hostFilter?}` pulls their saved Chrome/Edge/Brave passwords into the vault (one consent + UAC).+   Windows only: on Linux or a Mac it answers `unsupported_platform` (see `pup-import-logins`).    That is ab's signed decrypt helper doing the work locally; a cloud caller cannot, only pup can. 3. **Saved on ANOTHER of the user's desktops? Sync it.** `pup_vault_sync {peer, reason}` merges this    box's adom-you logins with a peer desktop's, BOTH directions, so both boxes hold the union@@ -121,7 +122,11 @@ than 2.0.602, a typed password is NOT stored on the default lane. Verify a captu ## 1. The vault (how pup remembers logins)  - **Auto-capture:** when the user (or pup) signs into a site, pup grabs the credential and stores it-  in the **OS keychain** (Windows DPAPI / macOS Keychain / Linux libsecret). Passwords are NEVER+  in the **OS keychain** (Windows Credential Manager / macOS Keychain / the Linux desktop keyring through the+  Secret Service; on a Linux desktop with no unlocked keyring, `~/.adom/pup-secrets.json` at mode 0600, and+  `credential_set` replies with `storage` naming which). On Linux a login saved in the keyring while the+  keyring is locked answers `keyring_locked`: ask the user to unlock it (Passwords and Keys, "Login"), then call+  `pup_login` again; pup never shows an unlock prompt. Passwords are NEVER   echoed back — `pup_credentials` lists host + username only. - **Autofill:** on a return visit pup fills the saved login in the **background** (no Chrome   dropdown, no foregrounding). Submitting is reason-gated (`pup_login {submit:true, submitReason}`)@@ -293,6 +298,9 @@ the extension lane too, so a gated file can be fetched as `bodyBase64` without a  ## Where secrets live (2.0.649+): one DPAPI file, no Windows Credential Manager cap +(Windows. On Linux secrets go to the Secret Service keyring, or `~/.adom/pup-secrets.json` at mode 0600, as+in section 1.)+ Every password is in `~/.adom/pup-vault.bin`, encrypted with Windows DPAPI for the current user (the same protection Chrome's own password store uses), indexed by `~/.adom/pup-credentials.index.json` (host + username only). Older builds kept one Credential Manager entry per login and hit the per-user cap after a full browser
skills/pup-import-logins/SKILL.md+13−3
@@ -49,6 +49,16 @@ adom-bridge --ai-thread "<your thread>" pup_import_browser_logins '{}'   nb to delete the file. Passwords never pass through you or the container, and are never printed. This survives   Chrome updates, because Chrome is the one decrypting. +- **Which browser profile.** nb reads the active profile of the user's browser, and the answer says which in+  `profile`. Got `no_data` but the user has saved logins? They are in another profile: `nbrowser_profiles` lists+  them, then call again with `profile:"chrome:[email protected]"` (works for `sessionFor` too).+- **Where nb cannot run (2.0.741, pup-bridge#145).** If nb is not installed, is older than 0.16.45, or has no+  extension connected in the user's browser, pup uses Adom Bridge's older decrypt helper instead: the user approves an+  Adom Bridge request (inside Hydrogen it can appear as a toast or a card, on any of their machines) and then a Windows+  admin prompt on the PC itself. The answer says `via:"ab-helper"` and why in `nbUnavailable`. That path reads Edge+  and Brave but not Chrome 156 or later, so for Chrome the real fix is nb with its extension (`nbrowser_readiness`).+  A user's own answer through nb (Hello declined, no session) is never retried this way.+ After it finishes, reload the sign-in page (`pup_reload {}`). pup fills it in the background and says so in `_autoLogin`. Confirm the live domain is the site you meant, then `pup_login {submit:true, submitReason:"<why this domain is right>"}` to sign in.@@ -69,10 +79,10 @@ printed. If the user is signed in under a different browser profile, the answer `nbrowser_profiles`; ask the user to check which profile they use. Per-site details are in `login-recipes/<domain>.md` on the pup-bridge wiki page. -## On a Mac+## On a Mac or Linux -The password import is Windows-only today (nb drives Chrome's export on Windows first). On a Mac,-`pup_import_browser_logins` answers `unsupported_platform`, and the `_loginTip` says so. What works on a Mac:+The password import is Windows-only today (nb drives Chrome's export on Windows first). On a Mac or a Linux desktop,+`pup_import_browser_logins` answers `unsupported_platform`, and the `_loginTip` says so. What works there:  - **Sign in once in the pup window.** Ask the user to sign in themselves in the pup window. pup saves the login as   they submit it, then fills it on that site from then on.
skills/pup-lanes/SKILL.md+1−1
@@ -20,7 +20,7 @@ only CDP can do AND the user has turned that lane on for the box (`cdpLane`, off | Debug port | none | yes, and vendor edges detect it (measured: st.com and mouser RST the HTTP/2 stream, arrow denies) | | Profile | one browser per profile: adom-you is shared by every thread; `isolated:true` and `profile:"<name>"` get their own lane browser, extension and queue (2.0.659) | any profile; on adom-you it TAKES the profile from the extension lane and closes those windows | | Toolbar | four icons: pup dots, pen, identity tile, nbe | three icons (nbe rides the extension lane only) |-| Taskbar identity, tiles, overlays, jump lists | yes: branded BEFORE the first show (2.0.567), tile and overlay badge drawn in the binary | yes |+| Taskbar identity, tiles, overlays, jump lists (per-window jump lists: Windows only) | yes: branded BEFORE the first show (2.0.567), tile and overlay badge drawn in the binary | yes | | Annotate (pen, snip) | yes (2.0.569, Trusted-Types safe since 2.0.570) | yes | | Vendor sites (st, mouser, digikey, arrow, analog, ti, avnet, nxp, infineon, and the rest of the 20) | load | walled by several of them | 
skills/pup-progress/SKILL.md+5−3
@@ -1,13 +1,15 @@ --- name: pup-progress-description: "Parent skill: pup. WHEN and HOW to paint the Windows taskbar progress bar on a pup window during long multi-stage work — site crawls, login→search→download loops, batch harvesting of datasheets/STEP files/libraries. Teaches the judgment call (multi-stage = yes, single page open = no), the pup_progress verb (indeterminate / value / off + stage notes), and clearing hygiene. Trigger words: progress bar, taskbar progress, pup_progress, long crawl, show progress, crawling indicator, harvesting progress, download progress pup."+description: "Parent skill: pup. WHEN and HOW to paint the Windows taskbar (or Linux dock) progress bar on a pup window during long multi-stage work — site crawls, login→search→download loops, batch harvesting of datasheets/STEP files/libraries. Teaches the judgment call (multi-stage = yes, single page open = no), the pup_progress verb (indeterminate / value / off + stage notes), and clearing hygiene. Trigger words: progress bar, taskbar progress, pup_progress, long crawl, show progress, crawling indicator, harvesting progress, download progress pup." ---  # pup-progress — the taskbar progress bar, and when to use it  Windows taskbar buttons can carry a real **progress bar** (the green fill you see on file copies). pup exposes it per-window via `pup_progress`, and the humans watching the taskbar-love it: a glance says "that thread is deep in a crawl" without opening anything.+love it: a glance says "that thread is deep in a crawl" without opening anything. On Linux the same+verb paints a bar on the thread's dock icon (GNOME Ubuntu Dock / Dash to Dock) or a fill across its+Cinnamon window-list button; per-desktop detail in `pup-taskbar-progress`.  ## The judgment call (this is YOUR decision, not a rule to follow blindly) @@ -62,7 +64,7 @@ pup_progress {mode:"off"}          # the crawl is done — never leave a stale b    desktop paint, not a log line. For a 500-item loop, update every 5-10 items. 3. **One window, one story**: set progress on the window doing the work. If your thread runs    several windows, give each its own progress only if each genuinely has its own long task.-4. Progress is Windows-only and skipped in minimal-touch mode; calling it elsewhere is a safe+4. Progress is Windows and Linux only (no macOS) and skipped in minimal-touch mode; calling it elsewhere is a safe    no-op, so you never need to platform-check first.  ## Measured progress on pup's own slow verbs (v2.0.383, Bridge SDK #59/#64)
skills/pup-screenshots-recording/SKILL.md+6
@@ -50,6 +50,12 @@ changes, so a static page yields few frames and a small file; that is by design, The response carries `filePath`, `bytes`, `frames` and `recordedMs`; pull the file with `adom-bridge pull_file {filePaths:[path]}`. +On Linux: Adom Bridge for Linux has no native window recorder yet, so `pup_record_*` records the page+itself, up to 10 frames a second in the background, saved as a VP9 WebM (with ffmpeg; otherwise an MJPEG+AVI). The window can stay behind the user's work. `pup_screen_record_*` on a Wayland session waits on the+desktop's share dialog: nothing records until the user presses **Share** (`blocked_on_user`,+`share_dialog`), and a stop with nothing captured answers `nothing_recorded`.+ | Ask | Verb | Captures | |---|---|---| | "record my app", a UI walkthrough | `pup_record_start` / `pup_record_stop` | ONE pup window, whole window frame, MP4, no audio |
skills/pup-settings/SKILL.md+9−1
@@ -34,6 +34,10 @@ Two keys underneath the ladder, still settable directly and surfaced by `pup_sta The AUMID identity is registered **registry-only (no Start Menu shortcut)**, pup writes the icon straight into HKCU and never leaves an "Adom Pup" entry in your Start Menu (v2.0.118). +On Linux there is no AUMID: each AI thread gets a hidden per-thread `.desktop` entry in+`~/.local/share/applications` plus the window's WM_CLASS and icon, so the dock files its windows under one+icon wearing the thread tile (see `linux/README.md`).+ ## Jump lists: the right-click menu on a pup taskbar button  | Key | Default | What it does |@@ -45,6 +49,9 @@ straight into HKCU and never leaves an "Adom Pup" entry in your Start Menu (v2.0 | `aumidShortcut` | **off** | Anchor each taskbar identity with a visible Start Menu shortcut (ab 2.1.49 `anchor:true`, one collapsed "Adom Pup" folder). ON = the jump list stays up on the FIRST right-click; OFF = clean Start Menu, the first right-click flashes and the second stays (ab#155, measured: no invisible anchor exists). Since 2.0.668 the anchor is removed with its identity when the window closes, and a boot sweep clears anything older builds left (`pup_sweep_identities`). Decision (John, 2026-09-21): OFF stays the default even though he dislikes the double right-click, because most users never find the jump list and a Start Menu entry per thread is worse. Flip it per box only when that user asks. | | `jlAnnotate` | **on** | "Annotate this page" task, draw on the page; the capture goes to the owning AI thread. | +On Linux per-window jump-list tasks are Windows-only: the per-thread dock entry carries only the thread-wide+tasks (**Open pup dashboard**, **Close ALL Adom Pup windows**).+ ## Toolbar extension family: buttons in the browser toolbar  pup ships a small family of MV3 toolbar tiles (pup dots, the identity avatar, the annotate pen).@@ -102,7 +109,8 @@ Measured 2026-09-05 (pup-bridge#99): Windows 11 25H2 (build 26200) draws no task ## Credential vault: pup manages your logins (no Chrome dialog)  pup runs its own OS-keychain password manager so the AI signs you in without Chrome's foreground-picker. Chrome's native "Save password?" bubble is disabled; pup captures and fills silently.+picker. On Linux the keychain is the Secret Service (GNOME Keyring, KWallet) when a keyring is unlocked,+otherwise `~/.adom/pup-secrets.json` (mode 0600); pup never asks you to unlock a keyring. Chrome's native "Save password?" bubble is disabled; pup captures and fills silently.  | Key | Default | What it does | |---|---|---|
skills/pup-taskbar-progress/SKILL.md+9−2
@@ -1,7 +1,7 @@ --- name: pup-taskbar-progress user-invocable: true-description: "Parent skill: pup. How pup signals it is WORKING on a window — a DETERMINATE progress bar painted right on the window's Windows taskbar button, sized to how long that operation usually takes, INSTEAD of the old orange flash. pup keeps a running average of every operation's duration (persisted) and fills the bar over that estimate: the bar starts at the EARLIEST point of the operation and ends at the LATEST. Built-in cases: applying a window's taskbar icon (from open until the icon actually lands) and taking a screenshot; any operation can be wrapped. Controlled by the `taskbarActivity` setting (progress | flash | off, default progress). Read this to understand the taskbar bar you see, to change/disable it, or to wrap a new operation with a bar. Trigger words: taskbar progress bar, progress bar on taskbar, taskbar activity, replace the flash, orange flash, taskbarActivity, verb timing, average duration, how long a screenshot takes, icon apply progress, progress instead of flash, pup progress bar, determinate progress."+description: "Parent skill: pup. How pup signals it is WORKING on a window — a DETERMINATE progress bar painted right on the window's Windows taskbar button (or, on Linux, its thread's dock icon), sized to how long that operation usually takes, INSTEAD of the old orange flash. pup keeps a running average of every operation's duration (persisted) and fills the bar over that estimate: the bar starts at the EARLIEST point of the operation and ends at the LATEST. Built-in cases: applying a window's taskbar icon (from open until the icon actually lands) and taking a screenshot; any operation can be wrapped. Controlled by the `taskbarActivity` setting (progress | flash | off, default progress). Read this to understand the taskbar bar you see, to change/disable it, or to wrap a new operation with a bar. Trigger words: taskbar progress bar, progress bar on taskbar, taskbar activity, replace the flash, orange flash, taskbarActivity, verb timing, average duration, how long a screenshot takes, icon apply progress, progress instead of flash, pup progress bar, determinate progress." ---  # pup — the taskbar progress bar (activity signal)@@ -44,7 +44,14 @@ Any other operation can get one (see the dev note). | `off` | neither — pup signals activity on the taskbar in no way. |  Notes:-- **Windows only.** macOS/Linux have no taskbar, so the setting is stored but inert.+- **Windows and Linux.** On Linux the bar is drawn on the thread's dock icon (Ubuntu Dock, Dash to Dock, and KDE+  and Plank, which take the same `com.canonical.Unity.LauncherEntry` signal) and as a fill across the window's button+  on Cinnamon's window list (XApp progress). A Linux dock has no red or yellow bar: `pup_progress` `error` keeps the+  bar and makes the icon urgent (it bounces), `paused` keeps the bar, and `indeterminate` is an empty bar (Cinnamon:+  its "busy" pulse). The dock also shows the thread's open tabs as a count when there are two or more. Table per+  desktop: [pup on Linux](../../linux/README.md#taskbar-icons-and-progress). macOS: stored but inert.+- On Linux `flash` makes the window ask for attention (window lists highlight it until it is focused) and the+  dock icon urgent for 5 s; nothing is raised or focused. - In `progress` mode pup NEVER paints orange, not even for an explicit `pup_alert_window`: the   nudge becomes a 2.5s full progress pulse on the button (v2.0.304). A STOP of a leftover flash   still works. If you ever see an orange flash in `progress` mode, it is Windows itself: the shell
skills/pup-taskbar-states/SKILL.md+7−1
@@ -58,7 +58,13 @@ preview is DERIVED from the same sources listed here, so preview and reality can - `tiles` takes precedence over the aumid grouping/style options; those two rows only apply in   `aumid` mode (the dialog grays them out otherwise). - Windows with no owning AI thread (rare) fall back to aumid-style identity even in tiles mode.-- Everything is Windows-only; on other platforms these settings store but paint nothing.+- **Linux** (2026-11-03, Ubuntu GNOME and Debian Cinnamon measured): `tiles` gives each AI thread its own dock+  icon or window-list button with its name tile, through a hidden per-thread `.desktop` entry and the window's+  WM_CLASS; `overlayBadges` puts the favicon on the window's own icon (window lists that draw per-window icons; GNOME's+  dock draws the thread tile without it), and the dock shows the thread's open tabs as a count. `plain` keeps the+  browser's own icon. Right-click on the thread icon gives the thread-wide jump-list tasks (dashboard, close all);+  per-window tasks stay Windows-only. Details: [pup on Linux](../../linux/README.md#taskbar-icons-and-progress).+- macOS: these settings store but paint nothing.  ## The foreground-window tradeoff (bounded, and it self-heals for real now) 
skills/pup-vendor-login/SKILL.md+2−1
@@ -142,7 +142,8 @@ a license seat, editing users). Do not promise to click through it. Options, in  ## The RESPECTFUL way to do the FedCM click: pup_assisted_click (v1.9.167) -Do NOT hand-roll raise + hover + click for the account chooser — use `pup_assisted_click`, which+Do NOT hand-roll raise + hover + click for the account chooser — use `pup_assisted_click` (Windows only;+on Linux it answers plainly that it is unavailable), which does the whole protocol John required (never foreground-and-act while the user is not ready): ``` # first: desktop_screenshot_window {hwnd} to SEE the chooser and read the account's window-pixel x,y
skills/pup-windows-sessions-tabs/SKILL.md+3−2
@@ -62,7 +62,7 @@ windows, no focus stolen) and stays fully drivable + screenshottable there — y - **Sizing/positioning does NOT foreground** — `pup_open_window {width,height,x,y}` places the window   at an exact size/spot **for clean screenshot framing** while it stays in the background. So you can frame   a shot without ever disturbing the user's main work.-- **`pup_alert_window`** flashes the taskbar (a non-intrusive "it's ready/done" nudge) — prefer it over+- **`pup_alert_window`** flashes the taskbar (on Linux the dock icon bounces or fills; a non-intrusive "it's ready/done" nudge) — prefer it over   raising the window. Use it for an explicit end-of-task "it's ready". - `pup_raise_os_window` / `pup_focus_window` bring a window forward — **GATED the same way:   REFUSED (`foreground_reason_required`) unless you pass `foregroundReason`.** Use only for an@@ -98,7 +98,8 @@ adom-bridge-cli pup_open_window '{"sessionId":"demo-web","owner":"demo","url":"<  ## Taskbar look — split vs grouped (user preference, `pup_configure`) -Each pup window wears the Adom Pup icon; how they arrange on the taskbar is the USER's choice:+Each pup window wears the Adom Pup icon; how they arrange on the taskbar is the USER's choice+(Windows; on Linux `tiles` and `plain` are the measured `taskbarMode`s, see `linux/README.md`):  - **`split` (default):** one taskbar button PER WINDOW, each badged with its own tab count   (no badge = 1 tab). Best for telling AI threads apart: "the button with the 8 is my
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-2.0.739\ No newline at end of file+2.0.741\ No newline at end of file
src/bridge.json+2−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "puppeteer",   "displayName": "Puppeteer (Chrome for Testing + Native)",-  "version": "2.0.739",+  "version": "2.0.741",   "description": "Drives a browser ALREADY on the machine \u2014 installed Chrome, else Microsoft Edge (every Windows PC ships with Edge) \u2014 launched with a FRESH isolated profile, so the common case has NO ~150 MB Chrome-for-Testing download and it never touches the user's real tabs/logins. Binds its local server to LOOPBACK (127.0.0.1) only, so no Windows Firewall 'allow Node through the firewall' prompt ever pops. Chrome for Testing is fetched only as a LAST RESORT if the machine has no Chromium browser at all. SPAWN-VERIFIED fallback: pup tries each detected browser (Chrome \u2192 Edge \u2192 CfT) and actually launches it, falling through to the next if one won't start (and handling browsers like Edge that relaunch onto another PID), so a corrupt/partial install never hard-fails an open. The browser that launches is cached as the default (persisted across restarts) so it isn't re-probed each time. On spawn the bridge SELF-WARMS: detects browsers + disk, and only if the box has no Chrome/Edge and no cached CfT does it background-fetch CfT so the first open is instant. pup_use pins a specific browser (chrome | edge | cft | auto); {browser:'chrome',install:true} downloads & installs REAL Google Chrome (user-scoped, no admin) on an Edge-only box. pup_readiness (READ-ONLY) reports which browser will be used + the full candidate list + the default + free disk. Every failure returns a specific errorCode + _hint (chrome_install_no_disk, chrome_download_network, chrome_install_permission, chrome_stable_not_detected, \u2026). Launches with FULL browser permissions by default for full debug (strictPermissions:true opts out). Headful automation: open/close windows+tabs, navigate, screenshot, eval JS, record. pup is a GENERIC, non-profile browser only \u2014 every session is a fresh isolated Chrome/Edge process, never the user's real signed-in browser; profile defaults to sessionId. To drive the user's REAL Chrome/Edge (their profile, cookies, SSO), use the Adom browser EXTENSION's nbrowser_* verbs \u2014 a separate bridge, not pup's job. macOS (v1.9.74+): full parity with mac-native mechanics \u2014 background-by-default is the app-level hide (windows stay drivable/screenshottable while hidden), raise/lower map to unhide+activate/hide, pup_alert_window posts a native toast whose click focuses the window, pup_use {browser:'chrome',install:true} installs Chrome from the official dmg, and taskbar identity/badging remains Windows-only.",   "homepage": "https://wiki.adom.inc/adom/adom-bridge",   "author": "Adom Inc.",@@ -106,6 +106,7 @@     "pup_screenshot_element",     "pup_screenshot_full_res",     "pup_download",+    "pup_upload_file",     "pup_launch",     "pup_deps",     "pup_install_toolbar",
src/extension/annotate-overlay.js+7−1
@@ -363,6 +363,7 @@ window.__pupStartAnnotate = function (initTool, initMode) {     }, 2200);   };   window.__pupAnnotate = {+    cancel: function () { exitAll(); },     show: function () { window.__pupAnnotateHidden = false; wrap.style.display = ''; bar.style.display = 'flex'; cv.style.pointerEvents = 'auto'; armKeys(); },     setMode: function (m) { S.quick = m !== 'multi'; paintMode(); },   };@@ -370,4 +371,9 @@ window.__pupStartAnnotate = function (initTool, initMode) {   document.documentElement.appendChild(wrap);   fit(); selTool(initTool);   return 'started';-};\ No newline at end of file+};+// pup_annotate {action:"cancel"}: close the toolbar and drop the drawings, exactly as the X button does (nothing is captured).+window.__pupCancelAnnotate = function () {+  if (window.__pupAnnotate && window.__pupAnnotate.cancel) { window.__pupAnnotate.cancel(); return 'cancelled'; }+  return 'none';+};
src/extension/pup-errors-main.jsadded+9
@@ -0,0 +1,9 @@+// pup_errors on the extension lane, MAIN-world half (registered at document_start by worker.js).+// A page's uncaught exceptions and unhandled rejections are dispatched ONLY to listeners in the page's own world, so+// this half listens here and hands each one to the extension's isolated world (pup-errors.js) as a DOM event. It+// defines no globals and wraps nothing (console and fetch stay the page's own), so a page cannot tell it is there.+(() => {+  const send = (type, text) => { try { document.dispatchEvent(new CustomEvent('__pup_err__', { detail: JSON.stringify({ type, text: String(text).slice(0, 2000) }) })); } catch (e) {} };+  addEventListener('error', (e) => { if (e instanceof ErrorEvent) send('exception', (e.error && e.error.stack) || `${e.message} (${e.filename}:${e.lineno}:${e.colno})`); }, true);+  addEventListener('unhandledrejection', (e) => { const r = e.reason; send('exception', 'Unhandled rejection: ' + ((r && (r.stack || r.message)) || String(r))); });+})();
src/extension/pup-errors.jsadded+17
@@ -0,0 +1,17 @@+// pup_errors on the extension lane, isolated-world half (registered at document_start by worker.js).+// Buffers what pup_errors reports for a tab: page exceptions (from pup-errors-main.js), resources that failed to load,+// and every response with an HTTP error status (the document included). Console errors come from pup's DevTools driver+// (nbe-cdp.js getErrors) while it is attached. The buffer lives in the extension's isolated world, invisible to the page.+(() => {+  if (window.__pupErrs) return;+  const buf = window.__pupErrs = [];+  const push = (type, text) => { buf.push({ type, text, ts: Date.now(), url: location.href }); if (buf.length > 200) buf.splice(0, buf.length - 200); };+  document.addEventListener('__pup_err__', (e) => { try { const d = JSON.parse(e.detail); push(d.type, d.text); } catch (x) {} });+  addEventListener('error', (e) => {+    const t = e.target;+    if (t && t !== window && t.tagName) { const u = t.currentSrc || t.src || t.href; if (u) push('network', `failed to load ${t.tagName.toLowerCase()} ${u}`); }+  }, true);+  const http = (kind) => (list) => { for (const en of list.getEntries()) if (en.responseStatus >= 400) push('network', `HTTP ${en.responseStatus} ${kind === 'navigation' ? 'document' : (en.initiatorType || 'resource')} ${en.name}`); };+  try { new PerformanceObserver(http('resource')).observe({ type: 'resource', buffered: true }); } catch (x) {}+  try { new PerformanceObserver(http('navigation')).observe({ type: 'navigation', buffered: true }); } catch (x) {}+})();
src/extension/worker.js+42
@@ -152,6 +152,20 @@ chrome.tabs.onUpdated.addListener((tabId, info, tab) => {   chrome.scripting.executeScript({ target: { tabId }, files: ['capture-login.js'] }).catch(() => {}); }); +// pup_errors on the extension lane (found by the Linux Ralph test, the same on Windows): Rust read a per-tab store only+// the CDP lane fills, so the default lane always answered count:0. Two page-start scripts now buffer a tab's errors+// (pup-errors-main.js -> pup-errors.js, see their headers); registered once per profile, they persist across restarts.+(async () => {+  try {+    const have = new Set((await chrome.scripting.getRegisteredContentScripts()).map((s) => s.id));+    const want = [+      { id: 'pup-errors', js: ['pup-errors.js'], matches: ['<all_urls>'], runAt: 'document_start', world: 'ISOLATED' },+      { id: 'pup-errors-main', js: ['pup-errors-main.js'], matches: ['<all_urls>'], runAt: 'document_start', world: 'MAIN' },+    ].filter((s) => !have.has(s.id));+    if (want.length) await chrome.scripting.registerContentScripts(want);+  } catch (e) {}+})();+ chrome.commands.onCommand.addListener(async (command) => {   if (command !== 'pup-snip' && command !== 'pup-view') return;   const found = await findPort();@@ -393,6 +407,28 @@ async function pupExtPollOnce() {         const v = window[name]; if (clear) window[name] = null;         return { value: v === undefined ? null : v, dpr: window.devicePixelRatio || 1 }; }, args: [cmd.name || '', !!cmd.clear] });       out = Object.assign({ id: cmd.id, ok: true, tabId: tab.id }, (res && res[0] && res[0].result) || {});+    } else if (cmd.action === 'dialog') {+      // pup_dialog on the extension lane. While pup's DevTools driver is attached Chrome cancels page dialogs at once+      // (nbe-cdp.js auto-handles them so the page never wedges), so a dialog is rarely still open to answer:+      // - answer {accept, promptText}: answers an open dialog, else reports the one Chrome already auto-handled;+      // - policy "manual" pre-answers the NEXT dialogs on this tab; "auto" disarms and lists what was answered;+      // - neither: just report the open and the last dialog.+      const tab = await pupExtTab(cmd);+      if (cmd.answer || cmd.policy) out = Object.assign({ id: cmd.id }, await nbe.handleDialog(tab.id, cmd.accept !== false, cmd.promptText, cmd.policy || undefined));+      else out = { id: cmd.id, ok: true, open: nbe.dialogFor(tab.id), last: nbe.lastDialogFor(tab.id) };+      out.tabId = tab.id;+    } else if (cmd.action === 'errors') {+      // pup_errors: the page buffer (pup-errors.js, isolated world) plus the console errors the DevTools driver saw.+      const tab = await pupExtTab(cmd);+      const clear = cmd.clear !== false;+      let page = null;+      try {+        const res = await chrome.scripting.executeScript({ target: { tabId: tab.id }, world: 'ISOLATED', func: (c) => {+          const b = window.__pupErrs; if (!b) return null; const v = b.slice(); if (c) b.length = 0; return v; }, args: [clear] });+        page = res && res[0] ? res[0].result : null;+      } catch (e) {}+      const drv = await nbe.getErrors(tab.id, clear);+      out = { id: cmd.id, ok: true, tabId: tab.id, page: page || [], pageCapture: page !== null, driver: (drv && drv.errors) || [] };     } else if (cmd.action === 'captureVisible') {       const tab = await pupExtTab(cmd);       const cap = await pupCaptureTab(tab);@@ -406,6 +442,12 @@ async function pupExtPollOnce() {       const deadline = new Promise((_, rej) => setTimeout(() => rej(new Error(`${cmd.method} timed out after ${ms} ms`)), ms));       const res = await Promise.race([nbe.raw(t.id, cmd.method, cmd.params || {}), deadline]);       out = { id: cmd.id, ok: true, tabId: t.id, result: res === undefined ? null : res };+    } else if (cmd.action === 'fileChooser') {+      // pup-bridge#145: pup_upload_file for an input the page creates when Browse is clicked. The extension channel+      // streams no DevTools events to pup, so nbe's driver arms the interception and answers the chooser here.+      const t = await pupExtTab(cmd);+      const r = await nbe.setFileInput(t.id, { filePaths: cmd.filePaths || [], armForNextChooser: cmd.arm === true, chooserStatus: cmd.status === true });+      out = Object.assign({}, r, { id: cmd.id, tabId: t.id, ok: !!(r && r.ok) });     } else if (cmd.action === 'reload') {       // Answer first, then restart this worker so a freshly installed pup's extension files take effect       // without the browser relaunching (2.0.569: an update used to wait until every thread's window closed).
tools/ab-smoke.sh+9
@@ -75,6 +75,15 @@ say download R=$($AB pup_navigate '{"url":"https://the-internet.herokuapp.com/download"}'); sleep 2 R=$($AB pup_download '{"url":"https://the-internet.herokuapp.com/download/some-file.txt","waitMs":30000}'); chk "download landed" "$(echo "$R" | j "d.get('bytes',0)>0 and bool(d.get('savedTo'))")" True +say upload+SAVED=$(echo "$R" | j "d.get('savedTo') or ''")+R=$($AB pup_navigate '{"url":"https://the-internet.herokuapp.com/upload"}'); sleep 2+R=$($AB pup_upload_file "{\"selector\":\"#file-upload\",\"filePath\":\"$SAVED\"}"); chk "file on the input, read back" "$(echo "$R" | j "d.get('attached') is True and d.get('verified') is True")" True+R=$($AB pup_click '{"selector":"#file-submit"}'); sleep 3+R=$($AB pup_eval '{"expr":"document.querySelector(\"h3\") ? document.querySelector(\"h3\").innerText : \"\""}'); chk "the site received it" "$(echo "$R" | j "'File Uploaded' in str(d.get('result'))")" True+R=$($AB pup_upload_file '{"filePath":"C:/Windows/win.ini"}'); chk "system file refused" "$(echo "$R" | j "d.get('errorCode')")" path_refused+R=$($AB pup_upload_file '{"filePath":"C:/no/such/file.zip"}'); chk "missing file refused" "$(echo "$R" | j "d.get('errorCode')")" file_not_found+ say dashboard R=$($AB pup_dashboard '{}'); DSID=$(echo "$R" | j "d.get('sessionId')"); chk "dashboard window" "$(echo "$R" | j "d.get('ok')")" True sleep 4
tools/check-borders.sh+9−2
@@ -1,12 +1,13 @@ #!/usr/bin/env bash # The host boundary of the Rust port (docs/PLAN-rust-port.md section 4): only rust/host-win may-# name Win32; only rust/host-mac may name macOS APIs. Everything else goes through pup-host traits+# name Win32; only rust/host-mac may name macOS APIs; only rust/host-linux may name X11, procfs or D-Bus. Everything else goes through pup-host traits # or ab's verbs. Runs in tools/ship.sh; a leak fails the ship. set -u cd "$(dirname "$0")/../rust" || exit 2 fail=0 win_pat='windows_sys|windows-sys|winapi|SetWindowPos|HWND_BOTTOM|SHGetPropertyStore|powershell|HKCU|user32|shell32|GetForegroundWindow|EnumWindows' mac_pat='osascript|NSWindow|NSApp|CGWindowList|AXUIElement|cocoa'+linux_pat='x11rb|xproto|_NET_[A-Z_]+|zbus|org\.freedesktop|/proc/' for d in core host cdp browser session identity vault wiki annotate record verbs dashboard bridge; do   hits=$(grep -rnE "$win_pat" "$d" --include='*.rs' --include='Cargo.toml' 2>/dev/null | grep -vE '^\S+:\s*//' | grep -v 'check-borders' | grep -v 'todo!(')   # bridge/main.rs may select the impl by target, nothing more@@ -17,5 +18,11 @@ for d in core host cdp browser session identity vault wiki annotate record verbs   hits=$(grep -rnE "$mac_pat" "$d" --include='*.rs' 2>/dev/null | grep -vE '^\S+:\s*//' | grep -v 'todo!(')   if [ -n "$hits" ]; then echo "MAC API LEAK outside host-mac in $d:"; echo "$hits" | head -5; fail=1; fi done-[ $fail -eq 0 ] && echo "borders OK: Win32 only in host-win, macOS only in host-mac"+# host-win's non-Windows dev stub reads /proc on purpose; it never ships, so it is not checked here.+for d in core host cdp browser session identity vault wiki annotate record verbs dashboard bridge host-mac; do+  hits=$(grep -rnE "$linux_pat" "$d" --include='*.rs' --include='Cargo.toml' 2>/dev/null | grep -vE '^\S+:\s*//' | grep -v 'todo!(')+  if [ "$d" = "bridge" ]; then hits=$(echo "$hits" | grep -vE 'pup_host_linux::LinuxHost|pup-host-linux' ); fi+  if [ -n "$hits" ]; then echo "LINUX API LEAK outside host-linux in $d:"; echo "$hits" | head -5; fail=1; fi+done+[ $fail -eq 0 ] && echo "borders OK: Win32 only in host-win, macOS only in host-mac, X11/procfs/D-Bus only in host-linux" exit $fail
tools/gen-assets.py+19−1
@@ -64,6 +64,19 @@ if 'pup_select' not in _have:         _sv['hint'] = 'Sets the option through the native setter and fires input+change so framework-bound selects update; works on both lanes (DevTools evaluate, so page CSP and Trusted Types cannot refuse it). For custom dropdowns (Material, React Select) use pup_click on the control, then pup_click the option by text. option_not_found lists the first 30 options.'         _sv['related'] = ['pup_fill', 'pup_click', 'pup_type']         _verbs.append(_sv)+# pup-bridge#145: pup_upload_file, a local file onto a page's <input type=file>, both lanes; synthesised from pup_fill.+if 'pup_upload_file' not in _have:+    _f = next((v for v in _verbs if v['name'] == 'pup_fill'), None)+    if _f:+        _up = json.loads(json.dumps(_f)); _up['name'] = 'pup_upload_file'+        _up['summary'] = "Upload a file from this PC to a page: put it on the page's <input type=file> with no OS file picker, in the background, at any size."+        _up['input'] = { 'filePath': 'required str unless filePaths: absolute path on the PC the browser runs on', 'filePaths': 'optional array of paths, for an input that takes several files', 'selector': 'optional str (the input, or its Browse button or label; hidden inputs, open shadow roots and same-origin frames are searched)', 'text': 'optional str (visible text of the Browse control, instead of selector)', 'armForNextChooser': 'optional bool (the page creates its input when Browse is clicked: arm, then pup_click the Browse control)', 'chooserStatus': 'optional bool (did the armed chooser get answered?)', 'sessionId': _f['input'].get('sessionId', 'optional str') }+        _up['example'] = { 'selector': 'input[type=file]', 'filePath': 'C:/Users/me/Downloads/gerbers.zip' }+        _up['hint'] = "Chrome reads the file itself, so a 5 GB file costs the same as a small one: never base64 a file through pup_eval again. The path is on the PC, not in your container: adom-bridge send_files first if the file is in the container. The verb reads the input back and fires no extra events, so a page that uploads on change sends the file once. attached:true means the page has the file, not that the site has it: screenshot for the page's own progress. Key, password and browser-login files, AppData, hidden folders in the home folder, system folders and network paths are refused (errorCode path_refused)."+        _up['related'] = ['pup_click', 'pup_screenshot', 'pup_wait', 'pup_download']+        _up['pitfalls'] = ["Do not click a Browse button without arming first: the OS dialog opens in front of the user and freezes the window.", "A missing path, two files on a single-file input and a disabled input are refused here because Chrome would accept them silently."]+        _up['output'] = { 'attached': 'bool', 'verified': 'bool (read back from the page)', 'files': '[{name, sizeBytes}]', 'clearedByPage': 'bool (the page took the file and cleared the input)', 'acceptWarning': 'optional str' }+        _verbs.append(_up) # 2.0.596 (John, 2026-09-16): Google sign-in as the right account, with the account search behind it. _to = lambda n: next((v for v in _verbs if v['name'] == n), None) if 'pup_google_accounts' not in _have and _to('pup_credentials'):@@ -136,7 +149,7 @@ def _entry(name, summary, inp, example, hint, related, timeout=30, like=None): _STUBS = {     'pup_eval': lambda: dict(_entry('pup_eval', 'Alias of pup_evaluate: evaluate JS in the page and return the result. Add frame:"<name|url-substring|index>" to run inside an iframe.', {}, {}, '', ['pup_evaluate'], like='pup_evaluate'), summary='Alias of pup_evaluate: evaluate JS in the page and return the result. Add frame:"<name|url-substring|index>" to run inside an iframe.'),     'pup_focus_window': lambda: dict(_entry('pup_focus_window', '', {}, {}, '', ['pup_raise_os_window'], like='pup_raise_os_window'), summary="Alias of pup_raise_os_window (foregroundReason required). It is also what a pup taskbar button's relaunch command calls when the user clicks it; a human caller gets no warning toast."),-    'pup_dialog': lambda: _entry('pup_dialog', 'Answer a JavaScript dialog (alert, confirm, prompt, beforeunload) on the page: accept or dismiss, with promptText for a prompt.', {'action': 'accept | dismiss (default accept)', 'promptText': 'optional str: the answer typed into a prompt()', 'window': 'optional: the pup window (default the calling thread\'s)'}, {'action': 'accept'}, 'A dialog blocks every other verb on that tab until it is answered. pup_errors and the verb that tripped it name the open dialog.', ['pup_errors', 'pup_click']),+    'pup_dialog': lambda: _entry('pup_dialog', 'Answer a JavaScript dialog (alert, confirm, prompt, beforeunload) on the page: accept or dismiss, with promptText for a prompt.', {'action': 'accept | dismiss (default accept)', 'promptText': 'optional str: the answer typed into a prompt()', 'policy': 'optional "manual" | "auto": on the extension lane (the default) Chrome closes a page dialog the moment it opens while pup drives the tab, so "manual" PRE-ANSWERS the next dialogs with action/promptText and "auto" disarms and lists what was answered', 'window': 'optional: the pup window (default the calling thread\'s)'}, {'action': 'accept'}, 'A dialog blocks every other verb on that tab until it is answered. pup_errors and the verb that tripped it name the open dialog. On the extension lane a dialog is usually already handled (alert accepted, confirm/prompt dismissed); pup_dialog reports that as alreadyHandled. To answer one yourself: pup_dialog {policy:"manual", action:"accept"} BEFORE the action, then {policy:"auto"}.', ['pup_errors', 'pup_click']),     'pup_clear_cookies': lambda: _entry('pup_clear_cookies', "Delete cookies in the window's profile for one site (domain or origin), optionally only the named cookies.", {'domain': 'str, e.g. example.com (or origin / origins)', 'origin': 'optional str', 'origins': 'optional [str]', 'names': 'optional [str]: only these cookie names'}, {'domain': 'example.com'}, "Clears the SHARED profile's cookies for that site: every pup window on the profile is signed out of it. Use it to test a logged-out flow, not as a reset.", ['pup_clear_site_data', 'pup_credentials']),     'pup_clear_site_data': lambda: _entry('pup_clear_site_data', 'Clear storage for a site (cookies, localStorage, IndexedDB, cache) in the window profile.', {'origin': 'str: https://example.com (or origins)', 'origins': 'optional [str]', 'all': 'optional bool: every origin in the profile', 'everyone': 'optional bool: required with all:true because it signs every pup window out of everything'}, {'origin': 'https://example.com'}, 'all:true wipes the whole shared profile and is refused without everyone:true.', ['pup_clear_cookies']),     'pup_restart_browser': lambda: _entry('pup_restart_browser', "Restart pup's browser for a profile; every window on it comes back at its page, in the background.", {'profile': 'optional str (default adom-you)'}, {}, 'Use it when a lane is wedged. Windows are relaunched from their saved state; in-page drafts are lost.', ['pup_quit_idle_browsers', 'pup_readiness'], timeout=90),@@ -154,6 +167,11 @@ _STUBS = { # contract, so they must describe what the exe does (pup_uninstall took confirm:"uninstall" and offered a dry run in # Node; the Rust verb takes confirm:true and has no dry run). _OVERRIDES = {+    'pup_import_browser_logins': {+        'summary': "Seed pup's vault from the user's OWN saved browser passwords (or, with sessionFor, one site's signed-in session), so pup can sign in as them on sites they already use. Through nb, the browser exports its own store and the user answers one Windows Hello; where nb cannot run (not installed, too old, no extension connected) pup falls back to Adom Bridge's decrypt helper (a consent and a Windows admin prompt; Edge yes, Chrome 156+ no). Returns COUNTS ONLY; values are never logged or returned.",+        'input': {'hostFilter': 'optional str or str[]: only import logins whose host contains one of these', 'sessionFor': 'optional str: one site (e.g. "x.com") whose signed-in session to bring over instead of passwords', 'profile': 'optional str: the browser profile nb reads, as nbrowser_profiles lists it (e.g. "chrome:[email protected]"); default the active one', 'browsers': 'optional str[] (helper fallback only): subset of ["chrome","edge","brave"]', 'sourceProfile': 'optional str (helper fallback, sessionFor): which "<browser> / <profile>" to take when several have a session', 'reason': 'optional str: shown to the user with the prompt'},+        'hint': "Answers consent_pending while the user approves; call again with the SAME arguments to collect the result (no second prompt). The answer says via:\"nb\" or via:\"ab-helper\", and which profile was read. no_data means that profile has no saved passwords: pass profile:\"<key from nbrowser_profiles>\" for another.",+    },     'pup_uninstall': {         'summary': "DESTRUCTIVE: remove pup's on-disk footprint from this machine: every pup profile and saved window, the staged extensions, the taskbar icons and tiles, and every taskbar identity pup registered. Pass confirm:true. The saved-login vault is kept (credential_delete removes logins one by one), and Adom Bridge's bridge_uninstall removes the pup program itself.",         'input': {'confirm': 'required bool true: actually remove'},
tools/promote.sh+3
@@ -57,6 +57,9 @@ m.update({'version': ver, 'url': ins['url'], 'sha256': ins['sha256'], 'size': in           'insiders_released_at': ins.get('released_at'),           'promotion_attestation': words, 'promoted_by': by, 'promoted_at': now}) m.pop('tier', None)+# Per-OS packages travel with the version they were built for: take the insiders `platforms` block, or none.+if ins.get('platforms'): m['platforms'] = ins['platforms']+else: m.pop('platforms', None) for k in ('rollback_attestation', 'rolled_back_by', 'rolled_back_at'): m.pop(k, None) json.dump(m, open('adom-bridge-puppeteer-manifest.json', 'w'), indent=2); open('adom-bridge-puppeteer-manifest.json', 'a').write('\n') json.dump(m, open(os.path.join(arch, 'public-manifest.json'), 'w'), indent=2)
tools/rust-build.sh+12−25
@@ -3,14 +3,16 @@ # #   tools/rust-build.sh <version>            # -> dist/<version>/adom-bridge-puppeteer-v<version>.zip #-# The zip is ab's bridge package: adom-pup-bridge.exe + bridge.json (spawn.kind exe) + BRIDGE_VERSION-# at the zip root. Nothing else: every browser asset, extension, icon and font is embedded in the exe.+# The zip is ab's bridge package: adom-pup-bridge.exe (Windows) + adom-pup-bridge (Linux x86_64) + bridge.json+# (spawn.kind exe, spawn.entrypoints per OS) + BRIDGE_VERSION at the zip root (tools/rust-package.py). Nothing+# else: every browser asset, extension, icon and font is embedded in each binary. # Gates before the build: border check (Win32 only in host-win), embedded assets regenerated, host # tests green. The exe version is include_str!(src/BRIDGE_VERSION), so write that first (ship.sh does). set -euo pipefail VER="${1:?usage: tools/rust-build.sh <version>}" REPO="$(cd "$(dirname "$0")/.." && pwd)"; cd "$REPO" ARCH="dist/${VER}"; ZIP="adom-bridge-puppeteer-v${VER}.zip"; EXE="rust/target/x86_64-pc-windows-gnu/release/adom-pup-bridge.exe"+ELF="rust/target/x86_64-unknown-linux-gnu/release/adom-pup-bridge" [ "$(cat src/BRIDGE_VERSION)" = "$VER" ] || { echo "src/BRIDGE_VERSION is $(cat src/BRIDGE_VERSION), not $VER (ship.sh writes it first)"; exit 1; }  echo "== rust 0. gates =="@@ -26,6 +28,13 @@ echo "== rust 1. release build (x86_64-pc-windows-gnu) ==" [ -f "$EXE" ] || { echo "   exe missing"; exit 1; } grep -q "Puppeteer Bridge v" "$EXE" || { echo "   exe lacks the boot banner string"; exit 1; } +echo "== rust 1a. release build (x86_64-unknown-linux-gnu) =="+# Same workspace, host-linux picked by cfg(target_os = "linux") in bridge/main.rs. Build on a glibc no newer than+# the oldest distro you support (the binary needs only the glibc symbols it was linked against, 2.30 today).+( cd rust && cargo build --release --target x86_64-unknown-linux-gnu 2>&1 | grep -E "^(error|warning: unused)|Finished" )+[ -f "$ELF" ] || { echo "   linux binary missing"; exit 1; }+grep -q "Puppeteer Bridge v" "$ELF" || { echo "   linux binary lacks the boot banner string"; exit 1; }+ echo "== rust 1b. sign as Adom Industries (never package an unsigned exe) ==" # Windows 11 Smart App Control blocks unsigned exes. The signing itself lives in a PRIVATE script outside this repo # (John's build process); this public build only calls it and checks the result. No script, no package.@@ -42,26 +51,4 @@ grep -q "Puppeteer Bridge v" "$EXE" || { echo "   the signed exe lacks the boot  echo "== rust 2. package ==" mkdir -p "$ARCH"-python3 - "$VER" "$EXE" "$ARCH/$ZIP" <<'PY'-import json, sys, zipfile, hashlib, os-ver, exe, out = sys.argv[1:4]-d = json.load(open('src/bridge.json'))-assert d['version'] == ver, f"src/bridge.json is {d['version']}, not {ver}"-d['spawn'] = { 'kind': 'exe', 'entrypoint': 'adom-pup-bridge.exe', 'port': d['spawn'].get('port', 8851), 'healthEndpoint': '/status', 'stopMethod': 'kill', 'killImageName': 'adom-pup-bridge.exe', 'persistent': True,-    '_hint': 'spawn.kind exe: one sha256-verified binary, no Node, no npm, no postinstall (docs/PLAN-rust-port.md). Port pinned low per ab#83 (WinNAT exclusion ranges in the ephemeral band).',-    '_portNote': d['spawn'].get('_portNote', '') }-d.pop('runtime', None)-d['dependencies'] = { 'browser': d.get('dependencies', {}).get('browser', 'Chromium-family: installed Chrome or Edge, else Chrome for Testing is fetched as a last resort.') }-# The exe is Windows-only today (host-mac is stubs). Say so in the package so a Mac on the insiders-# tier is not handed a Windows binary; the Node build still serves macOS on the public tier.-d.setdefault('platforms', {})-d['platforms'] = {'windows': {'supported': True}, 'macos': {'supported': False, 'reason': 'The Rust exe is Windows-only for now; host-mac is stubbed for the Mac port. macOS runs the Node build (public tier).'}, 'linux': {'supported': False, 'reason': 'not yet'}}-d['shell'] = 'rust'-d['builtFor'] = 'x86_64-pc-windows-gnu' -with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z:-    z.write(exe, 'adom-pup-bridge.exe')-    z.writestr('bridge.json', json.dumps(d, indent=2) + '\n')-    z.writestr('BRIDGE_VERSION', ver)-json.dump(d, open(os.path.join(os.path.dirname(out), 'bridge.json'), 'w'), indent=2)-print(f"   {out}  sha256={hashlib.sha256(open(out,'rb').read()).hexdigest()}  size={os.path.getsize(out)}  exe={os.path.getsize(exe)}")-PY+python3 tools/rust-package.py "$VER" "$EXE" "$ELF" "$ARCH/$ZIP"
tools/rust-package.pyadded+86
@@ -0,0 +1,86 @@+#!/usr/bin/env python3+"""rust-package.py: build pup's release zip from already-built binaries.++    tools/rust-package.py <version> <windows-exe> <linux-elf> <out.zip>+    tools/rust-package.py <version> - <linux-elf> <out.zip>      # Linux-only zip (no Windows exe)++The Linux-only zip is for the wiki manifest's `platforms.linux {url, sha256, size}` (ab 2.1.175+ downloads that+package on Linux instead of the top-level url), so a Linux release never touches the signed Windows zip.++One zip serves Windows and Linux (ab 2.1.175+ picks `spawn.entrypoints.<os>`):++    adom-pup-bridge.exe   Windows x64 (signed by tools/rust-build.sh before it calls this)+    adom-pup-bridge       Linux x86_64 ELF (glibc 2.30+), stored with mode 0755+    bridge.json           spawn.kind exe + entrypoints {windows, linux}+    BRIDGE_VERSION++Everything else (extensions, dashboard, icons, fonts) is embedded in each binary. Writes bridge.json beside+the zip too and prints the sha256/size the manifest needs. tools/rust-build.sh is the normal caller.+"""+import hashlib, json, os, sys, zipfile++ver, exe, elf, out = sys.argv[1:5]+d = json.load(open('src/bridge.json'))+assert d['version'] == ver, f"src/bridge.json is {d['version']}, not {ver}"+linux_only = exe == '-'+for p in ((elf,) if linux_only else (exe, elf)):+    assert os.path.isfile(p), f"missing binary {p}"+assert open(elf, 'rb').read(4) == b'\x7fELF', f"{elf} is not a Linux ELF binary"+assert linux_only or open(exe, 'rb').read(2) == b'MZ', f"{exe} is not a Windows PE binary"++d['spawn'] = {+    'kind': 'exe', 'entrypoint': 'adom-pup-bridge.exe',+    'entrypoints': {'windows': 'adom-pup-bridge.exe', 'linux': 'adom-pup-bridge'},+    'port': d['spawn'].get('port', 8851), 'healthEndpoint': '/status', 'stopMethod': 'kill',+    'killImageName': 'adom-pup-bridge.exe', 'persistent': True,+    '_hint': 'spawn.kind exe: one sha256-verified binary per OS, no Node, no npm, no postinstall (docs/PLAN-rust-port.md). '+             'entrypoints (ab 2.1.175+) picks the binary for the running OS. Port pinned low per ab#83 (WinNAT exclusion ranges in the ephemeral band).',+    '_portNote': d['spawn'].get('_portNote', ''),+}+d.pop('runtime', None)+d['dependencies'] = {'browser': d.get('dependencies', {}).get('browser', 'Chromium-family: installed Chrome or Edge, else Chrome for Testing is fetched as a last resort.')}+d['platforms'] = {+    'windows': {'supported': True},+    'linux': {'supported': True, 'since': ver,+              'notes': 'x86_64, glibc 2.30+ (Ubuntu 20.04+, Debian 11+, Fedora 32+), X11 and Wayland desktops. pup runs Chrome for Testing '+                       '(linux64) under XWayland on a Wayland session so its windows stay in the background; the Chromium sandbox uses user '+                       "namespaces or an installed setuid helper (Google Chrome's or the distro Chromium's). Each AI thread's windows get "+                       'their own dock or taskbar icon and name (a per-thread .desktop entry and WM_CLASS). Jump lists, overlay badges, '+                       'assisted click and browser-login import are Windows-only and answer that plainly. Details: linux/README.md.'},+    'macos': {'supported': False, 'reason': 'The Rust exe is not built for macOS yet; host-mac is the Mac port. macOS runs the Node build (public tier).'},+}+d['shell'] = 'rust'+d['builtFor'] = ['x86_64-pc-windows-gnu', 'x86_64-unknown-linux-gnu']+if linux_only:+    d['spawn'].update({'entrypoint': 'adom-pup-bridge', 'entrypoints': {'linux': 'adom-pup-bridge'},+                       'killImageName': 'adom-pup-bridge'})+    d['builtFor'] = ['x86_64-unknown-linux-gnu']+    # ab shows `description` on the bridge card; the shared text talks about Edge on every Windows PC and the+    # Windows Firewall, which reads wrong on Linux. The Windows zip keeps its text byte for byte.+    d['description'] = (+        "pup, the AI's own browser: real, visible Chrome windows an AI drives in the BACKGROUND (open/close windows and tabs, "+        "navigate, screenshot, eval JS, download, record), never the user's own signed-in browser. On Linux pup downloads "+        "Chrome for Testing (linux64) into the user's folder by itself (no sudo), runs it under XWayland on a Wayland desktop "+        "so its windows stay behind the user's work, and gives each AI thread its own dock or taskbar icon with the thread's "+        "name. The vault uses the desktop keyring (Secret Service) when it is unlocked, else a 0600 file in ~/.adom. Binds "+        "its local server to loopback (127.0.0.1) only. pup_readiness (read-only) says which browser will be used. "+        "Windows-only: browser-login import, assisted click, jump lists. Details: linux/README.md on the pup-bridge page.")++def add(z, src, name, mode):+    zi = zipfile.ZipInfo.from_file(src, name)+    zi.compress_type = zipfile.ZIP_DEFLATED+    zi.create_system = 3                      # unix, so external_attr's mode bits are honoured+    zi.external_attr = (0o100000 | mode) << 16+    with open(src, 'rb') as f:+        z.writestr(zi, f.read())++os.makedirs(os.path.dirname(out) or '.', exist_ok=True)+with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z:+    if not linux_only:+        add(z, exe, 'adom-pup-bridge.exe', 0o755)+    add(z, elf, 'adom-pup-bridge', 0o755)+    z.writestr('bridge.json', json.dumps(d, indent=2) + '\n')+    z.writestr('BRIDGE_VERSION', ver)+json.dump(d, open(os.path.join(os.path.dirname(out) or '.', 'bridge.json'), 'w'), indent=2)+sha = hashlib.sha256(open(out, 'rb').read()).hexdigest()+print(f"   {out}  sha256={sha}  size={os.path.getsize(out)}  exe={'-' if linux_only else os.path.getsize(exe)}  elf={os.path.getsize(elf)}")
tools/ship.sh+5−1
@@ -153,6 +153,10 @@ for k in ('promotion_attestation', 'promoted_by', 'promoted_at', 'rollback_attes bj = json.load(open('src/bridge.json')) base.update({'verbPrefixes': bj.get('verbPrefixes', base.get('verbPrefixes')), 'statusVerb': bj.get('statusVerb', base.get('statusVerb')), 'healthEndpoint': bj.get('spawn', {}).get('healthEndpoint', '/status')}) base['shell'] = os.environ.get('PUP_SHELL_KIND', 'node')+# A `platforms` block (per-OS packages, e.g. the 2.0.741 Linux-only zip) belongs to ONE version: never carry it from the+# public manifest into a new version, or Linux would download the old package under the new number. rust-build.sh's+# zip holds both programs (spawn.entrypoints), so a release built by it needs no `platforms` at all.+base.pop('platforms', None) base.update({'version': ver, 'url': url, 'sha256': sha, 'size': size, 'tier': 'insiders',              'released_at': datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00', 'Z')}) json.dump(base, open(ins_path, 'w'), indent=2); open(ins_path, 'a').write('\n')@@ -202,7 +206,7 @@ RUST_FILES=$(find rust -path rust/target -prune -o -type f \( -name '*.rs' -o -n adom-wiki repo push "$PAGE" \   --files src/bridge.json src/BRIDGE_VERSION src/*.js src/extension/*.js src/extension/*.json SKILL.md package.json \           skills/*/SKILL.md dev-skills/*/SKILL.md publish-skills/*/SKILL.md \-          $RUST_FILES $EMBED_FILES tools/rust-build.sh tools/check-borders.sh tools/gen-assets.py tools/list-embedded.py tools/sync-nbe.sh \+          $RUST_FILES $EMBED_FILES tools/rust-build.sh tools/rust-package.py tools/check-borders.sh tools/gen-assets.py tools/list-embedded.py tools/sync-nbe.sh \   -m "v${VER} (insiders): ${LOG}" >/dev/null 2>&1 || {     [ "${FORCE_PUSH:-0}" = "1" ] || { echo "   PUSH FAILED (set FORCE_PUSH=1 to retry)"; exit 1; }   }