← Commit history

v2.0.740 (insiders): New verb pup_upload_file (pup-bridge#145): put a file from this PC on a page's file input with no OS dialog, at any size, on both lanes, including an input the page creates when Browse is clicked; it checks the path first, reads the input back, gives the page one change event per call, and refuses key and password files, AppData, system folders and network paths.

Colby Knox ·63fa650a86 ·5d ago ·parent 8d9353b
8 files changed +400−14
rust/Cargo.lock+12−12
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-pup-bridge"-version = "2.0.739"+version = "2.0.740" dependencies = [  "pup-browser",  "pup-cdp",@@ -781,7 +781,7 @@ dependencies = [  [[package]] name = "pup-browser"-version = "2.0.739"+version = "2.0.740" dependencies = [  "pup-core",  "pup-host",@@ -793,7 +793,7 @@ dependencies = [  [[package]] name = "pup-cdp"-version = "2.0.739"+version = "2.0.740" dependencies = [  "serde_json",  "thiserror",@@ -803,7 +803,7 @@ dependencies = [  [[package]] name = "pup-core"-version = "2.0.739"+version = "2.0.740" dependencies = [  "serde",  "serde_json",@@ -813,7 +813,7 @@ dependencies = [  [[package]] name = "pup-dashboard"-version = "2.0.739"+version = "2.0.740" dependencies = [  "pup-cdp",  "pup-core",@@ -826,7 +826,7 @@ dependencies = [  [[package]] name = "pup-host"-version = "2.0.739"+version = "2.0.740" dependencies = [  "serde",  "serde_json",@@ -835,7 +835,7 @@ dependencies = [  [[package]] name = "pup-host-mac"-version = "2.0.739"+version = "2.0.740" dependencies = [  "core-foundation",  "core-graphics",@@ -851,7 +851,7 @@ dependencies = [  [[package]] name = "pup-host-win"-version = "2.0.739"+version = "2.0.740" dependencies = [  "pup-core",  "pup-host",@@ -861,7 +861,7 @@ dependencies = [  [[package]] name = "pup-identity"-version = "2.0.739"+version = "2.0.740" dependencies = [  "fontdue",  "image",@@ -871,7 +871,7 @@ dependencies = [  [[package]] name = "pup-session"-version = "2.0.739"+version = "2.0.740" dependencies = [  "pup-core",  "serde",@@ -881,7 +881,7 @@ dependencies = [  [[package]] name = "pup-vault"-version = "2.0.739"+version = "2.0.740" dependencies = [  "pup-core",  "pup-host",@@ -892,7 +892,7 @@ dependencies = [  [[package]] name = "pup-verbs"-version = "2.0.739"+version = "2.0.740" dependencies = [  "base64",  "png 0.17.16",
rust/Cargo.toml+1−1
@@ -4,7 +4,7 @@ members = ["core", "host", "host-win", "host-mac", "cdp", "browser", "session",  # 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.740" edition = "2021" license = "MIT" 
rust/browser/src/ext_assets.rs+1
@@ -20,6 +20,7 @@ pub static EXTENSIONS: &[(&str, &str, &[(&str, &[u8])])] = &[         ("popup.js", include_bytes!("../../../src/extension/popup.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/verbs/assets/verb-catalog.json+38
@@ -1854,6 +1854,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": "",
rust/verbs/src/lib.rs+3−1
@@ -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;@@ -604,7 +605,7 @@ 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();@@ -701,6 +702,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),
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());+    }+}
src/extension/worker.js+6
@@ -406,6 +406,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/gen-assets.py+13
@@ -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'):