diff --git a/.gitignore b/.gitignore index 7bd71de..fc8bb44 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,7 @@ docs/package-lock.json # next .next/ out/ + +# extension signing key (never commit) + local-only id record +.secrets/ +*.pem diff --git a/cli/src/connect.rs b/cli/src/connect.rs index caf28f8..11c59fb 100644 --- a/cli/src/connect.rs +++ b/cli/src/connect.rs @@ -20,8 +20,24 @@ use std::path::PathBuf; pub const HOST_NAME: &str = "com.agent_browser.connect"; /// Stable id of the `ab-connect` extension, pinned by the `key` in its -/// manifest.json. Chrome only lets that extension talk to this host. -pub const EXTENSION_ID: &str = "bdoiejojpjogcjojeladhioioijhgade"; +/// manifest.json (and the signing key of the published `.crx`). Chrome only lets +/// that extension talk to this host, and the force-install policy references it. +pub const EXTENSION_ID: &str = "ciiljdlhdpfckdcfkphgmfalanpdejep"; + +/// Omaha/gupdate update manifest for the signed `ab-connect.crx`. The macOS +/// configuration profile force-installs the extension from here, so no +/// `chrome://extensions` "Load unpacked" GUI step is ever needed. Chrome 142+ +/// removed `--load-extension`, and macOS has blocked local-`.crx` external +/// installs since Chrome 44 — a policy `update_url` is the only GUI-free path +/// left into the real, logged-in profile. +pub const UPDATE_URL: &str = + "https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/extensions/updates.xml"; + +/// Stable identifiers for the generated Chrome configuration profile, so a +/// re-install replaces (rather than duplicates) it in System Settings. +const PROFILE_ID: &str = "work.pwtk.agent-browser.ab-connect"; +const PROFILE_UUID: &str = "A1B2C3D4-AB00-4CCE-9E10-AAAABBBBCCCC"; +const PROFILE_PAYLOAD_UUID: &str = "A1B2C3D4-AB01-4CCE-9E10-DDDDEEEEFFFF"; /// `agent-browser extension ` (local; no daemon). /// `args` is the cleaned argv including the leading "extension". @@ -31,18 +47,40 @@ pub fn run_connect(args: &[String], json: bool) { if uninstall { let removed = remove_host_manifests(); - report(json, true, &format!("removed {removed} native-host manifest(s)")); + let profile_removed = remove_force_install_profile(); + if json { + report(json, true, &format!("removed {removed} native-host manifest(s)")); + } else { + println!("✓ removed {removed} native-host manifest(s)."); + if profile_removed { + println!("✓ removed ~/.agent-browser/ab-connect.mobileconfig"); + } + if cfg!(target_os = "macos") { + println!( + " To fully remove the extension, delete the \"agent-browser connect\" profile\n\ + in System Settings → Profiles (or run: profiles remove -identifier {PROFILE_ID})." + ); + } + } return; } if install { + let no_open = args.iter().any(|a| a == "--no-open"); match install_native_host() { Ok(paths) => { + let profile = install_force_install_profile(no_open); if json { println!( "{}", serde_json::to_string(&serde_json::json!({ "success": true, - "data": { "installed": paths, "extensionId": EXTENSION_ID } + "data": { + "installed": paths, + "extensionId": EXTENSION_ID, + "profile": profile.as_ref().ok().map(|p| p.display().to_string()), + "profileError": profile.as_ref().err(), + "updateUrl": UPDATE_URL, + } })) .unwrap_or_default() ); @@ -51,11 +89,27 @@ pub fn run_connect(args: &[String], json: bool) { for p in &paths { println!(" {p}"); } - println!( - "\nNext: load the ab-connect extension in Chrome (chrome://extensions →\n\ - Developer mode → Load unpacked → extensions/ab-connect), then this host\n\ - is reachable with no token and no per-use confirmation." - ); + match profile { + Ok(path) => { + println!("\n✓ Chrome force-install profile written:\n {}", path.display()); + if cfg!(target_os = "macos") { + println!( + "\nOne-time step (no file dialog, ever): approve the profile, then restart Chrome.\n\ + System Settings → General → Device Management (or Privacy & Security →\n\ + Profiles) → double-click \"agent-browser connect\" → Install.\n\ + After approval Chrome force-installs the extension on next launch and\n\ + keeps it up to date — no token, no per-use confirmation." + ); + } + } + Err(e) => { + println!("\n! could not write the force-install profile: {e}"); + println!( + " Fallback: load extensions/ab-connect via chrome://extensions →\n\ + Developer mode → Load unpacked." + ); + } + } } } Err(e) => report(json, false, &format!("install failed: {e}")), @@ -136,6 +190,78 @@ fn install_native_host() -> Result, String> { Ok(written) } +/// Write a Chrome configuration profile that force-installs `ab-connect` from +/// [`UPDATE_URL`], and (unless `no_open`) `open` it so the user approves it once +/// in System Settings. Returns the profile path. macOS only — elsewhere it +/// returns an error and the caller prints the manual fallback. +fn install_force_install_profile(no_open: bool) -> Result { + if !cfg!(target_os = "macos") { + return Err("force-install profile is macOS-only; on Linux set Chrome's \ + ExtensionInstallForcelist policy JSON, or Load unpacked from chrome://extensions" + .into()); + } + let home = dirs::home_dir().ok_or("no home dir")?; + let ab_dir = home.join(".agent-browser"); + std::fs::create_dir_all(&ab_dir).map_err(|e| e.to_string())?; + let path = ab_dir.join("ab-connect.mobileconfig"); + std::fs::write(&path, force_install_mobileconfig()).map_err(|e| e.to_string())?; + if !no_open { + // `open` queues the profile in System Settings for one-time approval. + let _ = std::process::Command::new("open").arg(&path).status(); + } + Ok(path) +} + +/// The `.mobileconfig` payload: a user-scope Chrome policy that force-installs +/// the extension by id from our hosted update manifest. User scope installs +/// without admin — just a one-time approval click. +fn force_install_mobileconfig() -> String { + let forcelist = format!("{EXTENSION_ID};{UPDATE_URL}"); + format!( + r#" + + + + PayloadContent + + + PayloadTypecom.google.Chrome + PayloadVersion1 + PayloadIdentifier{PROFILE_ID}.chrome + PayloadUUID{PROFILE_PAYLOAD_UUID} + PayloadEnabled + PayloadDisplayNameagent-browser connect (Chrome) + ExtensionInstallForcelist + + {forcelist} + + + + PayloadTypeConfiguration + PayloadVersion1 + PayloadIdentifier{PROFILE_ID} + PayloadUUID{PROFILE_UUID} + PayloadDisplayNameagent-browser connect + PayloadDescriptionForce-installs the agent-browser connect extension so agent-browser can drive your logged-in Chrome. No token, no per-use confirmation. + PayloadOrganizationagent-browser-stealth + PayloadScopeUser + PayloadRemovalDisallowed + + +"# + ) +} + +/// Remove the generated `.mobileconfig` file (the profile itself is removed by +/// the user from System Settings, or via `profiles remove`). +fn remove_force_install_profile() -> bool { + dirs::home_dir() + .map(|h| h.join(".agent-browser").join("ab-connect.mobileconfig")) + .filter(|p| p.exists()) + .map(|p| std::fs::remove_file(&p).is_ok()) + .unwrap_or(false) +} + fn remove_host_manifests() -> usize { let mut n = 0; for dir in native_messaging_dirs() { diff --git a/extensions/ab-connect.crx b/extensions/ab-connect.crx new file mode 100644 index 0000000..dddfddc Binary files /dev/null and b/extensions/ab-connect.crx differ diff --git a/extensions/ab-connect/manifest.json b/extensions/ab-connect/manifest.json index cf1eb00..3ba7998 100644 --- a/extensions/ab-connect/manifest.json +++ b/extensions/ab-connect/manifest.json @@ -1,16 +1,28 @@ { "manifest_version": 3, "name": "agent-browser connect", - "version": "0.2.0", + "version": "0.3.0", "description": "Let agent-browser drive your logged-in Chrome — install once, no token, no per-use confirmation.", - "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvPGcSmMx7dSfq9gRBDbQuAgx/+TEavrDxP/4jLa2+Ycexf/FEmq1MN8gAHoTRjSyp66YKD+1qI1CF6bk0rH5ZtxpRO7DYRTUPcsA1IpHbgEn5mppx3YxNGZilfkEZyrxdhBqUIzq3J74+/kpZzEVsO+DQTbAZSsFfdUkoCb5mbJid2VQYeqeBnYGAhbpGvN1P99jdT9EA1nKINb3ji6tLobCpyQ1fjf2uWm4mUirWkkF/nbUFVFEAh33Q/IYZmtUHgDYea5LsM9xH4KAG2kxMvFGj6vHR39sZd5/+gnvwScTcItUWQ9lFIyWYiwrSB25Lu0FshfllevXUFrG5vrvRwIDAQAB", + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB", "icons": { "16": "icons/icon16.png", "32": "icons/icon32.png", "48": "icons/icon48.png", "128": "icons/icon128.png" }, - "permissions": ["debugger", "tabs", "nativeMessaging", "storage", "alarms", "webNavigation"], - "background": { "service_worker": "background.js", "type": "module" }, - "action": { "default_title": "agent-browser connect" } + "permissions": [ + "debugger", + "tabs", + "nativeMessaging", + "storage", + "alarms", + "webNavigation" + ], + "background": { + "service_worker": "background.js", + "type": "module" + }, + "action": { + "default_title": "agent-browser connect" + } } diff --git a/extensions/updates.xml b/extensions/updates.xml new file mode 100644 index 0000000..fdc8b88 --- /dev/null +++ b/extensions/updates.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/scripts/pack-extension.sh b/scripts/pack-extension.sh new file mode 100755 index 0000000..10b7bed --- /dev/null +++ b/scripts/pack-extension.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Re-pack and sign extensions/ab-connect into extensions/ab-connect.crx using the +# stable signing key, then print the extension id. Keeps the crx id (and thus the +# native-messaging allowed_origins + force-install policy) constant across versions. +# +# The private key lives at .secrets/ab-connect.pem and is git-ignored. To re-pack +# on another machine / in CI, restore it from a secret first (see RELEASING). +# +# After changing the extension: +# 1. bump "version" in extensions/ab-connect/manifest.json +# 2. bump in extensions/updates.xml to match +# 3. run this script +# 4. commit extensions/ab-connect.crx + updates.xml + manifest.json +set -e +cd "$(dirname "$0")/.." +KEY=.secrets/ab-connect.pem +EXT=extensions/ab-connect +CHROME="${CHROME_BIN:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome}" + +if [ ! -f "$KEY" ]; then + echo "error: $KEY missing. Restore the signing key (CI secret AB_CONNECT_PEM) before packing." >&2 + exit 1 +fi + +rm -f extensions/ab-connect.crx +"$CHROME" --pack-extension="$PWD/$EXT" --pack-extension-key="$PWD/$KEY" >/dev/null 2>&1 || true +[ -f extensions/ab-connect.crx ] || { echo "error: pack failed" >&2; exit 1; } + +ID=$(openssl rsa -in "$KEY" -pubout -outform DER 2>/dev/null \ + | openssl dgst -sha256 -binary | xxd -p -c256 | head -c32 | tr '0-9a-f' 'a-p') +echo "packed extensions/ab-connect.crx" +echo "extension id: $ID" +echo "manifest version: $(grep -o '"version"[^,]*' "$EXT/manifest.json" | head -1)" +echo "updates.xml version: $(grep -o "version='[^']*'" extensions/updates.xml | tail -1)"