From a8422dfb997a3efc1e8b03cb9011348eeb5a7f36 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 11 Nov 2026 07:34:48 +0100 Subject: [PATCH 1/2] feat(cli): launch throwaway browsers from the one connected to the relay Search reports itself as Chrome in the extension user-agent, so browser-path and doctor prefer the name in the debugger stub and resolve that macOS app. --- AGENTS.md | 2 +- CHANGELOG.md | 2 + cli-js/package.json | 2 +- cli-js/src/browser-path.js | 196 ++++++++ cli-js/src/cli.js | 32 +- cli-js/src/tool.js | 111 +++-- cli-js/test/browser-path.test.js | 456 ++++++++++++++++++ skills/browser-hand/SKILL.md | 23 +- .../references/remote-debug-fallback.md | 11 +- skills/browser-hand/references/setup.md | 2 +- .../browser-hand/scripts/anon-screenshot.sh | 32 +- 11 files changed, 800 insertions(+), 69 deletions(-) create mode 100644 cli-js/src/browser-path.js create mode 100644 cli-js/test/browser-path.test.js 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..778027d --- /dev/null +++ b/cli-js/src/browser-path.js @@ -0,0 +1,196 @@ +/** + * 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)); +} + +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; + seen.add(bundlePath); + 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; + 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..602ca63 --- /dev/null +++ b/cli-js/test/browser-path.test.js @@ -0,0 +1,456 @@ +/** + * 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("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..ed5d9d8 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,14 @@ EOF ## Connect to a dedicated debug profile -Quit Chrome fully first (macOS single-instance ignores flags otherwise). Then: +Quit the connected browser fully first (macOS single-instance ignores flags otherwise). Then launch the executable `browser-hand browser-path` prints, 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)})')" +"$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 +40,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" \ From 7894c2895ddc9a0aed625ca3cabe41073a239c13 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 11 Nov 2026 07:37:20 +0100 Subject: [PATCH 2/2] fix(cli): keep a non-matching app alias from hiding the real bundle browser-path now records a bundle only after its name matches, and the remote-debug recipe resolves the executable before the connected browser is quit. --- cli-js/src/browser-path.js | 7 +++++-- cli-js/test/browser-path.test.js | 18 ++++++++++++++++++ .../references/remote-debug-fallback.md | 3 ++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/cli-js/src/browser-path.js b/cli-js/src/browser-path.js index 778027d..6ad8071 100644 --- a/cli-js/src/browser-path.js +++ b/cli-js/src/browser-path.js @@ -90,7 +90,8 @@ function listAppBundles(dir) { } return entries .filter((entry) => entry.name.toLowerCase().endsWith(".app")) - .map((entry) => path.resolve(dir, entry.name)); + .map((entry) => path.resolve(dir, entry.name)) + .sort((left, right) => left.localeCompare(right)); } function sameName(field, name) { @@ -111,7 +112,6 @@ function collectMatches(dir, name, readPlist) { for (const listed of listAppBundles(dir)) { const bundlePath = bundleRealPath(listed); if (seen.has(bundlePath)) continue; - seen.add(bundlePath); const filename = path.basename(listed).replace(/\.app$/i, ""); const read = readPlist(listed); const plist = read.ok ? read.plist : null; @@ -120,6 +120,9 @@ function collectMatches(dir, name, readPlist) { 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; diff --git a/cli-js/test/browser-path.test.js b/cli-js/test/browser-path.test.js index 602ca63..60f4903 100644 --- a/cli-js/test/browser-path.test.js +++ b/cli-js/test/browser-path.test.js @@ -403,6 +403,24 @@ test("a non-executable file is rejected", () => { } }); +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(); diff --git a/skills/browser-hand/references/remote-debug-fallback.md b/skills/browser-hand/references/remote-debug-fallback.md index ed5d9d8..2776f6e 100644 --- a/skills/browser-hand/references/remote-debug-fallback.md +++ b/skills/browser-hand/references/remote-debug-fallback.md @@ -23,10 +23,11 @@ EOF ## Connect to a dedicated debug profile -Quit the connected browser fully first (macOS single-instance ignores flags otherwise). Then launch the executable `browser-hand browser-path` prints, with a user-data directory that is not the everyday profile: +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 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 \