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
|
||||||
.next/
|
.next/
|
||||||
out/
|
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";
|
pub const HOST_NAME: &str = "com.agent_browser.connect";
|
||||||
|
|
||||||
/// Stable id of the `ab-connect` extension, pinned by the `key` in its
|
/// Stable id of the `ab-connect` extension, pinned by the `key` in its
|
||||||
/// manifest.json. Chrome only lets that extension talk to this host.
|
/// manifest.json (and the signing key of the published `.crx`). Chrome only lets
|
||||||
pub const EXTENSION_ID: &str = "bdoiejojpjogcjojeladhioioijhgade";
|
/// 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).
|
/// `agent-browser extension <install|uninstall|status>` (local; no daemon).
|
||||||
/// `args` is the cleaned argv including the leading "extension".
|
/// `args` is the cleaned argv including the leading "extension".
|
||||||
@@ -31,18 +47,40 @@ pub fn run_connect(args: &[String], json: bool) {
|
|||||||
|
|
||||||
if uninstall {
|
if uninstall {
|
||||||
let removed = remove_host_manifests();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if install {
|
if install {
|
||||||
|
let no_open = args.iter().any(|a| a == "--no-open");
|
||||||
match install_native_host() {
|
match install_native_host() {
|
||||||
Ok(paths) => {
|
Ok(paths) => {
|
||||||
|
let profile = install_force_install_profile(no_open);
|
||||||
if json {
|
if json {
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
serde_json::to_string(&serde_json::json!({
|
serde_json::to_string(&serde_json::json!({
|
||||||
"success": true,
|
"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()
|
.unwrap_or_default()
|
||||||
);
|
);
|
||||||
@@ -51,11 +89,27 @@ pub fn run_connect(args: &[String], json: bool) {
|
|||||||
for p in &paths {
|
for p in &paths {
|
||||||
println!(" {p}");
|
println!(" {p}");
|
||||||
}
|
}
|
||||||
println!(
|
match profile {
|
||||||
"\nNext: load the ab-connect extension in Chrome (chrome://extensions →\n\
|
Ok(path) => {
|
||||||
Developer mode → Load unpacked → extensions/ab-connect), then this host\n\
|
println!("\n✓ Chrome force-install profile written:\n {}", path.display());
|
||||||
is reachable with no token and no per-use confirmation."
|
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}")),
|
Err(e) => report(json, false, &format!("install failed: {e}")),
|
||||||
@@ -136,6 +190,78 @@ fn install_native_host() -> Result<Vec<String>, String> {
|
|||||||
Ok(written)
|
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 {
|
fn remove_host_manifests() -> usize {
|
||||||
let mut n = 0;
|
let mut n = 0;
|
||||||
for dir in native_messaging_dirs() {
|
for dir in native_messaging_dirs() {
|
||||||
|
|||||||
Binary file not shown.
@@ -1,16 +1,28 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "agent-browser connect",
|
"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.",
|
"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": {
|
"icons": {
|
||||||
"16": "icons/icon16.png",
|
"16": "icons/icon16.png",
|
||||||
"32": "icons/icon32.png",
|
"32": "icons/icon32.png",
|
||||||
"48": "icons/icon48.png",
|
"48": "icons/icon48.png",
|
||||||
"128": "icons/icon128.png"
|
"128": "icons/icon128.png"
|
||||||
},
|
},
|
||||||
"permissions": ["debugger", "tabs", "nativeMessaging", "storage", "alarms", "webNavigation"],
|
"permissions": [
|
||||||
"background": { "service_worker": "background.js", "type": "module" },
|
"debugger",
|
||||||
"action": { "default_title": "agent-browser connect" }
|
"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