diff --git a/cli/build.rs b/cli/build.rs index 7bc9cf1..ddf1660 100644 --- a/cli/build.rs +++ b/cli/build.rs @@ -3,6 +3,23 @@ use std::env; use std::fs; use std::path::Path; +/// Embed the version of the `ab-connect` extension this CLI ships alongside, so +/// `doctor` can tell a connected extension "you're older than what this CLI +/// expects, update it." Read from the extension manifest at build time so it +/// stays in sync with whatever extension version is in the same checkout/release +/// (the ext is on its own 0.4.x line, separate from the CLI version). Falls back +/// to "unknown" if the manifest can't be read. +fn embed_extension_version() { + let manifest = Path::new("../extensions/ab-connect/manifest.json"); + println!("cargo:rerun-if-changed=../extensions/ab-connect/manifest.json"); + let version = fs::read_to_string(manifest) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from)) + .unwrap_or_else(|| "unknown".to_string()); + println!("cargo:rustc-env=AB_CONNECT_VERSION={}", version); +} + /// Ensure `packages/dashboard/out/` exists so `rust-embed` doesn't fail during /// Rust-only dev builds where the dashboard hasn't been built. The placeholder /// `index.html` is only written when the directory is completely absent. @@ -20,6 +37,7 @@ fn ensure_dashboard_dir() { fn main() { ensure_dashboard_dir(); + embed_extension_version(); let protocol_dir = Path::new("cdp-protocol"); let out_dir = env::var("OUT_DIR").unwrap(); diff --git a/cli/src/connect.rs b/cli/src/connect.rs index caab913..6504bc9 100644 --- a/cli/src/connect.rs +++ b/cli/src/connect.rs @@ -449,6 +449,28 @@ pub fn relay_url() -> Option { } } +/// Sidecar recording the connected extension's version, written by the host when +/// it receives the extension's `hello` (sibling of `relay-cdp-url`). Lets +/// `doctor` surface which extension build is live without a CDP round-trip. +fn relay_ext_version_path() -> PathBuf { + relay_url_path().with_file_name("relay-ext-version") +} + +/// Version of the connected `ab-connect` extension, if the host learned it from +/// the extension's `hello`. `None` when no extension has connected since the +/// host started, or the extension predates version reporting. +pub fn relay_ext_version() -> Option { + let s = std::fs::read_to_string(relay_ext_version_path()) + .ok()? + .trim() + .to_string(); + if s.is_empty() { + None + } else { + Some(s) + } +} + /// Hidden `__nm-host` mode: launched by Chrome for the ab-connect extension. /// /// Bridges the extension (native-messaging stdio, envelope protocol) to a local @@ -570,6 +592,15 @@ async fn nm_host_main() { Ok(v) => v, Err(_) => continue, }; + // Extension version handshake: record it next to the relay URL so + // `doctor` can report which extension build is live (and whether it's + // behind). Best-effort; the message carries no CDP payload. + if v.get("method").and_then(|m| m.as_str()) == Some("hello") { + if let Some(ver) = v.get("version").and_then(|x| x.as_str()) { + let _ = std::fs::write(relay_ext_version_path(), ver); + } + continue; + } let outs = { let mut s = state.lock().await; s.handle_ext_message(&v, "") @@ -602,6 +633,7 @@ async fn nm_host_main() { } nm_log("[nm-host] stdin EOF — Chrome closed the port"); let _ = std::fs::remove_file(relay_url_path()); + let _ = std::fs::remove_file(relay_ext_version_path()); } #[allow(clippy::too_many_arguments)] diff --git a/cli/src/doctor/mod.rs b/cli/src/doctor/mod.rs index 51c4484..64a3faa 100644 --- a/cli/src/doctor/mod.rs +++ b/cli/src/doctor/mod.rs @@ -18,6 +18,7 @@ mod launch; mod network; mod providers; mod security; +mod versions; use serde_json::{json, Value}; @@ -97,6 +98,7 @@ pub fn run_doctor(opts: DoctorOptions) -> i32 { let mut fixed: Vec = Vec::new(); environment::check(&mut checks); + versions::check(&mut checks); chrome::check(&mut checks); daemon::check(&mut checks); config::check(&mut checks); diff --git a/cli/src/doctor/versions.rs b/cli/src/doctor/versions.rs new file mode 100644 index 0000000..24f04d2 --- /dev/null +++ b/cli/src/doctor/versions.rs @@ -0,0 +1,89 @@ +//! Version-coherence checks across all four moving parts: the CLI binary, the +//! per-session daemons (covered by `daemon.rs`), the connected `ab-connect` +//! extension, and the bundled skill. The extension was previously a black box — +//! nothing reported which build was live — so a user could sit on an old +//! extension with no signal. The extension now reports its version over the +//! relay (`hello`), the host records it, and this surfaces it in one place. + +use super::{Check, Status}; +use crate::{connect, upgrade}; + +pub(super) fn check(checks: &mut Vec) { + let category = "Versions"; + let cli_version = env!("CARGO_PKG_VERSION"); + + // CLI — compare against the latest seen by the background update check. + match upgrade::cached_latest_version() { + Some(latest) if upgrade::version_is_newer(&latest, cli_version) => { + checks.push( + Check::new( + "versions.cli", + category, + Status::Warn, + format!("CLI {cli_version} (newer available: {latest})"), + ) + .with_fix("chrome-use upgrade".to_string()), + ); + } + _ => { + checks.push(Check::new( + "versions.cli", + category, + Status::Pass, + format!("CLI {cli_version}"), + )); + } + } + + // Extension — the build this CLI shipped alongside (embedded at compile time + // from the extension manifest) is what we expect to be running. + let expected_ext = env!("AB_CONNECT_VERSION"); + match connect::relay_ext_version() { + Some(ext) if upgrade::version_is_newer(expected_ext, &ext) => { + checks.push( + Check::new( + "versions.extension", + category, + Status::Warn, + format!("extension {ext} is behind the bundled {expected_ext}"), + ) + .with_fix( + "update ab-connect in Chrome: chrome://extensions \u{2192} reload \ + (or wait for the Web Store auto-update)" + .to_string(), + ), + ); + } + Some(ext) => { + checks.push(Check::new( + "versions.extension", + category, + Status::Pass, + format!("extension {ext}"), + )); + } + None => { + checks.push(Check::new( + "versions.extension", + category, + Status::Info, + format!( + "extension not connected (or it predates version reporting — \ + expected {expected_ext})" + ), + )); + } + } + + // Skill — ships inside the same release artifact as the binary, so it's + // version-locked here. Copies made elsewhere via `skills add` aren't. + checks.push(Check::new( + "versions.skill", + category, + Status::Info, + format!( + "skills bundled with this CLI ({cli_version}); copies made via `skills add` \ + elsewhere may be stale — re-run to refresh" + ), + )); +} diff --git a/cli/src/upgrade.rs b/cli/src/upgrade.rs index 8f29466..293ed3c 100644 --- a/cli/src/upgrade.rs +++ b/cli/src/upgrade.rs @@ -52,6 +52,27 @@ fn is_newer(latest: &str, current: &str) -> bool { matches!((parse_version(latest), parse_version(current)), (Some(l), Some(c)) if l > c) } +/// Public semver-ish comparison (`latest` strictly newer than `current`), so +/// `doctor` can flag a stale extension/CLI without re-implementing parsing. +pub fn version_is_newer(latest: &str, current: &str) -> bool { + is_newer(latest, current) +} + +/// The latest CLI version recorded by the background update check, if any. +/// `doctor` uses it to show "a newer chrome-use is available" without a network +/// call (the `__update-check` worker refreshes the cache out of band). +pub fn cached_latest_version() -> Option { + std::fs::read_to_string(update_cache_path()) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|j| { + j.get("latest") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .filter(|s| !s.is_empty()) +} + /// Hidden `__update-check` subcommand: fetch the latest release tag and cache it. /// Spawned detached by [`maybe_notify_update`] so the network call never blocks a /// real command. Uses `curl` (no extra deps, matches `upgrade`). diff --git a/extensions/ab-connect.crx b/extensions/ab-connect.crx index 08b5b32..598d59f 100644 Binary files a/extensions/ab-connect.crx and b/extensions/ab-connect.crx differ diff --git a/extensions/ab-connect.zip b/extensions/ab-connect.zip index d6f9866..5b8ce74 100644 Binary files a/extensions/ab-connect.zip and b/extensions/ab-connect.zip differ diff --git a/extensions/ab-connect/background.js b/extensions/ab-connect/background.js index 4c4cba5..ea2d059 100644 --- a/extensions/ab-connect/background.js +++ b/extensions/ab-connect/background.js @@ -108,6 +108,12 @@ function connectHost() { // reconnect. Keep chrome.debugger attached so reconnect is cheap. for (const tabId of tabs.keys()) setBadge(tabId, 'connecting') }) + // Report our version so the host can tell the CLI/`doctor` which extension + // build is live (otherwise the extension version is a black box — the user + // can't tell they're on an old one). Best-effort; ignored by older hosts. + try { + postToHost({ method: 'hello', version: chrome.runtime.getManifest().version }) + } catch {} // Tell the daemon about everything we already have attached, then attach // anything new. reannounceAttachedTabs() diff --git a/extensions/ab-connect/manifest.json b/extensions/ab-connect/manifest.json index 228d276..0558ae8 100644 --- a/extensions/ab-connect/manifest.json +++ b/extensions/ab-connect/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "chrome-use", - "version": "0.4.6", + "version": "0.4.7", "description": "Let chrome-use drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB", "icons": {