From c14709b8b8c16558e7d1bdc0d726c1b4566d1a58 Mon Sep 17 00:00:00 2001 From: "autohub-claude[bot]" <320328484+autohub-claude[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:15:32 +0100 Subject: [PATCH 1/4] feat: target the connected browser, not Chrome by default (#29) Browser Hand assumed Google Chrome across the stack. When the extension runs in a non-Chrome Chromium browser (e.g. "Search"), tab bootstrap failed with HTTP 500 ("debugger.attach isn't available in Search"), doctor recommended the wrong fix (path_b), file: URLs failed in tabs.create, and the skill text pushed agents to fall back to `open -a "Google Chrome"`. Implements the four asks from #29: 1. Detect the host browser. `extension/utils/browser-info.ts` reads the product brand/version from navigator.userAgentData (preferring the real product over the shared "Chromium" engine brand and the GREASE placeholder), falling back to a UA-string parse. The extension registers it with the relay on connect; the relay only ever holds one extension connection, so the connected browser is the current target. `--browser` lets the caller assert which browser they expect and warns on mismatch. 2. Report it. The relay exposes `browser: {name, version}` on the health endpoint; `doctor` surfaces it. `doctor` now distinguishes `debugger_unavailable` (this browser restricts chrome.debugger) from `path_b_required` (needs a separate debug profile). 3. Fall back to scripting. When chrome.debugger.attach is unavailable, TabManager marks the browser debugger-unavailable and switches the tab to the scripting transport instead of throwing, so `open` still creates the tab via chrome.tabs.create and continues rather than returning HTTP 500. 4. Local files. `file:` URLs fail in chrome.tabs.create, so the CLI rewrites them to the relay's new `/local-file` route, which reads the file and serves it over http with a content type. Docs: SKILL.md now says "your browser with the Browser Hand extension" (not Chrome), documents the file: and debugger_unavailable paths, and adds a rule to never open tabs with OS commands as a fallback. Tests: browser-info detection, debugger_unavailable classification + doctor distinction, file: URL rewrite, local-file serving, and the TabManager scripting fallback. Closes #29 Co-Authored-By: Claude Opus 4.8 --- cli-js/src/cli.js | 11 ++- cli-js/src/tool.js | 57 +++++++++++++++ cli-js/test/doctor-browser.test.js | 65 +++++++++++++++++ extension/__tests__/TabManager.test.ts | 36 ++++++++++ extension/__tests__/browser-info.test.ts | 81 +++++++++++++++++++++ extension/entrypoints/background.ts | 17 ++++- extension/services/TabManager.ts | 35 +++++++++ extension/utils/browser-info.ts | 92 ++++++++++++++++++++++++ relay/src/local-file.test.ts | 43 +++++++++++ relay/src/local-file.ts | 65 +++++++++++++++++ relay/src/relay.ts | 46 +++++++++++- skills/browser-hand/SKILL.md | 7 +- 12 files changed, 548 insertions(+), 7 deletions(-) create mode 100644 cli-js/test/doctor-browser.test.js create mode 100644 extension/__tests__/browser-info.test.ts create mode 100644 extension/utils/browser-info.ts create mode 100644 relay/src/local-file.test.ts create mode 100644 relay/src/local-file.ts diff --git a/cli-js/src/cli.js b/cli-js/src/cli.js index ccb50ca..ddac09f 100644 --- a/cli-js/src/cli.js +++ b/cli-js/src/cli.js @@ -41,7 +41,7 @@ const OP_ALIASES = { list: "tabs", }; -const HELP = `browser-hand — drive your real Chrome via the Browser Hand extension relay. +const HELP = `browser-hand — drive your real browser (whichever has the Browser Hand extension) via the relay. Operations: snapshot [--scope auto|full|nav] Dialog + main by default (role, name, value, ref). full adds nav and text. @@ -57,11 +57,13 @@ Operations: goto --url Navigate the active tab. open --url [--page-name ] [--group-title ] Create/get a named relay tab and navigate it. - --group-title names the Chrome tab group for this session. + --group-title names the browser tab group for this session. + file: URLs are served through the relay (tabs.create rejects file:). focus [--focus window|tab] Bring a tab forward for human input (one-shot; default window). --reason Required for good audit (e.g. 2fa, confirm-publish). tabs [--query ] List every http(s) tab. Unique-active first; last-focused when stamped. - doctor [--verbose] Health check: status, tabCount, and the active tab. Not a tab list. + doctor [--verbose] [--browser ] Health check: status, browser:{name,version}, tabCount, active tab. + --browser asserts which connected browser you expect. 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 — @@ -263,6 +265,9 @@ function buildHandlerInput(operation, args) { break; case "doctor": if (args.verbose === true) input.verbose = true; + if (typeof args.browser === "string" && args.browser.trim()) { + input.browser = args.browser.trim(); + } break; case "tabs": if (typeof args.query === "string" && args.query.trim()) { diff --git a/cli-js/src/tool.js b/cli-js/src/tool.js index f341ff5..22caf20 100644 --- a/cli-js/src/tool.js +++ b/cli-js/src/tool.js @@ -500,6 +500,25 @@ function relayPageEndpoint(name) { return `${CURRENT_RELAY_URL}/pages${name ? `/${encodeURIComponent(name)}` : ""}`; } +/** + * chrome.tabs.create rejects file: URLs ("Cannot navigate to a file: URL"), so + * rewrite them to the relay's /local-file route. The relay reads the file and + * serves it over http, which the extension can open like any other page. + * Non-file URLs pass through unchanged. + */ +export function rewriteLocalFileUrl(url, relayBase = CURRENT_RELAY_URL) { + if (typeof url !== "string" || !/^file:\/\//i.test(url)) { + return url; + } + let filePath; + try { + filePath = fileURLToPath(url); + } catch { + return url; + } + return `${relayBase}/local-file?path=${encodeURIComponent(filePath)}`; +} + async function lookupNamedRelayPage(pageName, { timeoutMs = 5000 } = {}) { if (!pageName || typeof pageName !== "string") { return null; @@ -763,6 +782,16 @@ export async function resolveNamedPageInfo({ return lookupPage(pageName); } +/** + * Detect "chrome.debugger unavailable in this browser" errors (e.g. the + * "Search" Chromium fork reports `debugger.attach isn't available in Search`). + * Distinct from the path_b separate-debug-profile case. + */ +export function isDebuggerUnavailableError(error) { + const message = String(error || ""); + return /\bdebugger\b/i.test(message) && /(isn['’]t|is not|not)\s+available/i.test(message); +} + export function classifyDevBrowserDoctor({ relayInfo, recentExtensionDisconnected = false, @@ -801,6 +830,15 @@ export function classifyDevBrowserDoctor({ "Reload the Browser Hand extension; the extension socket reconnected during tab creation, so retrying browser work will be flaky until the worker is stable.", }; } + if (isDebuggerUnavailableError(smoke?.error)) { + // The host browser restricts chrome.debugger (e.g. the "Search" fork). This + // is a browser capability gap, not the separate-debug-profile (path_b) case. + return { + status: "debugger_unavailable", + action: + "This browser restricts chrome.debugger, so Browser Hand drives tabs via the scripting transport. Most work (open, snapshot, fill, click, evaluate) still runs; if a command needs full CDP, use a browser that allows chrome.debugger.", + }; + } if (targetCount === 0) { return { status: "target_registry_empty", @@ -1368,12 +1406,24 @@ async function runCurrentDoctor(timeoutMs, input = {}) { extensionConnected: relayInfo?.extensionConnected === true, mode: relayInfo?.mode || null, }, + // 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, pages, targetCount: 0, smoke: null, recentExtensionDisconnected, }; + // Optional --browser selector: warn when the connected browser does not match. + if (typeof input.browser === "string" && input.browser.trim()) { + const wanted = input.browser.trim(); + const connectedName = result.browser?.name || ""; + if (connectedName && !connectedName.toLowerCase().includes(wanted.toLowerCase())) { + result.browserMismatch = `Requested --browser "${wanted}" but the connected extension is in "${connectedName}".`; + } + } + if (relayInfo?.wsEndpoint && relayInfo.extensionConnected === true) { const cdp = new CdpClient(relayInfo.wsEndpoint); const smokeName = `autohub-doctor-${Date.now()}`; @@ -1477,6 +1527,13 @@ async function runCurrentOperation(input, timeoutMs) { return await runCurrentTabs(input, timeoutMs); } + if ((operation === "open" || operation === "goto") && typeof input.url === "string") { + const rewritten = rewriteLocalFileUrl(input.url); + if (rewritten !== input.url) { + input = { ...input, url: rewritten }; + } + } + if (input.query && !(input.target && input.target.query)) { input = { ...input, diff --git a/cli-js/test/doctor-browser.test.js b/cli-js/test/doctor-browser.test.js new file mode 100644 index 0000000..bbdd485 --- /dev/null +++ b/cli-js/test/doctor-browser.test.js @@ -0,0 +1,65 @@ +/** + * Browser-aware doctor + local-file handling (issue #29). + * + * Browser Hand should target whichever browser has the extension connected, + * distinguish "chrome.debugger unavailable in this browser" from the separate + * debug-profile (path_b) case, and serve file: URLs through the relay. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + classifyDevBrowserDoctor, + isDebuggerUnavailableError, + rewriteLocalFileUrl, +} from "../src/tool.js"; + +test("isDebuggerUnavailableError matches the Search fork message", () => { + assert.equal(isDebuggerUnavailableError("debugger.attach isn't available in Search"), true); + assert.equal(isDebuggerUnavailableError("debugger.attach is not available in Foo"), true); + assert.equal(isDebuggerUnavailableError("Another debugger is already attached"), false); + assert.equal(isDebuggerUnavailableError(""), false); +}); + +test("doctor distinguishes debugger_unavailable from path_b_required", () => { + const unavailable = classifyDevBrowserDoctor({ + relayInfo: { extensionConnected: true }, + targetCount: 5, + smoke: { success: false, error: "HTTP 500: debugger.attach isn't available in Search" }, + }); + assert.equal(unavailable.status, "debugger_unavailable"); + assert.match(unavailable.action, /scripting transport/i); + + const pathB = classifyDevBrowserDoctor({ + relayInfo: { extensionConnected: true }, + targetCount: 5, + smoke: { success: false, error: "Refusing to keep an uncontrollable tab: about:blank" }, + }); + assert.equal(pathB.status, "path_b_required"); +}); + +test("doctor stays healthy when the smoke tab bootstraps", () => { + const healthy = classifyDevBrowserDoctor({ + relayInfo: { extensionConnected: true }, + targetCount: 5, + smoke: { success: true }, + }); + assert.equal(healthy.status, "tab_bootstrap_works"); +}); + +test("rewriteLocalFileUrl routes file: URLs through the relay", () => { + const out = rewriteLocalFileUrl("file:///Users/me/brief.html", "http://127.0.0.1:9333"); + assert.equal(out, "http://127.0.0.1:9333/local-file?path=%2FUsers%2Fme%2Fbrief.html"); +}); + +test("rewriteLocalFileUrl leaves http(s) URLs untouched", () => { + assert.equal( + rewriteLocalFileUrl("https://example.com/x", "http://127.0.0.1:9333"), + "https://example.com/x" + ); + assert.equal( + rewriteLocalFileUrl("http://127.0.0.1:8799/", "http://127.0.0.1:9333"), + "http://127.0.0.1:8799/" + ); +}); diff --git a/extension/__tests__/TabManager.test.ts b/extension/__tests__/TabManager.test.ts index 8229e8e..27bec6b 100644 --- a/extension/__tests__/TabManager.test.ts +++ b/extension/__tests__/TabManager.test.ts @@ -189,6 +189,42 @@ describe("TabManager", () => { }); }); + describe("debugger unavailable in this browser", () => { + it("falls back to scripting instead of throwing when attach is unavailable", async () => { + mockTabsGet.mockResolvedValue( + chromeTab({ id: 77, url: "https://example.com/", status: "complete" }) + ); + mockDebuggerDetach.mockResolvedValue(undefined); + // The "Search" Chromium fork rejects attach entirely. + mockDebuggerAttach.mockRejectedValue(new Error("debugger.attach isn't available in Search")); + tabManager.set(77, { + sessionId: "session-77", + targetId: "target-77", + state: "connected", + debuggerAttached: false, + targetInfo: { + targetId: "target-77", + type: "page", + title: "Example", + url: "https://example.com/", + }, + }); + + await expect(tabManager.ensureDebuggerAttached(77)).resolves.toBeUndefined(); + expect(tabManager.usesScriptingFallback(77)).toBe(true); + expect(tabManager.isDebuggerUnavailable()).toBe(true); + }); + + it("classifies the unavailable error", () => { + expect( + tabManager.isDebuggerUnavailableError("debugger.attach isn't available in Search") + ).toBe(true); + expect(tabManager.isDebuggerUnavailableError("Another debugger is already attached")).toBe( + false + ); + }); + }); + describe("detach", () => { it("should send detached event and remove tab", () => { tabManager.set(123, { diff --git a/extension/__tests__/browser-info.test.ts b/extension/__tests__/browser-info.test.ts new file mode 100644 index 0000000..b415236 --- /dev/null +++ b/extension/__tests__/browser-info.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { pickBrand, parseUserAgent, detectHostBrowser } from "../utils/browser-info"; + +describe("pickBrand", () => { + it("prefers the product brand over Chromium and the GREASE placeholder", () => { + const brands = [ + { brand: "Not=A?Brand", version: "99" }, + { brand: "Chromium", version: "140" }, + { brand: "Search", version: "140.1.2.3" }, + ]; + expect(pickBrand(brands)).toEqual({ brand: "Search", version: "140.1.2.3" }); + }); + + it("falls back to Chromium when no product brand is present", () => { + const brands = [ + { brand: "Not/A)Brand", version: "8" }, + { brand: "Chromium", version: "140" }, + ]; + expect(pickBrand(brands)).toEqual({ brand: "Chromium", version: "140" }); + }); + + it("returns null for empty input", () => { + expect(pickBrand([])).toBeNull(); + expect(pickBrand(undefined)).toBeNull(); + }); +}); + +describe("parseUserAgent", () => { + it("recognizes Edge before generic Chrome", () => { + const ua = + "Mozilla/5.0 (Macintosh) AppleWebKit/537.36 (KHTML) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.1"; + expect(parseUserAgent(ua)).toEqual({ name: "Microsoft Edge", version: "140.0.1" }); + }); + + it("falls back to Google Chrome", () => { + const ua = "Mozilla/5.0 AppleWebKit/537.36 Chrome/141.0.0.0 Safari/537.36"; + expect(parseUserAgent(ua)).toEqual({ name: "Google Chrome", version: "141.0.0.0" }); + }); + + it("returns Unknown for an unrecognized UA", () => { + expect(parseUserAgent("Lynx/2.9")).toEqual({ name: "Unknown", version: "" }); + }); +}); + +describe("detectHostBrowser", () => { + it("reads the product brand from userAgentData high-entropy values", async () => { + const nav = { + userAgentData: { + brands: [{ brand: "Chromium", version: "140" }], + getHighEntropyValues: async () => ({ + fullVersionList: [ + { brand: "Chromium", version: "140.0.7000.1" }, + { brand: "Search", version: "140.0.7000.1" }, + ], + }), + }, + }; + expect(await detectHostBrowser(nav)).toEqual({ name: "Search", version: "140.0.7000.1" }); + }); + + it("falls back to the UA string when userAgentData is absent", async () => { + const nav = { userAgent: "Mozilla/5.0 Chrome/139.0.0.0 Safari/537.36" }; + expect(await detectHostBrowser(nav)).toEqual({ name: "Google Chrome", version: "139.0.0.0" }); + }); + + it("keeps the low-entropy brands when getHighEntropyValues throws", async () => { + const nav = { + userAgentData: { + brands: [ + { brand: "Not.A/Brand", version: "24" }, + { brand: "Brave", version: "131" }, + ], + getHighEntropyValues: async () => { + throw new Error("rejected"); + }, + }, + userAgent: "Mozilla/5.0 Chrome/131.0.0.0 Safari/537.36", + }; + expect(await detectHostBrowser(nav)).toEqual({ name: "Brave", version: "131" }); + }); +}); diff --git a/extension/entrypoints/background.ts b/extension/entrypoints/background.ts index 5a90582..3622269 100644 --- a/extension/entrypoints/background.ts +++ b/extension/entrypoints/background.ts @@ -10,6 +10,7 @@ import { TabManager } from "../services/TabManager"; import { ConnectionManager } from "../services/ConnectionManager"; import { CDPRouter } from "../services/CDPRouter"; import { StateManager } from "../services/StateManager"; +import { detectHostBrowser } from "../utils/browser-info"; import type { PopupMessage, StateResponse } from "../utils/types"; export default defineBackground(() => { @@ -39,11 +40,25 @@ export default defineBackground(() => { clearFocusOverride: () => stateManager.clearFocusOverride(), }); + // Tell the relay which browser is driving this connection, so doctor/tabs + // report browser:{name,version} and agents never assume Chrome. + async function registerHostBrowser(): Promise { + try { + const browser = await detectHostBrowser(); + connectionManager.send({ method: "register", params: { browser } }); + } catch (error) { + logger.debug("Error detecting host browser:", error); + } + } + // Create connection manager connectionManager = new ConnectionManager({ logger, onMessage: (msg) => cdpRouter.handleCommand(msg), - onConnect: () => tabManager.syncExistingTabs(), + onConnect: async () => { + await registerHostBrowser(); + await tabManager.syncExistingTabs(); + }, onDisconnect: () => tabManager.detachAll(), }); diff --git a/extension/services/TabManager.ts b/extension/services/TabManager.ts index 7772614..774e66f 100644 --- a/extension/services/TabManager.ts +++ b/extension/services/TabManager.ts @@ -298,6 +298,31 @@ export class TabManager { ); } + /** + * Some Chromium forks (e.g. the "Search" browser) restrict the + * chrome.debugger API entirely: attach throws "debugger.attach isn't + * available in ". That is a browser capability gap, not a + * password-manager wedge — we switch the tab to the scripting transport and + * remember the browser lacks the debugger so doctor can report it. + */ + isDebuggerUnavailableError(message: string): boolean { + return /\bdebugger\b/i.test(message) && /(isn['’]t|is not|not)\s+available/i.test(message); + } + + private debuggerUnavailable = false; + + /** Whether this host browser has reported chrome.debugger as unavailable. */ + isDebuggerUnavailable(): boolean { + return this.debuggerUnavailable; + } + + private markDebuggerUnavailable(reason: string): void { + if (!this.debuggerUnavailable) { + this.logger.log("chrome.debugger unavailable in this browser:", reason); + } + this.debuggerUnavailable = true; + } + /** * Ensure the Chrome debugger API is attached for CDP commands. * On password-manager wedge, falls back to scripting transport. @@ -353,6 +378,11 @@ export class TabManager { const message = error instanceof Error ? error.message : String(error); if (/already attached/i.test(message)) { // ok + } else if (this.isDebuggerUnavailableError(message)) { + // This browser has no chrome.debugger — stay on scripting for good. + this.markDebuggerUnavailable(message); + this.markScriptingFallback(tabId, message); + return; } else if (this.isDebuggerWedgeError(message)) { await this.sleep(300); try { @@ -368,6 +398,11 @@ export class TabManager { } } catch (error) { const message = error instanceof Error ? error.message : String(error); + if (this.isDebuggerUnavailableError(message)) { + this.markDebuggerUnavailable(message); + this.markScriptingFallback(tabId, message); + return; + } if (this.isDebuggerWedgeError(message)) { this.markScriptingFallback(tabId, message); return; diff --git a/extension/utils/browser-info.ts b/extension/utils/browser-info.ts new file mode 100644 index 0000000..defe89a --- /dev/null +++ b/extension/utils/browser-info.ts @@ -0,0 +1,92 @@ +/** + * Detect the host browser (name + version) the extension is running in. + * + * Browser Hand targets whichever Chromium browser currently has the extension + * connected — not Chrome by assumption. `navigator.userAgentData` exposes the + * product brand ("Google Chrome", "Microsoft Edge", "Brave", "Search", ...) + * separately from the shared "Chromium" engine brand, so agents can report the + * real host browser and never fall back to `open -a "Google Chrome"`. + */ + +export interface HostBrowser { + name: string; + version: string; +} + +interface UaBrand { + brand: string; + version: string; +} + +interface UaDataLike { + brands?: UaBrand[]; + getHighEntropyValues?: (hints: string[]) => Promise<{ fullVersionList?: UaBrand[] }>; +} + +interface NavigatorLike { + userAgentData?: UaDataLike; + userAgent?: string; +} + +/** + * Engine / GREASE placeholder brands that are never the product name. The + * "Not A;Brand" GREASE entry uses arbitrary punctuation between the words + * (e.g. "Not=A?Brand", "Not/A)Brand"), so match the words, not the separators. + */ +function isGenericBrand(brand: string): boolean { + const b = brand.trim(); + return /^(chromium|google chrome crashpad)$/i.test(b) || /not\W*a\W*brand/i.test(b); +} + +/** + * Pick the product brand (Search, Microsoft Edge, Brave, ...) in preference to + * the shared "Chromium" engine brand and the "Not A;Brand" GREASE placeholder. + */ +export function pickBrand(brands: UaBrand[] | undefined): UaBrand | null { + if (!Array.isArray(brands) || brands.length === 0) return null; + const product = brands.find((b) => b?.brand && !isGenericBrand(b.brand)); + if (product) return product; + const chromium = brands.find((b) => /^chromium$/i.test((b?.brand || "").trim())); + return chromium ?? brands[0]; +} + +/** Last-resort parse of the UA string when userAgentData is unavailable. */ +export function parseUserAgent(ua: string | undefined): HostBrowser { + const text = ua || ""; + // Order matters: more specific forks before generic Chrome. + const patterns: Array<[RegExp, string]> = [ + [/Edg\/([\d.]+)/, "Microsoft Edge"], + [/OPR\/([\d.]+)/, "Opera"], + [/Brave\/([\d.]+)/, "Brave"], + [/Chrome\/([\d.]+)/, "Google Chrome"], + ]; + for (const [re, name] of patterns) { + const m = text.match(re); + if (m) return { name, version: m[1] }; + } + return { name: "Unknown", version: "" }; +} + +export async function detectHostBrowser( + nav: NavigatorLike = typeof navigator !== "undefined" ? navigator : {} +): Promise { + const uaData = nav?.userAgentData; + if (uaData) { + let brands = uaData.brands; + if (uaData.getHighEntropyValues) { + try { + const high = await uaData.getHighEntropyValues(["fullVersionList"]); + if (Array.isArray(high?.fullVersionList) && high.fullVersionList.length > 0) { + brands = high.fullVersionList; + } + } catch { + // Keep the low-entropy brands when the high-entropy query is refused. + } + } + const picked = pickBrand(brands); + if (picked?.brand) { + return { name: picked.brand.trim(), version: (picked.version || "").trim() }; + } + } + return parseUserAgent(nav?.userAgent); +} diff --git a/relay/src/local-file.test.ts b/relay/src/local-file.test.ts new file mode 100644 index 0000000..a60fc5c --- /dev/null +++ b/relay/src/local-file.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { contentTypeForPath, readLocalFile } from "./local-file.ts"; + +test("contentTypeForPath maps known extensions", () => { + assert.equal(contentTypeForPath("/tmp/brief.html"), "text/html; charset=utf-8"); + assert.equal(contentTypeForPath("/tmp/report.HTML"), "text/html; charset=utf-8"); + assert.equal(contentTypeForPath("/a/b/data.json"), "application/json; charset=utf-8"); + assert.equal(contentTypeForPath("/a/logo.svg"), "image/svg+xml"); + assert.equal(contentTypeForPath("/a/shot.png"), "image/png"); + assert.equal(contentTypeForPath("/a/doc.pdf"), "application/pdf"); +}); + +test("contentTypeForPath defaults to octet-stream", () => { + assert.equal(contentTypeForPath("/tmp/archive.bin"), "application/octet-stream"); + assert.equal(contentTypeForPath("/tmp/noext"), "application/octet-stream"); +}); + +test("readLocalFile serves an existing file with its content type", async () => { + const dir = await mkdtemp(join(tmpdir(), "bh-localfile-")); + const filePath = join(dir, "brief.html"); + await writeFile(filePath, "

hi

", "utf8"); + try { + const result = await readLocalFile(filePath); + assert.equal(result.status, 200); + assert.equal(result.contentType, "text/html; charset=utf-8"); + assert.equal(Buffer.from(result.body).toString("utf8"), "

hi

"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("readLocalFile reports 400 for a missing path and 404 for a missing file", async () => { + const missingPath = await readLocalFile(undefined); + assert.equal(missingPath.status, 400); + + const missingFile = await readLocalFile(join(tmpdir(), "bh-does-not-exist-xyz.html")); + assert.equal(missingFile.status, 404); +}); diff --git a/relay/src/local-file.ts b/relay/src/local-file.ts new file mode 100644 index 0000000..985d8a7 --- /dev/null +++ b/relay/src/local-file.ts @@ -0,0 +1,65 @@ +/** + * Serve a local file through the relay. + * + * `file:` URLs fail in chrome.tabs.create ("Cannot navigate to a file: URL"), + * so the CLI rewrites them to the relay's /local-file route and this reads the + * file back over http. Localhost-only — same trust boundary as the relay itself. + */ + +import { readFile, stat } from "node:fs/promises"; +import { extname, resolve as resolvePath } from "node:path"; + +const LOCAL_FILE_CONTENT_TYPES: Record = { + ".html": "text/html; charset=utf-8", + ".htm": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".txt": "text/plain; charset=utf-8", + ".md": "text/markdown; charset=utf-8", + ".xml": "application/xml; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".pdf": "application/pdf", +}; + +/** Content-Type for a local file served through the relay. */ +export function contentTypeForPath(filePath: string): string { + return LOCAL_FILE_CONTENT_TYPES[extname(filePath).toLowerCase()] || "application/octet-stream"; +} + +export interface LocalFileResult { + status: number; + contentType?: string; + /** Plain ArrayBuffer so it drops straight into a web Response body. */ + body?: ArrayBuffer; + error?: string; +} + +/** Read a local file for serving. Returns a status + body or an error payload. */ +export async function readLocalFile(requested: string | undefined): Promise { + if (!requested) { + return { status: 400, error: "path query parameter is required" }; + } + const abs = resolvePath(requested); + try { + const info = await stat(abs); + if (!info.isFile()) { + return { status: 404, error: `Not a regular file: ${abs}` }; + } + const data = await readFile(abs); + const body = data.buffer.slice( + data.byteOffset, + data.byteOffset + data.byteLength + ) as ArrayBuffer; + return { status: 200, contentType: contentTypeForPath(abs), body }; + } catch { + return { status: 404, error: `Cannot read file: ${abs}` }; + } +} diff --git a/relay/src/relay.ts b/relay/src/relay.ts index ed5c4c7..51e094d 100644 --- a/relay/src/relay.ts +++ b/relay/src/relay.ts @@ -8,6 +8,13 @@ import { planBlankNavigation, type AdoptTarget, } from "./adopt-existing-tab.js"; +import { readLocalFile } from "./local-file.js"; + +/** Host browser reported by the extension on connect. */ +export interface HostBrowser { + name: string; + version: string; +} export interface RelayOptions { port?: number; @@ -58,7 +65,8 @@ interface ExtensionEventMessage { type ExtensionMessage = | ExtensionResponseMessage | ExtensionEventMessage - | { method: "log"; params: { level: string; args: string[] } }; + | { method: "log"; params: { level: string; args: string[] } } + | { method: "register"; params: { browser?: Partial } }; interface CDPCommand { id: number; @@ -157,6 +165,8 @@ export async function serveRelay(options: RelayOptions = {}): Promise(); const playwrightClients = new Map(); let extensionWs: WSContext | null = null; + /** Browser the connected extension is running in (null until it registers). */ + let extensionBrowser: HostBrowser | null = null; const deferredCdpEvents: CDPEvent[] = []; @@ -551,10 +561,30 @@ export async function serveRelay(options: RelayOptions = {}): Promise { + const result = await readLocalFile(c.req.query("path")); + if (result.status !== 200 || !result.body) { + return c.json({ error: result.error || "Not found" }, result.status === 400 ? 400 : 404); + } + return new Response(result.body, { + status: 200, + headers: { + "content-type": result.contentType || "application/octet-stream", + "cache-control": "no-store", + }, + }); + }); + app.get("/pages", (c) => { return c.json({ pages: Array.from(namedPages.keys()), @@ -887,6 +917,7 @@ export async function serveRelay(options: RelayOptions = {}): Promise` 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. +- To show a **local file** (a generated HTML brief, report, etc.), pass its `file:` URL to `open --url file:///…`. The CLI serves it through the relay automatically — `chrome.tabs.create` cannot navigate to `file:` URLs, so do not try to open the file path directly. +- If `doctor` reports `status: debugger_unavailable`, the connected browser restricts `chrome.debugger`; 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. - 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`. - Default snapshot is the open dialog (and an open listbox or menu) plus the main landmark. Read `snapshot.nodes`: `ref`, `role`, `name`, and `value` / `ariaChecked`. Nav is omitted. `--scope full` adds `nav` and page text. `--scope nav` is the landmark only. - Click with `--ref` from that snapshot, or `--role` and `--name` (exact). `--text "A"` is ambiguous when the same string is both a value and a label. `--selector` remains for CSS. From c100e1ed7af85e506456c59ed3bf6c095cf1737b Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 4 Nov 2026 03:08:59 +0100 Subject: [PATCH 2/4] ci: pin daemon tests to ubuntu-24.04 (Playwright has no Chromium for 26.04) --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8b91d0..026b6cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,8 +52,11 @@ jobs: name: Daemon (TypeScript + Tests) needs: build-daemon strategy: + fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + # Pinned: ubuntu-latest moved to 26.04, which Playwright's Chromium + # does not support yet. Revisit when Playwright adds 26.04. + os: [ubuntu-24.04, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 From 6c12c4a552dd7c26b74ab53d899ca4bc070710a9 Mon Sep 17 00:00:00 2001 From: "autohub-claude[bot]" <320328484+autohub-claude[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:27:48 +0100 Subject: [PATCH 3/4] fix(relay): serve local documents behind per-document capability tokens Codex review on #30. P1: /local-file?path=... let any page served by the relay (same origin as the relay API) read any file the relay process could read and call every relay route. Replace it with a registration flow: POST /local-docs (CLI-only: no Origin header, JSON body) returns a 192-bit token, and documents are served from a second listener (its own origin) at /local//, resolved with realpath strictly inside the document's directory. Traversal, encoded traversal, absolute paths, escaping symlinks, dotfiles and sibling directories sharing the prefix are all 404. Doc responses carry CSP connect-src 'self', nosniff, no-referrer, CORP same-origin and no CORS headers. Control routes (including the /cdp and /extension upgrades) reject requests whose Host or Origin is the document origin. Tokens expire after 2h idle, the registry is bounded (64, LRU) and cleared on stop(). P2: rewriteLocalFileUrl is now async: it registers with the relay and returns the capability URL, so ./style.css, img/a.png etc. resolve inside the document's directory, and the file: URL's query string and hash are kept. Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 2 +- cli-js/src/cli.js | 4 +- cli-js/src/tool.js | 49 ++++-- cli-js/test/doctor-browser.test.js | 89 +++++++++-- relay/package.json | 4 +- relay/src/local-file-server.test.ts | 234 ++++++++++++++++++++++++++++ relay/src/local-file.test.ts | 145 ++++++++++++++--- relay/src/local-file.ts | 229 +++++++++++++++++++++++---- relay/src/relay.ts | 135 ++++++++++++++-- relay/src/standalone.ts | 1 + skills/browser-hand/SKILL.md | 2 +- 11 files changed, 804 insertions(+), 90 deletions(-) create mode 100644 relay/src/local-file-server.test.ts diff --git a/AGENTS.md b/AGENTS.md index 5e67a6e..7db0746 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ cd cli && cargo build ## Architecture notes - **Extension services:** `extension/services/{ConnectionManager,CDPRouter,TabManager,StateManager}.ts` — attach, tab targeting, CDP routing, focus policy. -- **Relay:** `relay/src/relay.ts` — extension ↔ CLI WebSocket. +- **Relay:** `relay/src/relay.ts` — extension ↔ CLI WebSocket. `relay/src/local-docs.ts` serves `file:` documents the CLI registers: a second listener (own origin/port) serving only `/local//…`, confined to the document's directory. Never add a route that takes a filesystem path from a request. - **CLI surface:** `cli-js/src/{cli,tool,autofill*}.js` — doctor, open, snapshot, fill, click, type, evaluate, screenshot, focus. - **Skill contract:** `skills/browser-hand/SKILL.md` + `references/*` — agent recipes and platform gotchas. Prefer skill updates over scattering recipes into random docs. - **Challenge ledger:** `extension/challenges/LEDGER.md` — pass/fail and fix commits for the agent gym. diff --git a/cli-js/src/cli.js b/cli-js/src/cli.js index ddac09f..66d05e9 100644 --- a/cli-js/src/cli.js +++ b/cli-js/src/cli.js @@ -58,7 +58,9 @@ Operations: open --url [--page-name ] [--group-title ] Create/get a named relay tab and navigate it. --group-title names the browser tab group for this session. - file: URLs are served through the relay (tabs.create rejects file:). + file: URLs are registered with the relay and opened from its + separate local-doc origin (tabs.create rejects file:); only the + document's own directory is served. focus [--focus window|tab] Bring a tab forward for human input (one-shot; default window). --reason Required for good audit (e.g. 2fa, confirm-publish). tabs [--query ] List every http(s) tab. Unique-active first; last-focused when stamped. diff --git a/cli-js/src/tool.js b/cli-js/src/tool.js index 22caf20..3ffd0c4 100644 --- a/cli-js/src/tool.js +++ b/cli-js/src/tool.js @@ -502,21 +502,44 @@ function relayPageEndpoint(name) { /** * chrome.tabs.create rejects file: URLs ("Cannot navigate to a file: URL"), so - * rewrite them to the relay's /local-file route. The relay reads the file and - * serves it over http, which the extension can open like any other page. - * Non-file URLs pass through unchanged. + * register the document with the relay and open the capability URL it returns: + * `/local//`. The token scopes the document to its + * own directory, so `./style.css` or `img/a.png` resolve against that URL and + * the relay serves them from beside the file (never from outside it). The + * original query string and hash carry over. Non-file URLs pass through. */ -export function rewriteLocalFileUrl(url, relayBase = CURRENT_RELAY_URL) { +export async function rewriteLocalFileUrl(url, relayBase = CURRENT_RELAY_URL) { if (typeof url !== "string" || !/^file:\/\//i.test(url)) { return url; } + let parsed; let filePath; try { - filePath = fileURLToPath(url); + parsed = new URL(url); + filePath = fileURLToPath(parsed); } catch { return url; } - return `${relayBase}/local-file?path=${encodeURIComponent(filePath)}`; + let registered; + try { + registered = await fetchJson(`${relayBase}/local-docs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: filePath }), + timeoutMs: 5000, + }); + } catch (error) { + throw new Error( + `Could not serve ${filePath} through the Browser Hand relay (${error.message}). ` + + "A relay started before local-document support needs a restart: `browser-hand relay`." + ); + } + if (typeof registered?.url !== "string") { + throw new Error( + `The Browser Hand relay at ${relayBase} did not return a URL for ${filePath}; restart it with \`browser-hand relay\`.` + ); + } + return `${registered.url}${parsed.search}${parsed.hash}`; } async function lookupNamedRelayPage(pageName, { timeoutMs = 5000 } = {}) { @@ -1527,13 +1550,6 @@ async function runCurrentOperation(input, timeoutMs) { return await runCurrentTabs(input, timeoutMs); } - if ((operation === "open" || operation === "goto") && typeof input.url === "string") { - const rewritten = rewriteLocalFileUrl(input.url); - if (rewritten !== input.url) { - input = { ...input, url: rewritten }; - } - } - if (input.query && !(input.target && input.target.query)) { input = { ...input, @@ -1542,6 +1558,13 @@ async function runCurrentOperation(input, timeoutMs) { } const info = await ensureRelayConnected(Math.min(timeoutMs, 15000)); + // Registering needs a running relay, so this waits for ensureRelayConnected. + if ((operation === "open" || operation === "goto") && typeof input.url === "string") { + const rewritten = await rewriteLocalFileUrl(input.url); + if (rewritten !== input.url) { + input = { ...input, url: rewritten }; + } + } const cdp = new CdpClient(info.wsEndpoint); try { const targetResult = await cdp.send("Target.getTargets"); diff --git a/cli-js/test/doctor-browser.test.js b/cli-js/test/doctor-browser.test.js index bbdd485..ec72564 100644 --- a/cli-js/test/doctor-browser.test.js +++ b/cli-js/test/doctor-browser.test.js @@ -7,6 +7,8 @@ */ import assert from "node:assert/strict"; +import http from "node:http"; +import path from "node:path"; import test from "node:test"; import { @@ -48,18 +50,81 @@ test("doctor stays healthy when the smoke tab bootstraps", () => { assert.equal(healthy.status, "tab_bootstrap_works"); }); -test("rewriteLocalFileUrl routes file: URLs through the relay", () => { - const out = rewriteLocalFileUrl("file:///Users/me/brief.html", "http://127.0.0.1:9333"); - assert.equal(out, "http://127.0.0.1:9333/local-file?path=%2FUsers%2Fme%2Fbrief.html"); +/** Stub relay recording what the CLI sends; the real relay is covered in relay/src/local-file-server.test.ts. */ +async function withStubRelay(reply, fn) { + const requests = []; + const server = http.createServer((req, res) => { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + requests.push({ method: req.method, url: req.url, headers: req.headers, body }); + reply(res, body); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + return await fn(`http://127.0.0.1:${server.address().port}`, requests); + } finally { + server.close(); + } +} + +const registered = (res, body) => { + const name = encodeURIComponent(path.basename(JSON.parse(body).path)); + res.writeHead(201, { "content-type": "application/json" }); + res.end(JSON.stringify({ url: `http://127.0.0.1:41873/local/tok/${name}` })); +}; + +test("rewriteLocalFileUrl registers the file and keeps directory context (P2)", async () => { + await withStubRelay(registered, async (relay, requests) => { + const out = await rewriteLocalFileUrl("file:///tmp/my%20report/index.html", relay); + assert.equal(out, "http://127.0.0.1:41873/local/tok/index.html"); + // The old query-only /local-file URL sent ./style.css to /style.css (404). + assert.equal(new URL("./style.css", out).pathname, "/local/tok/style.css"); + assert.equal(new URL("img/a.png", out).pathname, "/local/tok/img/a.png"); + + const [req] = requests; + assert.deepEqual([req.method, req.url], ["POST", "/local-docs"]); + assert.match(req.headers["content-type"], /^application\/json/); + assert.equal( + req.headers.origin, + undefined, + "relay refuses registration when Origin is present" + ); + assert.deepEqual(JSON.parse(req.body), { path: "/tmp/my report/index.html" }); + }); +}); + +test("rewriteLocalFileUrl preserves the query string and hash", async () => { + await withStubRelay(registered, async (relay, requests) => { + const out = await rewriteLocalFileUrl( + "file:///tmp/r/index.html?tab=2&q=a%20b#section-3", + relay + ); + const parsed = new URL(out); + assert.deepEqual([parsed.search, parsed.hash], ["?tab=2&q=a%20b", "#section-3"]); + assert.deepEqual(JSON.parse(requests[0].body), { path: "/tmp/r/index.html" }); + }); +}); + +test("rewriteLocalFileUrl leaves non-file URLs alone without calling the relay", async () => { + await withStubRelay(registered, async (relay, requests) => { + for (const url of ["https://example.com/x", "http://127.0.0.1:8799/", undefined]) { + assert.equal(await rewriteLocalFileUrl(url, relay), url); + } + assert.equal(requests.length, 0); + }); }); -test("rewriteLocalFileUrl leaves http(s) URLs untouched", () => { - assert.equal( - rewriteLocalFileUrl("https://example.com/x", "http://127.0.0.1:9333"), - "https://example.com/x" - ); - assert.equal( - rewriteLocalFileUrl("http://127.0.0.1:8799/", "http://127.0.0.1:9333"), - "http://127.0.0.1:8799/" - ); +test("rewriteLocalFileUrl fails loudly when the relay refuses or predates local documents", async () => { + const notFound = (res) => res.writeHead(404).end("404 Not Found"); + const noUrl = (res) => res.writeHead(201, { "content-type": "application/json" }).end("{}"); + for (const [reply, message] of [ + [notFound, /Could not serve \/tmp\/r\/index\.html.*HTTP 404.*browser-hand relay/s], + [noUrl, /did not return a URL/], + ]) { + await withStubRelay(reply, (relay) => + assert.rejects(rewriteLocalFileUrl("file:///tmp/r/index.html", relay), message) + ); + } }); diff --git a/relay/package.json b/relay/package.json index 5d69166..89172f5 100644 --- a/relay/package.json +++ b/relay/package.json @@ -10,7 +10,9 @@ "scripts": { "build": "tsc -p tsconfig.json", "start": "node dist/standalone.js", - "dev": "tsx src/standalone.ts" + "dev": "tsx src/standalone.ts", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "node --import tsx --test \"src/*.test.ts\"" }, "engines": { "node": ">=18" diff --git a/relay/src/local-file-server.test.ts b/relay/src/local-file-server.test.ts new file mode 100644 index 0000000..1e135e4 --- /dev/null +++ b/relay/src/local-file-server.test.ts @@ -0,0 +1,234 @@ +/** + * Real relay, real HTTP, real files. Covers the P1/P2 review findings on #30: a + * local document must not read outside its directory or reach the relay API, + * and must still resolve its own relative resources. + */ +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import http from "node:http"; +import net from "node:net"; +import { after, before, describe, test } from "node:test"; +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { serveRelay, type RelayServer } from "./relay.ts"; +// The CLI's own registration path, so the whole file: -> served-URL flow is covered. +import { rewriteLocalFileUrl } from "../../cli-js/src/tool.js"; + +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==", + "base64" +); + +async function freePort(): Promise { + const probe = net.createServer(); + await new Promise((resolve) => probe.listen(0, "127.0.0.1", resolve)); + const { port } = probe.address() as net.AddressInfo; + await new Promise((resolve) => probe.close(() => resolve())); + return port; +} + +/** GET with the path sent verbatim: fetch() would normalize `..` away before the server saw it. */ +const raw = (port: number, path: string, headers: Record = {}) => + new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = http.request({ host: "127.0.0.1", port, path, headers }, (res) => { + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => resolve({ status: res.statusCode ?? 0, body })); + }); + req.on("error", reject).end(); + }); + +/** WebSocket upgrade attempt; resolves with the status (101 = accepted). */ +const upgrade = (port: number, path: string, origin?: string) => + new Promise((resolve, reject) => { + const headers = { + Connection: "Upgrade", + Upgrade: "websocket", + "Sec-WebSocket-Version": "13", + "Sec-WebSocket-Key": randomBytes(16).toString("base64"), + ...(origin ? { Origin: origin } : {}), + }; + http + .request({ host: "127.0.0.1", port, path, headers }) + .on("upgrade", (_res, socket) => (socket.destroy(), resolve(101))) + .on("response", (res) => (res.resume(), resolve(res.statusCode ?? 0))) + .on("error", reject) + .end(); + }); + +const postDoc = (relayOrigin: string, path: string, headers: Record = {}) => + fetch(`${relayOrigin}/local-docs`, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify({ path }), + }); + +describe("local documents through the relay", () => { + let base: string; + let relay: RelayServer; + let relayOrigin: string; + let relayPort: number; + let docPort: number; + let indexPath: string; + let doc: { token: string; url: string; origin: string }; + const register = (path: string, headers?: Record) => + postDoc(relayOrigin, path, headers); + + before(async () => { + base = await realpath(await mkdtemp(join(tmpdir(), "bh-localfile-e2e-"))); + for (const dir of ["report/img", "report/sub", "report-secret"]) { + await mkdir(join(base, dir), { recursive: true }); + } + const files: Record = { + "report/index.html": ``, + "report/style.css": "body{color:red}", + "report/app.js": "window.__loaded = true;", + "report/img/a.png": PNG, + "report/sub/page.html": ``, + "report/.env": "API_KEY=hunter2", + "report-secret/creds.txt": "TOP-SECRET", + "outside.txt": "outside the report directory", + }; + for (const [file, content] of Object.entries(files)) await writeFile(join(base, file), content); + await symlink(join(base, "report-secret"), join(base, "report", "leak")); + indexPath = join(base, "report", "index.html"); + + relayPort = await freePort(); + relayOrigin = `http://127.0.0.1:${relayPort}`; + relay = await serveRelay({ port: relayPort, host: "127.0.0.1" }); + docPort = Number(new URL(relay.localDocOrigin).port); + doc = await (await register(indexPath)).json(); + }); + + after(async () => { + await relay.stop(); + await rm(base, { recursive: true, force: true }); + }); + + test("local pages are served from a different origin than the relay API, hardened", async () => { + assert.notEqual(docPort, relayPort); + assert.equal(doc.origin, relay.localDocOrigin); + assert.match(doc.url, new RegExp(`^${doc.origin}/local/[A-Za-z0-9_-]{32}/index\\.html$`)); + + const res = await fetch(doc.url); + assert.equal(res.status, 200); + assert.equal(res.headers.get("content-type"), "text/html; charset=utf-8"); + assert.match(res.headers.get("content-security-policy") ?? "", /connect-src 'self'/); + assert.equal(res.headers.get("x-content-type-options"), "nosniff"); + assert.equal(res.headers.get("referrer-policy"), "no-referrer"); + assert.equal([...res.headers.keys()].filter((h) => h.startsWith("access-control-")).length, 0); + }); + + test("relative CSS, JS, image and nested-page resources load (P2)", async () => { + const at = (rel: string, from = doc.url) => fetch(new URL(rel, from)); + const css = await at("./style.css"); + assert.deepEqual( + [css.status, css.headers.get("content-type")], + [200, "text/css; charset=utf-8"] + ); + assert.equal(await css.text(), "body{color:red}"); + assert.equal((await at("app.js")).status, 200); + const png = await at("img/a.png"); + assert.deepEqual([png.status, png.headers.get("content-type")], [200, "image/png"]); + assert.deepEqual(Buffer.from(await png.arrayBuffer()), PNG); + const sub = new URL("sub/page.html", doc.url).href; + assert.equal((await at("../style.css", sub)).status, 200); // `../` within the directory + }); + + test("a document cannot read outside its directory (P1 regression)", async () => { + const root = `/local/${doc.token}`; + const outside = join(base, "outside.txt"); + const attempts = [ + `${root}/../outside.txt`, + `${root}/%2e%2e/outside.txt`, + `${root}/..%2foutside.txt`, + `${root}/${encodeURIComponent(outside)}`, + `${root}/../report-secret/creds.txt`, // sibling directory sharing the prefix + `${root}/leak/creds.txt`, // symlink planted in the document directory + `${root}/.env`, // dotfile next to the document + ]; + for (const path of attempts) { + const res = await raw(docPort, path); + assert.equal(res.status, 404, path); + assert.doesNotMatch(res.body, /hunter2|TOP-SECRET|outside the report/, path); + } + }); + + test("no endpoint accepts a raw filesystem path; unknown tokens are 404", async () => { + const secret = encodeURIComponent(join(base, "outside.txt")); + for (const port of [relayPort, docPort]) { + assert.equal((await raw(port, `/local-file?path=${secret}`)).status, 404); + } + assert.equal((await raw(docPort, join(base, "outside.txt"))).status, 404); + for (const token of ["A".repeat(32), "short", "..", "%2e%2e"]) { + assert.equal((await raw(docPort, `/local/${token}/index.html`)).status, 404, token); + } + }); + + test("the document origin cannot reach the relay API, /cdp or /extension", async () => { + const origin = relay.localDocOrigin; + for (const path of ["/", "/pages", "/pages/x", "/cdp", "/extension", "/local-docs"]) { + assert.equal((await raw(docPort, path)).status, 404, `${path} on the doc listener`); + } + // What a document would send (Origin = doc origin), or aim at the doc authority, is refused. + for (const path of ["/", "/pages", "/pages/x", "/local-docs/x"]) { + assert.equal((await raw(relayPort, path, { Origin: origin })).status, 403, path); + } + for (const host of [`127.0.0.1:${docPort}`, `localhost:${docPort}`]) { + assert.equal((await raw(relayPort, "/pages", { Host: host })).status, 403, host); + } + for (const path of ["/cdp", "/extension"]) { + assert.equal(await upgrade(relayPort, path, origin), 403, path); + } + + // Controls: the CLI (no doc Origin/Host) and unrelated origins are unaffected. + assert.equal(await upgrade(relayPort, "/cdp"), 101); + assert.equal((await raw(relayPort, "/pages")).status, 200); + assert.equal((await raw(relayPort, "/", { Origin: "http://localhost:3000" })).status, 200); + }); + + test("registration is CLI-only and validates input", async () => { + for (const origin of [relay.localDocOrigin, "http://evil.example"]) { + assert.equal((await register(indexPath, { Origin: origin })).status, 403, origin); + } + assert.equal((await register(indexPath, { "Content-Type": "text/plain" })).status, 415); + for (const [path, expected] of [ + ["report/index.html", 400], + [join(base, "report", "nope.html"), 404], + ] as const) { + const res = await register(path); + assert.equal(res.status, expected, path); + assert.ok(!(await res.text()).includes(base), "errors do not echo filesystem paths"); + } + }); + + test("CLI flow: file: URL in, capability URL out, query and hash preserved", async () => { + const fileUrl = `${pathToFileURL(indexPath).href}?tab=2&q=a%20b#section-3`; + const out = await rewriteLocalFileUrl(fileUrl, relayOrigin); + const parsed = new URL(out); + assert.equal(parsed.origin, relay.localDocOrigin); + assert.match(parsed.pathname, /^\/local\/[A-Za-z0-9_-]{32}\/index\.html$/); + assert.deepEqual([parsed.search, parsed.hash], ["?tab=2&q=a%20b", "#section-3"]); + assert.equal((await fetch(new URL("style.css", out))).status, 200); + }); +}); + +test("an expired capability is 404 over HTTP; localDocPort must differ from the relay port", async () => { + const dir = await realpath(await mkdtemp(join(tmpdir(), "bh-localfile-ttl-"))); + const port = await freePort(); + const relay = await serveRelay({ port, host: "127.0.0.1", localDocTtlMs: 150 }); + try { + await writeFile(join(dir, "a.html"), "

a

"); + const { url } = await (await postDoc(`http://127.0.0.1:${port}`, join(dir, "a.html"))).json(); + assert.equal((await fetch(url)).status, 200); + await new Promise((resolve) => setTimeout(resolve, 300)); + assert.equal((await fetch(url)).status, 404, "expired token"); + } finally { + await relay.stop(); + await rm(dir, { recursive: true, force: true }); + } + await assert.rejects(serveRelay({ port, host: "127.0.0.1", localDocPort: port }), /must differ/); +}); diff --git a/relay/src/local-file.test.ts b/relay/src/local-file.test.ts index a60fc5c..ad30075 100644 --- a/relay/src/local-file.test.ts +++ b/relay/src/local-file.test.ts @@ -1,10 +1,15 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { contentTypeForPath, readLocalFile } from "./local-file.ts"; +import { + LOCAL_DOC_TOKEN_PATTERN, + LocalDocError, + LocalDocRegistry, + contentTypeForPath, +} from "./local-file.ts"; test("contentTypeForPath maps known extensions", () => { assert.equal(contentTypeForPath("/tmp/brief.html"), "text/html; charset=utf-8"); @@ -20,24 +25,128 @@ test("contentTypeForPath defaults to octet-stream", () => { assert.equal(contentTypeForPath("/tmp/noext"), "application/octet-stream"); }); -test("readLocalFile serves an existing file with its content type", async () => { - const dir = await mkdtemp(join(tmpdir(), "bh-localfile-")); - const filePath = join(dir, "brief.html"); - await writeFile(filePath, "

hi

", "utf8"); +type Fixture = { base: string; report: string; index: string }; + +/** /report/{index.html,style.css,sub/,.env,link-*}, /report-secret/ (prefix sibling), /outside.txt */ +async function withFixture(run: (fx: Fixture) => Promise) { + const base = await realpath(await mkdtemp(join(tmpdir(), "bh-localfile-"))); + const report = join(base, "report"); try { - const result = await readLocalFile(filePath); - assert.equal(result.status, 200); - assert.equal(result.contentType, "text/html; charset=utf-8"); - assert.equal(Buffer.from(result.body).toString("utf8"), "

hi

"); + await mkdir(join(report, "sub"), { recursive: true }); + await mkdir(join(base, "report-secret")); + const files = ["report/index.html", "report/style.css", "report/sub/page.html"]; + for (const file of [...files, "report/.env", "report/.draft.html", "report/sub/.hidden"]) { + await writeFile(join(base, file), `

${file}

`); + } + await writeFile(join(base, "report-secret", "secret.txt"), "sibling-prefix secret"); + await writeFile(join(base, "outside.txt"), "outside"); + await symlink(join(base, "report-secret"), join(report, "link-dir")); + await symlink(join(base, "outside.txt"), join(report, "link-file")); + await symlink(join(report, "style.css"), join(report, "link-inside")); + await run({ base, report, index: join(report, "index.html") }); } finally { - await rm(dir, { recursive: true, force: true }); + await rm(base, { recursive: true, force: true }); } -}); +} -test("readLocalFile reports 400 for a missing path and 404 for a missing file", async () => { - const missingPath = await readLocalFile(undefined); - assert.equal(missingPath.status, 400); +const status = async (registry: LocalDocRegistry, token: string, path: string) => + (await registry.read(token, path)).status; - const missingFile = await readLocalFile(join(tmpdir(), "bh-does-not-exist-xyz.html")); - assert.equal(missingFile.status, 404); -}); +test("register mints a 192-bit token; the document, siblings and nested files are served", () => + withFixture(async ({ base, index }) => { + const registry = new LocalDocRegistry(); + const doc = await registry.register(index); + assert.match(doc.token, LOCAL_DOC_TOKEN_PATTERN); + assert.equal(Buffer.from(doc.token, "base64url").length, 24); + assert.equal(doc.name, "index.html"); + + const page = await registry.read(doc.token, "/index.html"); + assert.deepEqual([page.status, page.contentType], [200, "text/html; charset=utf-8"]); + assert.equal(Buffer.from(page.body!).toString(), "

report/index.html

"); + for (const ok of ["", "/style.css", "/sub/%70age.html", "/link-inside"]) { + assert.equal(await status(registry, doc.token, ok), 200, ok); // "" = the document itself + } + assert.notEqual((await registry.register(join(base, "outside.txt"))).token, doc.token); + })); + +test("traversal, absolute paths, symlinks, dotfiles and prefix siblings are 404", () => + withFixture(async ({ base, report, index }) => { + const registry = new LocalDocRegistry(); + const { token } = await registry.register(index); + const outside = join(base, "outside.txt"); + const attempts = [ + "/../outside.txt", + "/sub/../../outside.txt", + "/%2e%2e/outside.txt", + "/..%2foutside.txt", + `/${encodeURIComponent(outside)}`, + "/style.css%00.png", + "/%E0%A4%A", // malformed percent-encoding + "/../report-secret/secret.txt", // sibling directory sharing the prefix + "/link-dir/secret.txt", // symlinks planted in the document directory + "/link-file", + "/.env", + "/sub/.hidden", + ]; + for (const attempt of attempts) + assert.equal(await status(registry, token, attempt), 404, attempt); + assert.equal(await status(registry, token, "/style.css"), 200, "control: in-directory file"); + + // A dot-named document can serve itself, and nothing else dotted. + const draft = await registry.register(join(report, ".draft.html")); + assert.equal(await status(registry, draft.token, "/.draft.html"), 200); + assert.equal(await status(registry, draft.token, "/.env"), 404); + })); + +test("register rejects bad input and documents that escape their directory", () => + withFixture(async ({ base, report, index }) => { + const registry = new LocalDocRegistry(); + const cases: [unknown, number][] = [ + [undefined, 400], + ["report/index.html", 400], // relative + [`${index}\0.png`, 400], + [join(report, "missing.html"), 404], + [join(base, "no-such-dir", "x.html"), 404], + [report, 400], // a directory + [join(report, "link-file"), 400], // symlinked out of its own directory + [join(report, "link-inside"), 200], + ]; + for (const [value, expected] of cases) { + const got = await registry.register(value).then( + () => 200, + (err) => (assert.ok(err instanceof LocalDocError), err.status) + ); + assert.equal(got, expected, String(value)); + } + })); + +test("tokens: unknown 404, idle expiry slides, LRU bound, clear", () => + withFixture(async ({ base, report, index }) => { + let now = 0; + const registry = new LocalDocRegistry({ ttlMs: 1000, maxEntries: 2, now: () => now }); + for (const token of ["", "short", "A".repeat(32), "x".repeat(33)]) { + assert.equal(await status(registry, token, "/index.html"), 404, token); + } + + const a = await registry.register(index); // expires at 1000 + now = 900; + assert.equal(await status(registry, a.token, "/style.css"), 200); // a read slides it to 1900 + now = 1800; + assert.equal(await status(registry, a.token, "/style.css"), 200); // ...and to 2800 + now = 2801; + assert.equal(await status(registry, a.token, "/style.css"), 404); + + now = 3000; + const x = await registry.register(index); + now = 3001; + const y = await registry.register(join(report, "style.css")); + now = 3002; + await registry.read(x.token, "/index.html"); // x is now newer than y + now = 3003; + const z = await registry.register(join(base, "outside.txt")); // at the bound: evicts y + assert.equal(await status(registry, y.token, "/style.css"), 404); + assert.equal(await status(registry, x.token, "/index.html"), 200); + + registry.clear(); + assert.equal(await status(registry, z.token, "/outside.txt"), 404); + })); diff --git a/relay/src/local-file.ts b/relay/src/local-file.ts index 985d8a7..837444c 100644 --- a/relay/src/local-file.ts +++ b/relay/src/local-file.ts @@ -1,13 +1,15 @@ /** - * Serve a local file through the relay. - * - * `file:` URLs fail in chrome.tabs.create ("Cannot navigate to a file: URL"), - * so the CLI rewrites them to the relay's /local-file route and this reads the - * file back over http. Localhost-only — same trust boundary as the relay itself. + * Serve local documents through the relay without exposing the filesystem. + * `file:` URLs fail in chrome.tabs.create, so the CLI registers a document and + * opens the returned http URL. Registration mints an unguessable token; the relay + * serves `/local//` from that document's directory and + * nothing else, resolving every read with realpath. No served route accepts a + * filesystem path from a request. */ -import { readFile, stat } from "node:fs/promises"; -import { extname, resolve as resolvePath } from "node:path"; +import { randomBytes } from "node:crypto"; +import { readFile, realpath, stat } from "node:fs/promises"; +import { basename, dirname, extname, isAbsolute, join, relative, sep } from "node:path"; const LOCAL_FILE_CONTENT_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -34,32 +36,205 @@ export function contentTypeForPath(filePath: string): string { return LOCAL_FILE_CONTENT_TYPES[extname(filePath).toLowerCase()] || "application/octet-stream"; } -export interface LocalFileResult { - status: number; +/** URL prefix on the document origin: `/local//`. */ +export const LOCAL_DOC_ROUTE_PREFIX = "/local/"; + +/** 24 random bytes = 192 bits of entropy, base64url-encoded to 32 characters. */ +const TOKEN_BYTES = 24; +export const LOCAL_DOC_TOKEN_PATTERN = /^[A-Za-z0-9_-]{32}$/; + +/** Idle lifetime of a capability. Each successful read slides it forward. */ +export const DEFAULT_LOCAL_DOC_TTL_MS = 2 * 60 * 60 * 1000; +/** Upper bound on live capabilities; the least recently used is evicted first. */ +export const DEFAULT_LOCAL_DOC_MAX_ENTRIES = 64; + +/** + * Headers for every document-origin response: CSP `connect-src 'self'` confines + * scripts to their own origin, no-referrer keeps the token out of Referer, and + * there are deliberately no CORS headers. + */ +export const LOCAL_DOC_SECURITY_HEADERS: Record = { + "cache-control": "no-store", + "content-security-policy": [ + "default-src 'self' data: blob: https: 'unsafe-inline' 'unsafe-eval'", + "connect-src 'self'", + "form-action 'self'", + "base-uri 'self'", + "object-src 'none'", + "frame-ancestors 'self'", + ].join("; "), + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer", + "cross-origin-resource-policy": "same-origin", +}; + +/** Registration failed; `status` is the HTTP status the control route should return. */ +export class LocalDocError extends Error { + readonly status: number; + constructor(status: number, message: string) { + super(message); + this.name = "LocalDocError"; + this.status = status; + } +} + +export interface LocalDocRegistryOptions { + ttlMs?: number; + maxEntries?: number; + /** Clock override for tests. */ + now?: () => number; +} + +export interface RegisteredLocalDoc { + token: string; + /** Basename of the registered document; the last segment of its URL. */ + name: string; + expiresAt: number; +} + +export interface LocalDocResult { + status: 200 | 404; contentType?: string; /** Plain ArrayBuffer so it drops straight into a web Response body. */ body?: ArrayBuffer; - error?: string; } -/** Read a local file for serving. Returns a status + body or an error payload. */ -export async function readLocalFile(requested: string | undefined): Promise { - if (!requested) { - return { status: 400, error: "path query parameter is required" }; +const NOT_FOUND: LocalDocResult = { status: 404 }; + +interface Entry extends RegisteredLocalDoc { + /** Realpath of the document's directory: the capability's whole reach. */ + root: string; + lastUsed: number; +} + +/** True when `target` is a strict descendant of `root` (both realpaths). */ +function isInside(root: string, target: string): boolean { + const rel = relative(root, target); + return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel); +} + +/** Decode a request path per segment; null if it tries anything but walking down (`..`, `%2f`, `%5c`, NUL). */ +function toSegments(requestPath: string): string[] | null { + const segments: string[] = []; + for (const raw of requestPath.split("/")) { + let segment: string; + try { + segment = decodeURIComponent(raw); + } catch { + return null; + } + if (segment === "" || segment === ".") continue; + if (segment === ".." || /[/\\\0]/.test(segment)) return null; + segments.push(segment); } - const abs = resolvePath(requested); - try { - const info = await stat(abs); - if (!info.isFile()) { - return { status: 404, error: `Not a regular file: ${abs}` }; + return segments; +} + +export class LocalDocRegistry { + private readonly entries = new Map(); + private readonly ttlMs: number; + private readonly maxEntries: number; + private readonly now: () => number; + + constructor(options: LocalDocRegistryOptions = {}) { + this.ttlMs = options.ttlMs ?? DEFAULT_LOCAL_DOC_TTL_MS; + this.maxEntries = Math.max(1, options.maxEntries ?? DEFAULT_LOCAL_DOC_MAX_ENTRIES); + this.now = options.now ?? Date.now; + } + + /** + * Authorize one document (and the files beneath its directory) and mint its + * capability token. + */ + async register(filePath: unknown): Promise { + if (typeof filePath !== "string" || !filePath || filePath.includes("\0")) { + throw new LocalDocError(400, "path must be a non-empty string"); + } + if (!isAbsolute(filePath)) { + throw new LocalDocError(400, "path must be absolute"); + } + const name = basename(filePath); + let root: string; + let real: string; + try { + root = await realpath(dirname(filePath)); + real = await realpath(filePath); + } catch { + throw new LocalDocError(404, "Document not found"); + } + if (!(await stat(real)).isFile()) { + throw new LocalDocError(400, "path is not a regular file"); + } + // A document symlinked to somewhere outside its own directory would hand + // out a capability over that other directory. Refuse instead. + if (!isInside(root, real)) { + throw new LocalDocError(400, "document resolves outside its directory"); + } + + const now = this.now(); + this.pruneExpired(); + while (this.entries.size >= this.maxEntries) { + let oldest: Entry | undefined; + for (const entry of this.entries.values()) { + if (!oldest || entry.lastUsed < oldest.lastUsed) oldest = entry; + } + if (!oldest) break; + this.entries.delete(oldest.token); + } + const token = randomBytes(TOKEN_BYTES).toString("base64url"); + const entry: Entry = { token, name, root, expiresAt: now + this.ttlMs, lastUsed: now }; + this.entries.set(token, entry); + return { token, name, expiresAt: entry.expiresAt }; + } + + /** Read `requestPath` (encoded remainder of `/local//…`). Every failure is the same bare 404. */ + async read(token: string, requestPath: string): Promise { + const entry = this.lookup(token); + if (!entry) return NOT_FOUND; + const segments = toSegments(requestPath); + if (!segments) return NOT_FOUND; + // Dotfiles (.env, .git, .ssh, …) are the usual exfiltration targets next to + // a document. Only the registered document itself may be dot-named. + if (segments.some((s, i) => s.startsWith(".") && !(i === 0 && s === entry.name))) { + return NOT_FOUND; + } + const target = join(entry.root, ...(segments.length ? segments : [entry.name])); + try { + const real = await realpath(target); + if (!isInside(entry.root, real) || !(await stat(real)).isFile()) return NOT_FOUND; + const data = await readFile(real); + const now = this.now(); + entry.lastUsed = now; + entry.expiresAt = now + this.ttlMs; + const body = data.buffer.slice( + data.byteOffset, + data.byteOffset + data.byteLength + ) as ArrayBuffer; + return { status: 200, contentType: contentTypeForPath(real), body }; + } catch { + return NOT_FOUND; + } + } + + clear(): void { + this.entries.clear(); + } + + private lookup(token: string): Entry | undefined { + if (!LOCAL_DOC_TOKEN_PATTERN.test(token)) return undefined; + const entry = this.entries.get(token); + if (!entry) return undefined; + if (entry.expiresAt <= this.now()) { + this.entries.delete(token); + return undefined; + } + return entry; + } + + private pruneExpired(): void { + const now = this.now(); + for (const [token, entry] of this.entries) { + if (entry.expiresAt <= now) this.entries.delete(token); } - const data = await readFile(abs); - const body = data.buffer.slice( - data.byteOffset, - data.byteOffset + data.byteLength - ) as ArrayBuffer; - return { status: 200, contentType: contentTypeForPath(abs), body }; - } catch { - return { status: 404, error: `Cannot read file: ${abs}` }; } } diff --git a/relay/src/relay.ts b/relay/src/relay.ts index 51e094d..26cabd6 100644 --- a/relay/src/relay.ts +++ b/relay/src/relay.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { serve } from "@hono/node-server"; +import { serve, type ServerType } from "@hono/node-server"; import { createNodeWebSocket } from "@hono/node-ws"; import type { WSContext } from "hono/ws"; import { @@ -8,7 +8,12 @@ import { planBlankNavigation, type AdoptTarget, } from "./adopt-existing-tab.js"; -import { readLocalFile } from "./local-file.js"; +import { + LOCAL_DOC_ROUTE_PREFIX, + LOCAL_DOC_SECURITY_HEADERS, + LocalDocError, + LocalDocRegistry, +} from "./local-file.js"; /** Host browser reported by the extension on connect. */ export interface HostBrowser { @@ -19,11 +24,17 @@ export interface HostBrowser { export interface RelayOptions { port?: number; host?: string; + /** Port of the document listener (default: OS-assigned). Must differ from `port`: it is the origin boundary. */ + localDocPort?: number; + /** Idle lifetime of a local-document capability token (default 2h, sliding). */ + localDocTtlMs?: number; } export interface RelayServer { wsEndpoint: string; port: number; + /** Origin local documents are served from, e.g. http://127.0.0.1:41873. */ + localDocOrigin: string; stop(): Promise; } @@ -138,9 +149,17 @@ function adoptedNavigationEvents( return events; } +/** `host` as it appears in a URL authority (IPv6 literals need brackets). */ +function urlHost(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +} + export async function serveRelay(options: RelayOptions = {}): Promise { const port = options.port ?? 9222; const host = options.host ?? "127.0.0.1"; + if (options.localDocPort !== undefined && options.localDocPort === port) { + throw new Error("localDocPort must differ from the relay port (separate origins)"); + } const connectedTargets = new Map(); /** Named page → last known session + target. Prefer targetId rebind when session churns. */ @@ -554,9 +573,68 @@ export async function serveRelay(options: RelayOptions = {}): Promise/…. It starts first so the control guard knows its port. + const localDocs = new LocalDocRegistry({ ttlMs: options.localDocTtlMs }); + const docApp = new Hono(); + let docAuthorities = new Set(); + const isDocAuthority = (authority: string | undefined): boolean => + authority !== undefined && docAuthorities.has(authority.toLowerCase()); + + docApp.use("*", async (c, next) => { + await next(); + for (const [name, value] of Object.entries(LOCAL_DOC_SECURITY_HEADERS)) { + c.res.headers.set(name, value); + } + }); + docApp.get("*", async (c) => { + if (!isDocAuthority(c.req.header("host"))) return c.notFound(); + const match = /^\/local\/([^/]+)(\/.*)?$/.exec(new URL(c.req.url).pathname); + if (!match) return c.notFound(); + const result = await localDocs.read(match[1], match[2] ?? ""); + if (result.status !== 200 || !result.body) return c.notFound(); + return new Response(result.body, { + status: 200, + headers: { "content-type": result.contentType || "application/octet-stream" }, + }); + }); + + let docServer!: ServerType; + const docPort = await new Promise((resolve, reject) => { + docServer = serve( + { fetch: docApp.fetch, port: options.localDocPort ?? 0, hostname: host }, + (info) => resolve(info.port) + ); + docServer.once("error", reject); + }); + const docHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : urlHost(host); + const localDocOrigin = `http://${docHost}:${docPort}`; + docAuthorities = new Set( + [docHost, "127.0.0.1", "localhost", "[::1]"].map((h) => `${h}:${docPort}`.toLowerCase()) + ); + const app = new Hono(); const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }); + // Nothing addressed to or issued by the document origin may reach the relay + // API, /cdp or /extension: reject by Host and by Origin before any route runs. + app.use("*", async (c, next) => { + const origin = c.req.header("origin"); + let fromDocument = false; + if (origin) { + try { + fromDocument = isDocAuthority(new URL(origin).host); + } catch { + // Opaque or malformed Origin ("null"): not the document origin. + } + } + if (fromDocument || isDocAuthority(c.req.header("host"))) { + return c.json({ error: "Forbidden" }, 403); + } + await next(); + }); + app.get("/", (c) => { return c.json({ wsEndpoint: `ws://${host}:${port}/cdp`, @@ -568,21 +646,41 @@ export async function serveRelay(options: RelayOptions = {}): Promise { - const result = await readLocalFile(c.req.query("path")); - if (result.status !== 200 || !result.body) { - return c.json({ error: result.error || "Not found" }, result.status === 400 ? 400 : 404); + // Register a local document (file: URLs fail in chrome.tabs.create) and return + // its capability URL. The only route taking a filesystem path, so CLI-only: + // browsers attach Origin to every POST and a JSON body forces a preflight the + // relay never answers, so a web page can neither call it nor read the token. + app.post("/local-docs", async (c) => { + if (c.req.header("origin") !== undefined) { + return c.json({ error: "Forbidden" }, 403); + } + if (!c.req.header("content-type")?.toLowerCase().startsWith("application/json")) { + return c.json({ error: "Content-Type must be application/json" }, 415); + } + let body: { path?: unknown } | null; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + try { + const doc = await localDocs.register(body?.path); + return c.json( + { + token: doc.token, + name: doc.name, + expiresAt: doc.expiresAt, + origin: localDocOrigin, + url: `${localDocOrigin}${LOCAL_DOC_ROUTE_PREFIX}${doc.token}/${encodeURIComponent(doc.name)}`, + }, + 201 + ); + } catch (err) { + if (err instanceof LocalDocError) { + return c.json({ error: err.message }, err.status as 400 | 404); + } + throw err; } - return new Response(result.body, { - status: 200, - headers: { - "content-type": result.contentType || "application/octet-stream", - "cache-control": "no-store", - }, - }); }); app.get("/pages", (c) => { @@ -1168,18 +1266,23 @@ export async function serveRelay(options: RelayOptions = {}): Promise { try { diff --git a/skills/browser-hand/SKILL.md b/skills/browser-hand/SKILL.md index 076823f..2f8fed2 100644 --- a/skills/browser-hand/SKILL.md +++ b/skills/browser-hand/SKILL.md @@ -103,7 +103,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. -- To show a **local file** (a generated HTML brief, report, etc.), pass its `file:` URL to `open --url file:///…`. The CLI serves it through the relay automatically — `chrome.tabs.create` cannot navigate to `file:` URLs, so do not try to open the file path directly. +- 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`; 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. - 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`. - Default snapshot is the open dialog (and an open listbox or menu) plus the main landmark. Read `snapshot.nodes`: `ref`, `role`, `name`, and `value` / `ariaChecked`. Nav is omitted. `--scope full` adds `nav` and page text. `--scope nav` is the landmark only. From 4cde108cf242183cb2bea424105ea7afe522f014 Mon Sep 17 00:00:00 2001 From: "autohub-claude[bot]" <320328484+autohub-claude[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:56:54 +0100 Subject: [PATCH 4/4] fix(doctor): report chrome.debugger unavailability the extension now hides When the host browser rejects debugger.attach, TabManager falls back to the scripting transport, so the doctor's smoke tab opens fine and classification returned tab_bootstrap_works before ever seeing the error it keyed on. The documented debugger_unavailable status was unreachable with the updated extension, and limits such as Page.navigate being rejected on scripting tabs went unreported. - extension: TabManager reports the limitation to the relay (register message) the first time it sees it; registerHostBrowser re-sends it on every connect, since ConnectionManager.send drops messages while the socket is down. - relay: keep the report per extension connection (cleared on replace/close), validated and bounded, and expose it as `debugger` on GET /. - doctor: re-read the relay after the smoke tab (the attach, and so the discovery, happens during it), classify debugger_unavailable explicitly even when the smoke succeeded, expose `debugger` in the result, and drop the misleading "Tab bootstrap failed" path_b note for this status. Tests: fake extension -> real relay -> real doctor end-to-end, classifier unit tests, and TabManager report-once / wedge-is-not-a-limitation tests. Co-Authored-By: Claude Sonnet 5.5 --- cli-js/src/tool.js | 27 +++- cli-js/test/doctor-browser.test.js | 36 ++++++ extension/__tests__/TabManager.test.ts | 57 ++++++++ extension/entrypoints/background.ts | 9 +- extension/services/TabManager.ts | 24 +++- relay/src/doctor-debugger.test.ts | 172 +++++++++++++++++++++++++ relay/src/relay.ts | 26 +++- skills/browser-hand/SKILL.md | 2 +- 8 files changed, 340 insertions(+), 13 deletions(-) create mode 100644 relay/src/doctor-debugger.test.ts diff --git a/cli-js/src/tool.js b/cli-js/src/tool.js index 3ffd0c4..51610d7 100644 --- a/cli-js/src/tool.js +++ b/cli-js/src/tool.js @@ -815,11 +815,17 @@ export function isDebuggerUnavailableError(error) { return /\bdebugger\b/i.test(message) && /(isn['’]t|is not|not)\s+available/i.test(message); } +/** + * `debuggerState` is the relay's `debugger` report ({available:false, reason}). It is + * what surfaces the limitation when the extension's scripting fallback lets the smoke + * tab open anyway; the smoke error text only covers an extension without that fallback. + */ export function classifyDevBrowserDoctor({ relayInfo, recentExtensionDisconnected = false, targetCount = 0, smoke = null, + debuggerState = null, } = {}) { if (!relayInfo) { return { @@ -840,7 +846,9 @@ export function classifyDevBrowserDoctor({ action: `Enable the Browser Hand Chrome extension (Load unpacked → extension/dist/chrome-mv3) and confirm it points to ws://${CURRENT_RELAY_HOST}:${CURRENT_RELAY_PORT}/extension.`, }; } - if (smoke?.success === true) { + const debuggerUnavailable = + debuggerState?.available === false || isDebuggerUnavailableError(smoke?.error); + if (smoke?.success === true && !debuggerUnavailable) { return { status: "tab_bootstrap_works", action: "Browser Hand is healthy. Run authenticated work through the extension relay.", @@ -853,7 +861,7 @@ export function classifyDevBrowserDoctor({ "Reload the Browser Hand extension; the extension socket reconnected during tab creation, so retrying browser work will be flaky until the worker is stable.", }; } - if (isDebuggerUnavailableError(smoke?.error)) { + if (debuggerUnavailable) { // The host browser restricts chrome.debugger (e.g. the "Search" fork). This // is a browser capability gap, not the separate-debug-profile (path_b) case. return { @@ -1432,6 +1440,8 @@ 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, + // Set when the connected browser restricts chrome.debugger (null = no limitation seen). + debugger: relayInfo?.debugger || null, pages, targetCount: 0, smoke: null, @@ -1500,16 +1510,27 @@ async function runCurrentDoctor(timeoutMs, input = {}) { } } + // The extension only learns chrome.debugger is unavailable when the smoke tab first + // attaches, after the probe above, so read what it reported before classifying. + if (result.smoke) { + result.debugger = (await probeRelay(1000))?.debugger || result.debugger; + } + const classification = classifyDevBrowserDoctor({ relayInfo, recentExtensionDisconnected, targetCount: result.targetCount, smoke: result.smoke, + debuggerState: result.debugger, }); result.status = classification.status; result.action = classification.action; result.success = classification.status === "tab_bootstrap_works"; - if (classification.status !== "tab_bootstrap_works") { + // debugger_unavailable is a browser capability gap, not a failed bootstrap or path_b. + if ( + classification.status !== "tab_bootstrap_works" && + classification.status !== "debugger_unavailable" + ) { result.pathBNote = "Tab bootstrap failed. For disposable/debug profiles use upstream headless or --connect on a non-default user-data-dir — not your everyday Chrome profile."; } diff --git a/cli-js/test/doctor-browser.test.js b/cli-js/test/doctor-browser.test.js index ec72564..7b39578 100644 --- a/cli-js/test/doctor-browser.test.js +++ b/cli-js/test/doctor-browser.test.js @@ -41,6 +41,42 @@ test("doctor distinguishes debugger_unavailable from path_b_required", () => { assert.equal(pathB.status, "path_b_required"); }); +test("relay-reported debugger limitation wins even when the smoke tab bootstrapped (P2)", () => { + // The extension's scripting fallback lets the smoke tab open, so smoke.success is true + // and there is no error text to match: only the relay's report carries the limitation. + const reported = classifyDevBrowserDoctor({ + relayInfo: { extensionConnected: true }, + targetCount: 5, + smoke: { success: true }, + debuggerState: { available: false, reason: "debugger.attach isn't available in Search" }, + }); + assert.equal(reported.status, "debugger_unavailable"); + assert.match(reported.action, /scripting transport/i); + + for (const debuggerState of [null, undefined, {}, { available: true }]) { + const healthy = classifyDevBrowserDoctor({ + relayInfo: { extensionConnected: true }, + targetCount: 5, + smoke: { success: true }, + debuggerState, + }); + assert.equal(healthy.status, "tab_bootstrap_works", JSON.stringify(debuggerState)); + } + + // The report never masks an earlier, more fundamental state. + const asleep = classifyDevBrowserDoctor({ + relayInfo: { extensionConnected: false }, + debuggerState: { available: false }, + }); + assert.equal(asleep.status, "extension_disconnected"); + const unstable = classifyDevBrowserDoctor({ + relayInfo: { extensionConnected: true }, + smoke: { success: false, error: "Extension connection replaced" }, + debuggerState: { available: false }, + }); + assert.equal(unstable.status, "extension_unstable"); +}); + test("doctor stays healthy when the smoke tab bootstraps", () => { const healthy = classifyDevBrowserDoctor({ relayInfo: { extensionConnected: true }, diff --git a/extension/__tests__/TabManager.test.ts b/extension/__tests__/TabManager.test.ts index 27bec6b..515a00e 100644 --- a/extension/__tests__/TabManager.test.ts +++ b/extension/__tests__/TabManager.test.ts @@ -215,6 +215,63 @@ describe("TabManager", () => { expect(tabManager.isDebuggerUnavailable()).toBe(true); }); + it("reports the limitation to the relay once, since the fallback hides the error", async () => { + const reason = "debugger.attach isn't available in Search"; + mockTabsGet.mockImplementation(async (id: number) => + chromeTab({ id, url: `https://example.com/${id}`, status: "complete" }) + ); + mockDebuggerDetach.mockResolvedValue(undefined); + mockDebuggerAttach.mockRejectedValue(new Error(reason)); + for (const id of [77, 78]) { + tabManager.set(id, { + sessionId: `session-${id}`, + targetId: `target-${id}`, + state: "connected", + debuggerAttached: false, + targetInfo: { + targetId: `target-${id}`, + type: "page", + title: "Example", + url: `https://example.com/${id}`, + }, + }); + } + expect(tabManager.debuggerCapability()).toBeNull(); + + await tabManager.ensureDebuggerAttached(77); + await tabManager.ensureDebuggerAttached(78); + + const report = { available: false, reason }; + expect(tabManager.debuggerCapability()).toEqual(report); + const registrations = mockSendMessage.mock.calls.filter(([m]) => m?.method === "register"); + expect(registrations).toEqual([[{ method: "register", params: { debugger: report } }]]); + }); + + it("does not report a limitation for a password-manager wedge", async () => { + mockTabsGet.mockResolvedValue( + chromeTab({ id: 79, url: "https://example.com/", status: "complete" }) + ); + mockDebuggerDetach.mockResolvedValue(undefined); + mockDebuggerAttach.mockRejectedValue( + new Error("Cannot access a chrome-extension:// URL of different extension") + ); + tabManager.set(79, { + sessionId: "session-79", + targetId: "target-79", + state: "connected", + debuggerAttached: false, + targetInfo: { targetId: "target-79", type: "page", title: "Example", url: "https://example.com/" }, + }); + + await tabManager.ensureDebuggerAttached(79); + + expect(tabManager.usesScriptingFallback(79)).toBe(true); + expect(tabManager.debuggerCapability()).toBeNull(); + expect(mockSendMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ method: "register" }) + ); + }); + it("classifies the unavailable error", () => { expect( tabManager.isDebuggerUnavailableError("debugger.attach isn't available in Search") diff --git a/extension/entrypoints/background.ts b/extension/entrypoints/background.ts index 3622269..07fee6e 100644 --- a/extension/entrypoints/background.ts +++ b/extension/entrypoints/background.ts @@ -41,11 +41,16 @@ export default defineBackground(() => { }); // Tell the relay which browser is driving this connection, so doctor/tabs - // report browser:{name,version} and agents never assume Chrome. + // report browser:{name,version} and agents never assume Chrome. A known + // chrome.debugger limitation is re-sent: the relay forgets it per connection. async function registerHostBrowser(): Promise { try { const browser = await detectHostBrowser(); - connectionManager.send({ method: "register", params: { browser } }); + const debuggerCapability = tabManager.debuggerCapability(); + connectionManager.send({ + method: "register", + params: { browser, ...(debuggerCapability ? { debugger: debuggerCapability } : {}) }, + }); } catch (error) { logger.debug("Error detecting host browser:", error); } diff --git a/extension/services/TabManager.ts b/extension/services/TabManager.ts index 774e66f..dae795e 100644 --- a/extension/services/TabManager.ts +++ b/extension/services/TabManager.ts @@ -309,18 +309,30 @@ export class TabManager { return /\bdebugger\b/i.test(message) && /(isn['’]t|is not|not)\s+available/i.test(message); } - private debuggerUnavailable = false; + private debuggerUnavailableReason: string | null = null; /** Whether this host browser has reported chrome.debugger as unavailable. */ isDebuggerUnavailable(): boolean { - return this.debuggerUnavailable; + return this.debuggerUnavailableReason !== null; + } + + /** + * Capability report for the relay's `register` message, or null while no + * limitation has been seen. Sent on connect too: the flag outlives a socket. + */ + debuggerCapability(): { available: false; reason: string } | null { + return this.debuggerUnavailableReason === null + ? null + : { available: false, reason: this.debuggerUnavailableReason }; } private markDebuggerUnavailable(reason: string): void { - if (!this.debuggerUnavailable) { - this.logger.log("chrome.debugger unavailable in this browser:", reason); - } - this.debuggerUnavailable = true; + if (this.debuggerUnavailableReason !== null) return; + this.debuggerUnavailableReason = reason; + this.logger.log("chrome.debugger unavailable in this browser:", reason); + // The scripting fallback makes the attach succeed from the caller's side, so + // the relay (and doctor) would never see the error. Report it directly. + this.sendMessage({ method: "register", params: { debugger: this.debuggerCapability() } }); } /** diff --git a/relay/src/doctor-debugger.test.ts b/relay/src/doctor-debugger.test.ts new file mode 100644 index 0000000..c9d1c33 --- /dev/null +++ b/relay/src/doctor-debugger.test.ts @@ -0,0 +1,172 @@ +/** + * Codex P2 on #30: a browser without chrome.debugger (the "Search" fork) makes the + * extension fall back to scripting, so the doctor's smoke tab opens fine and the + * error doctor used to classify on never reaches it. The extension now reports the + * limitation to the relay; this drives a fake extension through the real relay and + * the real CLI doctor to prove `debugger_unavailable` is reported anyway. + */ +import assert from "node:assert/strict"; +import net from "node:net"; +import { after, afterEach, before, describe, test } from "node:test"; + +import { serveRelay, type RelayServer } from "./relay.ts"; + +async function freePort(): Promise { + const probe = net.createServer(); + await new Promise((resolve) => probe.listen(0, "127.0.0.1", resolve)); + const { port } = probe.address() as net.AddressInfo; + await new Promise((resolve) => probe.close(() => resolve())); + return port; +} + +const DEBUGGER_REASON = "debugger.attach isn't available in Search"; + +/** + * Speaks the extension side of the relay protocol. Like the real TabManager, a + * debugger-less browser only discovers the limitation inside createTarget's attach, + * so it reports it there, before answering, and still hands back a working tab. + */ +async function connectExtension( + port: number, + { debuggerless, reportOnConnect = false }: { debuggerless: boolean; reportOnConnect?: boolean } +) { + const ws = new WebSocket(`ws://127.0.0.1:${port}/extension`); + const send = (message: unknown) => ws.send(JSON.stringify(message)); + const report = { method: "register", params: { debugger: { available: false, reason: DEBUGGER_REASON } } }; + ws.addEventListener("message", (event) => { + const { id, method, params } = JSON.parse(String(event.data)); + if (method !== "forwardCDPCommand") return; + switch (params.method) { + case "DevBrowser.listTargets": + return send({ + id, + result: { + targetInfos: [ + { targetId: "t-open", type: "page", title: "Open", url: "https://example.com/" }, + ], + }, + }); + case "Target.createTarget": + if (debuggerless) send(report); + send({ + method: "forwardCDPEvent", + params: { + method: "Target.attachedToTarget", + params: { + sessionId: "s-new", + targetInfo: { + targetId: "t-new", + type: "page", + title: "", + url: params.params.url, + attached: true, + }, + }, + }, + }); + return send({ id, result: { targetId: "t-new" } }); + case "Target.closeTarget": + return send({ id, result: { success: true } }); + default: + return send({ id, result: {} }); + } + }); + await new Promise((resolve, reject) => { + ws.addEventListener("open", () => resolve(), { once: true }); + ws.addEventListener("error", () => reject(new Error("extension socket failed")), { once: true }); + }); + const browser = { name: "Search", version: "1.0" }; + send({ + method: "register", + params: { browser, ...(reportOnConnect ? { debugger: report.params.debugger } : {}) }, + }); + return ws; +} + +describe("doctor reports debugger_unavailable through the relay", () => { + let relay: RelayServer; + let relayUrl: string; + let doctor: () => Promise>; + let extension: WebSocket | null = null; + + const info = async () => (await fetch(relayUrl)).json() as Promise>; + const settled = async (ready: (i: Record) => boolean) => { + for (let i = 0; i < 100; i++) { + const current = await info(); + if (ready(current)) return current; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error("relay never reached the expected state"); + }; + const disconnect = async () => { + extension?.close(); + extension = null; + await settled((i) => i.extensionConnected === false); + }; + + before(async () => { + const port = await freePort(); + relayUrl = `http://127.0.0.1:${port}`; + // The CLI reads its relay address from the environment at import time. + process.env.DEV_BROWSER_RELAY_HOST = "127.0.0.1"; + process.env.DEV_BROWSER_RELAY_PORT = String(port); + const { default: tool } = await import("../../cli-js/src/tool.js"); + doctor = () => tool.handler({ mode: "current", operation: "doctor" }); + relay = await serveRelay({ port, host: "127.0.0.1" }); + }); + + afterEach(disconnect); + after(() => relay.stop()); + + test("smoke tab opens via the scripting fallback, yet status is debugger_unavailable (P2)", async () => { + extension = await connectExtension(relay.port, { debuggerless: true }); + await settled((i) => i.extensionConnected && i.browser?.name === "Search"); + + const result = await doctor(); + + assert.equal(result.smoke.success, true, "the smoke tab bootstrapped"); + assert.equal(result.status, "debugger_unavailable"); + assert.deepEqual(result.debugger, { available: false, reason: DEBUGGER_REASON }); + assert.deepEqual(result.browser, { name: "Search", version: "1.0" }); + assert.match(result.action, /scripting transport/i); + assert.equal(result.pathBNote, undefined, "not the separate-debug-profile case"); + }); + + test("a browser whose debugger works stays tab_bootstrap_works", async () => { + extension = await connectExtension(relay.port, { debuggerless: false }); + await settled((i) => i.extensionConnected && i.browser?.name === "Search"); + + const result = await doctor(); + + assert.equal(result.status, "tab_bootstrap_works"); + assert.equal(result.success, true); + assert.equal(result.debugger, null); + }); + + test("the report belongs to one extension connection and is re-sent on reconnect", async () => { + extension = await connectExtension(relay.port, { debuggerless: true, reportOnConnect: true }); + assert.deepEqual((await settled((i) => i.debugger !== null)).debugger, { + available: false, + reason: DEBUGGER_REASON, + }); + + await disconnect(); + assert.equal((await info()).debugger, null, "forgotten once the extension disconnects"); + + extension = await connectExtension(relay.port, { debuggerless: false }); + await settled((i) => i.extensionConnected && i.browser !== null); + assert.equal((await info()).debugger, null, "a fresh connection starts clean"); + }); + + test("a malformed or available:true report does not flag the browser", async () => { + extension = await connectExtension(relay.port, { debuggerless: false }); + await settled((i) => i.extensionConnected && i.browser !== null); + for (const debuggerReport of [{ available: true }, "nope", null, {}]) { + extension.send(JSON.stringify({ method: "register", params: { debugger: debuggerReport } })); + } + // register is handled in order; a later browser report proves the earlier ones ran. + extension.send(JSON.stringify({ method: "register", params: { browser: { name: "Brave" } } })); + await settled((i) => i.browser?.name === "Brave"); + assert.equal((await info()).debugger, null); + }); +}); diff --git a/relay/src/relay.ts b/relay/src/relay.ts index 26cabd6..efb480b 100644 --- a/relay/src/relay.ts +++ b/relay/src/relay.ts @@ -77,7 +77,13 @@ type ExtensionMessage = | ExtensionResponseMessage | ExtensionEventMessage | { method: "log"; params: { level: string; args: string[] } } - | { method: "register"; params: { browser?: Partial } }; + | { + method: "register"; + params: { + browser?: Partial; + debugger?: { available?: boolean; reason?: string }; + }; + }; interface CDPCommand { id: number; @@ -186,6 +192,12 @@ export async function serveRelay(options: RelayOptions = {}): Promise` 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. - 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`; 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. +- 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`. - Default snapshot is the open dialog (and an open listbox or menu) plus the main landmark. Read `snapshot.nodes`: `ref`, `role`, `name`, and `value` / `ariaChecked`. Nav is omitted. `--scope full` adds `nav` and page text. `--scope nav` is the landmark only. - Click with `--ref` from that snapshot, or `--role` and `--name` (exact). `--text "A"` is ambiguous when the same string is both a value and a label. `--selector` remains for CSS.