From 3603bf602be494347ebb90a632b413f0fb5b7e62 Mon Sep 17 00:00:00 2001 From: "autohub-claude[bot]" <320328484+autohub-claude[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 06:14:54 +0100 Subject: [PATCH] feat(extension): debugger-free page control so Browser Hand works in Search Problem: every page operation went through chrome.debugger. Search (macOS, WebKit-based, Chrome-extension compatible) stubs chrome.debugger.attach/detach/ sendCommand to refuse with "debugger.attach isn't available in Search", so doctor reported path_b_required and named-tab creation, snapshot, click, fill, evaluate and screenshot all failed, although the relay and tab list worked. Sites also increasingly block sessions with a debugger attached. Fix: - Debugger-free transport is now the default. Page.navigate runs through chrome.tabs.update (waits for load, reports loadComplete), Runtime.evaluate through chrome.scripting MAIN world, screenshots through captureVisibleTab. Snapshot/click/type/fill all ride on evaluate, so they work unchanged. - Auto-detect a stubbed or missing chrome.debugger (probe attach on tabId -1; match "isn't available") and never attempt CDP there, even in debugger mode. - Strict-CSP pages (no unsafe-eval) fall back to chrome.userScripts.execute, which injects script text instead of eval'ing it; the manifest now declares userScripts. Without it the error says how to turn it on. - Tab bootstrap: tab grouping is cosmetic, so a missing chrome.tabGroups no longer fails Target.createTarget or group requests. - Popup "Page control" selector opts into the CDP transport; doctor reports transport {mode, effective, debuggerAvailable}. - CLI skips waiting for Page.loadEventFired when the navigate already settled. Expected improvement: doctor reaches tab_bootstrap_works in Search and the full operation set works there, with no debugging banner for sites to detect. Known limits without the debugger: screenshot --full-page is viewport only, and CSP-strict pages need user scripts allowed. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 + cli-js/src/tool.js | 36 ++- cli-js/test/navigation-settled.test.js | 32 ++ extension/__tests__/StateManager.test.ts | 30 +- .../__tests__/scripting-transport.test.ts | 288 ++++++++++++++++++ extension/entrypoints/background.ts | 45 ++- extension/entrypoints/popup/index.html | 7 + extension/entrypoints/popup/main.ts | 30 ++ extension/services/CDPRouter.ts | 50 ++- extension/services/StateManager.ts | 18 +- extension/services/TabManager.ts | 262 +++++++++++++++- extension/utils/types.ts | 13 + extension/wxt.config.ts | 5 +- skills/browser-hand/SKILL.md | 9 + .../references/troubleshooting.md | 5 + 15 files changed, 777 insertions(+), 55 deletions(-) create mode 100644 cli-js/test/navigation-settled.test.js create mode 100644 extension/__tests__/scripting-transport.test.ts diff --git a/README.md b/README.md index 0c44c75..07441fa 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Attaching automation through Chrome’s remote debugging port (or “controlled Browser Hand takes a different route: a small **Chrome extension** bridges your normal profile to a **local relay** on your machine. Agents talk to the CLI; the extension drives tabs. No remote-debug flag on the default profile, no WebDriver banner, and **background work by default** so automation doesn’t yank focus. +The extension is also **debugger-free by default**: it uses `chrome.scripting` and `chrome.tabs` rather than `chrome.debugger`, so there is no “started debugging this browser” infobar for sites to detect. That is why it also runs in Chrome-extension-compatible browsers that stub `chrome.debugger`, such as the macOS **Search** browser. Pick the CDP debugger transport in the popup (**Page control**) only if you want it; see [the skill](./skills/browser-hand/SKILL.md#page-control-debugger-free-by-default) for what each mode can and cannot do. + ## What you get - **Real sessions** — use the Chrome profile you already use (cookies, passkeys, extensions, open tabs). diff --git a/cli-js/src/tool.js b/cli-js/src/tool.js index f341ff5..89cdcc5 100644 --- a/cli-js/src/tool.js +++ b/cli-js/src/tool.js @@ -1117,6 +1117,18 @@ class CdpClient { } } +/** + * True when Page.navigate already finished loading, so waiting for + * Page.loadEventFired would only burn the timeout: a relay-adopted blank tab, or + * the extension's debugger-free transport (reports loadComplete). + */ +export function navigationSettled(navResult) { + return ( + navResult?.loadComplete === true || + (typeof navResult?.loaderId === "string" && navResult.loaderId.startsWith("adopted-")) + ); +} + async function attachTarget(cdp, targetId) { const tryOnce = async () => { const attach = await cdp.send("Target.attachToTarget", { @@ -1302,13 +1314,15 @@ async function selectOrOpenCurrentTarget({ cdp, input, operation, targets, timeo pageName: plan.pageName, }; sessionId = await attachTarget(cdp, selected.targetId); - await cdp.send("Page.navigate", { url: cached.url }, sessionId); - await cdp - .waitForEvent("Page.loadEventFired", { - sessionId, - timeoutMs: 15000, - }) - .catch(() => null); + const renav = await cdp.send("Page.navigate", { url: cached.url }, sessionId); + if (!navigationSettled(renav)) { + await cdp + .waitForEvent("Page.loadEventFired", { + sessionId, + timeoutMs: 15000, + }) + .catch(() => null); + } selected.url = cached.url; setCachedNamedPage(plan.pageName, { targetId: selected.targetId, @@ -1391,6 +1405,10 @@ async function runCurrentDoctor(timeoutMs, input = {}) { result.truncated = shaped.truncated; } + // Which transport the extension drives pages with (debugger-free by default). + // Older extensions don't know the command; that is fine. + result.transport = await cdp.send("DevBrowser.getCapabilities").catch(() => null); + try { const pageInfo = await openNamedRelayPage(smokeName, { timeoutMs: Math.min(timeoutMs, 15000), @@ -1796,9 +1814,7 @@ async function runCurrentOperation(input, timeoutMs) { throw err; } const urlBeforeNav = selected.url; - const adopted = - typeof navResult?.loaderId === "string" && navResult.loaderId.startsWith("adopted-"); - if (!adopted) { + if (!navigationSettled(navResult)) { await cdp.waitForEvent("Page.loadEventFired", { sessionId, timeoutMs }).catch(() => null); } if (!navResult?.errorText) { diff --git a/cli-js/test/navigation-settled.test.js b/cli-js/test/navigation-settled.test.js new file mode 100644 index 0000000..1cec5d8 --- /dev/null +++ b/cli-js/test/navigation-settled.test.js @@ -0,0 +1,32 @@ +/** + * The debugger-free transport (chrome.tabs.update) finishes loading before it + * replies, so the CLI must not wait for Page.loadEventFired afterwards: that + * event can already be gone, and the wait would burn the whole timeout on every + * goto/open. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { navigationSettled } from "../src/tool.js"; + +test("navigationSettled: the scripting transport reports loadComplete", () => { + assert.equal(navigationSettled({ frameId: "tab-1", loaderId: "scripting-x", loadComplete: true }), true); +}); + +test("navigationSettled: a relay-adopted blank tab is already settled", () => { + assert.equal(navigationSettled({ loaderId: "adopted-abc" }), true); +}); + +test("navigationSettled: a CDP navigate still waits for Page.loadEventFired", () => { + assert.equal(navigationSettled({ frameId: "F1", loaderId: "L1" }), false); +}); + +test("navigationSettled: a scripting navigation that timed out is not settled", () => { + assert.equal(navigationSettled({ frameId: "tab-1", loaderId: "scripting-x" }), false); +}); + +test("navigationSettled: tolerates a missing result", () => { + assert.equal(navigationSettled(undefined), false); + assert.equal(navigationSettled(null), false); +}); diff --git a/extension/__tests__/StateManager.test.ts b/extension/__tests__/StateManager.test.ts index 1f8d64d..cafbde4 100644 --- a/extension/__tests__/StateManager.test.ts +++ b/extension/__tests__/StateManager.test.ts @@ -13,7 +13,11 @@ describe("StateManager", () => { describe("getState", () => { it("should return default active + background focus when no stored state", async () => { const state = await stateManager.getState(); - expect(state).toEqual({ isActive: true, focusPolicy: "background" }); + expect(state).toEqual({ + isActive: true, + focusPolicy: "background", + transportMode: "scripting", + }); }); it("should return stored state when available and fill missing focusPolicy", async () => { @@ -22,7 +26,11 @@ describe("StateManager", () => { }); const state = await stateManager.getState(); - expect(state).toEqual({ isActive: true, focusPolicy: "background" }); + expect(state).toEqual({ + isActive: true, + focusPolicy: "background", + transportMode: "scripting", + }); }); it("should preserve a stored window focus policy", async () => { @@ -43,15 +51,29 @@ describe("StateManager", () => { expect(stored.devBrowserActiveState).toEqual({ isActive: true, focusPolicy: "background", + transportMode: "scripting", }); }); + it("defaults to the debugger-free transport and persists an explicit opt-in", async () => { + expect(await stateManager.getTransportMode()).toBe("scripting"); + await stateManager.setState({ transportMode: "debugger" }); + expect(await stateManager.getTransportMode()).toBe("debugger"); + // Junk values never leave the safe default. + await stateManager.setState({ transportMode: "bogus" as never }); + expect(await stateManager.getTransportMode()).toBe("scripting"); + }); + it("should update state from active to inactive", async () => { await stateManager.setState({ isActive: true }); await stateManager.setState({ isActive: false }); const state = await stateManager.getState(); - expect(state).toEqual({ isActive: false, focusPolicy: "background" }); + expect(state).toEqual({ + isActive: false, + focusPolicy: "background", + transportMode: "scripting", + }); }); it("should update focus policy without clearing isActive", async () => { @@ -59,7 +81,7 @@ describe("StateManager", () => { await stateManager.setState({ focusPolicy: "tab" }); const state = await stateManager.getState(); - expect(state).toEqual({ isActive: true, focusPolicy: "tab" }); + expect(state).toEqual({ isActive: true, focusPolicy: "tab", transportMode: "scripting" }); }); }); diff --git a/extension/__tests__/scripting-transport.test.ts b/extension/__tests__/scripting-transport.test.ts new file mode 100644 index 0000000..4844c45 --- /dev/null +++ b/extension/__tests__/scripting-transport.test.ts @@ -0,0 +1,288 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { fakeBrowser } from "wxt/testing"; +import { CDPRouter } from "../services/CDPRouter"; +import { TabManager } from "../services/TabManager"; +import type { Logger } from "../utils/logger"; +import type { ExtensionCommandMessage, TransportMode } from "../utils/types"; + +const tabsGet = vi.fn(); +const tabsUpdate = vi.fn(); +const tabsCreate = vi.fn(); +const executeScript = vi.fn(); +const debuggerAttach = vi.fn(); +const debuggerSendCommand = vi.fn(); + +function installChrome(overrides: Record = {}) { + vi.stubGlobal("chrome", { + ...fakeBrowser, + debugger: { + attach: debuggerAttach, + detach: vi.fn().mockResolvedValue(undefined), + sendCommand: debuggerSendCommand, + }, + tabs: { + ...fakeBrowser.tabs, + get: tabsGet, + update: tabsUpdate, + create: tabsCreate, + remove: vi.fn(), + }, + scripting: { executeScript }, + // No tabGroups / tabs.group: Search grants neither. + ...overrides, + }); +} + +const tab = (partial: Partial & { id: number }) => + ({ + windowId: 1, + active: false, + url: "https://example.com/", + status: "complete", + ...partial, + }) as chrome.tabs.Tab; + +function command(method: string, params: Record = {}, sessionId?: string) { + return { + id: 1, + method: "forwardCDPCommand", + params: { method, params, sessionId }, + } as ExtensionCommandMessage; +} + +describe("debugger-free transport", () => { + let mode: TransportMode; + let manager: TabManager; + let router: CDPRouter; + let sendMessage: ReturnType; + const logger: Logger = { log: vi.fn(), debug: vi.fn(), error: vi.fn() }; + + beforeEach(() => { + fakeBrowser.reset(); + for (const fn of [ + tabsGet, + tabsUpdate, + tabsCreate, + executeScript, + debuggerAttach, + debuggerSendCommand, + ]) { + fn.mockReset(); + } + installChrome(); + // Run the injected func in-process, the way the page would. + executeScript.mockImplementation(async ({ args, func }) => [{ result: await func(...args) }]); + mode = "scripting"; + sendMessage = vi.fn(); + manager = new TabManager({ logger, sendMessage, getTransportMode: () => mode }); + router = new CDPRouter({ logger, tabManager: manager }); + }); + + async function registerTab(id = 7) { + const t = tab({ id }); + tabsGet.mockResolvedValue(t); + await manager.register(t); + return manager.get(id)!; + } + + it("never touches chrome.debugger in the default scripting mode", async () => { + const info = await registerTab(); + await router.handleCommand(command("Runtime.enable", {}, info.sessionId)); + const out = (await router.handleCommand( + command("Runtime.evaluate", { expression: "1 + 2" }, info.sessionId) + )) as { result: { value: number } }; + expect(out.result.value).toBe(3); + expect(debuggerAttach).not.toHaveBeenCalled(); + expect(debuggerSendCommand).not.toHaveBeenCalled(); + expect(manager.usesScriptingFallback(7)).toBe(true); + expect(manager.get(7)?.errorText).toBeUndefined(); + }); + + it("falls back and remembers when chrome.debugger is a stub (Search)", async () => { + mode = "debugger"; + debuggerAttach.mockRejectedValue(new Error("debugger.attach isn't available in Search")); + await registerTab(7); + await manager.ensureDebuggerAttached(7); + expect(manager.usesScriptingFallback(7)).toBe(true); + expect(manager.isDebuggerAvailable()).toBe(false); + + await registerTab(8); + await manager.ensureDebuggerAttached(8); + expect(debuggerAttach).toHaveBeenCalledTimes(1); // no second attempt + expect((await manager.getTransportInfo()).effective).toBe("scripting"); + }); + + it("treats a missing chrome.debugger as unavailable without calling it", async () => { + installChrome({ debugger: undefined }); + mode = "debugger"; + await registerTab(); + await manager.ensureDebuggerAttached(7); + expect(manager.usesScriptingFallback(7)).toBe(true); + expect(manager.debuggerApiMissing()).toBe(true); + }); + + it("still attaches the debugger when the user opts in and it works", async () => { + mode = "debugger"; + debuggerAttach.mockResolvedValue(undefined); + await registerTab(); + await manager.ensureDebuggerAttached(7); + expect(debuggerAttach).toHaveBeenCalledTimes(1); + expect(manager.usesScriptingFallback(7)).toBe(false); + }); + + describe("pages whose CSP forbids eval", () => { + const blocked = [ + { result: { ok: false, name: "EvalError", error: "Refused: 'unsafe-eval' is not allowed" } }, + ]; + + it("injects the expression as script text through chrome.userScripts", async () => { + const info = await registerTab(); + executeScript.mockResolvedValue(blocked); + const execute = vi.fn().mockResolvedValue([{ result: { ok: true, value: "dom-ok" } }]); + installChrome({ userScripts: { execute } }); + const out = (await router.handleCommand( + command("Runtime.evaluate", { expression: "document.title" }, info.sessionId) + )) as { result: { value: string } }; + expect(out.result.value).toBe("dom-ok"); + const injection = execute.mock.calls[0][0]; + expect(injection.world).toBe("MAIN"); + expect(injection.target).toEqual({ tabId: 7 }); + expect(injection.js[0].code).toContain("await(\ndocument.title\n)"); + }); + + it("says how to unblock when userScripts is unavailable (no dead-end debugger advice)", async () => { + const info = await registerTab(); + executeScript.mockResolvedValue(blocked); + const out = (await router.handleCommand( + command("Runtime.evaluate", { expression: "1" }, info.sessionId) + )) as { exceptionDetails: { text: string } }; + expect(out.exceptionDetails.text).toMatch(/Allow User Scripts/); + }); + }); + + it("probes chrome.debugger without a tab and spots Search's stub", async () => { + debuggerAttach.mockRejectedValue(new Error("debugger.attach isn't available in Search")); + expect(manager.isDebuggerAvailable()).toBe(true); // unproven until probed + await manager.probeDebugger(); + expect(debuggerAttach).toHaveBeenCalledWith({ tabId: -1 }, "1.3"); + expect(manager.isDebuggerAvailable()).toBe(false); + expect((await manager.getTransportInfo()).debuggerUnavailableReason).toMatch(/Search/); + }); + + it("keeps real Chrome's debugger available after the probe", async () => { + debuggerAttach.mockRejectedValue(new Error("No tab with given id -1.")); + await manager.probeDebugger(); + expect(manager.isDebuggerAvailable()).toBe(true); + await manager.probeDebugger(); + expect(debuggerAttach).toHaveBeenCalledTimes(1); // probed once + }); + + it("reports eval exceptions the way the CLI reads them", async () => { + const info = await registerTab(); + const out = (await router.handleCommand( + command("Runtime.evaluate", { expression: "throw new Error('boom')" }, info.sessionId) + )) as { exceptionDetails: { text: string; exception: { description: string } } }; + expect(out.exceptionDetails.text).toBe("boom"); + expect(out.exceptionDetails.exception.description).toBe("boom"); + }); + + describe("Page.navigate via chrome.tabs", () => { + it("updates the tab, waits for load, and reports loadComplete", async () => { + const info = await registerTab(); + tabsUpdate.mockResolvedValue(undefined); + tabsGet + .mockResolvedValueOnce(tab({ id: 7, url: "about:blank", status: "complete" })) // before + .mockResolvedValueOnce(tab({ id: 7, url: "https://example.com/next", status: "loading" })) + .mockResolvedValue(tab({ id: 7, url: "https://example.com/next", status: "complete" })); + vi.useFakeTimers(); + const pending = router.handleCommand( + command("Page.navigate", { url: "https://example.com/next" }, info.sessionId) + ); + await vi.advanceTimersByTimeAsync(500); + const result = (await pending) as { loadComplete?: boolean; errorText?: string }; + expect(tabsUpdate).toHaveBeenCalledWith(7, { url: "https://example.com/next" }); + expect(result.loadComplete).toBe(true); + expect(result.errorText).toBeUndefined(); + expect(debuggerSendCommand).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(200); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + method: "Page.loadEventFired", + sessionId: info.sessionId, + }), + }) + ); + vi.useRealTimers(); + }); + + it("returns immediately for a fragment-only navigation", async () => { + const info = await registerTab(); + tabsUpdate.mockResolvedValue(undefined); + tabsGet.mockResolvedValue( + tab({ id: 7, url: "https://example.com/page", status: "complete" }) + ); + const result = (await router.handleCommand( + command("Page.navigate", { url: "https://example.com/page#section" }, info.sessionId) + )) as { loadComplete?: boolean }; + expect(result.loadComplete).toBe(true); + }); + + it("surfaces chrome-error pages as errorText", async () => { + const info = await registerTab(); + tabsUpdate.mockResolvedValue(undefined); + tabsGet + .mockResolvedValueOnce(tab({ id: 7, url: "https://example.com/", status: "complete" })) + .mockResolvedValue( + tab({ id: 7, url: "chrome-error://chromewebdata/", status: "complete" }) + ); + const result = (await router.handleCommand( + command("Page.navigate", { url: "https://nope.invalid/" }, info.sessionId) + )) as { errorText?: string; loadComplete?: boolean }; + expect(result.errorText).toMatch(/net::ERR_FAILED/); + expect(result.loadComplete).toBeUndefined(); + }); + }); + + it("creates a named tab with no debugger and no tabGroups API", async () => { + const created = tab({ id: 42, url: "about:blank", pendingUrl: "about:blank" }); + tabsCreate.mockResolvedValue(created); + tabsGet.mockResolvedValue(created); + const out = (await router.handleCommand( + command("Target.createTarget", { url: "about:blank", groupTitle: "Demo" }) + )) as { targetId: string }; + expect(out.targetId).toBe("tab-42"); + expect(manager.usesScriptingFallback(42)).toBe(true); + expect(debuggerAttach).not.toHaveBeenCalled(); + expect(chrome.tabs.remove).not.toHaveBeenCalled(); + }); + + it("DevBrowser.groupTarget degrades to ungrouped instead of failing", async () => { + await registerTab(7); + const out = await router.handleCommand( + command("DevBrowser.groupTarget", { targetId: "tab-7" }) + ); + expect(out).toEqual({ groupId: null }); + }); + + it("reports the transport via DevBrowser.getCapabilities", async () => { + const out = (await router.handleCommand(command("DevBrowser.getCapabilities"))) as { + mode: string; + effective: string; + debuggerAvailable: boolean; + }; + expect(out).toMatchObject({ + mode: "scripting", + effective: "scripting", + debuggerAvailable: true, + }); + }); + + it("gives a clear error for CDP methods the scripting transport cannot emulate", async () => { + const info = await registerTab(); + await expect( + router.handleCommand(command("Input.dispatchMouseEvent", {}, info.sessionId)) + ).rejects.toThrow(/not supported by the debugger-free transport/); + }); +}); diff --git a/extension/entrypoints/background.ts b/extension/entrypoints/background.ts index 5a90582..4361b01 100644 --- a/extension/entrypoints/background.ts +++ b/extension/entrypoints/background.ts @@ -26,6 +26,8 @@ export default defineBackground(() => { const tabManager = new TabManager({ logger, sendMessage: (msg) => connectionManager.send(msg), + // Debugger-free (chrome.scripting + chrome.tabs) unless the user opts into CDP. + getTransportMode: () => stateManager.getTransportMode(), }); // Create CDP router (background focus by default — do not steal OS focus; @@ -85,6 +87,8 @@ export default defineBackground(() => { isActive: state.isActive, isConnected, focusPolicy: state.focusPolicy, + transportMode: state.transportMode, + debuggerAvailable: tabManager.isDebuggerAvailable(), }; } @@ -132,6 +136,13 @@ export default defineBackground(() => { if (message.focusPolicy) { await stateManager.setState({ focusPolicy: message.focusPolicy }); } + if (message.transportMode) { + const previous = await stateManager.getTransportMode(); + await stateManager.setState({ transportMode: message.transportMode }); + if ((await stateManager.getTransportMode()) !== previous) { + tabManager.resetTransports(); + } + } sendResponse(await buildStateResponse()); })(); return true; // Async response @@ -187,20 +198,26 @@ export default defineBackground(() => { }); }); - // Register debugger event listeners - chrome.debugger.onEvent.addListener(onDebuggerEvent); - chrome.debugger.onDetach.addListener(onDebuggerDetach); - - // Reset any stale debugger connections on startup - chrome.debugger.getTargets().then((targets) => { - const attached = targets.filter((t) => t.tabId && t.attached); - if (attached.length > 0) { - logger.log(`Detaching ${attached.length} stale debugger connections`); - for (const target of attached) { - chrome.debugger.detach({ tabId: target.tabId }).catch(() => {}); - } - } - }); + // Debugger listeners and stale-attach cleanup only apply where chrome.debugger + // is real. Browsers that omit or stub it (Search) run debugger-free. + void tabManager.probeDebugger(); + if (!tabManager.debuggerApiMissing()) { + chrome.debugger.onEvent?.addListener(onDebuggerEvent); + chrome.debugger.onDetach?.addListener(onDebuggerDetach); + + // Reset any stale debugger connections on startup + Promise.resolve(chrome.debugger.getTargets?.()) + .then((targets) => { + const attached = (targets ?? []).filter((t) => t.tabId && t.attached); + if (attached.length > 0) { + logger.log(`Detaching ${attached.length} stale debugger connections`); + for (const target of attached) { + chrome.debugger.detach({ tabId: target.tabId }).catch(() => {}); + } + } + }) + .catch(() => {}); + } // MV3 keepalive. The setTimeout-based reconnect loop dies with the service // worker, so once Chrome kills the idle worker (~30s) nothing recovers until diff --git a/extension/entrypoints/popup/index.html b/extension/entrypoints/popup/index.html index 7b71935..f471234 100644 --- a/extension/entrypoints/popup/index.html +++ b/extension/entrypoints/popup/index.html @@ -35,6 +35,13 @@

Browser Hand

Agents keep working without stealing your keyboard focus.

+ + + +

diff --git a/extension/entrypoints/popup/main.ts b/extension/entrypoints/popup/main.ts index 2cdd13c..e84b7ab 100644 --- a/extension/entrypoints/popup/main.ts +++ b/extension/entrypoints/popup/main.ts @@ -3,6 +3,7 @@ import type { GetStateMessage, SetStateMessage, StateResponse, + TransportMode, } from "../../utils/types"; const toggle = document.getElementById("active-toggle") as HTMLInputElement; @@ -11,6 +12,8 @@ const popupMark = document.getElementById("popup-mark") as HTMLImageElement; const connectionStatus = document.getElementById("connection-status") as HTMLParagraphElement; const focusPolicySelect = document.getElementById("focus-policy") as HTMLSelectElement; const focusHint = document.getElementById("focus-hint") as HTMLParagraphElement; +const transportSelect = document.getElementById("transport-mode") as HTMLSelectElement; +const transportHint = document.getElementById("transport-hint") as HTMLParagraphElement; const FOCUS_HINTS: Record = { background: "Agents keep working without stealing your keyboard focus.", @@ -18,12 +21,27 @@ const FOCUS_HINTS: Record = { window: "Brings Chrome to the front (old behavior).", }; +const TRANSPORT_HINTS: Record = { + scripting: "No debugging banner and nothing for sites to detect. Uses scripting + tabs APIs.", + debugger: "Full CDP via chrome.debugger. Shows Chrome's debugging banner; some sites block it.", +}; + function updateUI(state: StateResponse): void { toggle.checked = state.isActive; statusText.textContent = state.isActive ? "Active" : "Inactive"; popupMark.src = state.isActive ? "/icons/icon-48.png" : "/icons/icon-inactive-48.png"; focusPolicySelect.value = state.focusPolicy || "background"; focusHint.textContent = FOCUS_HINTS[state.focusPolicy] || FOCUS_HINTS.background; + const transportMode = state.transportMode || "scripting"; + transportSelect.value = transportMode; + const debuggerOption = transportSelect.querySelector('option[value="debugger"]'); + if (debuggerOption instanceof HTMLOptionElement) { + debuggerOption.disabled = state.debuggerAvailable === false; + } + transportHint.textContent = + state.debuggerAvailable === false + ? "This browser has no usable chrome.debugger; Browser Hand runs debugger-free." + : TRANSPORT_HINTS[transportMode]; if (state.isActive) { connectionStatus.textContent = state.isConnected ? "Connected to relay" : "Connecting..."; @@ -64,6 +82,18 @@ toggle.addEventListener("change", () => { ); }); +transportSelect.addEventListener("change", () => { + const transportMode = transportSelect.value as TransportMode; + chrome.runtime.sendMessage( + { type: "setState", transportMode }, + (response) => { + if (response) { + updateUI(response); + } + } + ); +}); + focusPolicySelect.addEventListener("change", () => { const focusPolicy = focusPolicySelect.value as FocusPolicy; chrome.runtime.sendMessage( diff --git a/extension/services/CDPRouter.ts b/extension/services/CDPRouter.ts index a6ba83f..ae5d94f 100644 --- a/extension/services/CDPRouter.ts +++ b/extension/services/CDPRouter.ts @@ -273,6 +273,11 @@ export class CDPRouter { return { ok: true }; } + case "DevBrowser.getCapabilities": { + await this.tabManager.probeDebugger(); + return await this.tabManager.getTransportInfo(); + } + case "DevBrowser.listTargets": { const targetInfos = await this.tabManager.listTargets(); return { targetInfos }; @@ -285,8 +290,14 @@ export class CDPRouter { if (!found?.tabId) { throw new Error(`No tab for group target ${targetId}`); } - const groupId = await this.groupExistingTarget(found.tabId, params.groupTitle); - return { groupId }; + try { + const groupId = await this.groupExistingTarget(found.tabId, params.groupTitle); + return { groupId }; + } catch (err) { + // Grouping is cosmetic. Browsers without tabGroups (Search) skip it. + this.logger.debug("Tab grouping unavailable; leaving tab ungrouped", err); + return { groupId: null }; + } } case "Runtime.enable": @@ -373,6 +384,21 @@ export class CDPRouter { } } + case "Page.navigate": { + // Unknown session: fall out of the switch for the generic "No tab found" error. + if (!debuggee || !targetTabId) break; + await this.tabManager.ensureDebuggerAttached(targetTabId); + // Debugger transport: fall out of the switch and forward over CDP. + if (!this.tabManager.usesScriptingFallback(targetTabId)) break; + const url = typeof msg.params.params?.url === "string" ? msg.params.params.url : ""; + if (!url) throw new Error("Page.navigate requires a url"); + return await this.tabManager.navigateViaTabs( + targetTabId, + url, + msg.params.sessionId ?? targetTab?.sessionId + ); + } + case "Target.createTarget": { const url = (msg.params.params?.url as string) || "about:blank"; this.logger.debug("Creating new tab with URL:", url); @@ -380,10 +406,16 @@ export class CDPRouter { if (!tab.id) throw new Error("Failed to create tab"); try { - await this.getOrCreateBrowserHandGroup( - tab.id, - msg.params.params?.groupTitle as string | undefined - ); + try { + await this.getOrCreateBrowserHandGroup( + tab.id, + msg.params.params?.groupTitle as string | undefined + ); + } catch (err) { + // Grouping is cosmetic. Browsers without tabGroups (Search) skip it + // instead of failing tab bootstrap. + this.logger.debug("Tab grouping unavailable; continuing without a group", err); + } await new Promise((resolve) => setTimeout(resolve, 100)); const registered = await this.tabManager.register(tab); @@ -453,15 +485,15 @@ export class CDPRouter { await this.tabManager.ensureDebuggerAttached(targetTabId); if (this.tabManager.usesScriptingFallback(targetTabId)) { - // Only evaluate is supported without the debugger; surface a clear error - // for other CDP methods so agents can re-open / re-navigate. + // Only evaluate, navigate and screenshot are emulated without the debugger; + // surface a clear error for other CDP methods. if (msg.params.method === "Runtime.evaluate") { const expression = typeof msg.params.params?.expression === "string" ? msg.params.params.expression : ""; return await this.tabManager.evaluateViaScripting(targetTabId, expression); } throw new Error( - `Tab is on scripting fallback after password-manager wedge; ${msg.params.method} requires debugger. Re-open the page or use evaluate/fill/type/click.` + `${msg.params.method} is not supported by the debugger-free transport (chrome.scripting/chrome.tabs). Use snapshot/evaluate/fill/type/click/goto/screenshot, or switch Browser Hand to the debugger transport.` ); } diff --git a/extension/services/StateManager.ts b/extension/services/StateManager.ts index 151d6cd..2bf8ca9 100644 --- a/extension/services/StateManager.ts +++ b/extension/services/StateManager.ts @@ -2,7 +2,7 @@ * StateManager - Manages extension active/inactive state with persistence. */ -import type { FocusPolicy } from "../utils/types"; +import type { FocusPolicy, TransportMode } from "../utils/types"; const STORAGE_KEY = "devBrowserActiveState"; const OVERRIDE_KEY = "devBrowserFocusOverride"; @@ -15,6 +15,8 @@ export interface ExtensionState { * window: previous behavior — activate tab and focus the Chrome window. */ focusPolicy: FocusPolicy; + /** scripting (default, debugger-free) or debugger (CDP; falls back when unavailable). */ + transportMode: TransportMode; } /** One-shot / TTL agent override of the popup focus policy. */ @@ -30,8 +32,13 @@ export interface FocusOverride { const DEFAULT_STATE: ExtensionState = { isActive: true, focusPolicy: "background", + transportMode: "scripting", }; +function normalizeTransportMode(value: unknown): TransportMode { + return value === "debugger" || value === "scripting" ? value : DEFAULT_STATE.transportMode; +} + function normalizeFocusPolicy(value: unknown): FocusPolicy { if (value === "tab" || value === "window" || value === "background") { return value; @@ -52,6 +59,7 @@ export class StateManager { return { isActive: state?.isActive ?? DEFAULT_STATE.isActive, focusPolicy: normalizeFocusPolicy(state?.focusPolicy), + transportMode: normalizeTransportMode(state?.transportMode), }; } @@ -66,11 +74,19 @@ export class StateManager { partial.focusPolicy !== undefined ? normalizeFocusPolicy(partial.focusPolicy) : current.focusPolicy, + transportMode: + partial.transportMode !== undefined + ? normalizeTransportMode(partial.transportMode) + : current.transportMode, }; await chrome.storage.local.set({ [STORAGE_KEY]: next }); return next; } + async getTransportMode(): Promise { + return (await this.getState()).transportMode; + } + async getFocusPolicy(): Promise { return (await this.getState()).focusPolicy; } diff --git a/extension/services/TabManager.ts b/extension/services/TabManager.ts index 7772614..834fc76 100644 --- a/extension/services/TabManager.ts +++ b/extension/services/TabManager.ts @@ -3,13 +3,32 @@ */ import type { Logger } from "../utils/logger"; -import type { TabInfo, TargetInfo } from "../utils/types"; +import type { TabInfo, TargetInfo, TransportMode } from "../utils/types"; export type SendMessageFn = (message: unknown) => void; export interface TabManagerDeps { logger: Logger; sendMessage: SendMessageFn; + /** + * Which transport to prefer. The extension entrypoint passes the persisted + * setting (default "scripting", debugger-free). Standalone use keeps the + * historical "debugger" behavior. + */ + getTransportMode?: () => Promise | TransportMode; +} + +/** chrome.debugger stubs (Search) reject with " isn't available in ". */ +const DEBUGGER_UNAVAILABLE_RE = + /isn['’]?t available|debugger (api )?(is )?(unavailable|not available|not supported)|not implemented/i; + +/** Page CSP (or the isolated-world CSP) refused string evaluation. */ +const EVAL_BLOCKED_RE = /unsafe-eval|content security policy|trusted ?types?/i; + +type EvalOutcome = { ok: true; value?: unknown } | { ok: false; error?: string; name?: string }; + +function stripHash(url: string | undefined): string { + return (url ?? "").split("#")[0]; } export class TabManager { @@ -19,10 +38,60 @@ export class TabManager { private lastFocusedTabId: number | null = null; private logger: Logger; private sendMessage: SendMessageFn; + private getTransportMode: () => Promise | TransportMode; + /** Set once an attach proves chrome.debugger is a stub; skips further attempts. */ + private debuggerUnavailableReason: string | null = null; + private debuggerProbed = false; constructor(deps: TabManagerDeps) { this.logger = deps.logger; this.sendMessage = deps.sendMessage; + this.getTransportMode = deps.getTransportMode ?? (() => "debugger"); + } + + /** chrome.debugger is absent, or lacks the calls we need. */ + debuggerApiMissing(): boolean { + const api = (globalThis as { chrome?: typeof chrome }).chrome?.debugger; + return typeof api?.attach !== "function" || typeof api?.sendCommand !== "function"; + } + + /** False on browsers that stub chrome.debugger (e.g. Search) or omit it. */ + isDebuggerAvailable(): boolean { + return this.debuggerUnavailableReason === null && !this.debuggerApiMissing(); + } + + /** + * Scripting mode never attaches, so it would never learn that chrome.debugger is + * a stub. Attach to a tab that cannot exist: real Chrome rejects with "No tab with + * given id", Search's stub with "isn't available". Nothing is ever attached. + */ + async probeDebugger(): Promise { + if (this.debuggerProbed || this.debuggerApiMissing()) return; + this.debuggerProbed = true; + try { + await chrome.debugger.attach({ tabId: -1 }, "1.3"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (DEBUGGER_UNAVAILABLE_RE.test(message)) this.debuggerUnavailableReason = message; + } + } + + async getTransportInfo(): Promise<{ + mode: TransportMode; + effective: TransportMode; + debuggerAvailable: boolean; + debuggerUnavailableReason?: string; + }> { + const mode = await this.getTransportMode(); + const debuggerAvailable = this.isDebuggerAvailable(); + return { + mode, + effective: mode === "debugger" && debuggerAvailable ? "debugger" : "scripting", + debuggerAvailable, + ...(this.debuggerUnavailableReason + ? { debuggerUnavailableReason: this.debuggerUnavailableReason } + : {}), + }; } /** @@ -280,6 +349,37 @@ export class TabManager { }); } + /** + * Drive a tab through chrome.scripting + chrome.tabs only: no CDP attach, no + * infobar, nothing for a site to detect. Not an error state (no errorText). + */ + markScriptingTransport(tabId: number): void { + const tab = this.tabs.get(tabId); + if (!tab || tab.debugTransport === "scripting") return; + this.tabs.set(tabId, { + ...tab, + debugTransport: "scripting", + debuggerAttached: false, + state: "connected", + }); + } + + /** Re-evaluate every tab's transport after the mode setting changes. */ + resetTransports(): void { + for (const [tabId, tab] of this.tabs) { + if (tab.debuggerAttached && !this.debuggerApiMissing()) { + chrome.debugger.detach({ tabId }).catch(() => {}); + } + this.tabs.set(tabId, { + ...tab, + debugTransport: undefined, + debuggerAttached: false, + debuggerDismissed: false, + errorText: undefined, + }); + } + } + clearScriptingFallback(tabId: number): void { const tab = this.tabs.get(tabId); if (!tab || tab.debugTransport !== "scripting" || tab.debuggerDismissed) return; @@ -308,14 +408,20 @@ export class TabManager { throw new Error(`Tab ${tabId} is not registered`); } - if (tab.debuggerDismissed) { - if (tab.debugTransport !== "scripting") { - this.markScriptingFallback(tabId, "debugger dismissed by user"); - } + if (tab.debugTransport === "scripting") { return; } - if (tab.debugTransport === "scripting") { + // Debugger-free by default; also the automatic path when the browser stubs + // chrome.debugger (Search). Only an explicit "debugger" mode attaches CDP. + const { effective } = await this.getTransportInfo(); + if (effective === "scripting") { + this.markScriptingTransport(tabId); + return; + } + + if (tab.debuggerDismissed) { + this.markScriptingFallback(tabId, "debugger dismissed by user"); return; } @@ -368,6 +474,12 @@ export class TabManager { } } catch (error) { const message = error instanceof Error ? error.message : String(error); + if (DEBUGGER_UNAVAILABLE_RE.test(message)) { + this.debuggerUnavailableReason = message; + this.logger.log("chrome.debugger is unavailable; using debugger-free transport:", message); + this.markScriptingTransport(tabId); + return; + } if (this.isDebuggerWedgeError(message)) { this.markScriptingFallback(tabId, message); return; @@ -417,20 +529,23 @@ export class TabManager { } /** - * Evaluate expression in page MAIN world via chrome.scripting (no debugger). - * Returns a CDP-like Runtime.evaluate result shape. + * Evaluate an expression with no debugger. Returns a CDP-like Runtime.evaluate + * result. Runs in the page's MAIN world so page globals are visible. A page + * whose CSP forbids eval refuses that, and the extension's ISOLATED world is no + * escape (its own CSP forbids eval too), so the source is then injected as + * script text through chrome.userScripts, which never calls eval. */ async evaluateViaScripting( tabId: number, expression: string ): Promise<{ result: { type: string; value?: unknown; description?: string }; - exceptionDetails?: { text: string }; + exceptionDetails?: { text: string; exception?: { description: string } }; }> { if (!chrome.scripting?.executeScript) { throw new Error("chrome.scripting is unavailable"); } - const [{ result }] = await chrome.scripting.executeScript({ + const [first] = await chrome.scripting.executeScript({ target: { tabId }, world: "MAIN", args: [expression], @@ -441,31 +556,146 @@ export class TabManager { if (value != null && typeof (value as { then?: unknown }).then === "function") { value = await value; } - return { ok: true as const, value }; + // Only plain data crosses back; DOM nodes etc. degrade to a string. + let safe: unknown = value; + if (value !== undefined) { + try { + safe = JSON.parse(JSON.stringify(value)); + } catch { + safe = String(value); + } + } + return { ok: true as const, value: safe }; } catch (e) { + const err = e as { name?: string; message?: string }; return { ok: false as const, - error: e instanceof Error ? e.message : String(e), + error: err?.message ? String(err.message) : String(e), + name: err?.name, }; } }, }); + let result = first?.result as EvalOutcome | undefined; + if ( + result?.ok === false && + (result.name === "EvalError" || EVAL_BLOCKED_RE.test(result.error ?? "")) + ) { + this.logger.debug("Page CSP blocked eval; injecting as script text", tabId); + result = await this.evaluateViaUserScripts(tabId, expression, result.error); + } if (!result || typeof result !== "object") { return { result: { type: "undefined" } }; } - if ((result as { ok?: boolean }).ok === false) { - const text = String((result as { error?: string }).error || "evaluate failed"); + if (result.ok === false) { + const text = String(result.error || "evaluate failed"); return { result: { type: "undefined" }, - exceptionDetails: { text }, + exceptionDetails: { text, exception: { description: text } }, }; } - const value = (result as { value?: unknown }).value; + const value = result.value; const type = value === null ? "object" : value === undefined ? "undefined" : typeof value; return { result: { type, value } }; } + /** CSP-proof evaluate: the expression is the script itself, so nothing is eval'd. */ + private async evaluateViaUserScripts( + tabId: number, + expression: string, + blockedError = "Content Security Policy blocked string evaluation" + ): Promise { + let api: typeof chrome.userScripts | undefined; + try { + api = chrome.userScripts; // undefined (or throws) until the user allows user scripts + } catch { + api = undefined; + } + if (typeof api?.execute !== "function") { + return { + ok: false, + error: `${blockedError} — this page's CSP blocks string evaluation. Turn on "Allow User Scripts" for Browser Hand on the browser's extensions page, or use the debugger transport where the browser has one.`, + }; + } + // A newline after the expression keeps a trailing // comment from eating the wrapper. + const code = `(async()=>{try{const v=await(\n${expression}\n);let s=v;if(v!==undefined){try{s=JSON.parse(JSON.stringify(v))}catch(e){s=String(v)}}return{ok:true,value:s}}catch(e){return{ok:false,error:e&&e.message?String(e.message):String(e),name:e&&e.name}}})()`; + try { + const [first] = await api.execute({ + js: [{ code }], + target: { tabId }, + world: "MAIN", + }); + return first?.result ?? { ok: false, error: "userScripts.execute returned no result" }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } + } + + /** + * Page.navigate without the debugger: chrome.tabs.update, then wait for the + * load to settle so the caller gets a real errorText and a loaded DOM. + * Reports loadComplete (the CLI skips waiting for Page.loadEventFired) and + * also emits that event shortly after replying, for clients that wait on it. + */ + async navigateViaTabs( + tabId: number, + url: string, + sessionId?: string, + timeoutMs = 20000 + ): Promise<{ frameId: string; loaderId: string; errorText?: string; loadComplete?: boolean }> { + const before = await chrome.tabs.get(tabId); + const frameId = this.tabs.get(tabId)?.targetId ?? `tab-${tabId}`; + const loaderId = `scripting-${Date.now().toString(36)}`; + const sameDocument = url.includes("#") && stripHash(before.url) === stripHash(url); + + await chrome.tabs.update(tabId, { url }); + + let loaded = false; + let live = before; + const start = Date.now(); + let sawLoading = before.status === "loading"; + while (Date.now() - start < timeoutMs) { + if (sameDocument) { + loaded = true; // fragment navigation never reloads the document + break; + } + try { + live = await chrome.tabs.get(tabId); + } catch { + return { frameId, loaderId, errorText: "net::ERR_ABORTED (tab closed during navigation)" }; + } + if (live.status === "loading") sawLoading = true; + // A fast load can flip loading→complete between polls; the URL change or a + // short grace period stands in for the "loading" sighting. + const moved = stripHash(live.url) !== stripHash(before.url); + if (live.status === "complete" && (sawLoading || moved || Date.now() - start > 1000)) { + loaded = true; + break; + } + await this.sleep(100); + } + + const finalUrl = live.url || live.pendingUrl || ""; + if (finalUrl.startsWith("chrome-error://")) { + return { frameId, loaderId, errorText: `net::ERR_FAILED (navigation failed: ${finalUrl})` }; + } + if (loaded) { + await this.register(live).catch(() => null); + setTimeout(() => { + this.sendMessage({ + method: "forwardCDPEvent", + params: { + sessionId, + method: "Page.loadEventFired", + params: { timestamp: Date.now() / 1000 }, + }, + }); + }, 150); + } + return { frameId, loaderId, ...(loaded ? { loadComplete: true } : {}) }; + } + /** * Detach a tab and clean up. */ diff --git a/extension/utils/types.ts b/extension/utils/types.ts index 74716ab..87ea5a1 100644 --- a/extension/utils/types.ts +++ b/extension/utils/types.ts @@ -13,6 +13,15 @@ export type TabState = "connecting" | "connected" | "error"; export type DebugTransport = "debugger" | "scripting"; +/** + * How the extension drives pages. + * scripting (default): debugger-free. chrome.scripting + chrome.tabs only, so no + * "started debugging this browser" infobar and nothing for sites to detect. + * debugger: CDP over chrome.debugger. Falls back to scripting on browsers that + * stub the API (e.g. Search), so it never hard-fails. + */ +export type TransportMode = "scripting" | "debugger"; + export interface TabInfo { sessionId?: string; targetId?: string; @@ -107,12 +116,16 @@ export interface SetStateMessage { type: "setState"; isActive?: boolean; focusPolicy?: FocusPolicy; + transportMode?: TransportMode; } export interface StateResponse { isActive: boolean; isConnected: boolean; focusPolicy: FocusPolicy; + transportMode: TransportMode; + /** False when the browser stubs chrome.debugger (Search) or it is missing. */ + debuggerAvailable: boolean; } export type PopupMessage = GetStateMessage | SetStateMessage; diff --git a/extension/wxt.config.ts b/extension/wxt.config.ts index 2f6f863..a9a00d9 100644 --- a/extension/wxt.config.ts +++ b/extension/wxt.config.ts @@ -6,7 +6,10 @@ export default defineConfig({ manifest: { name: "Browser Hand", description: "Give agents a hand on your real Chrome — local extension bridge for Browser Hand", - permissions: ["debugger", "scripting", "tabGroups", "tabs", "storage", "alarms"], + // userScripts lets debugger-free mode run on pages whose CSP forbids eval; it + // only takes effect once the user turns on "Allow User Scripts" (Chrome) or + // the browser grants it (Search). + permissions: ["debugger", "scripting", "tabGroups", "tabs", "storage", "alarms", "userScripts"], host_permissions: [""], icons: { 16: "icons/icon-16.png", diff --git a/skills/browser-hand/SKILL.md b/skills/browser-hand/SKILL.md index 6838820..170fc84 100644 --- a/skills/browser-hand/SKILL.md +++ b/skills/browser-hand/SKILL.md @@ -55,6 +55,15 @@ This is a **skill**, not an MCP server: no always-on tool list and no extra MCP 3. This stack defaults to **background tabs** — no stealing OS focus while the user works. 4. Recovery paths for password-manager wedges and multi-step agent flows; local **challenge gym** for agent-shaped failures. +## Page control: debugger-free by default + +The extension drives pages with `chrome.scripting` + `chrome.tabs` (no `chrome.debugger`), so there is no "started debugging this browser" banner and nothing for a site to detect. Snapshot, click, type, fill, evaluate, goto and screenshot all work this way. Browsers that stub `chrome.debugger` (the macOS **Search** browser) therefore work with no setup. + +- `browser-hand doctor` reports `transport` (`mode`, `effective`, `debuggerAvailable`). +- The popup's **Page control** selector opts into the CDP debugger transport; it falls back to debugger-free automatically where the API is stubbed or missing. +- Pages whose CSP forbids `eval` need `chrome.userScripts`: turn on **Allow User Scripts** for Browser Hand on the extensions page (Chrome 138+). Search provides it once the extension declares the permission. Without it those pages fail with an error that says so. +- Not available without the debugger: `screenshot --full-page` (visible viewport only) and tab groups where `chrome.tabGroups` is missing (tabs are left ungrouped). + ## Pieces (repo root) | Piece | Command / path | diff --git a/skills/browser-hand/references/troubleshooting.md b/skills/browser-hand/references/troubleshooting.md index 609a715..b3233c2 100644 --- a/skills/browser-hand/references/troubleshooting.md +++ b/skills/browser-hand/references/troubleshooting.md @@ -19,6 +19,11 @@ retrying browser work; repeated `open`/`snapshot` calls will be flaky. - **`tab_bootstrap_works`** → extension mode is healthy. Continue with `open` or `snapshot`. +- **`path_b_required`** → creating a named tab failed. The current build drives + pages debugger-free, so this normally means an **old extension build** (e.g. + `debugger.attach isn't available in Search`): rebuild, reload the extension from + `extension/dist/chrome-mv3`, and check `doctor`'s `transport` field + (`effective: scripting`, `debuggerAvailable`). Two failure modes look identical but have different owners — probe first, then route to the right fix: - `curl :9333` **refused** = no relay listening → the extension has nothing to connect to → start a persistent relay. This is yours to fix, don't ask the user.