Compare commits

...
Author SHA1 Message Date
leeguooooo 340886293a chore(release): 0.27.0-fork.26 — stealth navigator.platform=MacIntel (anti-detection fix)
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
2026-06-10 08:46:43 +09:00
leeguooooo fc1699a526 fix(stealth): navigator.platform = MacIntel/Win32/Linux x86_64 (was UA-CH value)
platform_string() feeds the CDP Emulation.setUserAgentOverride 'platform' field,
which sets the LEGACY navigator.platform. It was returning the UA-CH form
("macOS"/"Linux") — but real Chrome reports navigator.platform = "MacIntel" on
macOS and "Linux x86_64" on Linux. "macOS" contradicts the UA's "Intel Mac OS X"
and is a trivial bot-detection tell (platform vs UA mismatch). UA-CH
(navigator.userAgentData.platform via platform_hint) stays "macOS"/"Windows"/
"Linux" — that form is correct there.

Verified locally on bot.sannysoft.com (all rows green incl. navigator.platform=
MacIntel) + eval probes: webdriver false, no Headless in UA, real WebGL
(Apple M3 Metal, not SwiftShader), plugins/permissions consistent.
2026-06-10 08:46:42 +09:00
leeguooooo 4bcfe74514 chore(release): 0.27.0-fork.25 — relay liveness fix (Browser.getVersion local) stops reconnect-storm drift
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
2026-06-10 01:38:19 +09:00
leeguooooo bb41c24c08 fix(connect): relay answers Browser.getVersion locally (stops reconnect storm)
ROOT CAUSE of per-session command drift on the extension path: the daemon's
liveness check (`is_connection_alive` → `Browser.getVersion`) is a BROWSER-level
command. The relay only answered Target.* locally and forwarded the rest, so
Browser.getVersion went to the extension, which can only do per-tab
chrome.debugger → it errored → CdpClient saw TransportError → connection deemed
DEAD → the daemon closed + reconnected + re-ran discover_and_attach_targets on
EVERY command. Each re-discover rebuilds pages from the relay's minimal
targetInfo and resets active_page_index=0, so eval/get-title/screenshot drifted
to the first tab (about:blank / a foreign focused tab).

Reproduced locally (throwaway Chrome + Extensions.loadUnpacked + fork.24 nm-host):
trace showed discover_and_attach_targets running on every command (pages
before=0) and [ev] active_idx reset to 0.

Fix: relay answers Browser.getVersion locally with a stub version (like
getTargets), so the liveness probe succeeds → connection stays alive → no
reconnect/re-discover → the session's active tab is preserved. Pairs with
fork.24's add_background_page. relay.rs: 10 unit tests.
2026-06-10 01:38:18 +09:00
leeguooooo 75bd1d21a7 chore(release): 0.27.0-fork.24 — passive tab discovery no longer hijacks active tab (per-session control)
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
2026-06-10 00:42:16 +09:00
leeguooooo 06c75af46a fix(connect): passively-discovered tabs no longer steal the active tab
After connect+grouping worked, follow-up eval/get-title/screenshot drifted to a
foreign tab: on a shared browser, Target.targetCreated events for tabs the user
or OTHER agent sessions open stream in and are drained on every command. The
drain path routed them through add_page(), which sets active_page_index to the
new page — so the session's active tab silently jumped to a foreign tab and its
commands landed there.

Add BrowserManager::add_background_page() (push without touching active, dedup by
target_id) and use it in the event-drain path. Explicit opens (tab new, the
add-and-switch paths) keep using add_page() and still focus the new tab.

Closes the last gap in concurrent multi-agent: each session now drives its OWN
tab regardless of other sessions'/the user's tab activity.
2026-06-10 00:42:16 +09:00
leeguooooo 312bb0d65b chore(release): 0.27.0-fork.23 — tolerate minimal targetInfo from relay (extension connect getTargets)
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
2026-06-10 00:07:59 +09:00
leeguooooo cff003c333 fix(connect): tolerate minimal targetInfo (relay re-announce omits title/url)
After the connect fix, extension connect reached the relay but Target.getTargets
failed: 'missing field title'. The ab-connect relay builds targets from the
extension's synthesized Target.attachedToTarget; the re-announce path
(reannounceAttachedTabs) emits a minimal targetInfo {targetId,type,attached}
with no title/url, so strict deserialize of TargetInfo blew up the whole
getTargets response.

Make TargetInfo.title/url #[serde(default)] (empty) — tolerant of minimal CDP
targetInfo from the relay (and the occasional real-CDP omission). Titles
re-populate from Target.targetInfoChanged / page events after attach.
2026-06-10 00:07:58 +09:00
leeguooooo f2b0c2ea9b chore(release): 0.27.0-fork.22 — extension connect uses relay URL (fixes --session connect hang)
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
2026-06-09 23:39:42 +09:00
leeguooooo ea58bce19e fix(connect): extension connect now uses the relay URL (was falling through to auto-connect)
`extension connect` rewrote argv to ["connect", <relay-url>] but the connect path
reads flags.cdp — parsed earlier from the original argv ("extension connect" →
None). So the relay URL was dropped and the daemon ran AUTO-CONNECT, grabbing
whatever Chrome it could discover: a stale remote-debugging Chrome on :9222
(indefinite hang), or triggering Chrome's "Allow remote debugging?" prompt on
machines without one. This is the EAGAIN/hang hermes hit on --session connect.

Fix: set flags.cdp = Some(relay_url) (+ disable auto_connect) in the
extension-connect branch so the daemon connects to the live relay endpoint.
Diagnosed via local repro (trace showed connect_cdp resolving ws://...:9222/
devtools/browser/... instead of the relay's ws://...:<port>/<guid>).
2026-06-09 23:39:41 +09:00
leeguooooo afb68ded93 chore(release): 0.27.0-fork.21 — multi-client relay (concurrent agents)
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
2026-06-09 22:47:30 +09:00
leeguooooo a6631cd7d8 fix(connect): multi-client relay — concurrent agents no longer cross-talk
The nm-host fanned extension→client messages over a broadcast channel and
forwarded commands under the client's own id, so two sessions connected to one
relay collided: command replies went to every client and ids overlapped → the
2nd session's connect hung (EAGAIN after 30s×5) and responses cross-talked.

Now the relay demultiplexes:
- each forwarded command is re-keyed to a relay-global id mapped to (client,
  original_id); the extension's reply routes back to ONLY that client with its
  original id restored (relay.rs: pending map + ClientId)
- CDP events fan out to all clients (they ignore unknown sessions)
- nm-host keeps a client_id -> sender registry instead of a broadcast; clients
  are unregistered + their pending dropped on disconnect

Unblocks concurrent multi-agent on one shared Chrome (each --session its own tab
group from fork.20). relay.rs: 9 unit tests incl. cross-client id isolation.
2026-06-09 22:47:30 +09:00
leeguooooo 4f630e29ad chore(release): 0.27.0-fork.20 — per-session tab groups (ab-connect 0.4.0)
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
2026-06-09 21:17:54 +09:00
leeguooooo d232763ff7 feat(connect): per-session Chrome tab groups on the shared real browser
Shared browser, separate tab groups: when an agent drives the user's real Chrome
via ab-connect, every tab it opens lands in a Chrome tab group named after its
--session (stable color per name). Each agent's tabs stay visually separated from
other agents' and from the user's own (ungrouped) tabs. Visibility is NOT
restricted — all agents still see all tabs (per design).

- CreateTargetParams gains an optional non-CDP `agentGroup` hint (skip-if-none),
  so a strict real-Chrome endpoint never receives it
- BrowserManager.agent_group(): Some(session) only when ws_url == the live
  ab-connect relay URL (never on launched/direct CDP); DAEMON_SESSION set at
  daemon start supplies the name; emitted at all createTarget sites (transient
  storage target stays None)
- ab-connect: +tabGroups permission; Target.createTarget reads agentGroup and
  groups the new tab (create/reuse by title, deterministic color), best-effort
- extension 0.3.0 -> 0.4.0; re-signed crx + zip (id unchanged)

Needs the v0.4.0 extension reloaded + a build with this change to take effect.
2026-06-09 21:17:53 +09:00
leeguooooo 85f4635358 chore(release): 0.27.0-fork.19 — new extension id (Web Store signing key) + store-aware install
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
Transport (native messaging + extension connect) works today via Load unpacked.
Silent force-install is pending the Chrome Web Store listing going live (off-store
force-install is [BLOCKED] on unmanaged Chrome 149).
2026-06-09 19:20:45 +09:00
leeguooooo 726e9d4ea3 chore(store): add listing screenshot (force-add past png ignore) 2026-06-09 19:16:04 +09:00
leeguooooo ff8a340269 chore(store): add 1280x800 listing screenshot + bake in GitHub Pages privacy URL 2026-06-09 19:15:49 +09:00
leeguooooo c1fa237183 chore: add .nojekyll for GitHub Pages (serve privacy policy as-is) 2026-06-09 19:10:32 +09:00
leeguooooo f6b21461e9 feat(connect): pivot extension install to Chrome Web Store path
Verified on Chrome 149 (unmanaged macOS): a force-install policy pointing at a
SELF-HOSTED crx is tagged [BLOCKED] in chrome://policy ("Error, Warning") — Chrome
refuses off-Web-Store force-installs on non-cloud-managed browsers. So the
self-hosted-crx approach cannot work on consumer Chrome; the extension must ship
via the Chrome Web Store (same reason codex/claude do).

- UPDATE_URL -> Chrome Web Store update endpoint; add STORE_URL (one-click Add to
  Chrome) as the guaranteed path + headless fallback
- install instructions now offer: A) one-click store link, B) silent profile
  force-install (works once published), with Load-unpacked as the pre-publish stopgap
- build extensions/ab-connect.zip (CWS upload package; manifest "key" kept so the
  published id stays ciiljdlhdpfckdcfkphgmfalanpdejep)
- extensions/store/{SUBMISSION.html,privacy.html}: full listing copy, permission
  justifications (debugger is the review-sensitive one), privacy policy
- drop dead self-hosted extensions/updates.xml; pack-extension.sh now builds the zip

Not released yet — force-install only works after the store listing is Published.
2026-06-09 19:03:10 +09:00
leeguooooo e8ef57bf00 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
2026-06-09 18:27:51 +09:00
23 changed files with 808 additions and 79 deletions
+4
View File
@@ -67,3 +67,7 @@ docs/package-lock.json
# next
.next/
out/
# extension signing key (never commit) + local-only id record
.secrets/
*.pem
View File
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies = [
[[package]]
name = "agent-browser-stealth"
version = "0.27.0-fork.18"
version = "0.27.0-fork.26"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-browser-stealth"
version = "0.27.0-fork.18"
version = "0.27.0-fork.26"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
+185 -22
View File
@@ -20,8 +20,27 @@ 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";
/// Update URL the force-install policy points at. MUST be the Chrome Web Store
/// endpoint: Chrome 149 tags any **off-Web-Store** force-installed extension
/// `[BLOCKED]` on an unmanaged browser (verified on macOS — chrome://policy shows
/// `[BLOCKED]…` / "Error, Warning"). Self-hosting a `.crx` therefore does NOT
/// work on consumer Chrome; the extension must be published to the Web Store, and
/// then this policy force-installs it silently (Web Store extensions are allowed).
pub const UPDATE_URL: &str = "https://clients2.google.com/service/update2/crx";
/// Public Web Store listing — the guaranteed one-click "Add to Chrome" path,
/// and the fallback when the force-install profile can't be approved headlessly.
pub const STORE_URL: &str = "https://chromewebstore.google.com/detail/ciiljdlhdpfckdcfkphgmfalanpdejep";
/// 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 +50,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 +92,30 @@ 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!(
"\nGet the extension into Chrome (one-time). Either:\n\
A) One click: open {STORE_URL}\n and press \"Add to Chrome\".\n\
B) Silent: approve the profile, then restart Chrome —\n \
System Settings → General → Device Management → double-click\n \
\"agent-browser connect\" → Install. Chrome then force-installs +\n \
auto-updates it (no token, no per-use confirmation).\n\
Both need the extension published to the Web Store; until then use\n \
chrome://extensions → Developer mode → Load unpacked → extensions/ab-connect."
);
}
}
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 +196,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() {
@@ -267,9 +399,14 @@ pub fn run_nm_host() {
async fn nm_host_main() {
use crate::native::relay::{RelayOut, RelayState};
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::{broadcast, mpsc, Mutex};
use tokio::sync::{mpsc, Mutex};
/// client_id -> unbounded sender feeding that client's ws writer.
type ClientMap = Arc<Mutex<HashMap<u64, mpsc::UnboundedSender<String>>>>;
nm_log(&format!(
"[nm-host] start argv={:?}",
@@ -300,7 +437,8 @@ async fn nm_host_main() {
nm_log(&format!("[nm-host] cdp endpoint {url}"));
let state = Arc::new(Mutex::new(RelayState::new()));
let (to_clients, _) = broadcast::channel::<String>(4096);
let clients: ClientMap = Arc::new(Mutex::new(HashMap::new()));
let next_client_id = Arc::new(AtomicU64::new(1));
let (to_ext, mut to_ext_rx) = mpsc::channel::<Vec<u8>>(4096);
// Single writer to Chrome (extension) over stdout, native-messaging framed.
@@ -318,7 +456,8 @@ async fn nm_host_main() {
// Accept agent-browser CDP clients on the guid-scoped ws endpoint.
{
let state = state.clone();
let to_clients = to_clients.clone();
let clients = clients.clone();
let next_client_id = next_client_id.clone();
let to_ext = to_ext.clone();
let guid = guid.clone();
tokio::spawn(async move {
@@ -328,11 +467,14 @@ async fn nm_host_main() {
Err(_) => break,
};
let st = state.clone();
let rx = to_clients.subscribe();
let client_id = next_client_id.fetch_add(1, Ordering::Relaxed);
let (ctx, crx) = mpsc::unbounded_channel::<String>();
clients.lock().await.insert(client_id, ctx);
let tx = to_ext.clone();
let g = guid.clone();
let cls = clients.clone();
tokio::spawn(async move {
handle_cdp_client(stream, g, st, rx, tx).await;
handle_cdp_client(stream, g, st, client_id, crx, tx, cls).await;
});
}
});
@@ -360,8 +502,23 @@ async fn nm_host_main() {
};
for o in outs {
match o {
RelayOut::ToClient(m) => {
let _ = to_clients.send(m.to_string());
RelayOut::ToClient { to, msg } => {
let text = msg.to_string();
let cls = clients.lock().await;
match to {
// Command reply → only the client that issued it.
Some(cid) => {
if let Some(tx) = cls.get(&cid) {
let _ = tx.send(text);
}
}
// CDP event → fan out to every connected client.
None => {
for tx in cls.values() {
let _ = tx.send(text.clone());
}
}
}
}
RelayOut::ToExt(m) => {
let _ = to_ext.send(m.to_string().into_bytes()).await;
@@ -373,16 +530,20 @@ async fn nm_host_main() {
let _ = std::fs::remove_file(relay_url_path());
}
#[allow(clippy::too_many_arguments)]
async fn handle_cdp_client(
stream: tokio::net::TcpStream,
guid: String,
state: std::sync::Arc<tokio::sync::Mutex<crate::native::relay::RelayState>>,
mut from_relay: tokio::sync::broadcast::Receiver<String>,
client_id: u64,
mut from_relay: tokio::sync::mpsc::UnboundedReceiver<String>,
to_ext: tokio::sync::mpsc::Sender<Vec<u8>>,
clients: std::sync::Arc<
tokio::sync::Mutex<std::collections::HashMap<u64, tokio::sync::mpsc::UnboundedSender<String>>>,
>,
) {
use crate::native::relay::ClientRoute;
use futures_util::{SinkExt, StreamExt};
use tokio::sync::broadcast::error::RecvError;
use tokio_tungstenite::tungstenite::Message;
let want_path = format!("/{guid}");
@@ -410,9 +571,8 @@ async fn handle_cdp_client(
loop {
tokio::select! {
relayed = from_relay.recv() => match relayed {
Ok(text) => { if tx.send(Message::Text(text)).await.is_err() { break } }
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => break,
Some(text) => { if tx.send(Message::Text(text)).await.is_err() { break } }
None => break,
},
incoming = rx.next() => match incoming {
Some(Ok(Message::Text(text))) => {
@@ -420,7 +580,7 @@ async fn handle_cdp_client(
Ok(v) => v,
Err(_) => continue,
};
let route = { state.lock().await.route_client_command(&v) };
let route = { state.lock().await.route_client_command(client_id, &v) };
match route {
ClientRoute::Local(reply) => {
if tx.send(Message::Text(reply.to_string())).await.is_err() { break }
@@ -435,5 +595,8 @@ async fn handle_cdp_client(
},
}
}
// Unregister and forget this client's in-flight commands.
clients.lock().await.remove(&client_id);
state.lock().await.drop_client(client_id);
nm_log("[nm-host] cdp client disconnected");
}
+8 -1
View File
@@ -658,8 +658,15 @@ fn main() {
if clean.get(1).map(|s| s.as_str()) == Some("connect") {
match connect::relay_url() {
Some(url) => {
// The connect path reads `flags.cdp` (parsed from the original
// argv, which was `extension connect` → None), NOT `clean`.
// Without this the relay URL is dropped and we fall through to
// auto-connect, grabbing some other Chrome (stale :9222) or
// popping the remote-debug prompt. Point the daemon at the
// relay explicitly.
flags.cdp = Some(url.clone());
flags.auto_connect = false;
clean = vec!["connect".to_string(), url];
// fall through to the normal connect handling below
}
None => {
eprintln!(
+4 -1
View File
@@ -672,7 +672,10 @@ impl DaemonState {
}
let tab_id = mgr.assign_tab_id();
mgr.add_page(super::browser::PageInfo {
// Passively discovered (event-driven) — must NOT steal the
// active tab, or a foreign/user/other-session tab opening
// hijacks this session's eval/screenshot target.
mgr.add_background_page(super::browser::PageInfo {
tab_id,
label: None,
target_id: te.target_info.target_id.clone(),
+47
View File
@@ -12,6 +12,11 @@ use super::cdp::lightpanda::{launch_lightpanda, LightpandaLaunchOptions, Lightpa
use super::cdp::types::*;
use super::element::{resolve_element_object_id, RefMap};
/// The daemon's session name, set once at daemon start. Names the Chrome tab
/// group that abs-created tabs land in when driving the user's real Chrome via
/// the `ab-connect` extension, so each agent/session gets its own group.
pub static DAEMON_SESSION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
// ---------------------------------------------------------------------------
// Launch validation
// ---------------------------------------------------------------------------
@@ -568,12 +573,14 @@ impl BrowserManager {
if page_targets.is_empty() {
// Create a new tab
let agent_group = self.agent_group();
let result: CreateTargetResult = self
.client
.send_command_typed(
"Target.createTarget",
&CreateTargetParams {
url: "about:blank".to_string(),
agent_group,
},
None,
)
@@ -958,12 +965,14 @@ impl BrowserManager {
return Ok(());
}
let agent_group = self.agent_group();
let result: CreateTargetResult = self
.client
.send_command_typed(
"Target.createTarget",
&CreateTargetParams {
url: "about:blank".to_string(),
agent_group,
},
None,
)
@@ -1072,6 +1081,27 @@ impl BrowserManager {
self.pages.iter().any(|p| p.label.as_deref() == Some(label))
}
/// Chrome tab-group name for tabs this manager creates, or `None` when not
/// driving the user's real Chrome via the `ab-connect` extension relay.
///
/// Grouping only makes sense on the shared real browser (one Chrome, many
/// agents): each session's tabs go into its own group. On a launched / direct
/// CDP browser the endpoint is strict, so we must NOT send the custom param —
/// hence `None` there. We detect the relay by matching our `ws_url` against
/// the live relay URL the native-messaging host published.
fn agent_group(&self) -> Option<String> {
let via_relay = crate::connect::relay_url().as_deref() == Some(self.ws_url.as_str());
if !via_relay {
return None;
}
let name = DAEMON_SESSION.get().map(String::as_str).unwrap_or("default");
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
pub async fn tab_new(
&mut self,
url: Option<&str>,
@@ -1096,12 +1126,14 @@ impl BrowserManager {
let target_url = url.unwrap_or("about:blank");
let agent_group = self.agent_group();
let result: CreateTargetResult = self
.client
.send_command_typed(
"Target.createTarget",
&CreateTargetParams {
url: target_url.to_string(),
agent_group,
},
None,
)
@@ -1500,6 +1532,21 @@ impl BrowserManager {
self.active_page_index = index;
}
/// Add a passively-discovered page WITHOUT changing the active tab.
///
/// On a shared browser (ab-connect), `Target.targetCreated` events stream in
/// for tabs the user or OTHER agent sessions open. Those are drained on every
/// command; routing them through `add_page` made the active tab silently jump
/// to a foreign tab, so the session's own `eval`/`get title`/`screenshot`
/// landed on the wrong page. Passively-tracked pages must not steal focus —
/// only explicit opens (`tab new`, switch) set the active tab.
pub fn add_background_page(&mut self, page: PageInfo) {
if self.pages.iter().any(|p| p.target_id == page.target_id) {
return;
}
self.pages.push(page);
}
pub fn update_page_target_info(&mut self, target: &TargetInfo) -> bool {
update_page_target_info_in_pages(&mut self.pages, target)
}
+12
View File
@@ -106,7 +106,13 @@ pub struct TargetInfo {
pub target_id: String,
#[serde(rename = "type")]
pub target_type: String,
// Tolerate minimal targetInfo: the ab-connect relay's synthesized
// Target.attachedToTarget (re-announce path) omits title/url, and real CDP
// occasionally omits them too. Default to empty rather than fail the whole
// Target.getTargets deserialize.
#[serde(default)]
pub title: String,
#[serde(default)]
pub url: String,
pub attached: Option<bool>,
pub browser_context_id: Option<String>,
@@ -141,6 +147,12 @@ pub struct SetDiscoverTargetsParams {
#[serde(rename_all = "camelCase")]
pub struct CreateTargetParams {
pub url: String,
/// Non-CDP hint consumed only by the `ab-connect` extension: the Chrome
/// tab-group name to drop the new tab into (per-session grouping on the
/// shared real Chrome). `None` on the normal CDP path so a strict real-Chrome
/// endpoint never receives an unknown parameter.
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_group: Option<String>,
}
#[derive(Debug, Deserialize)]
+4
View File
@@ -17,6 +17,10 @@ use super::state;
use super::stream::StreamServer;
pub async fn run_daemon(session: &str) {
// Record this daemon's session so tabs it opens on the shared real Chrome
// (via the ab-connect extension) land in a per-session Chrome tab group.
let _ = super::browser::DAEMON_SESSION.set(session.to_string());
let socket_dir = get_daemon_socket_dir();
if !socket_dir.exists() {
let _ = fs::create_dir_all(&socket_dir);
+174 -44
View File
@@ -10,6 +10,17 @@
//! extension as `forwardCDPCommand`. That keeps `CdpClient` and `browser.rs`
//! unchanged.
//!
//! ## Multiple clients (concurrent agents on one shared browser)
//!
//! Several agent-browser daemons (one per `--session`) can connect to the same
//! relay/Chrome at once. The extension is a single peer, so the relay must
//! demultiplex: every forwarded command is re-keyed to a relay-global id mapped
//! back to the originating client, and the extension's reply is routed to **only
//! that client** (with its original id restored). Command ids from different
//! clients therefore never collide, and one client never sees another's command
//! replies. CDP *events* (no id) fan out to all clients, which ignore events for
//! sessions they didn't attach.
//!
//! This module is the pure translation core (no I/O) so the protocol can be
//! unit-tested; the tokio WebSocket server that drives it lives alongside.
@@ -20,6 +31,9 @@ use serde_json::{json, Value};
/// Protocol version advertised in the connect handshake (matches the extension).
pub const RELAY_PROTOCOL: i64 = 3;
/// Identifies one connected CDP client (agent-browser daemon) for routing.
pub type ClientId = u64;
/// One target (tab) the extension has attached, as the relay tracks it.
#[derive(Clone)]
struct TargetEntry {
@@ -27,27 +41,36 @@ struct TargetEntry {
target_info: Value,
}
/// Relay translation state: the set of targets the extension currently exposes.
/// Relay translation state: the targets the extension exposes, plus the
/// in-flight command map used to route extension replies back to the right
/// client.
#[derive(Default)]
pub struct RelayState {
/// targetId -> entry
targets: HashMap<String, TargetEntry>,
/// relay-global command id -> (client that sent it, its original id)
pending: HashMap<i64, (ClientId, Value)>,
/// monotonic source of relay-global command ids
next_global_id: i64,
}
/// What to do with a raw CDP command received from the `CdpClient`.
/// What to do with a raw CDP command received from a `CdpClient`.
#[derive(Debug, PartialEq)]
pub enum ClientRoute {
/// Answer locally; the value is a raw CDP response `{id, result}`.
/// Answer locally; the value is a raw CDP response `{id, result}` to send
/// back to the originating client only.
Local(Value),
/// Forward to the extension; the value is a `forwardCDPCommand` envelope.
/// Forward to the extension; the value is a `forwardCDPCommand` envelope
/// already re-keyed to a relay-global id.
Forward(Value),
}
/// An output the relay emits while handling an extension message.
#[derive(Debug, PartialEq)]
pub enum RelayOut {
/// Send this raw CDP message to the `CdpClient`.
ToClient(Value),
/// Send this raw CDP message to clients. `to = Some(id)` targets one client
/// (a command reply); `to = None` broadcasts (a CDP event).
ToClient { to: Option<ClientId>, msg: Value },
/// Send this envelope message back to the extension.
ToExt(Value),
}
@@ -68,16 +91,38 @@ impl RelayState {
json!({ "method": "ping" })
}
/// Route a raw CDP command `{id, method, params?, sessionId?}` from the
/// Forget a disconnected client's in-flight commands so its orphaned
/// `pending` entries don't leak.
pub fn drop_client(&mut self, client_id: ClientId) {
self.pending.retain(|_, (cid, _)| *cid != client_id);
}
/// Route a raw CDP command `{id, method, params?, sessionId?}` from a
/// `CdpClient`: answer browser-level `Target.*` discovery locally, forward
/// the rest to the extension.
pub fn route_client_command(&self, raw: &Value) -> ClientRoute {
/// the rest to the extension under a relay-global id keyed to `client_id`.
pub fn route_client_command(&mut self, client_id: ClientId, raw: &Value) -> ClientRoute {
let id = raw.get("id").cloned().unwrap_or(Value::Null);
let method = raw.get("method").and_then(|m| m.as_str()).unwrap_or("");
let params = raw.get("params").cloned().unwrap_or_else(|| json!({}));
let session_id = raw.get("sessionId").and_then(|s| s.as_str());
match method {
// Browser-level command the daemon uses as its liveness probe
// (`is_connection_alive` → `Browser.getVersion`). The extension only
// speaks per-tab `chrome.debugger`, so forwarding it errors → the
// daemon would deem the connection dead and reconnect+re-discover on
// EVERY command, resetting the active tab (eval/screenshot drift).
// Answer it locally so the relay connection reads as alive.
"Browser.getVersion" => ClientRoute::Local(json!({
"id": id,
"result": {
"protocolVersion": "1.3",
"product": "Chrome/ab-connect-relay",
"revision": "",
"userAgent": "",
"jsVersion": ""
}
})),
// Discovery is best-effort and event-driven in real CDP; abs only
// reads the getTargets result, so an empty ack is enough here.
"Target.setDiscoverTargets" | "Target.setAutoAttach" => {
@@ -100,17 +145,23 @@ impl RelayState {
})),
}
}
// Everything else goes to the extension's chrome.debugger.
_ => ClientRoute::Forward(json!({
"id": id,
"method": "forwardCDPCommand",
"params": { "method": method, "params": params, "sessionId": session_id },
})),
// Everything else goes to the extension's chrome.debugger. Re-key the
// id so this client's reply can be routed back unambiguously.
_ => {
self.next_global_id += 1;
let gid = self.next_global_id;
self.pending.insert(gid, (client_id, id));
ClientRoute::Forward(json!({
"id": gid,
"method": "forwardCDPCommand",
"params": { "method": method, "params": params, "sessionId": session_id },
}))
}
}
}
/// Handle one decoded message from the extension. Updates target state and
/// returns the messages to emit (to the client and/or back to the
/// returns the messages to emit (routed to a client and/or back to the
/// extension). `expected_token` is matched against the connect handshake.
pub fn handle_ext_message(&mut self, msg: &Value, expected_token: &str) -> Vec<RelayOut> {
// Connect handshake request from the extension.
@@ -137,12 +188,20 @@ impl RelayState {
return vec![];
}
// Response to a forwardCDPCommand we sent → raw CDP response to client.
// Response to a forwardCDPCommand we sent → route the raw CDP response
// back to the client that issued it, with its original id restored.
if msg.get("id").is_some()
&& (msg.get("result").is_some() || msg.get("error").is_some())
&& msg.get("method").is_none()
{
let mut out = json!({ "id": msg.get("id").cloned().unwrap_or(Value::Null) });
let gid = msg.get("id").and_then(|i| i.as_i64());
let (to, orig_id) = match gid.and_then(|g| self.pending.remove(&g)) {
Some((client_id, orig)) => (Some(client_id), orig),
// No mapping (stale/unknown id) — fall back to broadcasting with
// whatever id the extension echoed.
None => (None, msg.get("id").cloned().unwrap_or(Value::Null)),
};
let mut out = json!({ "id": orig_id });
if let Some(r) = msg.get("result") {
out["result"] = r.clone();
}
@@ -153,7 +212,7 @@ impl RelayState {
other => other.clone(),
};
}
return vec![RelayOut::ToClient(out)];
return vec![RelayOut::ToClient { to, msg: out }];
}
// CDP event forwarded from a tab.
@@ -194,12 +253,13 @@ impl RelayState {
_ => {}
}
// Regular CDP event → deliver to the client with its sessionId.
// Regular CDP event → fan out to all clients (each filters by the
// sessions it attached to).
let mut ev = json!({ "method": inner_method, "params": inner_params });
if let Some(sid) = session_id {
ev["sessionId"] = json!(sid);
}
return vec![RelayOut::ToClient(ev)];
return vec![RelayOut::ToClient { to: None, msg: ev }];
}
vec![]
@@ -247,7 +307,7 @@ mod tests {
let out = s.handle_ext_message(&attached_event("T1", "cb-tab-1"), "tok");
assert!(out.is_empty(), "attachedToTarget should be consumed, not forwarded");
// Now getTargets must report it.
let route = s.route_client_command(&json!({ "id": 1, "method": "Target.getTargets" }));
let route = s.route_client_command(1, &json!({ "id": 1, "method": "Target.getTargets" }));
match route {
ClientRoute::Local(v) => {
let infos = v["result"]["targetInfos"].as_array().unwrap();
@@ -258,11 +318,27 @@ mod tests {
}
}
#[test]
fn browser_get_version_is_answered_locally() {
// Liveness probe must NOT be forwarded (the extension can't do
// browser-level commands) — else the daemon reconnects on every command.
let mut s = RelayState::new();
let route = s.route_client_command(1, &json!({ "id": 7, "method": "Browser.getVersion" }));
match route {
ClientRoute::Local(v) => {
assert_eq!(v["id"], 7);
assert!(v["result"]["protocolVersion"].is_string());
}
_ => panic!("Browser.getVersion must be answered locally"),
}
}
#[test]
fn attach_to_target_returns_known_session() {
let mut s = RelayState::new();
s.seed_target("T1", "cb-tab-1");
let route = s.route_client_command(
7,
&json!({ "id": 5, "method": "Target.attachToTarget", "params": { "targetId": "T1", "flatten": true } }),
);
assert_eq!(
@@ -273,8 +349,9 @@ mod tests {
#[test]
fn attach_to_unknown_target_errors_locally() {
let s = RelayState::new();
let mut s = RelayState::new();
let route = s.route_client_command(
1,
&json!({ "id": 6, "method": "Target.attachToTarget", "params": { "targetId": "nope" } }),
);
match route {
@@ -284,16 +361,17 @@ mod tests {
}
#[test]
fn other_commands_forward_as_envelope() {
let s = RelayState::new();
let route = s.route_client_command(&json!({
"id": 9, "method": "Page.navigate",
"params": { "url": "https://x" }, "sessionId": "cb-tab-1"
}));
fn other_commands_forward_under_global_id() {
let mut s = RelayState::new();
let route = s.route_client_command(
42,
&json!({ "id": 9, "method": "Page.navigate", "params": { "url": "https://x" }, "sessionId": "cb-tab-1" }),
);
match route {
ClientRoute::Forward(v) => {
assert_eq!(v["method"], "forwardCDPCommand");
assert_eq!(v["id"], 9);
// id is re-keyed to a relay-global id (not the client's 9).
assert_eq!(v["id"], 1);
assert_eq!(v["params"]["method"], "Page.navigate");
assert_eq!(v["params"]["sessionId"], "cb-tab-1");
assert_eq!(v["params"]["params"]["url"], "https://x");
@@ -303,30 +381,62 @@ mod tests {
}
#[test]
fn forward_command_response_becomes_client_response() {
fn reply_routes_back_to_the_issuing_client_with_original_id() {
let mut s = RelayState::new();
let out = s.handle_ext_message(&json!({ "id": 9, "result": { "frameId": "F1" } }), "tok");
// Two clients each send a command that happens to share original id 1.
let r1 = s.route_client_command(100, &json!({ "id": 1, "method": "Page.navigate", "params": {} }));
let r2 = s.route_client_command(200, &json!({ "id": 1, "method": "Page.reload", "params": {} }));
let g1 = match r1 {
ClientRoute::Forward(v) => v["id"].as_i64().unwrap(),
_ => panic!(),
};
let g2 = match r2 {
ClientRoute::Forward(v) => v["id"].as_i64().unwrap(),
_ => panic!(),
};
assert_ne!(g1, g2, "global ids must be distinct across clients");
// Extension replies for g2 → must go to client 200 with original id 1.
let out = s.handle_ext_message(&json!({ "id": g2, "result": { "ok": true } }), "tok");
assert_eq!(
out,
vec![RelayOut::ToClient(json!({ "id": 9, "result": { "frameId": "F1" } }))]
vec![RelayOut::ToClient {
to: Some(200),
msg: json!({ "id": 1, "result": { "ok": true } })
}]
);
// And g1 → client 100.
let out = s.handle_ext_message(&json!({ "id": g1, "result": { "ok": false } }), "tok");
assert_eq!(
out,
vec![RelayOut::ToClient {
to: Some(100),
msg: json!({ "id": 1, "result": { "ok": false } })
}]
);
}
#[test]
fn forward_command_error_is_wrapped() {
fn forward_command_error_is_wrapped_and_routed() {
let mut s = RelayState::new();
let out = s.handle_ext_message(&json!({ "id": 9, "error": "boom" }), "tok");
let r = s.route_client_command(5, &json!({ "id": 3, "method": "Page.navigate", "params": {} }));
let gid = match r {
ClientRoute::Forward(v) => v["id"].as_i64().unwrap(),
_ => panic!(),
};
let out = s.handle_ext_message(&json!({ "id": gid, "error": "boom" }), "tok");
match &out[0] {
RelayOut::ToClient(v) => {
assert_eq!(v["id"], 9);
assert_eq!(v["error"]["message"], "boom");
RelayOut::ToClient { to, msg } => {
assert_eq!(*to, Some(5));
assert_eq!(msg["id"], 3);
assert_eq!(msg["error"]["message"], "boom");
}
_ => panic!("expected ToClient"),
}
}
#[test]
fn regular_event_is_forwarded_with_session() {
fn regular_event_broadcasts_with_session() {
let mut s = RelayState::new();
let ev = json!({
"method": "forwardCDPEvent",
@@ -335,14 +445,34 @@ mod tests {
let out = s.handle_ext_message(&ev, "tok");
assert_eq!(
out,
vec![RelayOut::ToClient(json!({
"method": "Page.loadEventFired",
"params": { "timestamp": 1.0 },
"sessionId": "cb-tab-1"
}))]
vec![RelayOut::ToClient {
to: None,
msg: json!({
"method": "Page.loadEventFired",
"params": { "timestamp": 1.0 },
"sessionId": "cb-tab-1"
})
}]
);
}
#[test]
fn drop_client_clears_its_pending() {
let mut s = RelayState::new();
let r = s.route_client_command(9, &json!({ "id": 1, "method": "Page.navigate", "params": {} }));
let gid = match r {
ClientRoute::Forward(v) => v["id"].as_i64().unwrap(),
_ => panic!(),
};
s.drop_client(9);
// Reply now has no mapping → broadcast fallback (to: None), echoed id.
let out = s.handle_ext_message(&json!({ "id": gid, "result": {} }), "tok");
match &out[0] {
RelayOut::ToClient { to, .. } => assert_eq!(*to, None),
_ => panic!(),
}
}
#[test]
fn connect_handshake_validates_token() {
let mut s = RelayState::new();
+2
View File
@@ -119,6 +119,8 @@ async fn collect_storage_via_temp_target(
"Target.createTarget",
&CreateTargetParams {
url: "about:blank".to_string(),
// Transient internal target (storage collection) — never grouped.
agent_group: None,
},
None,
)
+7 -2
View File
@@ -270,13 +270,18 @@ pub fn strip_source_url_labels(input: &str) -> String {
re_block.replace_all(&output, "").to_string()
}
/// The legacy `navigator.platform` value (set via the CDP
/// `Emulation.setUserAgentOverride` `platform` field). This is NOT the UA-CH
/// platform (see `platform_hint`): real Chrome reports `MacIntel` on macOS and
/// `Linux x86_64` on Linux, so emitting the UA-CH form ("macOS"/"Linux") here is
/// a detectable mismatch against the UA's "Intel Mac OS X" / Linux strings.
fn platform_string() -> &'static str {
if cfg!(target_os = "macos") {
"macOS"
"MacIntel"
} else if cfg!(target_os = "windows") {
"Win32"
} else {
"Linux"
"Linux x86_64"
}
}
Binary file not shown.
Binary file not shown.
+47
View File
@@ -28,6 +28,46 @@ const tabs = new Map()
const sessionToTab = new Map()
/** child (OOPIF/worker) sessionId -> tabId */
const childSessionToTab = new Map()
/** tab-group name -> chrome tabGroups id (best-effort cache) */
const groupIdByName = new Map()
// Deterministic color per group name so a given session keeps the same color.
const GROUP_COLORS = ['blue', 'cyan', 'green', 'yellow', 'orange', 'red', 'pink', 'purple', 'grey']
function colorForName(name) {
let h = 0
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
return GROUP_COLORS[h % GROUP_COLORS.length]
}
// Put a freshly-created tab into the agent/session's own Chrome tab group, so
// each agent's tabs are visually separated (from each other and from the user's
// own tabs) on the shared real browser. Best-effort: grouping failures never
// break tab creation.
async function groupTabInto(tabId, name) {
if (!name || !chrome.tabGroups || !chrome.tabs.group) return
const tab = await chrome.tabs.get(tabId).catch(() => null)
if (!tab) return
let gid = groupIdByName.get(name)
if (gid != null) {
const ok = await chrome.tabGroups.get(gid).then(() => true).catch(() => false)
if (!ok) {
gid = null
groupIdByName.delete(name)
}
}
if (gid == null) {
// Reuse a same-titled group already in this window (survives SW restarts).
const found = await chrome.tabGroups.query({ windowId: tab.windowId, title: name }).catch(() => [])
if (found && found[0]) gid = found[0].id
}
if (gid == null) {
gid = await chrome.tabs.group({ tabIds: tabId })
await chrome.tabGroups.update(gid, { title: name, color: colorForName(name) }).catch(() => {})
} else {
await chrome.tabs.group({ groupId: gid, tabIds: tabId }).catch(() => {})
}
groupIdByName.set(name, gid)
}
function postToHost(msg) {
try {
@@ -121,6 +161,13 @@ async function handleForwardCdpCommand(msg) {
if (!tab.id) throw new Error('createTarget: no tab id')
await new Promise((r) => setTimeout(r, 100))
const t = await attachTab(tab.id)
// Per-session tab grouping (non-CDP hint from the daemon). Best-effort.
const group = typeof params?.agentGroup === 'string' ? params.agentGroup.trim() : ''
if (group) {
try {
await groupTabInto(tab.id, group)
} catch {}
}
return { targetId: t.targetId }
}
if (method === 'Target.closeTarget') {
+18 -5
View File
@@ -1,16 +1,29 @@
{
"manifest_version": 3,
"name": "agent-browser connect",
"version": "0.2.0",
"version": "0.4.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",
"tabGroups",
"nativeMessaging",
"storage",
"alarms",
"webNavigation"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"action": {
"default_title": "agent-browser connect"
}
}
+131
View File
@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chrome Web Store 提交指南 — agent-browser connect</title>
<style>
:root{--fg:#1a1a1a;--muted:#5c5c5c;--accent:#2563eb;--warn:#b45309;--ok:#15803d;--border:#e2e2e2;--bg:#fff;--code:#f5f5f7}
*{box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;color:var(--fg);background:var(--bg);max-width:880px;margin:0 auto;padding:48px 24px;line-height:1.65}
header{border-bottom:2px solid var(--fg);padding-bottom:16px;margin-bottom:24px}
h1{font-size:1.7rem;margin:0 0 4px}
.sub{color:var(--muted)}
h2{font-size:1.2rem;margin:34px 0 10px;border-left:3px solid var(--accent);padding-left:10px}
h3{font-size:1rem;margin:20px 0 6px}
code{background:var(--code);padding:1px 5px;border-radius:4px;font-size:.88em}
pre{background:var(--code);border:1px solid var(--border);border-radius:8px;padding:12px 14px;overflow:auto;font-size:.86rem;white-space:pre-wrap}
table{border-collapse:collapse;width:100%;margin:12px 0;font-size:.92rem}
th,td{border:1px solid var(--border);padding:8px 10px;text-align:left;vertical-align:top}
th{background:var(--code)}
ol li,ul li{margin:6px 0}
.warn{background:#fffbeb;border:1px solid #fde68a;border-left:4px solid var(--warn);padding:12px 14px;border-radius:6px;margin:16px 0}
.ok{background:#f0fdf4;border:1px solid #bbf7d0;border-left:4px solid var(--ok);padding:12px 14px;border-radius:6px;margin:16px 0}
.field{font-weight:600;color:var(--accent)}
footer{margin-top:40px;padding-top:16px;border-top:1px solid var(--border);color:var(--muted);font-size:.85rem}
</style>
</head>
<body>
<header>
<h1>Chrome Web Store 提交指南</h1>
<div class="sub">agent-browser connect · 上传包 <code>extensions/ab-connect.zip</code> · id 锁定为 <code>ciiljdlhdpfckdcfkphgmfalanpdejep</code></div>
</header>
<p>为什么必须走商店:实测 Chrome 149 在<strong>非企业托管</strong>的 Mac 上,会把"非 Web Store"的 force-install 扩展直接标成 <code>[BLOCKED]</code>。商店扩展不受此限。这也是 codex / claude 扩展都发商店的原因。</p>
<div class="warn">
<strong>评审风险(务必知道):</strong> 本扩展用了 <code>debugger</code> 权限,这是 Chrome Web Store 审核最严的权限之一。理由必须写清楚"只在用户本机、用户主动发指令时驱动用户自己的标签页,无远程服务器"。类似工具(如 claude-in-chrome)能过审,但可能被多问一轮、审核时间偏长(几天到一两周)。
</div>
<h2>一、前置(你来做,一次性)</h2>
<ol>
<li>用一个 Google 账号登录 <code>https://chrome.google.com/webstore/devconsole</code></li>
<li>首次需付 <strong>$5</strong> 一次性开发者注册费</li>
<li>(隐私政策需要一个公开 URL,见第四节 —— 我可以帮你开 GitHub Pages 托管 <code>privacy.html</code>)</li>
</ol>
<h2>二、上传</h2>
<ol>
<li>devconsole → <span class="field">New item</span> → 上传 <code>extensions/ab-connect.zip</code></li>
<li>上传后确认分配到的 Item ID = <code>ciiljdlhdpfckdcfkphgmfalanpdejep</code>(因为 manifest 里保留了 <code>key</code>,id 会被锁成这个,native messaging 的 allowed_origins 才对得上)。<strong>若 id 不是这个,告诉我,我重签。</strong></li>
</ol>
<h2>三、商店信息(直接复制以下文案)</h2>
<h3>名称 / Name</h3>
<pre>agent-browser connect</pre>
<h3>简介 / Summary(≤132 字符)</h3>
<pre>Let your own agent-browser CLI drive your logged-in Chrome — a local automation bridge. No remote server, no token.</pre>
<h3>详细描述 / Description</h3>
<pre>agent-browser connect is the in-browser half of the open-source agent-browser CLI. It lets the
command-line tool you installed on this same computer automate the Chrome you're already logged
into — opening pages, clicking, filling forms, reading the DOM — driven entirely by you.
How it works
- The extension talks ONLY to the local agent-browser CLI over Chrome native messaging (a local
inter-process channel — no network socket, no token, no remote server).
- When you run an automation command, the extension relays Chrome DevTools Protocol operations to
the tab you target, then returns the result to the CLI.
Privacy
- No analytics, no trackers, no data collection.
- Nothing is sent to any remote server. The only message peer is the local CLI.
- Source is open (Apache-2.0): https://github.com/leeguooooo/agent-browser-stealth
You need the agent-browser CLI installed and paired (run: agent-browser extension install) for this
extension to do anything.</pre>
<h3>类别 / Category</h3>
<pre>Developer Tools</pre>
<h3>语言 / Language</h3>
<pre>English</pre>
<h2>四、隐私实践(Privacy practices 标签页 —— 必填)</h2>
<h3>Single purpose(单一用途)</h3>
<pre>Bridge the user's locally-installed agent-browser CLI to their own logged-in Chrome so the CLI can
automate pages the user is working with, entirely on the user's machine and at the user's command.</pre>
<h3>各权限理由 / Permission justifications</h3>
<table>
<tr><th>权限</th><th>理由(复制到对应输入框)</th></tr>
<tr><td class="field">debugger</td><td>Attaches the Chrome DevTools Protocol to the user's own active tab so the paired local agent-browser CLI can automate it (navigate, click, read DOM) only while the user is running a command. Commands arrive solely from the local CLI via native messaging; there is no remote endpoint.</td></tr>
<tr><td class="field">tabs</td><td>Enumerate and target the correct open tab to attach automation to.</td></tr>
<tr><td class="field">nativeMessaging</td><td>The sole communication channel: a local native-messaging connection to the agent-browser CLI installed on the same machine. No network is used.</td></tr>
<tr><td class="field">storage</td><td>Persist small local pairing/configuration state for the extension.</td></tr>
<tr><td class="field">alarms</td><td>Keep the MV3 service worker alive during longer automation sessions.</td></tr>
<tr><td class="field">webNavigation</td><td>Detect page loads/navigations so automation can wait for the right moment before acting.</td></tr>
<tr><td class="field">host permissions(若被问)</td><td>The extension declares none; tab access is mediated through the debugger attach the user initiates.</td></tr>
</table>
<h3>数据用途勾选 / Data usage</h3>
<ul>
<li>不勾选任何"collects user data"类别。</li>
<li>三个合规声明全部勾选可以为真:不卖数据 / 不挪作无关用途 / 不用于判断信用资质。</li>
<li><span class="field">Privacy policy URL</span>:填 <code>privacy.html</code> 的公开地址(见下)。</li>
</ul>
<h2>五、隐私政策 URL</h2>
<p>商店要求一个公开可访问的隐私政策地址。GitHub Pages <strong>已开启</strong>,直接填这个(渲染好看):</p>
<pre>https://leeguooooo.github.io/agent-browser-stealth/extensions/store/privacy.html</pre>
<p>(部署需 1–2 分钟生效。raw 备用直链:<code>https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/extensions/store/privacy.html</code>。)</p>
<h2>六、截图 / Screenshots(至少 1 张,1280×800 或 640×400</h2>
<p>可以截一张 CLI + Chrome 并排的演示图。<em>需要的话我用 cua-driver 截一张合规尺寸的图给你。</em></p>
<h2>七、提交后</h2>
<ol>
<li>提交审核 → 等几天。审核通过且状态变 <em>Published</em> 后告诉我。</li>
<li>我会把 <code>extension install</code> 的 force-install <code>update_url</code> 切到商店地址并发布新 fork;之后用户 <code>extension install</code> → 批准一次描述文件 → 静默装好(商店扩展不再 <code>[BLOCKED]</code>);或者用户在商店页一键 <span class="field">Add to Chrome</span></li>
</ol>
<div class="ok">
<strong>今天的临时可用方案:</strong> 在你这台 Mac 上 <code>chrome://extensions</code> → 打开开发者模式 → Load unpacked → 选 <code>extensions/ab-connect</code>,30 秒手动装一次,native messaging + <code>extension connect</code> 立即可用。等商店过审再切静默路径。
</div>
<footer>agent-browser-stealth · 提交包与文案随扩展版本更新;改扩展后重跑 <code>scripts/pack-extension.sh</code> 并重打 <code>ab-connect.zip</code></footer>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Privacy Policy — agent-browser connect</title>
<style>
:root{
--fg:#1a1a1a; --muted:#5c5c5c; --accent:#2563eb; --border:#e2e2e2; --bg:#fff; --code:#f5f5f5;
}
*{box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
color:var(--fg);background:var(--bg);max-width:820px;margin:0 auto;padding:48px 24px;line-height:1.65}
header{border-bottom:2px solid var(--fg);padding-bottom:16px;margin-bottom:28px}
h1{font-size:1.7rem;margin:0 0 4px}
.sub{color:var(--muted);font-size:.95rem}
h2{font-size:1.15rem;margin:32px 0 8px;border-left:3px solid var(--accent);padding-left:10px}
code{background:var(--code);padding:1px 5px;border-radius:4px;font-size:.88em}
table{border-collapse:collapse;width:100%;margin:12px 0;font-size:.92rem}
th,td{border:1px solid var(--border);padding:8px 10px;text-align:left;vertical-align:top}
th{background:var(--code)}
.key{font-weight:600;color:var(--accent)}
footer{margin-top:40px;padding-top:16px;border-top:1px solid var(--border);color:var(--muted);font-size:.85rem}
strong{color:var(--fg)}
</style>
</head>
<body>
<header>
<h1>Privacy Policy — agent-browser connect</h1>
<div class="sub">Chrome extension (id <code>ciiljdlhdpfckdcfkphgmfalanpdejep</code>) · Last updated 2026-06-09</div>
</header>
<p><strong>Summary: this extension collects no personal data, contains no analytics or
trackers, and sends nothing to any remote server.</strong> It is a local bridge that lets the
user's own <code>agent-browser</code> command-line tool, running on the same computer, drive the
user's logged-in Chrome.</p>
<h2>What the extension does</h2>
<p>agent-browser connect pairs Chrome with the locally-installed <code>agent-browser</code> CLI over
Chrome <em>native messaging</em> (a local inter-process channel; no network socket, no token). When
the user issues an automation command in the CLI, the extension relays Chrome DevTools Protocol
operations to the tab the user targets. Everything happens on the user's machine, initiated by the
user.</p>
<h2>Data collection &amp; use</h2>
<table>
<tr><th>Category</th><th>Collected?</th><th>Detail</th></tr>
<tr><td class="key">Personally identifiable information</td><td>No</td><td>Never read, stored, or transmitted.</td></tr>
<tr><td class="key">Browsing history</td><td>No</td><td>Not collected. Page content is acted on transiently only while the user is running an automation command, and is never stored or sent off-device.</td></tr>
<tr><td class="key">Authentication / cookies / credentials</td><td>No</td><td>Not read or exported by the extension.</td></tr>
<tr><td class="key">Analytics / telemetry</td><td>No</td><td>The extension contains no analytics, tracking, or crash-reporting code.</td></tr>
<tr><td class="key">Remote transmission</td><td>No</td><td>The extension's only message peer is the local <code>agent-browser</code> CLI via native messaging. It makes no outbound network requests of its own.</td></tr>
</table>
<h2>Permissions &amp; why they are needed</h2>
<table>
<tr><th>Permission</th><th>Purpose</th></tr>
<tr><td class="key">debugger</td><td>Attach the Chrome DevTools Protocol to the user's own tab so the local CLI can automate it, only while the user is actively running a command.</td></tr>
<tr><td class="key">tabs</td><td>Enumerate and target the correct open tab to automate.</td></tr>
<tr><td class="key">nativeMessaging</td><td>The local transport to the paired <code>agent-browser</code> CLI — the extension's sole communication channel.</td></tr>
<tr><td class="key">storage</td><td>Persist small local pairing/state values.</td></tr>
<tr><td class="key">alarms</td><td>Keep the MV3 service worker alive during longer automation sessions.</td></tr>
<tr><td class="key">webNavigation</td><td>Detect page loads so automation can wait for the right moment.</td></tr>
</table>
<h2>Data sharing</h2>
<p>None. No data is sold, shared, or transferred to third parties. There are no third parties — the
extension talks only to a program the user installed on the same computer.</p>
<h2>Contact</h2>
<p>Source code, issues, and contact: <code>https://github.com/leeguooooo/agent-browser-stealth</code></p>
<footer>
agent-browser connect is open source (Apache-2.0). This policy applies to the extension only.
</footer>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<style>
html,body{margin:0;width:1280px;height:800px;overflow:hidden;
font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",sans-serif;
background:linear-gradient(135deg,#0f172a 0%,#1e293b 100%);color:#e2e8f0}
.wrap{display:flex;flex-direction:column;height:100%;padding:56px 64px;box-sizing:border-box}
h1{font-size:46px;margin:0 0 6px;font-weight:700;letter-spacing:-.5px;color:#fff}
.tag{font-size:21px;color:#94a3b8;margin:0 0 32px;font-weight:400}
.accent{color:#38bdf8}
.term{background:#0b1220;border:1px solid #334155;border-radius:14px;
box-shadow:0 24px 60px rgba(0,0,0,.45);overflow:hidden;flex:1;display:flex;flex-direction:column}
.bar{background:#1e293b;padding:13px 18px;display:flex;gap:9px;align-items:center;border-bottom:1px solid #334155}
.dot{width:13px;height:13px;border-radius:50%}
.r{background:#ff5f56}.y{background:#ffbd2e}.g{background:#27c93f}
.bartitle{color:#64748b;font-size:14px;margin-left:12px;font-family:ui-monospace,monospace}
pre{margin:0;padding:26px 30px;font-family:ui-monospace,"SF Mono",Menlo,monospace;
font-size:19.5px;line-height:1.72;flex:1}
.p{color:#38bdf8}.c{color:#f1f5f9;font-weight:600}.o{color:#94a3b8}.ok{color:#4ade80}.dim{color:#475569}
.foot{display:flex;gap:40px;margin-top:30px;font-size:18px;color:#cbd5e1}
.foot b{color:#fff}
.pill{display:inline-block;background:#0c4a6e;color:#7dd3fc;font-size:15px;padding:4px 13px;
border-radius:999px;margin-left:14px;vertical-align:middle;font-weight:600}
</style></head>
<body><div class="wrap">
<h1>agent-browser&nbsp;connect <span class="pill">local · no token · no remote</span></h1>
<p class="tag">Let your own <span class="accent">agent-browser</span> CLI drive the Chrome you're already logged into.</p>
<div class="term">
<div class="bar"><span class="dot r"></span><span class="dot y"></span><span class="dot g"></span><span class="bartitle">zsh — agent-browser</span></div>
<pre><span class="p">$</span> <span class="c">agent-browser extension install</span>
<span class="ok"></span> <span class="o">native-messaging host installed (com.agent_browser.connect)</span>
<span class="ok"></span> <span class="o">extension ready — add it from the Chrome Web Store</span>
<span class="p">$</span> <span class="c">agent-browser open</span> <span class="o">"https://mail.google.com"</span> <span class="dim"># your logged-in tab</span>
<span class="p">$</span> <span class="c">agent-browser snapshot -i</span> <span class="dim"># read the page</span>
<span class="p">$</span> <span class="c">agent-browser click</span> <span class="o">@e42</span> <span class="dim"># act on it</span>
<span class="ok"></span> <span class="o">driving your real session — no re-login, no confirmation</span>
</pre>
</div>
<div class="foot">
<span>🔌 <b>Native messaging</b> — local only</span>
<span>🧩 <b>chrome.debugger</b> — on your command</span>
<span>🔓 <b>Open source</b> · Apache-2.0</span>
</div>
</div></body></html>
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "agent-browser-stealth",
"version": "0.27.0-fork.18",
"description": "Browser automation CLI for AI agents stealth fork with anti-detection",
"version": "0.27.0-fork.26",
"description": "Browser automation CLI for AI agents \u2014 stealth fork with anti-detection",
"type": "module",
"packageManager": "pnpm@11.1.3",
"files": [
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
# Build the Chrome Web Store upload package extensions/ab-connect.zip (and a signed
# extensions/ab-connect.crx for reference) from extensions/ab-connect, keeping the
# extension id constant via the stable signing key + manifest "key".
#
# The id MUST stay ciiljdlhdpfckdcfkphgmfalanpdejep so the native-messaging
# allowed_origins and the force-install policy keep matching. The id is pinned by
# the "key" field in manifest.json (kept in the uploaded zip on purpose).
#
# The private key lives at .secrets/ab-connect.pem and is git-ignored.
#
# After changing the extension:
# 1. bump "version" in extensions/ab-connect/manifest.json
# 2. run this script
# 3. commit extensions/ab-connect.zip (+ .crx) + manifest.json
# 4. upload ab-connect.zip to the Web Store (see extensions/store/SUBMISSION.html)
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}"
# Web Store upload package (zip of the unpacked extension, dotfiles excluded).
rm -f extensions/ab-connect.zip
( cd "$EXT" && zip -rq ../ab-connect.zip . -x '.*' )
[ -f extensions/ab-connect.zip ] || { echo "error: zip failed" >&2; exit 1; }
# Signed crx (reference / non-store force-install for managed setups).
if [ -f "$KEY" ]; then
rm -f extensions/ab-connect.crx
"$CHROME" --pack-extension="$PWD/$EXT" --pack-extension-key="$PWD/$KEY" >/dev/null 2>&1 || true
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 "extension id: $ID"
else
echo "note: $KEY missing — built zip only (no crx)."
fi
echo "packed extensions/ab-connect.zip"
echo "manifest version: $(grep -o '"version"[^,]*' "$EXT/manifest.json" | head -1)"