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 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 ccb50ca..66d05e9 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,15 @@ 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 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. - 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 +267,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..51610d7 100644 --- a/cli-js/src/tool.js +++ b/cli-js/src/tool.js @@ -500,6 +500,48 @@ function relayPageEndpoint(name) { return `${CURRENT_RELAY_URL}/pages${name ? `/${encodeURIComponent(name)}` : ""}`; } +/** + * chrome.tabs.create rejects file: URLs ("Cannot navigate to a file: URL"), so + * 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 async function rewriteLocalFileUrl(url, relayBase = CURRENT_RELAY_URL) { + if (typeof url !== "string" || !/^file:\/\//i.test(url)) { + return url; + } + let parsed; + let filePath; + try { + parsed = new URL(url); + filePath = fileURLToPath(parsed); + } catch { + return url; + } + 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 } = {}) { if (!pageName || typeof pageName !== "string") { return null; @@ -763,11 +805,27 @@ 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); +} + +/** + * `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 { @@ -788,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.", @@ -801,6 +861,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 (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 { + 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 +1437,26 @@ 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, + // Set when the connected browser restricts chrome.debugger (null = no limitation seen). + debugger: relayInfo?.debugger || 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()}`; @@ -1427,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."; } @@ -1485,6 +1579,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 new file mode 100644 index 0000000..7b39578 --- /dev/null +++ b/cli-js/test/doctor-browser.test.js @@ -0,0 +1,166 @@ +/** + * 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 http from "node:http"; +import path from "node:path"; +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("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 }, + targetCount: 5, + smoke: { success: true }, + }); + assert.equal(healthy.status, "tab_bootstrap_works"); +}); + +/** 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 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/extension/__tests__/TabManager.test.ts b/extension/__tests__/TabManager.test.ts index 8229e8e..515a00e 100644 --- a/extension/__tests__/TabManager.test.ts +++ b/extension/__tests__/TabManager.test.ts @@ -189,6 +189,99 @@ 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("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") + ).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..07fee6e 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,30 @@ 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. A known + // chrome.debugger limitation is re-sent: the relay forgets it per connection. + async function registerHostBrowser(): Promise { + try { + const browser = await detectHostBrowser(); + const debuggerCapability = tabManager.debuggerCapability(); + connectionManager.send({ + method: "register", + params: { browser, ...(debuggerCapability ? { debugger: debuggerCapability } : {}) }, + }); + } 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..dae795e 100644 --- a/extension/services/TabManager.ts +++ b/extension/services/TabManager.ts @@ -298,6 +298,43 @@ 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 debuggerUnavailableReason: string | null = null; + + /** Whether this host browser has reported chrome.debugger as unavailable. */ + isDebuggerUnavailable(): boolean { + 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.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() } }); + } + /** * Ensure the Chrome debugger API is attached for CDP commands. * On password-manager wedge, falls back to scripting transport. @@ -353,6 +390,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 +410,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/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/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/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 new file mode 100644 index 0000000..ad30075 --- /dev/null +++ b/relay/src/local-file.test.ts @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import 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 { + 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"); + 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"); +}); + +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 { + 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(base, { recursive: true, force: true }); + } +} + +const status = async (registry: LocalDocRegistry, token: string, path: string) => + (await registry.read(token, path)).status; + +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 new file mode 100644 index 0000000..837444c --- /dev/null +++ b/relay/src/local-file.ts @@ -0,0 +1,240 @@ +/** + * 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 { 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", + ".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"; +} + +/** 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; +} + +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); + } + 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); + } + } +} diff --git a/relay/src/relay.ts b/relay/src/relay.ts index ed5c4c7..efb480b 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,15 +8,33 @@ import { planBlankNavigation, type AdoptTarget, } from "./adopt-existing-tab.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 { + name: string; + version: string; +} 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; } @@ -58,7 +76,14 @@ 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; + debugger?: { available?: boolean; reason?: string }; + }; + }; interface CDPCommand { id: number; @@ -130,9 +155,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. */ @@ -157,6 +190,14 @@ 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; + /** + * Set once the extension reports its browser has no chrome.debugger. The extension + * falls back to scripting and the failing call then succeeds, so this report is the + * only way doctor learns the limitation. Null = none reported on this connection. + */ + let extensionDebugger: { available: false; reason: string } | null = null; const deferredCdpEvents: CDPEvent[] = []; @@ -544,17 +585,118 @@ 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`, extensionConnected: extensionWs !== null, + // Which browser the extension is driving. Agents read this so they target + // the connected browser instead of assuming Chrome. + browser: extensionBrowser, + // { available: false, reason } when the browser restricts chrome.debugger. + debugger: extensionDebugger, mode: "extension", }); }); + // 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; + } + }); + app.get("/pages", (c) => { return c.json({ pages: Array.from(namedPages.keys()), @@ -887,6 +1029,8 @@ export async function serveRelay(options: RelayOptions = {}): Promise { try { diff --git a/skills/browser-hand/SKILL.md b/skills/browser-hand/SKILL.md index 6838820..3d82707 100644 --- a/skills/browser-hand/SKILL.md +++ b/skills/browser-hand/SKILL.md @@ -31,7 +31,7 @@ resources: # Browser Hand -Control the user’s **real Chrome profile** through a local extension bridge. No `--remote-debugging-port` on the everyday profile. +Control the user’s **real browser profile** — whichever browser has the Browser Hand extension connected (usually Chrome, but any Chromium browser: Edge, Brave, Arc, "Search", …) — through a local extension bridge. No `--remote-debugging-port` on the everyday profile. `doctor` reports `browser: {name, version}` so you target the connected browser instead of assuming Chrome. This is a **skill**, not an MCP server: no always-on tool list and no extra MCP process. The agent loads it when the task matches (signed-in browser work). @@ -101,7 +101,10 @@ Full reference: `references/extension-cli.md`. - Prefer **named pages** (`--page-name`) for multi-step work. - On the first `open` of a task, pass `--group-title` with a few words ("DNS records", "Invoice review"). That names the Chrome tab group and gives it a color. Later commands in the same task omit it and join that group. A different title starts a new group. Leaving it off keeps the group named Browser Hand. - Pass **`--quiet`** on snapshot, click, fill, evaluate, and steps. It drops the repeated `mode` / `target` / `url` / `title` wrapper. -- `doctor` returns `status`, `tabCount`, and the active tab. It is a health check. Use `tabs` for the inventory. `doctor --verbose` is only for diagnosing target selection. +- `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:///…`. `chrome.tabs.create` cannot navigate to `file:` URLs, so the CLI registers the file with the relay and opens a capability URL (`http://127.0.0.1:/local//`) on a **separate origin** from the relay API. Relative resources (`./style.css`, `img/a.png`, `sub/page.html`) resolve from that URL, and the `file:` URL's query string and `#hash` are kept. Only files inside the document's own directory are served: no `..` escapes, no symlinks out, no dotfiles (`.env`), nothing from a sibling directory. The page can't call the relay or other origins (CSP `connect-src 'self'`), so a report that `fetch`es remote JSON won't work this way. The token expires after 2 hours idle and dies with the relay. If `open` says the relay can't serve local documents, restart it (`browser-hand relay`); don't open the file path directly. +- If `doctor` reports `status: debugger_unavailable`, the connected browser restricts `chrome.debugger` (`doctor.debugger` carries the browser's reason); Browser Hand drives it via the scripting transport. open/snapshot/fill/click/evaluate still work — this is **not** the `path_b_required` (separate debug profile) case. It is reported even though the smoke tab opened, and `doctor` still exits non-zero because only `tab_bootstrap_works` is the healthy status; full-CDP methods such as `Page.navigate` are rejected on these tabs. - To work on the tab the user is looking at, run `snapshot` (no flags) or `tabs` first. If several windows each have an active tab, pass `--query`. - 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.