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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<token>/…`, 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.
Expand Down
13 changes: 10 additions & 3 deletions cli-js/src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -57,11 +57,15 @@ Operations:
goto --url <url> Navigate the active tab.
open --url <url> [--page-name <name>] [--group-title <text>]
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 <why> Required for good audit (e.g. 2fa, confirm-publish).
tabs [--query <text>] 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 <name>] 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 —
Expand Down Expand Up @@ -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()) {
Expand Down
105 changes: 103 additions & 2 deletions cli-js/src/tool.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
* `<doc origin>/local/<token>/<basename>`. 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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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.",
Expand All @@ -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",
Expand Down Expand Up @@ -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()}`;
Expand Down Expand Up @@ -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.";
}
Expand Down Expand Up @@ -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");
Expand Down
166 changes: 166 additions & 0 deletions cli-js/test/doctor-browser.test.js
Original file line number Diff line number Diff line change
@@ -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)
);
}
});
Loading
Loading