Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion cli-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
199 changes: 199 additions & 0 deletions cli-js/src/browser-path.js
Original file line number Diff line number Diff line change
@@ -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 };
}
32 changes: 31 additions & 1 deletion cli-js/src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -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"}]'
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading