feat(connect): force-install ab-connect via Chrome config profile (no Load-unpacked GUI)
Chrome 149 killed every GUI-free way to load an *unpacked* extension into the real profile: --load-extension removed in Chrome 142 (incl. the --disable-features workaround), local-.crx external install blocked on macOS since Chrome 44, remote-debugging-port killed in Chrome 136. So agents were stuck automating the chrome://extensions Load-unpacked native file dialog — unworkable. `extension install` now writes a macOS configuration profile that force-installs the signed .crx from a hosted update_url (ExtensionInstallForcelist policy). One approval in System Settings (a single fixed Install button — cua-driver-friendly, unlike a file dialog) → Chrome force-installs + auto-updates the extension on next launch. No token, no per-use confirmation, and binary-install users no longer need the extensions/ folder (crx is fetched from the URL). - pin a stable signing key; new extension id ciiljdlhdpfckdcfkphgmfalanpdejep - ship signed extensions/ab-connect.crx + extensions/updates.xml (raw GH host) - scripts/pack-extension.sh re-signs with the stable key; .secrets/*.pem ignored - uninstall removes the profile file + prints `profiles remove` hint
This commit is contained in:
@@ -67,3 +67,7 @@ docs/package-lock.json
|
||||
# next
|
||||
.next/
|
||||
out/
|
||||
|
||||
# extension signing key (never commit) + local-only id record
|
||||
.secrets/
|
||||
*.pem
|
||||
|
||||
+135
-9
@@ -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 <install|uninstall|status>` (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<Vec<String>, 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<PathBuf, String> {
|
||||
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#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>PayloadType</key><string>com.google.Chrome</string>
|
||||
<key>PayloadVersion</key><integer>1</integer>
|
||||
<key>PayloadIdentifier</key><string>{PROFILE_ID}.chrome</string>
|
||||
<key>PayloadUUID</key><string>{PROFILE_PAYLOAD_UUID}</string>
|
||||
<key>PayloadEnabled</key><true/>
|
||||
<key>PayloadDisplayName</key><string>agent-browser connect (Chrome)</string>
|
||||
<key>ExtensionInstallForcelist</key>
|
||||
<array>
|
||||
<string>{forcelist}</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadType</key><string>Configuration</string>
|
||||
<key>PayloadVersion</key><integer>1</integer>
|
||||
<key>PayloadIdentifier</key><string>{PROFILE_ID}</string>
|
||||
<key>PayloadUUID</key><string>{PROFILE_UUID}</string>
|
||||
<key>PayloadDisplayName</key><string>agent-browser connect</string>
|
||||
<key>PayloadDescription</key><string>Force-installs the agent-browser connect extension so agent-browser can drive your logged-in Chrome. No token, no per-use confirmation.</string>
|
||||
<key>PayloadOrganization</key><string>agent-browser-stealth</string>
|
||||
<key>PayloadScope</key><string>User</string>
|
||||
<key>PayloadRemovalDisallowed</key><false/>
|
||||
</dict>
|
||||
</plist>
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
|
||||
Binary file not shown.
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<!-- Omaha/gupdate update manifest for the ab-connect extension.
|
||||
Chrome's ExtensionInstallForcelist policy points at this file (see
|
||||
cli/src/connect.rs UPDATE_URL); Chrome reads it, then fetches the codebase
|
||||
.crx. Bump `version` here and in extensions/ab-connect/manifest.json, then
|
||||
re-pack ab-connect.crx, on every extension change. -->
|
||||
<gupdate xmlns='http://www.google.com/update2/response' protocol='2.0'>
|
||||
<app appid='ciiljdlhdpfckdcfkphgmfalanpdejep'>
|
||||
<updatecheck
|
||||
codebase='https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/extensions/ab-connect.crx'
|
||||
version='0.3.0' />
|
||||
</app>
|
||||
</gupdate>
|
||||
Executable
+34
@@ -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 <updatecheck version=...> 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)"
|
||||
Reference in New Issue
Block a user