qz-browser-agent 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,165 @@
1
+ ---
2
+ name: qz-browser-agent
3
+ description: Drive the connected browser through qz (轻舟) scripts. Create one titled session per task, pass its sessionId to every script, and bind each page with qz.tab(tabRef). Read qz skill for the installed CLI's guide. Read references/create-task.md only when the built-in agent should plan the task.
4
+ ---
5
+
6
+ Use the installed `qz` command. If unavailable, use `npx -y qz-browser-agent@latest` in place of `qz` in the commands below. Run `qz skill` once at the start of a task for the matching guide. `qz --version` prints its version.
7
+
8
+ <!-- qz-drive-guide:en -->
9
+ The external agent plans the work and drives the connected browser through qz (轻舟). Do not configure a qz model or call `qz create`. Read references/create-task.md only when the built-in agent should plan and execute the task.
10
+
11
+ ## Connect
12
+
13
+ Run commands with Node.js 22+ on the machine running the browser extension:
14
+
15
+ ```sh
16
+ qz status --json
17
+ ```
18
+
19
+ Session and script commands prepare the connection automatically: they reuse a responding online browser, or open the connection page once in the system default browser and wait for its extension to respond. Complete the extension's first-use notice and connection prompt when shown. If the extension is missing, `qz install` opens the store; ask the user to install it. When qz does not respond in that browser, the page offers the store and a copyable connection link, and a pending connection error carries the same link in `details.url`: ask the user which browser has qz installed, open the link there, and retry. Do not search browser profiles for the extension.
20
+
21
+ When multiple browsers are registered, run `qz browsers --json` and pass `--client CLIENT_UUID` to select a profile explicitly. Existing sessions are located via read-only queries before execution; ambiguous or unavailable ownership requires explicit selection. To pair a specific profile without launching a browser, run `qz connect uri --name work --json` and open `result.url` in that profile. An incompatible local service is restarted once before browser operations; an incompatible extension requires updating the CLI and extension together.
22
+
23
+ ## One task uses one session
24
+
25
+ Run `qz session create --title "Inspect example page"` first. A readable `--title` is required (omitting it exits 2). stdout prints `sessionId <id>`; with `--json`, read `result.sessionId`, and `result.qz_skills` lists the workflows available to the new session. Each call creates a fresh session without starting a model.
26
+
27
+ Pass that same ID to every `qz script --session ID` call (missing `--session` exits 2). Scripts never create or resume a session implicitly. Use separate sessions for independent tasks. Run `qz session list` to list sessions and `qz session show ID` to read `{sessionId,title,status}`. If an ID is not found, check `qz session list` and the selected browser; never invent an ID.
28
+
29
+ ```sh
30
+ CREATED=$(qz session create --title "Inspect example page" --json) || exit 1
31
+ SID=$(printf '%s' "$CREATED" | node -e 'const x=JSON.parse(require("fs").readFileSync(0,"utf8")); if(!x.ok || !x.result.sessionId) process.exit(1); console.log(x.result.sessionId)') || exit 1
32
+ qz script --session "$SID" - <<'JS'
33
+ // Open one page and read its relevant text.
34
+ const opened = await qz.openTab({ url: "https://example.com", tabAlias: "page" });
35
+ return await qz.tab(opened).read({ limit: 3000 });
36
+ JS
37
+ ```
38
+
39
+ `qz.openTab({url,tabAlias?})` creates a page and returns a Tab `{tabRef,url,title,active}`; `qz.listTabs()` returns a list in `tabs`. `qz.tab(...)` binds an existing page using that Tab, its `tabRef`, or its optional alias. Aliases use 1–64 letters, digits, underscores, or hyphens (not all digits), and expire when the tab closes. Returned positive integers or numeric strings are valid references; never guess one. `active` marks the tab visible to the user and never selects an operation's target. Use `page.goto(url)` to navigate an existing task tab. `page.setMode("mobile")` switches to mobile mode (`"desktop"` restores it); a change reloads the page, so re-observe it afterward.
40
+
41
+ One tab belongs to one session at a time. Use `qz.listTabs({scope:"all"})` for read-only inspection of other tabs, and a session-owned page for actions or evaluation. Binding a Page neither changes ownership nor selects the active tab.
42
+
43
+ ## Scripts and the qz SDK
44
+
45
+ `qz script --session ID [FILE|-] [--args JSON]` runs JavaScript orchestration outside web pages. Read a local file or `-` for stdin; `--args` passes a JSON value exposed as `args`. Use top-level `await` and `return`, starting with a short comment stating the script's purpose. The outer script survives page navigation and provides `qz`, `args`, `trace()`, and `console`, without `document`/`window`, Node.js, `fs`, `process`, `require`, or `import`. Awaited SDK calls return business values directly and throw on failure.
46
+
47
+ Bind `const page = qz.tab(tabRef)` and use:
48
+
49
+ | Purpose | SDK methods |
50
+ | --- | --- |
51
+ | Navigation and page mode | `page.goto(url, options?)`, `page.close()`, `page.setMode(mode)` |
52
+ | Focused observations | `page.read({limit,...})`, `page.find({role:"link",...})`, `page.axTree(options)`, `page.screenshot(options)` |
53
+ | Page JavaScript | `page.evaluate(code, args?, options?)` |
54
+ | State waits | `page.waitForURL(pattern, options?)`, `page.waitForLoad(options?)`, `page.waitForFunction(code, args?, options?)` |
55
+ | Element operations | `page.locator(targetOrQuery)` with the methods below |
56
+
57
+ Use `qz.listTools()` for compact signatures of additional capabilities and `qz.listTools({names:["list_images"]})` for selected full schemas, including declared `outputSchema` fields and examples. Invoke those capabilities through `qz.call(name, args)`, using their catalog names and explicit `tabRef` for page calls. Read unfamiliar schemas before calling and use declared return fields.
58
+
59
+ `qz_skills` are browser workflows installed in the qz extension. `qz session create --json` returns their summaries in `result.qz_skills`, and each script receipt includes them in `context.qz_skills`: enabled user Global and Resident workflows, plus Site workflows matching sites visited by this task. Start a new site with a short observation script and check this catalog before further work. Global and Resident workflow tools are callable from session creation and appear in `qz.listTools()`; Site workflow tools become callable when the task visits a matching site. Load a relevant workflow with `return await qz.call("load_skill", {name:"skill-name"})`. Read its `content` and `tools`: each tool includes its exact calling `name`, description, input `parameters`, and declared `outputSchema` with examples. Loading activates those tools for the session immediately, so later calls in the same script can use them. Follow the workflow’s page prerequisites, then invoke `qz.call(name, args)` with those parameters and an explicit `tabRef` for page tools. After conversation compaction, reload the workflow if its instructions are missing. The catalog repeats on every receipt; full instructions are loaded on demand through qz, independently of the external agent's own Skill loader. Use `qz.call("list_skills", {url:"https://example.com"})` to query explicitly; omit `url` for all user workflows. Built-in Skills are hidden from discovery but remain loadable by name.
60
+
61
+ Access page DOM and authenticated `fetch` inside self-contained `page.evaluate` or `page.waitForFunction` callbacks, including DOM reads used to build the outer return value. Their second argument supplies the function's input; return a JSON-serializable value. Closures, DOM nodes, functions, Page objects, and Locators cannot cross the page boundary.
62
+
63
+ ```sh
64
+ qz script --session "$SID" --args '{"limit":1000}' - <<'JS'
65
+ // Reuse the page opened earlier and read a compact DOM summary.
66
+ const page = qz.tab("page");
67
+ await page.goto("https://example.org");
68
+ return await page.evaluate(
69
+ ({ limit }) => ({ title: document.title, text: document.body.innerText.slice(0, limit) }),
70
+ args
71
+ );
72
+ JS
73
+ ```
74
+
75
+ Use outer SDK methods for clicks, form input, and navigation. `page.evaluate` waits for `DOMContentLoaded` by default (third argument `{waitUntil:"commit"}` allows early observation); wait for specific conditions when reading dynamic content. The page function has DOM access and cannot call the qz SDK. Navigating or closing its page can interrupt an in-flight evaluation; catch its error if the outer script should continue. Inspect completed state before retrying an uncertain action.
76
+
77
+ ## Observe, act, and verify
78
+
79
+ Observe the relevant controls and choose the result needed by the next step. Use `page.read({limit})` for document text, lists, and tables, or a scoped `page.axTree(...)` / `page.find(...)` for controls. Element observations identify nodes by `target` (`uid:t<tab>.<snap>.<n>` or `css:...`); `rect` is `{x,y,width,height}`. Pass returned targets unchanged to `page.locator(target)`. A `uid:` always identifies its original node; navigation or DOM replacement can make it stale, requiring a new observation.
80
+
81
+ Use search results as leads; verify key facts on the original pages. Include source URLs for key findings and make clear when evidence or coverage is incomplete.
82
+
83
+ `page.locator(query)` stores a query and resolves it when an operation runs. Queries accept `target`, `role`, `name`, `text`, `exact`, `scope`, and `nth`; at least one of `target`, `role`, `name`, or `text` is required. The SDK accepts `{css:"#input"}` as shorthand for `{target:"css:#input"}`; use one form. CSS lists have one prefix for the whole expression (`"css:input, button"`). `name` matches the accessible name reported by `page.find` or `page.axTree`; `text` matches element text. Both normalize whitespace and match exactly, case-sensitively, by default; `exact:false` matches a substring. `scope` is an observed `uid:` or `css:` root, including a frame document reference. A pinned UID already identifies its scope; do not combine it with `scope`. Element operations require one match: narrow an ambiguous query or explicitly choose `locator.nth(index)` using a zero-based index.
84
+
85
+ Locator methods are `click(options?)`, `doubleClick(options?)`, `hover(options?)`, `fill(value, options?)`, `press(key, options?)`, `select(value, options?)`, `check(options?)`, `uncheck(options?)`, `scroll(options?)`, and `dragTo(destination, options?)` on the same tab. Actions locate and wait for a usable element within their shared `timeout` budget (seconds, default 20, maximum 120), then dispatch once. Call the next locator operation directly; it handles readiness. Dispatched actions are never automatically replayed. Ordinary success returns `{}`, with `navigated`/`url` or `navigationPending` when navigation is detected, and `popups` for observed new Tabs. If `confirmationRequired` is true, verify the field locally before continuing.
86
+
87
+ ```js
88
+ // Fill an observed form and choose its asynchronously appearing suggestion.
89
+ const page = qz.tab("page");
90
+ await page.locator(args.fieldTarget).fill(args.query);
91
+ await page.locator({ role: "option", name: args.expectedOption }).click({ timeout: 10 });
92
+ return await page.locator(args.fieldTarget).inspect();
93
+ ```
94
+
95
+ Use `locator.inspect()` for current element state and `locator.read({limit})` for its text. `locator.wait({state:"visible",timeout:10})` waits for `attached`, `visible` (default), `hidden`, or `detached` state. Hidden includes removal or invisibility; detached requires removal. `locator.count()` reads the current attached match count, including hidden elements; `locator.wait({count:3,timeout:10})` waits for exactly that count, including zero. Use either `state` or `count` per wait. A match or count describes the current moment; it does not prove a dynamic list has finished updating.
96
+
97
+ When an action must cause navigation or a popup, pass `waitFor:"navigation"` or `waitFor:"popup"` in its options. This installs the listener before dispatch. Navigation returns its observation; popup returns a Tab `{tabRef,url,title,active}` directly. A failed wait never replays the action.
98
+
99
+ ```js
100
+ // Open a detail page and read it.
101
+ const popup = await qz.tab("page").locator({ role: "link", name: "Details" })
102
+ .click({ waitFor: "popup", timeout: 10 });
103
+ return await qz.tab(popup).read({ limit: 2000 });
104
+ ```
105
+
106
+ Navigation waits observe new navigations, including reloads, SPA transitions, and hash changes. `page.waitForLoad()` checks the current load state; `page.waitForURL(pattern)` matches the full URL using a regular expression string or a `RegExp`. For in-page updates, use `page.waitForFunction(code, args?, options?)` to wait for the specific fields needed by the next step and return them. Keep action, condition, and focused result in one script. Match the requested item or mode as well as its required fields. Reserve fixed delays for tasks that require elapsed time.
107
+
108
+ ```js
109
+ // Open a place's details. This page clears the old detail fields while loading.
110
+ const page = qz.tab("page");
111
+ await page.locator(args.itemTarget).click();
112
+ return await page.waitForFunction(name => {
113
+ const panel = document.querySelector(".place-details");
114
+ const title = panel?.querySelector("h2")?.textContent?.trim();
115
+ const address = panel?.querySelector(".address")?.textContent?.trim();
116
+ return title === name && address ? { name: title, address } : null;
117
+ }, args.place, { timeout: 20 });
118
+ ```
119
+
120
+ Use selectors and fields observed on the page. If it retains previous values while loading, also wait for a completion signal that distinguishes the selected item's result.
121
+
122
+ `page.find` returns an array of current element observations (up to `limit`), or `[]` when none match. Use normal array operations, e.g. `(await page.find({role:"link"})).slice(0,5)`; `waitFor:"match",timeout:10` waits for a candidate. Use `role`, `tag`, and `type` for single filters, or `roles`, `tags`, and `types` for multiple alternatives; use one form per filter. For example, `page.find({tag:"input",type:"search"})` finds search inputs. Its `pattern` accepts a regex string (case-insensitive by default) or a `RegExp`, as in `page.find({role:"link",pattern:/download/i})`; a `RegExp` supplies its own flags. Locator `name` and `text` match their specific fields. `page.axTree` uses `scope:"viewport"` or `"full"`; narrow `target` or `limit` when inspecting one control. Reserve screenshots for visual checks.
123
+
124
+ Run one `qz script` at a time per session. Within it, use `await` for dependencies and `Promise.all` for independent page workflows. Same-page actions serialize; the caller sees results when the script returns. `Promise.all` preserves input order and rejects on first failure without cancelling siblings; use `Promise.allSettled` to retain every outcome. Await all calls before returning: exit cancels unfinished calls.
125
+
126
+ ```sh
127
+ qz script --session "$SID" - <<'JS'
128
+ // Open and read each page independently.
129
+ return await Promise.all(
130
+ ["https://example.com", "https://example.org"].map(async url => {
131
+ const opened = await qz.openTab({ url });
132
+ return await qz.tab(opened).read({ limit: 1500 });
133
+ })
134
+ );
135
+ JS
136
+ ```
137
+
138
+ Scripts are not transactions: completed actions remain in effect after a later failure. Return only the business data needed for the task. Use `trace()` at key checkpoints, such as completed stages and counts, to aid recovery. `trace` contains only errors and explicit `trace()`/`console` output, in order.
139
+
140
+ ## Documents and larger results
141
+
142
+ For PDFs, load the document Skill before reading: call `qz.call("load_skill", {name:"qingzhou-documents"})`, and follow its schemas using the original PDF URL. PDF viewers do not support ordinary body reads or element actions. Keep returned `fileId`/`docModelId` values within the session and follow chunk cursors only as needed. `qz.call("load_skill_resource", {name,path})` parses packaged documents directly.
143
+
144
+ Images and files remain in their artifact stores; return their references. Business results exceeding ~48,000 characters become `{truncated:true,totalChars,limitChars,preview}`; narrow the query or paginate instead of treating `preview` as complete. `ToolError` is never truncated.
145
+
146
+ ## Output and recovery
147
+
148
+ stdout is JSON `{ok,result,trace}` on success and failure, with `context.qz_skills` for currently available user workflows when discovery succeeds. `ok` reports execution success; `result` is the returned business value (`null` for `undefined`) or the uncaught `{code,message,hint?,details?}` error (with script line/column in `details`); `trace` lists errors and explicit `trace()`/`console` messages in order, without levels. Exit codes: `0` success, `1` execution or connection failure, `2` invalid CLI arguments.
149
+
150
+ `--timeout` sets script execution seconds (default `120`, range `>0` through `3600`); the request waits 60 seconds longer. Give the calling shell a longer deadline. A timeout or interrupted connection does not undo browser actions; inspect the session and page before repeating an action.
151
+
152
+ - Disconnected: retry with the same `--session ID`; connection preparation is automatic. Use `qz connect uri` to pair a specific browser profile.
153
+ - `DRIVE_SESSION_NOT_FOUND`: check `qz session list` and `--client`.
154
+ - `DRIVE_SESSION_ENDED`: create a new session for a new task.
155
+ - `DRIVE_SESSION_BUSY`: wait for the active operation to finish. Put concurrent page work inside one script with `Promise.all`.
156
+ - `TAB_REF_REQUIRED`: bind `qz.tab(tabRef)` using a reference from `qz.openTab` or `qz.listTabs`.
157
+ - Unknown alias or stale target: inspect `qz.listTabs()` or refresh the element observation. After a failed open, only reuse an alias if `qz.listTabs()` confirms it.
158
+ - Login or challenge: stop and ask the user to complete it in the browser.
159
+
160
+ ## Finish
161
+
162
+ Run `qz session cancel "$SID"` when the task is done. It ends the session and closes its created tabs (including while a script is running), while preserving pre-existing user tabs and tabs handed back to the user. To leave result pages open, pass their references with `--keep result --keep evidence`; all references are validated before stopping. Kept pages return to the user and the session still ends. Do not call `page.close()` after the session ends; further calls return `DRIVE_SESSION_ENDED`.
163
+
164
+ Ctrl-C (`SIGINT`) or `SIGTERM` cancels the current script while preserving its session and tabs; already-dispatched browser actions may still finish and are not undone. CLI process exit does not close task tabs; ~30 minutes of inactivity triggers cleanup while the extension runtime is running. Always run `qz session cancel` for task cleanup. The side panel for a drive session is view-only. Use the qz SDK for page content and the shell for local files.
165
+ <!-- qz-drive-guide:end -->
Binary file
Binary file
@@ -0,0 +1,40 @@
1
+ export const SCRIPT_API_VERSION = 2;
2
+
3
+ /** Script timeout stays at 120 unless --timeout is set. The request waits 60 seconds longer. */
4
+ export function scriptTimeouts({ timeoutOption, timeoutSeconds = 180 } = {}) {
5
+ const scriptSeconds = timeoutOption === undefined ? 120 : Number(timeoutSeconds);
6
+ return { scriptSeconds, requestSeconds: scriptSeconds + 60 };
7
+ }
8
+
9
+ /** Build an outer script request; each page tool supplies its own tabRef. */
10
+ export function buildScriptRequest({ code = "", timeoutSeconds = 120, sessionId = "", args = {} } = {}) {
11
+ const id = String(sessionId || "").trim();
12
+ if (!id) throw new Error("qz script requires --session <id>. Pass the sessionId from qz session create.");
13
+ return { name: "run_script", sessionId: id, scriptApiVersion: SCRIPT_API_VERSION, args: { timeout: timeoutSeconds, code: String(code), args } };
14
+ }
15
+
16
+ /** The execution envelope, never business keys, determines success. */
17
+ export function presentScriptResult(payload) {
18
+ const receipt = payload?.result;
19
+ if (payload?.scriptApiVersion !== SCRIPT_API_VERSION || typeof payload.isError !== "boolean"
20
+ || !receipt || typeof receipt.ok !== "boolean" || payload.isError === receipt.ok
21
+ || !Object.hasOwn(receipt, "result") || receipt.result === undefined
22
+ || !Array.isArray(receipt.trace) || !receipt.trace.every(row => typeof row === "string")) {
23
+ return presentScriptFailure({ code: "UPGRADE_REQUIRED", message: "The connected extension uses an incompatible script API.",
24
+ hint: "The script may have run. Inspect the browser before retrying, then update the CLI and extension together and reconnect." });
25
+ }
26
+ return { text: JSON.stringify(receipt) + "\n", exitCode: receipt.ok ? 0 : 1 };
27
+ }
28
+
29
+ /** Local and transport failures have no script receipt; only their diagnostics become trace. */
30
+ export function presentScriptFailure(error = {}) {
31
+ const { trace: hostTrace, ...details } = error.details || {};
32
+ const trace = Array.isArray(hostTrace) ? hostTrace.flatMap(row => typeof row === "string" ? [row]
33
+ : typeof row?.message === "string" ? [row.message] : []) : [];
34
+ const failure = {
35
+ code: String(error.code || "TOOL_EXECUTION_FAILED").toUpperCase(),
36
+ message: typeof error.message === "string" ? error.message : "Script failed.",
37
+ ...(error.hint ? { hint: error.hint } : {}), ...(Object.keys(details).length ? { details } : {}),
38
+ };
39
+ return { text: JSON.stringify({ ok: false, result: failure, trace }) + "\n", exitCode: 1 };
40
+ }
package/src/server.mjs CHANGED
@@ -3,18 +3,27 @@ import { randomUUID } from "node:crypto";
3
3
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
4
  import { isInitializeRequest, isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js";
5
5
  import { BridgeState, PROTOCOL_VERSION } from "./state.mjs";
6
- import { INSTALL_URL } from "./package-info.mjs";
6
+ import { CONNECT_PAGE, CONNECT_PAGE_CSP, CONNECT_LOGOS } from "./connect-page.mjs";
7
7
  import { createMcpServer } from "./tools.mjs";
8
8
  import { BrowserRegistry } from "./browser-registry.mjs";
9
9
 
10
+ import { SCRIPT_API_VERSION } from "./script-call.mjs";
11
+
10
12
  const MAX_BODY_BYTES = 32 * 1024 * 1024;
11
- const CONNECT_PAGE = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Connect qz</title><style>body{font:16px/1.6 system-ui,sans-serif;max-width:38rem;margin:15vh auto;padding:24px;background:#fafafa;color:#202020}h1{font-size:28px;line-height:1.2}a{color:inherit}p{color:#555}</style><h1>Connect qz</h1><p data-qz-connection-status>Connecting to the qz browser extension…</p><p>Keep this page open while qz connects. If qz is not installed, <a href="${INSTALL_URL}" target="_blank" rel="noopener noreferrer">install qz from the Chrome Web Store</a>.</p></html>`;
12
13
 
13
14
  function sendJson(response, status, body, headers = {}) {
14
15
  response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...headers });
15
16
  response.end(JSON.stringify(body));
16
17
  }
17
18
 
19
+ // Chrome origins stay `chrome-extension://` plus 32 characters from a-p.
20
+ // Firefox origins are `moz-extension://` plus the lowercase UUID stored for the
21
+ // gecko id in `extensions.webextensions.uuids`. The pairing token authenticates the profile.
22
+ export function isExtensionBridgeOrigin(origin) {
23
+ return /^chrome-extension:\/\/[a-p]{32}$/.test(origin)
24
+ || /^moz-extension:\/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(origin);
25
+ }
26
+
18
27
  async function readJson(request) {
19
28
  let length = 0;
20
29
  const chunks = [];
@@ -32,7 +41,7 @@ async function readJson(request) {
32
41
  }
33
42
  }
34
43
 
35
- export async function startBridge({ host = "127.0.0.1", port = 18765, instanceId = randomUUID(), scopeId = "", buildId = "", onClose,
44
+ export async function startBridge({ host = "127.0.0.1", port = 18765, instanceId = randomUUID(), scopeId = "", buildId = "", pidNamespace = "", onClose,
36
45
  registryPath, registry, requestTimeoutSeconds = 180, connectWaitMs = 2000, mcpSessionIdleTimeoutMs = 30 * 60 * 1000 } = {}) {
37
46
  if (!["127.0.0.1", "localhost"].includes(host)) throw new Error("qz bridge must bind to loopback");
38
47
  if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error("Invalid bridge port");
@@ -86,8 +95,9 @@ export async function startBridge({ host = "127.0.0.1", port = 18765, instanceId
86
95
  }
87
96
  const path = new URL(request.url, state.endpoint).pathname;
88
97
  const origin = String(request.headers.origin || "");
89
- // Browser sites cannot call the bridge. Only extension origins use poll/result routes.
90
- if (origin && (path === "/mcp" || !/^chrome-extension:\/\/[a-p]{32}$/.test(origin))) {
98
+ // Browser sites cannot call the bridge. Extension origins may use poll/result routes.
99
+ // /mcp stays closed to every Origin so a page cannot open an MCP session.
100
+ if (origin && (path === "/mcp" || !isExtensionBridgeOrigin(origin))) {
91
101
  return sendJson(response, 403, { error: "invalid bridge origin" });
92
102
  }
93
103
  cors = origin ? {
@@ -99,16 +109,21 @@ export async function startBridge({ host = "127.0.0.1", port = 18765, instanceId
99
109
  return response.end();
100
110
  }
101
111
  if (request.method === "GET" && path === "/health") return sendJson(response, 200, {
102
- ok: true, service: "qz-browser-agent", protocolVersion: PROTOCOL_VERSION, features: { browserPairing: 1 }, instanceId, scopeId, buildId, processId: process.pid, status: state.status(),
112
+ ok: true, service: "qz-browser-agent", protocolVersion: PROTOCOL_VERSION, features: { browserPairing: 1, scriptApiVersion: SCRIPT_API_VERSION }, instanceId, scopeId, buildId, processId: process.pid, status: state.status(),
113
+ ...(pidNamespace ? { pidNamespace } : {}),
103
114
  }, cors);
104
115
  if (request.method === "GET" && path === "/qz-connect") {
105
116
  response.writeHead(200, {
106
117
  "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
107
- "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'",
118
+ "content-security-policy": CONNECT_PAGE_CSP,
108
119
  "x-content-type-options": "nosniff",
109
120
  });
110
121
  return response.end(CONNECT_PAGE);
111
122
  }
123
+ if (request.method === "GET" && CONNECT_LOGOS.has(path)) {
124
+ response.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=86400", "x-content-type-options": "nosniff" });
125
+ return response.end(CONNECT_LOGOS.get(path));
126
+ }
112
127
  if (request.method === "POST" && String(request.headers["content-type"] || "").split(";")[0].trim().toLowerCase() !== "application/json") {
113
128
  return sendJson(response, 415, { error: "JSON content-type required" }, cors);
114
129
  }
@@ -130,7 +145,9 @@ export async function startBridge({ host = "127.0.0.1", port = 18765, instanceId
130
145
  let session = typeof sessionId === "string" ? sessions.get(sessionId) : undefined;
131
146
  const body = request.method === "POST" ? await readJson(request) : undefined;
132
147
  if (!session && !sessionId && request.method === "POST" && isInitializeRequest(body)) {
133
- const mcp = createMcpServer(state);
148
+ const clientName = typeof body?.params?.clientInfo?.name === "string" ? body.params.clientInfo.name : "";
149
+ const profile = new URL(request.url || "/", "http://127.0.0.1").searchParams.get("profile") || "";
150
+ const mcp = createMcpServer(state, { clientName, profile });
134
151
  const transport = new StreamableHTTPServerTransport({
135
152
  sessionIdGenerator: randomUUID,
136
153
  onsessioninitialized: id => sessions.set(id, session),
package/src/state.mjs CHANGED
@@ -11,16 +11,25 @@ const bounded = (value, fallback, min, max) => Number.isFinite(Number(value))
11
11
 
12
12
  export class BridgeError extends Error {
13
13
  constructor(code, message, details) {
14
- super(`${code}: ${message}`);
15
- this.code = code;
16
- const fields = code === "DOWNLOAD_INTERRUPTED" ? ["downloadId", "filename", "reason"]
17
- : code === "SKILL_NOT_FOUND" ? ["name"] : code === "SKILL_RESOURCE_NOT_FOUND" ? ["name", "path"] : [];
18
- this.details = Object.fromEntries(fields.filter(key => typeof details?.[key] === "string" || typeof details?.[key] === "number")
19
- .map(key => [key, details[key]]));
20
- if (["SKILL_NOT_FOUND", "SKILL_RESOURCE_NOT_FOUND"].includes(code) && Array.isArray(details?.available)) {
14
+ super(message);
15
+ this.code = String(code || "BRIDGE_TOOL_ERROR").toUpperCase();
16
+ const fields = ["tabRef", "target", "line", "column", "trace",
17
+ ...(this.code === "DOWNLOAD_INTERRUPTED" ? ["downloadId", "filename", "reason"]
18
+ : ["SKILL_NOT_FOUND", "SKILL_NAME_AMBIGUOUS"].includes(this.code) ? ["name"]
19
+ : this.code === "SKILL_RESOURCE_NOT_FOUND" ? ["name", "path"] : [])];
20
+ this.details = Object.fromEntries(fields.filter(key => details?.[key] !== undefined).map(key => [key, details[key]]));
21
+ if (this.code === "SKILL_NAME_AMBIGUOUS" && Array.isArray(details?.candidates)) {
22
+ this.details.candidates = details.candidates.map(({ name, displayName }) => ({ name: String(name || ""), displayName: String(displayName || "") }));
23
+ }
24
+ if (["SKILL_NOT_FOUND", "SKILL_RESOURCE_NOT_FOUND"].includes(this.code) && Array.isArray(details?.available)) {
21
25
  this.details.available = details.available.filter(value => typeof value === "string");
22
26
  }
23
27
  }
28
+
29
+ toJSON() {
30
+ return { code: this.code, message: this.message,
31
+ ...(this.hint ? { hint: this.hint } : {}), ...(Object.keys(this.details).length ? { details: this.details } : {}) };
32
+ }
24
33
  }
25
34
 
26
35
  export class BridgeState {
@@ -42,7 +51,7 @@ export class BridgeState {
42
51
  }
43
52
 
44
53
  liveClients() {
45
- return [...this.clients.values()].filter(client => now() - client.lastSeenAt <= client.pollLeaseSeconds);
54
+ return [...this.clients.values()].filter(client => client.pollLeaseSeconds > 0 && now() - client.lastSeenAt <= client.pollLeaseSeconds);
46
55
  }
47
56
 
48
57
  status() {
@@ -55,9 +64,13 @@ export class BridgeState {
55
64
  capabilities: client.capabilities || {},
56
65
  paired: Boolean(client.connectionId),
57
66
  })).sort((a, b) => a.clientId.localeCompare(b.clientId));
67
+ const connecting = [...this.clients.values()].filter(client => client.reconnectUntil > now()).map(client => ({
68
+ clientId: client.id, name: client.name, retryAfterMs: Math.ceil((client.reconnectUntil - now()) * 1000),
69
+ }));
58
70
  return {
59
71
  endpoint: this.endpoint, protocolVersion: PROTOCOL_VERSION, connected: clients.length > 0,
60
72
  clientCount: clients.length, clients, browsers: this.registry.list().map(entry => ({ ...entry, online: clients.some(client => client.clientId === entry.clientId) })), pending: this.pending.size, inflight: this.inflight.size,
73
+ ...(connecting.length ? { connecting } : {}),
61
74
  uptimeSeconds: rounded(now() - this.startedAt),
62
75
  };
63
76
  }
@@ -95,6 +108,10 @@ export class BridgeState {
95
108
  }
96
109
  const previous = this.clients.get(input.clientId);
97
110
  if (previous && this.liveClients().includes(previous) && previous.connectionId !== input.connectionId) {
111
+ if (sameToken(input.pairingToken, this.registry.entries.get(input.clientId)?.pairingToken)) {
112
+ previous.reconnectUntil = previous.lastSeenAt + previous.pollLeaseSeconds + 5;
113
+ this.events.emit("change");
114
+ }
98
115
  throw Object.assign(pairingError("BRIDGE_CLIENT_IN_USE", "This browser identity has an active connection. Wait for it to disconnect, or connect from a distinct browser profile."), {
99
116
  retryAfterMs: Math.max(1, Math.ceil((previous.lastSeenAt + previous.pollLeaseSeconds - now()) * 1000) + 1),
100
117
  });
@@ -102,8 +119,9 @@ export class BridgeState {
102
119
  const entry = await this.registry.pair(input);
103
120
  this.closed.signal.throwIfAborted();
104
121
  if (previous?.connectionId !== input.connectionId) this.revokeClient(input.clientId);
105
- const client = this.clients.get(input.clientId) || { id: input.clientId, connectionId: input.connectionId, controller: new AbortController() };
106
- Object.assign(client, { name: entry.name, lastSeenAt: now(), pollLeaseSeconds: 46 });
122
+ const client = this.clients.get(input.clientId) || { id: input.clientId, connectionId: input.connectionId, controller: new AbortController(), cancellations: new Map() };
123
+ Object.assign(client, { name: entry.name, lastSeenAt: now(), pollLeaseSeconds: 46, reconnectUntil: 0,
124
+ leaseRevision: (client.leaseRevision || 0) + 1 });
107
125
  this.clients.set(client.id, client);
108
126
  this.events.emit("change");
109
127
  return { ok: true, ...entry, connectionId: input.connectionId, instanceId: this.instanceId };
@@ -209,33 +227,53 @@ export class BridgeState {
209
227
  }
210
228
 
211
229
  async poll(client, { timeoutMs = 30000, maxBatch = 4, signal } = {}) {
212
- const info = await this.serializeRegistration(() => {
230
+ const { info, leaseRevision } = await this.serializeRegistration(() => {
213
231
  this.signal(signal).throwIfAborted();
214
232
  if (client.pairingVersion !== 1) throw pairingError("BRIDGE_PAIRING_REQUIRED", "Update qz to use the supported pairing protocol.");
215
233
  const info = this.authenticatedClient(client);
234
+ info.pollCount = (info.pollCount || 0) + 1;
216
235
  this.touchClient(info, client, timeoutMs);
217
- return info;
236
+ return { info, leaseRevision: info.leaseRevision };
218
237
  });
219
238
  const activeSignal = AbortSignal.any([this.signal(signal), info.controller.signal]);
220
- activeSignal.throwIfAborted();
221
- const deadline = Date.now() + bounded(timeoutMs, 30000, 100, 60000);
222
- const limit = Math.floor(bounded(maxBatch, 4, 1, 16));
223
- while (true) {
239
+ try {
224
240
  activeSignal.throwIfAborted();
225
- const requests = [];
226
- for (const [id, request] of this.pending) {
227
- if (requests.length >= limit) break;
228
- if (request.client !== info) continue;
229
- this.pending.delete(id);
230
- this.inflight.set(id, request);
231
- requests.push({ id, method: request.method, params: request.params, timeoutMs: request.timeoutMs });
241
+ const deadline = Date.now() + bounded(timeoutMs, 30000, 100, 60000);
242
+ const limit = Math.floor(bounded(maxBatch, 4, 1, 16));
243
+ while (true) {
244
+ activeSignal.throwIfAborted();
245
+ const requests = [];
246
+ // Deliver cancellation before new work, on the connection that received the request.
247
+ for (const [id, expiresAt] of info.cancellations) {
248
+ if (expiresAt <= Date.now()) { info.cancellations.delete(id); continue; }
249
+ if (requests.length >= limit) break;
250
+ info.cancellations.delete(id);
251
+ requests.push({ id, method: "cancel_request" });
252
+ }
253
+ for (const [id, request] of this.pending) {
254
+ if (requests.length >= limit) break;
255
+ if (request.client !== info) continue;
256
+ this.pending.delete(id);
257
+ this.inflight.set(id, request);
258
+ requests.push({ id, method: request.method, params: request.params, timeoutMs: request.timeoutMs });
259
+ }
260
+ if (requests.length || Date.now() >= deadline) {
261
+ info.leaseRevision += 1;
262
+ return requests;
263
+ }
264
+ await this.waitForChange(deadline - Date.now(), activeSignal);
265
+ }
266
+ } finally {
267
+ info.pollCount -= 1;
268
+ // A closed transport releases its lease, while issued work retains its result authority.
269
+ if (signal?.aborted && this.clients.get(info.id) === info && !info.pollCount && info.leaseRevision === leaseRevision) {
270
+ info.pollLeaseSeconds = 0;
271
+ this.events.emit("change");
232
272
  }
233
- if (requests.length || Date.now() >= deadline) return requests;
234
- await this.waitForChange(deadline - Date.now(), activeSignal);
235
273
  }
236
274
  }
237
275
 
238
- async callExtension(method, params = {}, { timeoutSeconds, clientId = "", signal } = {}) {
276
+ async callExtension(method, params = {}, { timeoutSeconds, clientId = "", signal, cancelOnAbort = false } = {}) {
239
277
  if (typeof method !== "string" || !method.trim()) throw new BridgeError("INVALID_ARGUMENTS", "method is required");
240
278
  const activeSignal = this.signal(signal);
241
279
  const seconds = bounded(timeoutSeconds || this.requestTimeoutSeconds, this.requestTimeoutSeconds, 1, 86400);
@@ -253,7 +291,16 @@ export class BridgeState {
253
291
  activeSignal.removeEventListener("abort", aborted);
254
292
  error ? reject(error) : resolve(result);
255
293
  };
256
- const aborted = () => finish(activeSignal.reason);
294
+ const aborted = () => {
295
+ if (cancelOnAbort && this.inflight.has(id) && !this.closed.signal.aborted && !client.controller.signal.aborted) {
296
+ for (const [requestId, expiresAt] of client.cancellations) {
297
+ if (expiresAt <= Date.now()) client.cancellations.delete(requestId);
298
+ }
299
+ client.cancellations.set(id, Date.now() + seconds * 1000);
300
+ }
301
+ finish(activeSignal.reason);
302
+ this.events.emit("change");
303
+ };
257
304
  const timer = setTimeout(() => finish(new BridgeError("BRIDGE_REQUEST_TIMEOUT",
258
305
  `Request timed out after ${seconds}s: ${method}. A started operation may still be running; check its state before retrying.`)), seconds * 1000);
259
306
  activeSignal.addEventListener("abort", aborted, { once: true });
@@ -268,7 +315,12 @@ export class BridgeState {
268
315
  let client;
269
316
  try { client = this.authenticatedClient(payload); } catch { return false; }
270
317
  if (client !== request.client) return false;
271
- if (payload.ok === false) request.finish(new BridgeError(payload.code || "BRIDGE_TOOL_ERROR", String(payload.error || "qz bridge request failed"), payload.details));
318
+ if (payload.ok === false) {
319
+ const failure = typeof payload.error === "object" && payload.error ? payload.error : payload;
320
+ request.finish(Object.assign(new BridgeError(failure.code || "BRIDGE_TOOL_ERROR",
321
+ String(failure.message || payload.error || "qz bridge request failed"), failure.details),
322
+ failure.hint ? { hint: failure.hint } : {}));
323
+ }
272
324
  else request.finish(null, payload.result);
273
325
  return true;
274
326
  }
@@ -289,7 +341,7 @@ export class BridgeState {
289
341
  job.result = await this.callExtension(method, params, { ...options, signal: options.connectionSignal });
290
342
  job.status = "completed";
291
343
  } catch (error) {
292
- job.error = error.message || String(error);
344
+ job.error = Object.assign(new BridgeError(error.code || "BRIDGE_TOOL_ERROR", error.message || String(error), error.details), { hint: error.hint }).toJSON();
293
345
  job.status = "failed";
294
346
  }
295
347
  job.completedAt = now();
@@ -72,7 +72,7 @@ export async function waitForTask({ sessionId, clientId, timeoutSeconds = null,
72
72
  const requestDeadline = Date.now() + milliseconds;
73
73
  const requestExpiresFirst = deadline === null || requestDeadline < deadline;
74
74
  const requestTimeout = Object.assign(new Error(`Reading task ${sessionId} timed out after ${requestTimeoutSeconds} seconds. Check the browser connection, then resume watching this task.`),
75
- { code: "request_timeout" });
75
+ { code: "REQUEST_TIMEOUT" });
76
76
  // A request timeout ends observation too. One controller preserves the first reason,
77
77
  // and equal deadlines belong to the total timer. Downstream keeps its full request budget.
78
78
  const requestTimer = requestExpiresFirst ? setTimeout(() => controller.abort(requestTimeout), milliseconds) : null;
@@ -127,10 +127,10 @@ export async function waitForTask({ sessionId, clientId, timeoutSeconds = null,
127
127
  const snapshot = record(await readSnapshot());
128
128
  checkActive();
129
129
  if (snapshot.sessionId && snapshot.sessionId !== sessionId) {
130
- throw Object.assign(new Error("The task snapshot belongs to a different session."), { code: "task_changed" });
130
+ throw Object.assign(new Error("The task snapshot belongs to a different session."), { code: "TASK_CHANGED" });
131
131
  }
132
132
  if (Object.hasOwn(snapshot, "session") && snapshot.session === null) {
133
- throw Object.assign(new Error(TASK_NOT_FOUND_MESSAGE), { code: "task_deleted" });
133
+ throw Object.assign(new Error(TASK_NOT_FOUND_MESSAGE), { code: "TASK_DELETED" });
134
134
  }
135
135
  const runtime = record(snapshot.runtime);
136
136
  const canonicalInput = Object.hasOwn(snapshot, "input") ? record(snapshot.input) : null;
@@ -138,7 +138,7 @@ export async function waitForTask({ sessionId, clientId, timeoutSeconds = null,
138
138
  const status = taskStatus(snapshot);
139
139
  if (!status) {
140
140
  throw Object.assign(new Error("The task snapshot has no status. Check the browser connection, then resume watching this task."),
141
- { code: "invalid_response" });
141
+ { code: "INVALID_RESPONSE" });
142
142
  }
143
143
  const transcript = Array.isArray(snapshot.transcript) ? snapshot.transcript : Array.isArray(snapshot.tail) ? snapshot.tail : [];
144
144
  const submittedMessageIndex = expectedMessageId