# Runner and extension protocol (v1 draft) Transport: one websocket per run, the extension is the client, the bridge is the server. `ws://127.0.0.1:/ext?token=`. (One bridge, and so one extension connection, per profile: a run whose script uses several profiles starts a bridge for each, with its own port and token, and each Chrome's extension connects to its own. The runner routes a request by the `profile` it carries; the bridge and the extension never see that key.) The bridge listens on 127.0.0.1 only and closes any connection with a wrong token. ## Handshake ``` ext -> { "type": "hello", "extVersion": "0.1.0", "chrome": "154.0.0.0", "profile": "test-qian" } bridge -> { "type": "welcome", "runId": "2026-10-09T13-00-00Z-ab12", "protocol": 1 } ``` If `protocol` differs from what the extension supports, the extension shows an error badge and closes. ## Requests and responses Every request from the bridge has a numeric `id`. The extension answers with the same `id`. Exactly one response per request. ``` { "id": 7, "cmd": "resolve", "target": { "text": "Send" }, "timeoutMs": 5000, "tab": 123 } -> { "id": 7, "ok": true, "descriptor": { ... }, "matches": 1, "repaired": false, "via": "exact" } ``` `resolve` may carry `"descriptor"`, the element as the lock file remembers it (the full shape of data-formats.md). Without it the target is resolved exactly and nothing else is tried. With it, and only when the exact target finds nothing (`not-found`; an ambiguous or hidden match is never repaired), the page tries the remembered fields that name an element on their own (`data-testid`, then `id`, `aria-label`, `name`, each only if it leads to exactly one visible, enabled element), then scores the visible, enabled elements (at most 2000 looked at, the best 60 by a quick comparison described in full; see locator-repair.md). Answers: ``` -> found by a remembered field or by scoring: { "id": 7, "ok": true, "descriptor": { ...the element now... }, "matches": 1, "repaired": true, "via": "descriptor" | "repair", "repair": { "score": 0.902, "runnerUp": 0.31, "old": { ... }, "changed": ["text", "name"], "changes": ["text \"Send\" -> \"Send now\""] } } -> a candidate between 0.60 and 0.85, never acted on: { "id": 7, "ok": false, "reason": "drifted", "detail": { "target": {...}, "candidate": { ... }, "score": 0.744, "runnerUp": 0.1, "old": { ... }, "changed": [...], "changes": [...] } } -> two candidates within 0.15 of each other: { "id": 7, "ok": false, "reason": "ambiguous", "detail": { "target": {...}, "repair": true, "candidates": [ { "descriptor": { ... }, "score": 0.88 } ], "old": { ... } } } -> nothing close enough: the plain not-found, plus "repairTried": true, "bestScore", and a "note" when the page had more than 2000 elements ``` ``` { "id": 8, "cmd": "act", "action": "click", "descriptor": { ... } } -> { "id": 8, "ok": true } { "id": 9, "cmd": "verify", "kind": "text", "value": "1 MALLBUCKS", "within": "Review send", "timeoutMs": 5000 } -> { "id": 9, "ok": true, "found": "1 MALLBUCKS" } { "id": 11, "cmd": "tabs" } -> { "id": 11, "ok": true, "tabs": [ { "id": 123, "url": "...", "title": "...", "active": true } ] } { "id": 12, "cmd": "navigate", "tab": 123, "url": "https://..." } -> { "id": 12, "ok": true, "tab": 123, "url": "https://..." } ``` `outline` draws boxes for the screenshot and removes them again (P4-2): ``` { "id": 13, "cmd": "outline", "items": [ { "xpath": "/html/body/a", "label": "possible match 0.74", "tone": "candidate" } ] } -> { "id": 13, "ok": true, "drawn": 1 } { "id": 14, "cmd": "outline", "clear": true } -> { "id": 14, "ok": true, "removed": 1, "remaining": 0 } ``` `tone` is `wanted` (blue), `candidate` (pink) or anything else (orange); at most 10 boxes, labels cut to 80 characters, text only. Each box is a temporary `div` of our own (attribute `data-aite-overlay`, inline styles only, `pointer-events: none`) added to the document element: the page's own elements, attributes and style sheets are not touched. Drawing again replaces the boxes; the page removes them itself after 15 s if nobody clears them. The runner draws only around the screenshot of a failed step (the drifted candidate, the matches it could not tell apart, or the element an action failed on), clears right after, and records a warning with code `outline` if drawing failed or `remaining` was not 0. `navigate` without `tab` opens a new tab, remembers it as the run's tab and answers with its id; `resolve`, `act` and `verify` without `tab` go to that tab (`tab-closed` if there is none yet). `navigate` waits for the page to finish loading (`timeout` after `timeoutMs`, `tab-closed` if the tab goes away) and only opens http and https pages. A page command that dies because the page is navigating under it (`navigation`) is sent again to the new page until its own `timeoutMs` if it only looks at the page (`resolve`, `verify`); an `act` is never sent again. Tabs that were open before the extension loaded get the content script injected on demand (Chrome does not do it for them), and a tab whose origin is not in the allow list fails as `internal` with a message naming the allow list. ``` { "id": 13, "cmd": "switchTab", "match": { "urlContains": "/pay" }, "tab": 123, "timeoutMs": 5000 } -> { "ok": true, "tab": 124, "url": "...", "title": "..." } { "id": 13, "cmd": "switchTab", "match": { "new": true }, "tab": 123 } -> the newest tab that opened by itself, not yet taken { "id": 14, "cmd": "closeTab", "tab": 124 } -> { "ok": true, "closed": 124, "tab": 123 | null, "alreadyClosed"?: true } ``` `switchTab` waits (until `timeoutMs`) for a matching tab to exist and finish loading, never counts the tab named by `tab` (the one the run is on), and brings the tab to the front. Two matching tabs is `ambiguous`; none in time is `timeout` with the open tabs' addresses. `closeTab` goes back along the tabs the run switched from; `tab` in its answer is where the run is now, or `null`. Page commands (the extension asks the content script in the run's tab; all of them only look at the page, so all may be sent again, except where `act` says): ``` { "id": 20, "cmd": "locate", "descriptor": { ... }, "timeoutMs": 5000 } -> { "ok": true, "x": 130, "y": 210, "rect": { "x": 100, "y": 200, "width": 60, "height": 20 }, "tag": "button", "editable": false, "kind"?: "checkbox"|"radio", "checked"?: true } { "id": 21, "cmd": "read", "target": { "css": ".balance" } } -> { "ok": true, "text": "5 MALLBUCKS" } { "id": 22, "cmd": "verify", "kind": "targetText", "target": { ... }, "value": "...", "negate"?: true, "within"?: "..." } { "id": 23, "cmd": "wait", "kind": "visible" | "gone" | "textChanges" | "url", "target": { ... } | "contains": "..." } { "id": 24, "cmd": "act", "action": "select", "descriptor": { ... }, "option": "Dark blue" } ``` `locate` scrolls the element into view, requires it visible and enabled, requires the middle of it to hit the element itself (`not-visible` with `coveredBy` otherwise), and answers where to click, in viewport pixels. The runner then sends the real mouse or keys over the debugging pipe; `act` with `click`, `doubleClick`, `type`, `press`, `check`, `uncheck` or `drag` is only used as plain events when there is no pipe. `verify` kinds: `exists`, `text`, `targetText`, `value`, `state`, `count`, `url`, `title`, `numberChange`. `wait network-idle` is answered by the runner from Chrome's network events, not by the extension. Failure response: ``` { "id": 9, "ok": false, "reason": "mismatch", "detail": { "expected": "1 MALLBUCKS", "found": "1 MUCK" } } ``` **Retries.** An answer may carry `"retries": N` (a whole number, 1 or more; absent when there were none). It counts the times the same command was sent again: by the extension to a new page after the page navigated under it (`resolve`, `verify`), and by the bridge after a reconnect (only `resolve`, `verify`, `locate`, `read`, `wait`, `tabs`). The two add up. An action is never sent again, so its answer never carries retries. The runner counts them and shows them; it never hides one. `reason` is one of: `not-found`, `ambiguous`, `not-visible`, `not-enabled`, `timeout`, `mismatch`, `drifted`, `navigation`, `load-failed` (a page the browser could not load: it showed its own error page), `tab-closed`, `connection-lost`, `unsupported`, `pre-state`, `internal`. (`pre-state` is used since P5-7.a: a precondition of the test, such as a balance, was not met before the first step ran; it is distinct from an app failure.) `detail` holds what the report needs (candidates, expected, found, a proposed descriptor for `drifted`). ## Screenshots are not a bridge message The extension cannot take them: `chrome.tabs.captureVisibleTab` needs the `` or `activeTab` permission (specific host permissions are not enough, checked in Chrome 155), and `` would remove the extension's main safety property, that it only touches the origins in the allow list. `activeTab` is only granted by a user gesture. So the runner takes the picture itself, over Chrome's debugging protocol on a private pipe (`--remote-debugging-pipe`: no network port, usable only by the process that started Chrome). It asks the extension for the `tabs` list to learn which url the run's tab shows, finds the page with that url in Chrome (and its title if two tabs share a url; if that is still not enough the capture is refused, not guessed), brings it to the front and captures a PNG. A picture that cannot be taken is a warning on the step, never a failed step. ## Events (extension -> bridge, no reply) ``` { "type": "event", "kind": "tab-opened", "tab": 124, "url": "..." } { "type": "event", "kind": "tab-closed", "tab": 124 } { "type": "event", "kind": "navigated", "tab": 123, "url": "..." } { "type": "event", "kind": "console", "tab": 123, "level": "error", "text": "..." } # page errors, shown in the report ``` ## Keeping the MV3 service worker alive A Manifest V3 service worker is stopped when idle. An open websocket that sends or receives a message at least every 20 s keeps it running in current Chrome. So: - The bridge sends `{ "type": "ping" }` every 15 s; the extension answers `{ "type": "pong" }`. - If the worker is stopped anyway, the extension reconnects (backoff 0.5 s, 1 s, 2 s, 4 s, 8 s, then every 8 s) and sends `hello` with the same `runId` field: `{ "type": "hello", ..., "resume": "" }`. - A `chrome.alarms` alarm every 30 s wakes the worker as a backup. - Close codes the bridge uses: `4401` wrong token, `4408` no hello in time, `4409` replaced by a newer connection, `4410` no answer to pings, `4426` protocol mismatch (sent by the extension). After `4401` the extension shows the error badge and keeps retrying (the launcher may have rewritten `bridge.json`). After `4409` another extension has taken over the bridge: the extension shows the error badge and stops retrying until the worker restarts, so two extensions sharing a token cannot fight over the bridge. ## Delivery rules - Requests are not retried blindly. After a reconnect the bridge re-sends only **idempotent** requests that had no response (`resolve`, `verify`, `locate`, `read`, `wait`, `tabs`). An `act` that had no response fails the step with `connection-lost`; the runner never clicks twice. - Commands run one at a time per run. The bridge does not send request N+1 before it has the response to N. - Message size limit 8 MB, enforced by both ends. ## Security - Token is 128 random bits, generated per run. The runner writes it, with the bridge address and the profile name, to `bridge.json` in the extension's own folder (`/aite-extension/`, mode 600) before Chrome starts; the extension reads it as one of its own packaged files each time it connects (so a new run's token is the one used: measured in P1-6), and the runner deletes the file when the run ends. It is never in a URL the page can see. The extension ignores messages from web pages for this channel; page scripts cannot reach the service worker. - The extension requests host permissions only for the origins listed in the run's `allow` list in `aite.config.json`.