diff --git a/AGENTS.md b/AGENTS.md
index 7db0746..d62fac6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,7 +4,7 @@ This file is the **canonical** project guidance for coding agents (Claude Code,
## Project
-**Browser Hand** gives agents a hand on the user's **real logged-in Chrome** — cookies, passkeys, open tabs, admin UIs — via a local MV3 extension bridge. Product default is the extension path, not remote debugging.
+**Browser Hand** gives agents a hand on the user's **real logged-in browser**, whichever Chromium has the Browser Hand extension connected — cookies, passkeys, open tabs, admin UIs — via a local MV3 extension bridge. Product default is the extension path, not remote debugging.
Upstream headless CLI (fork of [SawyerHood/dev-browser](https://github.com/SawyerHood/dev-browser)) remains for CI / disposable Chromium. See [MIGRATING.md](./MIGRATING.md) for rename map and [FORK.md](./FORK.md) for product intent.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b0672cd..5ca60f0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+- Throwaway launches use the connected browser's executable via `browser-hand browser-path`.
+
## [Extension 1.2.0] - 2026-11-08
- Chrome extension manifest is 1.2.0.
diff --git a/cli-js/package.json b/cli-js/package.json
index ad5f171..745ad26 100644
--- a/cli-js/package.json
+++ b/cli-js/package.json
@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"type": "module",
- "description": "browser-hand CLI — drive logged-in Chrome via the Browser Hand extension relay",
+ "description": "browser-hand CLI — drive the logged-in browser that has the Browser Hand extension connected",
"bin": {
"browser-hand": "./bin/browser-hand.js",
"browser-hand-cli": "./bin/browser-hand.js"
diff --git a/cli-js/src/browser-path.js b/cli-js/src/browser-path.js
new file mode 100644
index 0000000..6ad8071
--- /dev/null
+++ b/cli-js/src/browser-path.js
@@ -0,0 +1,199 @@
+/**
+ * Resolve the browser connected to the relay into a macOS app executable.
+ * Callers pass the relay payload and the folders to scan. This module does
+ * not open a network connection and does not assume /Applications.
+ */
+
+import { spawnSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+
+const NO_BROWSER =
+ "No browser is connected to the relay. Enable the Browser Hand extension in the browser you want, then retry.";
+const NO_NAME = "The connected extension did not report a browser name.";
+const MACOS_ONLY = "browser-path resolves macOS apps only.";
+
+/**
+ * Search stubs chrome.debugger with "debugger.attach isn't available in Search"
+ * while its extension user-agent still says Google Chrome. The stub names the
+ * real host. Other debugger errors ("No tab with given id") do not.
+ */
+export function browserNameFromDebuggerReason(reason) {
+ if (typeof reason !== "string") return null;
+ const match = reason.match(/available in ([^.]+)$/i);
+ if (!match) return null;
+ const name = match[1].trim();
+ if (!name || name.toLowerCase() === "unknown") return null;
+ return name;
+}
+
+/** Browser object the CLI should show and launch. The stub name wins. */
+export function reportedHostBrowser(relayInfo) {
+ if (!relayInfo || typeof relayInfo !== "object") return null;
+ const reported =
+ relayInfo.browser && typeof relayInfo.browser === "object" ? relayInfo.browser : null;
+ const fromStub = browserNameFromDebuggerReason(relayInfo.debugger?.reason);
+ if (!fromStub) return reported;
+ if (!reported) return { name: fromStub, version: "" };
+ return { ...reported, name: fromStub };
+}
+
+export function checkRelayBrowser(relayInfo, relayUrl) {
+ if (relayInfo == null) {
+ return { ok: false, message: `Browser Hand relay is not reachable at ${relayUrl}.` };
+ }
+ if (relayInfo.extensionConnected !== true) {
+ return { ok: false, message: NO_BROWSER };
+ }
+ const fromStub = browserNameFromDebuggerReason(relayInfo.debugger?.reason);
+ if (fromStub) return { ok: true, name: fromStub };
+ const raw =
+ relayInfo.browser && typeof relayInfo.browser.name === "string" ? relayInfo.browser.name : "";
+ const name = raw.trim();
+ if (!name || name.toLowerCase() === "unknown") {
+ return { ok: false, message: NO_NAME };
+ }
+ return { ok: true, name };
+}
+
+function defaultReadPlist(bundlePath) {
+ const plistPath = path.join(bundlePath, "Contents", "Info.plist");
+ let stat;
+ try {
+ stat = fs.statSync(plistPath);
+ } catch {
+ return { ok: false };
+ }
+ if (!stat.isFile()) return { ok: false };
+ const result = spawnSync("plutil", ["-convert", "json", "-o", "-", plistPath], {
+ encoding: "utf8",
+ });
+ if (result.status !== 0 || typeof result.stdout !== "string" || !result.stdout.trim()) {
+ return { ok: false };
+ }
+ try {
+ const json = JSON.parse(result.stdout);
+ if (!json || typeof json !== "object" || Array.isArray(json)) return { ok: false };
+ return { ok: true, plist: json };
+ } catch {
+ return { ok: false };
+ }
+}
+
+function listAppBundles(dir) {
+ if (!dir) return [];
+ let entries;
+ try {
+ entries = fs.readdirSync(dir, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+ return entries
+ .filter((entry) => entry.name.toLowerCase().endsWith(".app"))
+ .map((entry) => path.resolve(dir, entry.name))
+ .sort((left, right) => left.localeCompare(right));
+}
+
+function sameName(field, name) {
+ return typeof field === "string" && field.trim().toLowerCase() === name.trim().toLowerCase();
+}
+
+function bundleRealPath(bundlePath) {
+ try {
+ return fs.realpathSync(bundlePath);
+ } catch {
+ return bundlePath;
+ }
+}
+
+function collectMatches(dir, name, readPlist) {
+ const seen = new Set();
+ const matches = [];
+ for (const listed of listAppBundles(dir)) {
+ const bundlePath = bundleRealPath(listed);
+ if (seen.has(bundlePath)) continue;
+ const filename = path.basename(listed).replace(/\.app$/i, "");
+ const read = readPlist(listed);
+ const plist = read.ok ? read.plist : null;
+ const matched =
+ sameName(filename, name) ||
+ sameName(plist?.CFBundleName, name) ||
+ sameName(plist?.CFBundleDisplayName, name);
+ if (!matched) continue;
+ // A non-matching alias shares this real path. Marking it seen before the
+ // name check would hide the bundle whose filename actually matches.
+ seen.add(bundlePath);
+ matches.push({ bundlePath, plistOk: read.ok === true, plist });
+ }
+ return matches;
+}
+
+function executableMessage(bundlePath, executablePath) {
+ return `Matched ${bundlePath}, but ${executablePath} is not executable.`;
+}
+
+function isExecutableFile(filePath) {
+ try {
+ if (!fs.statSync(filePath).isFile()) return false;
+ fs.accessSync(filePath, fs.constants.X_OK);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export function resolveBrowserExecutable({
+ name,
+ platform = process.platform,
+ applicationsDir,
+ homeApplicationsDir,
+ readPlist = defaultReadPlist,
+} = {}) {
+ if (platform !== "darwin") {
+ return { ok: false, message: MACOS_ONLY };
+ }
+ const trimmed = String(name ?? "").trim();
+ const systemMatches = collectMatches(applicationsDir, trimmed, readPlist);
+ const matches =
+ systemMatches.length > 0
+ ? systemMatches
+ : collectMatches(homeApplicationsDir, trimmed, readPlist);
+ if (matches.length === 0) {
+ return {
+ ok: false,
+ message: `Connected browser is "${trimmed}", and no matching app was found in /Applications or ~/Applications.`,
+ };
+ }
+ if (matches.length > 1) {
+ return {
+ ok: false,
+ message: `Connected browser is "${trimmed}" and more than one app matches:\n${matches
+ .map((match) => match.bundlePath)
+ .join("\n")}`,
+ };
+ }
+ const match = matches[0];
+ if (!match.plistOk) {
+ return {
+ ok: false,
+ message: `Matched ${match.bundlePath}, but its Info.plist could not be read.`,
+ };
+ }
+ const exeName = match.plist?.CFBundleExecutable;
+ if (
+ typeof exeName !== "string" ||
+ exeName.length === 0 ||
+ exeName.includes("/") ||
+ exeName.includes("..")
+ ) {
+ return {
+ ok: false,
+ message: `Matched ${match.bundlePath}, but its CFBundleExecutable is missing.`,
+ };
+ }
+ const executablePath = path.join(match.bundlePath, "Contents", "MacOS", exeName);
+ if (!isExecutableFile(executablePath)) {
+ return { ok: false, message: executableMessage(match.bundlePath, executablePath) };
+ }
+ return { ok: true, name: trimmed, executablePath };
+}
diff --git a/cli-js/src/cli.js b/cli-js/src/cli.js
index ae0da6b..11b41f3 100644
--- a/cli-js/src/cli.js
+++ b/cli-js/src/cli.js
@@ -23,16 +23,24 @@
* 2 on argument error.
*/
+import os from "node:os";
import { fileURLToPath } from "node:url";
import path from "node:path";
+import { checkRelayBrowser, resolveBrowserExecutable } from "./browser-path.js";
+
// Force Browser Hand logger to stderr so stdout stays pure JSON for
// machine-readable consumption. Must be set BEFORE the tool import because
// the logger latches the stream choice at construction time.
process.env.AUTOHUB_LOG_STREAM = process.env.AUTOHUB_LOG_STREAM || "stderr";
process.env.BROWSER_HAND_LOG_STREAM = process.env.BROWSER_HAND_LOG_STREAM || "stderr";
-const { default: devBrowserTool, startPersistentRelay } = await import("./tool.js");
+const {
+ default: devBrowserTool,
+ startPersistentRelay,
+ probeRelay,
+ CURRENT_RELAY_URL,
+} = await import("./tool.js");
const OP_ALIASES = {
"autofill-profile": "autofill_profile",
@@ -72,6 +80,7 @@ Operations:
closes every tab in that Browser Hand tab group. Refuses a tab
you had open unless --include-user-tabs is passed.
doctor [--verbose] Health check: status, tabCount, and the active tab. Not a tab list.
+ browser-path Print the executable of the browser connected to the relay.
relay Start a persistent extension relay (foreground) so the
Chrome extension has a stable endpoint to connect to.
Run once and leave it up; Ctrl-C to stop. Idempotent —
@@ -110,6 +119,7 @@ Examples:
browser-hand close --query example.com --all
browser-hand close --group-title "DNS records"
browser-hand doctor
+ browser-hand browser-path
browser-hand click --page-name work --role combobox --name "Type"
browser-hand click --page-name work --ref e4
browser-hand steps --page-name work --quiet --steps '[{"op":"click","role":"button","name":"Save"}]'
@@ -405,6 +415,26 @@ export async function runCli(argv) {
process.env.LOG_LEVEL = process.env.LOG_LEVEL || "error";
}
+ if (operation === "browser-path") {
+ const relayInfo = await probeRelay(1000);
+ const checked = checkRelayBrowser(relayInfo, CURRENT_RELAY_URL);
+ if (!checked.ok) fail(checked.message);
+ const resolved = resolveBrowserExecutable({
+ name: checked.name,
+ platform: process.platform,
+ applicationsDir: "/Applications",
+ homeApplicationsDir: path.join(os.homedir(), "Applications"),
+ });
+ if (!resolved.ok) fail(resolved.message);
+ return {
+ code: 0,
+ stdout: encodeCliJson({
+ name: resolved.name,
+ executablePath: resolved.executablePath,
+ }),
+ };
+ }
+
if (operation === "relay") {
let res;
try {
diff --git a/cli-js/src/tool.js b/cli-js/src/tool.js
index d48d717..acfcd3c 100644
--- a/cli-js/src/tool.js
+++ b/cli-js/src/tool.js
@@ -20,6 +20,7 @@ import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
import { ContextLogger } from "./logger.js";
+import { reportedHostBrowser } from "./browser-path.js";
import { substitutePlaceholders } from "./autofill.js";
import { buildAutofillProfilePlan } from "./autofill-profile.js";
import { loadAutofillFile, readKeychainSecret, readProfileSecret } from "./autofill-io.js";
@@ -57,7 +58,7 @@ const MAX_CURRENT_TEXT_CHARS = 8000;
// How long to let the page react to a click before probing for a state change.
const CURRENT_RELAY_HOST = process.env.DEV_BROWSER_RELAY_HOST || process.env.HOST || "127.0.0.1";
const CURRENT_RELAY_PORT = Number(process.env.DEV_BROWSER_RELAY_PORT || process.env.PORT || 9333);
-const CURRENT_RELAY_URL = `http://${CURRENT_RELAY_HOST}:${CURRENT_RELAY_PORT}`;
+export const CURRENT_RELAY_URL = `http://${CURRENT_RELAY_HOST}:${CURRENT_RELAY_PORT}`;
const DEFAULT_PAGE_NAME = process.env.DEV_BROWSER_PAGE_NAME || "browser-hand-current";
const SCREENSHOT_DIR =
process.env.BROWSER_HAND_SCREENSHOT_DIR ||
@@ -501,7 +502,7 @@ async function fetchJson(url, { timeoutMs = 800, ...init } = {}) {
}
}
-async function probeRelay(timeoutMs = 800) {
+export async function probeRelay(timeoutMs = 800) {
try {
return await fetchJson(CURRENT_RELAY_URL, { timeoutMs });
} catch {
@@ -1480,7 +1481,7 @@ async function runCurrentDoctor(timeoutMs, input = {}) {
},
// Which browser the connected extension is running in. Agents read this so
// they target that browser and never fall back to `open -a "Google Chrome"`.
- browser: relayInfo?.browser || null,
+ browser: reportedHostBrowser(relayInfo),
// Set when the connected browser restricts chrome.debugger (null = no limitation seen).
debugger: relayInfo?.debugger || null,
pages,
@@ -1974,46 +1975,50 @@ async function runCurrentOperation(input, timeoutMs) {
error: err.message,
};
}
- const ran = await runStepList(steps, async (step) => {
- if (step.op === "click") {
- return evalValue(
- cdp,
- sessionId,
- buildClickExpression({
- selector: step.selector,
- text: step.text,
- ref: step.ref,
- role: step.role,
- name: step.name,
- })
- );
- }
- if (step.op === "fill") {
- return evalValue(cdp, sessionId, buildFillFieldsExpression(step.fields));
- }
- if (step.op === "type") {
- return evalValue(
- cdp,
- sessionId,
- buildTypeExpression({
- selector: step.selector,
- label: step.label,
- text: step.text || "",
- })
- );
- }
- if (step.op === "evaluate") {
- return evaluateUserCode(cdp, sessionId, step.code);
- }
- if (step.op === "snapshot") {
- return evalValue(
- cdp,
- sessionId,
- buildSnapshotExpression(MAX_CURRENT_TEXT_CHARS, { scope: step.scope || "auto" })
- );
- }
- throw new Error(`Unsupported step ${step.op}`);
- }, { deadlineAt });
+ const ran = await runStepList(
+ steps,
+ async (step) => {
+ if (step.op === "click") {
+ return evalValue(
+ cdp,
+ sessionId,
+ buildClickExpression({
+ selector: step.selector,
+ text: step.text,
+ ref: step.ref,
+ role: step.role,
+ name: step.name,
+ })
+ );
+ }
+ if (step.op === "fill") {
+ return evalValue(cdp, sessionId, buildFillFieldsExpression(step.fields));
+ }
+ if (step.op === "type") {
+ return evalValue(
+ cdp,
+ sessionId,
+ buildTypeExpression({
+ selector: step.selector,
+ label: step.label,
+ text: step.text || "",
+ })
+ );
+ }
+ if (step.op === "evaluate") {
+ return evaluateUserCode(cdp, sessionId, step.code);
+ }
+ if (step.op === "snapshot") {
+ return evalValue(
+ cdp,
+ sessionId,
+ buildSnapshotExpression(MAX_CURRENT_TEXT_CHARS, { scope: step.scope || "auto" })
+ );
+ }
+ throw new Error(`Unsupported step ${step.op}`);
+ },
+ { deadlineAt }
+ );
return {
success: ran.success === true,
mode: "current",
@@ -2554,7 +2559,14 @@ export function visibleIn(el, frameVisible) {
if (!frameVisible) return false;
const win = el.ownerDocument.defaultView || window;
if (typeof el.checkVisibility === "function") {
- if (!el.checkVisibility({ opacityProperty: true, visibilityProperty: true, contentVisibilityAuto: true })) return false;
+ if (
+ !el.checkVisibility({
+ opacityProperty: true,
+ visibilityProperty: true,
+ contentVisibilityAuto: true,
+ })
+ )
+ return false;
}
const rect = el.getBoundingClientRect();
if (!(rect.width > 0 && rect.height > 0)) return false;
@@ -2563,7 +2575,13 @@ export function visibleIn(el, frameVisible) {
for (let node = el, depth = 0; node && depth < 12; depth += 1) {
const style = win.getComputedStyle(node);
if (!style) break;
- if (style.display === "none" || style.visibility === "hidden" || style.pointerEvents === "none" || Number(style.opacity) <= 0.01) return false;
+ if (
+ style.display === "none" ||
+ style.visibility === "hidden" ||
+ style.pointerEvents === "none" ||
+ Number(style.opacity) <= 0.01
+ )
+ return false;
const parent = node.parentElement;
const root = !parent && node.getRootNode ? node.getRootNode() : null;
node = parent || (root && root.host ? root.host : null);
@@ -2592,7 +2610,8 @@ export function visibleIn(el, frameVisible) {
const root = node.getRootNode ? node.getRootNode() : null;
node = root && root.host ? root.host : null;
}
- if (!chain.some((node) => hit === node || node.contains(hit) || hit.contains(node))) return false;
+ if (!chain.some((node) => hit === node || node.contains(hit) || hit.contains(node)))
+ return false;
}
}
return true;
diff --git a/cli-js/test/browser-path.test.js b/cli-js/test/browser-path.test.js
new file mode 100644
index 0000000..60f4903
--- /dev/null
+++ b/cli-js/test/browser-path.test.js
@@ -0,0 +1,474 @@
+/**
+ * browser-path resolves the relay's connected browser to a macOS executable.
+ * Fixtures stand in for /Applications. No live browser and no relay process.
+ */
+
+import assert from "node:assert/strict";
+import {
+ chmodSync,
+ mkdtempSync,
+ mkdirSync,
+ realpathSync,
+ rmSync,
+ symlinkSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import { checkRelayBrowser, resolveBrowserExecutable } from "../src/browser-path.js";
+
+const RELAY_URL = "http://127.0.0.1:9333";
+const NO_BROWSER =
+ "No browser is connected to the relay. Enable the Browser Hand extension in the browser you want, then retry.";
+const NO_NAME = "The connected extension did not report a browser name.";
+
+function plistXml({ bundleName, displayName, executable } = {}) {
+ const rows = [];
+ if (bundleName !== undefined) {
+ rows.push(`CFBundleName${bundleName}`);
+ }
+ if (displayName !== undefined) {
+ rows.push(`CFBundleDisplayName${displayName}`);
+ }
+ if (executable !== undefined) {
+ rows.push(`CFBundleExecutable${executable}`);
+ }
+ return `
+
+${rows.join("")}
+`;
+}
+
+function makeRoot() {
+ return mkdtempSync(path.join(tmpdir(), "browser-path-"));
+}
+
+/**
+ * @param {string} root
+ * @param {string} bundleFile Search.app
+ * @param {{ bundleName?: string, displayName?: string, executable?: string, binary?: boolean, plist?: boolean }} [opts]
+ */
+function makeApp(root, bundleFile, opts = {}) {
+ const executable = opts.executable === undefined ? "AppBin" : opts.executable;
+ const app = path.join(root, bundleFile);
+ const macos = path.join(app, "Contents", "MacOS");
+ mkdirSync(macos, { recursive: true });
+ if (opts.plist !== false) {
+ writeFileSync(
+ path.join(app, "Contents", "Info.plist"),
+ plistXml({
+ bundleName: opts.bundleName,
+ displayName: opts.displayName,
+ executable,
+ })
+ );
+ }
+ const safeName =
+ typeof executable === "string" &&
+ executable.length > 0 &&
+ !executable.includes("/") &&
+ !executable.includes("..");
+ if (opts.binary !== false && safeName) {
+ const bin = path.join(macos, executable);
+ writeFileSync(bin, "#!/bin/sh\n");
+ chmodSync(bin, opts.mode ?? 0o755);
+ }
+ return app;
+}
+
+function real(filePath) {
+ return realpathSync(filePath);
+}
+
+function resolveAt(name, applicationsDir, homeApplicationsDir, extra = {}) {
+ return resolveBrowserExecutable({
+ name,
+ platform: "darwin",
+ applicationsDir,
+ homeApplicationsDir,
+ ...extra,
+ });
+}
+
+test("checkRelayBrowser reports an unreachable relay", () => {
+ const result = checkRelayBrowser(null, RELAY_URL);
+ assert.deepEqual(result, {
+ ok: false,
+ message: `Browser Hand relay is not reachable at ${RELAY_URL}.`,
+ });
+});
+
+test("checkRelayBrowser reports when no extension is connected", () => {
+ assert.equal(checkRelayBrowser({ extensionConnected: false }, RELAY_URL).message, NO_BROWSER);
+ assert.equal(checkRelayBrowser({}, RELAY_URL).message, NO_BROWSER);
+});
+
+test("checkRelayBrowser reports a missing or Unknown browser name", () => {
+ for (const relayInfo of [
+ { extensionConnected: true },
+ { extensionConnected: true, browser: null },
+ { extensionConnected: true, browser: {} },
+ { extensionConnected: true, browser: { name: "Unknown" } },
+ { extensionConnected: true, browser: { name: " unknown " } },
+ { extensionConnected: true, browser: { name: " " } },
+ ]) {
+ assert.equal(
+ checkRelayBrowser(relayInfo, RELAY_URL).message,
+ NO_NAME,
+ JSON.stringify(relayInfo)
+ );
+ }
+});
+
+test("checkRelayBrowser trims the connected browser name", () => {
+ assert.deepEqual(
+ checkRelayBrowser({ extensionConnected: true, browser: { name: " Search " } }, RELAY_URL),
+ {
+ ok: true,
+ name: "Search",
+ }
+ );
+});
+
+test("checkRelayBrowser prefers the browser named by a chrome.debugger stub", () => {
+ const result = checkRelayBrowser(
+ {
+ extensionConnected: true,
+ browser: { name: "Google Chrome", version: "156.0.0.0" },
+ debugger: { available: false, reason: "debugger.attach isn't available in Search" },
+ },
+ RELAY_URL
+ );
+ assert.deepEqual(result, { ok: true, name: "Search" });
+});
+
+test("checkRelayBrowser keeps the user-agent name when the debugger reason names no browser", () => {
+ const result = checkRelayBrowser(
+ {
+ extensionConnected: true,
+ browser: { name: "Google Chrome", version: "141.0.0.0" },
+ debugger: { available: false, reason: "No tab with given id" },
+ },
+ RELAY_URL
+ );
+ assert.deepEqual(result, { ok: true, name: "Google Chrome" });
+});
+
+test("resolveBrowserExecutable matches the bundle filename ignoring case", () => {
+ const applications = makeRoot();
+ const home = makeRoot();
+ try {
+ makeApp(applications, "Search.app", { executable: "Search" });
+ const result = resolveAt("search", applications, home);
+ assert.equal(result.ok, true);
+ assert.equal(result.name, "search");
+ assert.equal(
+ result.executablePath,
+ path.join(real(applications), "Search.app", "Contents", "MacOS", "Search")
+ );
+ } finally {
+ rmSync(applications, { recursive: true, force: true });
+ rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("resolveBrowserExecutable matches CFBundleName", () => {
+ const applications = makeRoot();
+ const home = makeRoot();
+ try {
+ makeApp(applications, "Other.app", { bundleName: "Search", executable: "Search" });
+ const result = resolveAt("Search", applications, home);
+ assert.equal(result.ok, true);
+ assert.equal(
+ result.executablePath,
+ path.join(real(applications), "Other.app", "Contents", "MacOS", "Search")
+ );
+ } finally {
+ rmSync(applications, { recursive: true, force: true });
+ rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("resolveBrowserExecutable matches CFBundleDisplayName", () => {
+ const applications = makeRoot();
+ const home = makeRoot();
+ try {
+ makeApp(applications, "Other.app", { displayName: "Search", executable: "Search" });
+ const result = resolveAt("Search", applications, home);
+ assert.equal(result.ok, true);
+ assert.equal(
+ result.executablePath,
+ path.join(real(applications), "Other.app", "Contents", "MacOS", "Search")
+ );
+ } finally {
+ rmSync(applications, { recursive: true, force: true });
+ rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("a match in /Applications wins over the same name in ~/Applications", () => {
+ const applications = makeRoot();
+ const home = makeRoot();
+ try {
+ makeApp(applications, "Search.app", { executable: "FromSystem" });
+ makeApp(home, "Search.app", { executable: "FromHome" });
+ const result = resolveAt("Search", applications, home);
+ assert.equal(
+ result.executablePath,
+ path.join(real(applications), "Search.app", "Contents", "MacOS", "FromSystem")
+ );
+ } finally {
+ rmSync(applications, { recursive: true, force: true });
+ rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("~/Applications is used only when /Applications has no match", () => {
+ const applications = makeRoot();
+ const home = makeRoot();
+ try {
+ makeApp(home, "Search.app", { executable: "FromHome" });
+ const result = resolveAt("Search", applications, home);
+ assert.equal(
+ result.executablePath,
+ path.join(real(home), "Search.app", "Contents", "MacOS", "FromHome")
+ );
+ } finally {
+ rmSync(applications, { recursive: true, force: true });
+ rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("a failed /Applications match does not fall through to ~/Applications", () => {
+ const applications = makeRoot();
+ const home = makeRoot();
+ try {
+ const app = makeApp(applications, "Search.app", { executable: "MissingBin", binary: false });
+ makeApp(home, "Search.app", { executable: "FromHome" });
+ const result = resolveAt("Search", applications, home);
+ assert.equal(result.ok, false);
+ assert.equal(
+ result.message,
+ `Matched ${real(app)}, but ${path.join(real(app), "Contents", "MacOS", "MissingBin")} is not executable.`
+ );
+ } finally {
+ rmSync(applications, { recursive: true, force: true });
+ rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("an app nested inside another bundle is not a candidate", () => {
+ const applications = makeRoot();
+ const home = makeRoot();
+ try {
+ const outer = makeApp(applications, "Outer.app", { executable: "Outer" });
+ makeApp(path.join(outer, "Contents"), "Nested.app", {
+ displayName: "Search",
+ executable: "Nested",
+ });
+ const result = resolveAt("Search", applications, home);
+ assert.equal(
+ result.message,
+ 'Connected browser is "Search", and no matching app was found in /Applications or ~/Applications.'
+ );
+ } finally {
+ rmSync(applications, { recursive: true, force: true });
+ rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("no matching app names the connected browser", () => {
+ const applications = makeRoot();
+ const home = makeRoot();
+ try {
+ makeApp(applications, "Other.app", { executable: "Other" });
+ const result = resolveAt("Search", applications, home);
+ assert.equal(
+ result.message,
+ 'Connected browser is "Search", and no matching app was found in /Applications or ~/Applications.'
+ );
+ } finally {
+ rmSync(applications, { recursive: true, force: true });
+ rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("several matches lists each bundle and does not pick one", () => {
+ const applications = makeRoot();
+ const home = makeRoot();
+ try {
+ const alpha = makeApp(applications, "Alpha.app", {
+ displayName: "Search",
+ executable: "Alpha",
+ });
+ const beta = makeApp(applications, "Beta.app", { bundleName: "Search", executable: "Beta" });
+ makeApp(home, "Search.app", { executable: "FromHome" });
+ const result = resolveAt("Search", applications, home);
+ assert.equal(result.ok, false);
+ assert.match(result.message, /^Connected browser is "Search" and more than one app matches:\n/);
+ assert.ok(result.message.includes(alpha));
+ assert.ok(result.message.includes(beta));
+ assert.equal(result.message.includes(path.join(home, "Search.app")), false);
+ } finally {
+ rmSync(applications, { recursive: true, force: true });
+ rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("a filename match with an unreadable plist reports the plist", () => {
+ const applications = makeRoot();
+ const home = makeRoot();
+ try {
+ const app = makeApp(applications, "Search.app", { plist: false, binary: false });
+ const result = resolveAt("Search", applications, home);
+ assert.equal(result.message, `Matched ${real(app)}, but its Info.plist could not be read.`);
+ } finally {
+ rmSync(applications, { recursive: true, force: true });
+ rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("two matches still win when one filename match has no plist", () => {
+ const applications = makeRoot();
+ const home = makeRoot();
+ try {
+ const named = makeApp(applications, "Search.app", { plist: false, binary: false });
+ const other = makeApp(applications, "Other.app", {
+ displayName: "Search",
+ executable: "Other",
+ });
+ const result = resolveAt("Search", applications, home);
+ assert.match(result.message, /more than one app matches:/);
+ assert.ok(result.message.includes(named));
+ assert.ok(result.message.includes(other));
+ assert.equal(result.message.includes("Info.plist"), false);
+ } finally {
+ rmSync(applications, { recursive: true, force: true });
+ rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("a missing or unsafe CFBundleExecutable is rejected", () => {
+ const applications = makeRoot();
+ const home = makeRoot();
+ try {
+ const missing = makeApp(applications, "Search.app", { executable: "" });
+ assert.equal(
+ resolveAt("Search", applications, home).message,
+ `Matched ${real(missing)}, but its CFBundleExecutable is missing.`
+ );
+ rmSync(missing, { recursive: true, force: true });
+ const unsafe = makeApp(applications, "Search.app", { executable: "../Outside" });
+ assert.equal(
+ resolveAt("Search", applications, home).message,
+ `Matched ${real(unsafe)}, but its CFBundleExecutable is missing.`
+ );
+ } finally {
+ rmSync(applications, { recursive: true, force: true });
+ rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("a missing executable file is rejected", () => {
+ const applications = makeRoot();
+ const home = makeRoot();
+ try {
+ const app = makeApp(applications, "Search.app", { executable: "Search", binary: false });
+ const result = resolveAt("Search", applications, home);
+ assert.equal(
+ result.message,
+ `Matched ${real(app)}, but ${path.join(real(app), "Contents", "MacOS", "Search")} is not executable.`
+ );
+ } finally {
+ rmSync(applications, { recursive: true, force: true });
+ rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("a non-executable file is rejected", () => {
+ const applications = makeRoot();
+ const home = makeRoot();
+ try {
+ const app = makeApp(applications, "Search.app", { executable: "Search", mode: 0o644 });
+ const result = resolveAt("Search", applications, home);
+ assert.equal(
+ result.message,
+ `Matched ${real(app)}, but ${path.join(real(app), "Contents", "MacOS", "Search")} is not executable.`
+ );
+ } finally {
+ rmSync(applications, { recursive: true, force: true });
+ rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("a non-matching alias listed first does not hide the real bundle", () => {
+ const applications = makeRoot();
+ const home = makeRoot();
+ try {
+ const app = makeApp(applications, "Search.app", {
+ bundleName: "NotSearch",
+ executable: "Search",
+ });
+ symlinkSync(app, path.join(applications, "Alias.app"));
+ const result = resolveAt("Search", applications, home);
+ assert.equal(result.ok, true);
+ assert.equal(result.executablePath, path.join(real(app), "Contents", "MacOS", "Search"));
+ } finally {
+ rmSync(applications, { recursive: true, force: true });
+ rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("symlink and target that are the same bundle count once", () => {
+ const applications = makeRoot();
+ const home = makeRoot();
+ try {
+ const app = makeApp(applications, "Search.app", { bundleName: "Search", executable: "Search" });
+ symlinkSync(app, path.join(applications, "Link.app"));
+ const result = resolveAt("Search", applications, home);
+ assert.equal(result.ok, true);
+ assert.equal(result.executablePath, path.join(real(app), "Contents", "MacOS", "Search"));
+ } finally {
+ rmSync(applications, { recursive: true, force: true });
+ rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("linux resolves nothing and does not read fixtures", () => {
+ const applications = makeRoot();
+ let reads = 0;
+ try {
+ makeApp(applications, "Search.app", { executable: "Search" });
+ const result = resolveBrowserExecutable({
+ name: "Search",
+ platform: "linux",
+ applicationsDir: applications,
+ homeApplicationsDir: applications,
+ readPlist() {
+ reads += 1;
+ throw new Error("should not read");
+ },
+ });
+ assert.deepEqual(result, { ok: false, message: "browser-path resolves macOS apps only." });
+ assert.equal(reads, 0);
+ } finally {
+ rmSync(applications, { recursive: true, force: true });
+ }
+});
+
+test("a missing applications directory is an empty scan", () => {
+ const home = makeRoot();
+ try {
+ makeApp(home, "Search.app", { executable: "Search" });
+ const result = resolveAt("Search", path.join(home, "no-such-applications"), home);
+ assert.equal(result.ok, true);
+ assert.equal(
+ result.executablePath,
+ path.join(real(home), "Search.app", "Contents", "MacOS", "Search")
+ );
+ } finally {
+ rmSync(home, { recursive: true, force: true });
+ }
+});
diff --git a/skills/browser-hand/SKILL.md b/skills/browser-hand/SKILL.md
index eec6df4..67bc72f 100644
--- a/skills/browser-hand/SKILL.md
+++ b/skills/browser-hand/SKILL.md
@@ -1,21 +1,23 @@
---
name: browser-hand
description: >
- Drive the user's already-open, logged-in Chrome — forms, clicks, screenshots,
- evaluate, authenticated admin UIs and real cookies/tabs. Prefer this whenever
- work needs the normal Chrome profile (Gmail, GitHub, OAuth-backed sites, open
- tabs). Do NOT use for disposable headless pages or plain HTTP fetches.
- Trigger phrases: signed-in Chrome, logged-in browser, real session, existing
- tabs, cookies, fill this form on my browser, screenshot my account, use my
- Chrome, authenticated site, without remote debugging.
+ Drive the user's already-open, logged-in browser — the Chromium browser that
+ has the Browser Hand extension connected (Chrome, Search, Edge, Brave, and
+ others). Forms, clicks, screenshots, evaluate, authenticated admin UIs, and
+ real cookies/tabs. Prefer this whenever work needs that browser's normal
+ profile (Gmail, GitHub, OAuth-backed sites, open tabs). Do NOT use for
+ disposable headless pages or plain HTTP fetches.
+ Trigger phrases: signed-in Chrome, logged-in browser, connected browser,
+ Search, real session, existing tabs, cookies, fill this form on my browser,
+ screenshot my account, use my Chrome, authenticated site, without remote debugging.
license: MIT
-compatibility: Requires local Chrome with the Browser Hand extension and relay.
+compatibility: Requires a local Chromium browser with the Browser Hand extension connected to the relay.
category: browser
tags: [browser, chrome, extension, automation, authenticated-sessions, agent]
agents: [claude-code, codex, grok, cursor]
metadata:
author: verygoodplugins
- version: "0.7.2"
+ version: "0.7.3"
resources:
- path: references/setup.md
type: file
@@ -82,6 +84,8 @@ npm run relay # leave running
npm run doctor # want status: tab_bootstrap_works
```
+Package `0.3.0` ships extension manifest **1.1.0**. After pulling, rebuild and reload the unpacked extension so Chrome shows 1.1.0.
+
Switching from old `dev-browser` skill/extension? `npm run setup -- --cleanup-legacy`
Details: `references/setup.md`.
@@ -114,6 +118,7 @@ Full reference: `references/extension-cli.md`.
- Pass **`--quiet`** on snapshot, click, fill, evaluate, and steps. It drops the repeated `mode` / `target` / `url` / `title` wrapper.
- `doctor` returns `status`, `browser: {name, version}`, `tabCount`, and the active tab. It is a health check. Use `tabs` for the inventory. `doctor --verbose` is only for diagnosing target selection. `doctor --browser ` asserts which connected browser you expect and warns on a mismatch.
- **Never open tabs with OS commands.** Do not fall back to `open -a "Google Chrome" `, `start`, `xdg-open`, or any shell launcher — that opens the wrong browser (not the one with the extension) and loses the session. Always use `browser-hand open`/`goto`. If `open` fails, report the error; do not route around it.
+- Anonymous screenshots go through `scripts/anon-screenshot.sh`, which launches the browser connected to the relay.
- To show a **local file** (a generated HTML brief, report, etc.), pass its `file:` URL to `open --url file:///…`. `chrome.tabs.create` cannot navigate to `file:` URLs, so the CLI registers the file with the relay and opens a capability URL (`http://127.0.0.1:/local//`) on a **separate origin** from the relay API. Relative resources (`./style.css`, `img/a.png`, `sub/page.html`) resolve from that URL, and the `file:` URL's query string and `#hash` are kept. Only files inside the document's own directory are served: no `..` escapes, no symlinks out, no dotfiles (`.env`), nothing from a sibling directory. The page can't call the relay or other origins (CSP `connect-src 'self'`), so a report that `fetch`es remote JSON won't work this way. The token expires after 2 hours idle and dies with the relay. If `open` says the relay can't serve local documents, restart it (`browser-hand relay`); don't open the file path directly.
- If `doctor` reports `status: debugger_unavailable`, the connected browser restricts `chrome.debugger` (`doctor.debugger` carries the browser's reason); Browser Hand drives it via the scripting transport. open/snapshot/fill/click/evaluate still work — this is **not** the `path_b_required` (separate debug profile) case. It is reported even though the smoke tab opened, and `doctor` still exits non-zero because only `tab_bootstrap_works` is the healthy status; full-CDP methods such as `Page.navigate` are rejected on these tabs.
- To work on the tab the user is looking at, run `snapshot` (no flags) or `tabs` first. If several windows each have an active tab, pass `--query`.
diff --git a/skills/browser-hand/references/remote-debug-fallback.md b/skills/browser-hand/references/remote-debug-fallback.md
index e2ee0b8..2776f6e 100644
--- a/skills/browser-hand/references/remote-debug-fallback.md
+++ b/skills/browser-hand/references/remote-debug-fallback.md
@@ -1,6 +1,6 @@
# Headless and remote-debug fallback
-Use this only when you need a **disposable** browser or CI — not the user’s everyday logged-in Chrome.
+Use this only when you need a **disposable** browser or CI — anonymous captures, not the user’s everyday logged-in browser.
For real sessions, use the extension CLI (`extension-cli.md`).
@@ -23,13 +23,15 @@ EOF
## Connect to a dedicated debug profile
-Quit Chrome fully first (macOS single-instance ignores flags otherwise). Then:
+Resolve the executable while the extension is still connected. `browser-path` refuses to answer once that browser quits and the relay drops. Then quit the connected browser fully (macOS single-instance ignores flags otherwise) and launch that executable with a user-data directory that is not the everyday profile:
```bash
-"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
+EXE="$(browser-hand browser-path | node -e 'let raw="";process.stdin.on("data",(c)=>raw+=c);process.stdin.on("end",()=>{process.stdout.write(JSON.parse(raw).executablePath)})')"
+# Quit the connected browser here, then:
+"$EXE" \
--remote-debugging-port=9222 \
--remote-allow-origins=http://127.0.0.1:9222 \
- --user-data-dir="$HOME/.browser-hand/chrome-debug"
+ --user-data-dir="$HOME/.browser-hand/debug-profile"
```
```bash
@@ -39,7 +41,7 @@ console.log(JSON.stringify(tabs, null, 2));
EOF
```
-Log into sites **once** in that profile if you need auth; do not expect it to share cookies with daily Chrome.
+Log into sites **once** in that profile if you need auth; do not expect it to share cookies with the everyday browser. The everyday profile still does not get a debugging port.
## When agents may use this
diff --git a/skills/browser-hand/references/setup.md b/skills/browser-hand/references/setup.md
index 257b468..b9b2fda 100644
--- a/skills/browser-hand/references/setup.md
+++ b/skills/browser-hand/references/setup.md
@@ -4,7 +4,7 @@ One-time setup for Browser Hand on a developer machine.
## Requirements
-- Google Chrome with your normal profile (cookies, logins, open tabs)
+- A Chromium browser (Chrome, Search, Edge, Brave, or another) with the Browser Hand extension enabled in that browser, and your normal profile (cookies, logins, open tabs)
- Node.js ≥ 18
- This repository cloned locally (for the runtime)
- Optional: [AutoVault](https://github.com/autoworks-ai/autovault) for skill install/sync
diff --git a/skills/browser-hand/scripts/anon-screenshot.sh b/skills/browser-hand/scripts/anon-screenshot.sh
index 93e572c..b63f1bb 100644
--- a/skills/browser-hand/scripts/anon-screenshot.sh
+++ b/skills/browser-hand/scripts/anon-screenshot.sh
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# anon-screenshot.sh — screenshot a public / anonymous URL via a throwaway
-# Chrome profile attached with `dev-browser --connect`.
+# profile of the browser connected to Browser Hand, attached with
+# `dev-browser --connect`.
#
# Unauthenticated captures only (localhost, public pages). A throwaway profile
# has no cookies — for signed-in work use the browser-hand extension CLI.
@@ -12,7 +13,7 @@
# Example: anon-screenshot.sh http://127.0.0.1:8788/cloud cloud.png 1440x1000
#
# Output: PNG under ~/.browser-hand/tmp/ (or ~/.dev-browser/tmp/); path on stdout.
-# Env: CHROME_BIN overrides the Chrome binary path.
+# The executable comes from `browser-hand browser-path`. CHROME_BIN is ignored.
set -euo pipefail
URL="${1:?usage: anon-screenshot.sh [output_name.png] [WxH]}"
@@ -21,9 +22,30 @@ VIEWPORT="${3:-1440x1000}"
WIDTH="${VIEWPORT%x*}"
HEIGHT="${VIEWPORT#*x}"
-CHROME="${CHROME_BIN:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome}"
-[ -x "$CHROME" ] || { echo "Chrome binary not found/executable: $CHROME (set CHROME_BIN)" >&2; exit 1; }
+command -v browser-hand >/dev/null 2>&1 || { echo "browser-hand is not on PATH" >&2; exit 1; }
command -v dev-browser >/dev/null || { echo "dev-browser not on PATH" >&2; exit 1; }
+if [ -n "${CHROME_BIN:-}" ]; then
+ echo "anon-screenshot.sh ignores CHROME_BIN and launches the browser connected to Browser Hand." >&2
+fi
+
+json="$(browser-hand browser-path)"
+BROWSER="$(printf '%s' "$json" | node -e '
+let raw = "";
+process.stdin.on("data", (chunk) => { raw += chunk; });
+process.stdin.on("end", () => {
+ let parsed;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ process.exit(1);
+ }
+ if (!parsed || typeof parsed.executablePath !== "string" || parsed.executablePath.length === 0) {
+ process.exit(1);
+ }
+ process.stdout.write(parsed.executablePath);
+});
+')"
+[ -x "$BROWSER" ] || { echo "Connected browser is not executable: $BROWSER" >&2; exit 1; }
# Pick a free debug port away from the relay (:9333) and the user's Chrome (:9222).
PORT=""
@@ -45,7 +67,7 @@ cleanup() {
}
trap cleanup EXIT
-"$CHROME" \
+"$BROWSER" \
--headless=new \
--remote-debugging-port="$PORT" \
--remote-allow-origins="http://127.0.0.1:$PORT" \