From 963b3a257f284e08fcaf0ce3a2eaab54fc4ab153 Mon Sep 17 00:00:00 2001 From: Sy-D <8460326+Sy-D@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:44:42 +0200 Subject: [PATCH 1/5] refactor(relay): name the wire vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `src/relay/guest/server.js` is plain JavaScript, so every `type === "frame"` in it was an unchecked string: a typo there is not a compile error, it is a message that is silently never matched. That is not hypothetical — `clear` was in the protocol and in the relay and missing from the agent's own switch for two releases, and the phone's Clear key died in it without a sound. Name the set once. `MSG` carries every message type on the wire in both directions and `MODE` the two things a handoff can ask of a human; the mode this process was started with is now `HANDOFF_MODE`, so the constants keep the plain names. The mobile page the relay serves gets the same object injected at serve time rather than a second copy of the strings, so the router and the page cannot drift apart. Three tests hold it together, and each was watched failing before it was trusted: `MSG` is asserted against a mapped type over `AgentToHuman`, `HumanToAgent` and `Heartbeat`, so a new protocol member does not compile until it is listed and does not go green until the relay names it; no message type may be spelled by hand anywhere in the file; and the served page must carry the relay's own vocabulary with the placeholder substituted. No behaviour change. Co-Authored-By: Claude Fable 5.1 --- src/relay/guest-source.ts | 167 ++++++++++++++++++++++++++++---------- src/relay/guest/server.js | 167 ++++++++++++++++++++++++++++---------- src/relay/relay.test.ts | 75 +++++++++++++++++ 3 files changed, 321 insertions(+), 88 deletions(-) diff --git a/src/relay/guest-source.ts b/src/relay/guest-source.ts index cb81983..f6d535f 100644 --- a/src/relay/guest-source.ts +++ b/src/relay/guest-source.ts @@ -43,28 +43,73 @@ const PORT = Number(process.argv[2] || process.env.HANDRAISE_RELAY_PORT || 3000) */ const AGENT_KEY = process.argv[3] || process.env.HANDRAISE_AGENT_KEY || "" +/** + * Every message type on the wire, in both directions, named once. + * + * This file is plain JavaScript, so comparing a message against a bare quoted + * string is unchecked: a typo is not a compile error, it is a message that is + * silently never matched. The literal unions in src/relay/protocol.ts do that + * work for the rest of the codebase; these constants are their counterpart + * here, and \`relay.test.ts\` asserts the two sets against each other so neither + * can grow a member alone. + * + * The mobile page below gets this same object injected at serve time — one + * definition for the relay and the page it serves, never two that can drift. + */ +const MSG = { + // agent -> human + FRAME: "frame", + STATE: "state", + FOCUS: "focus", + ENDED: "ended", + // human -> agent + TAP: "tap", + CHAR: "char", + KEY: "key", + CLEAR: "clear", + SCROLL: "scroll", + HANDBACK: "handback", + ABORT: "abort", + APPROVE: "approve", + DENY: "deny", + // either direction + PING: "ping", + PONG: "pong", +} + +/** The two things a handoff can ask of a human. */ +const MODE = { TAKEOVER: "takeover", APPROVAL: "approval" } + /** * What this handoff asks of the human: \`takeover\` (drive the page) or * \`approval\` (answer one question about one screenshot). It arrives as argv * and never as a message, so no client can talk the relay into the other set. */ -const MODE = - (process.argv[4] || process.env.HANDRAISE_MODE) === "approval" - ? "approval" - : "takeover" +const HANDOFF_MODE = + (process.argv[4] || process.env.HANDRAISE_MODE) === MODE.APPROVAL + ? MODE.APPROVAL + : MODE.TAKEOVER /** The human messages this relay forwards. Everything else from that side is dropped. */ const HUMAN_MESSAGES = new Set( - MODE === "approval" - ? ["approve", "deny"] - : ["tap", "char", "key", "clear", "scroll", "handback", "abort"], + HANDOFF_MODE === MODE.APPROVAL + ? [MSG.APPROVE, MSG.DENY] + : [ + MSG.TAP, + MSG.CHAR, + MSG.KEY, + MSG.CLEAR, + MSG.SCROLL, + MSG.HANDBACK, + MSG.ABORT, + ], ) /** * The human messages that end a handoff, in either mode. They are held for an * agent that is not connected at the moment, and they stop the frame replay. */ -const TERMINAL_HUMAN = new Set(["handback", "abort", "approve", "deny"]) +const TERMINAL_HUMAN = new Set([MSG.HANDBACK, MSG.ABORT, MSG.APPROVE, MSG.DENY]) /** Must equal HEARTBEAT_INTERVAL_MS in src/relay/protocol.ts (asserted in relay.test.ts). */ const HEARTBEAT_INTERVAL_MS = 20000 @@ -82,7 +127,7 @@ const MAX_MESSAGE_BYTES = 8 * 1024 * 1024 /** Grace before a replaced/closed socket is force-destroyed if it hangs on. */ const CLOSE_GRACE_MS = 1000 -const PONG = JSON.stringify({ type: "pong" }) +const PONG = JSON.stringify({ type: MSG.PONG }) /** role -> peer. At most one connection per role; a new one replaces the old. */ const peers = new Map() @@ -288,7 +333,7 @@ function messageType(payload) { /** Keep what a human who joins late has to be shown, and drop what they must not. */ function rememberFromAgent(type, payload) { - if (type === "ended") { + if (type === MSG.ENDED) { // Terminal: keep the ending for a late human, drop everything that could // show the logged-in page to whoever opens the link next. lastEnded = payload @@ -301,9 +346,9 @@ function rememberFromAgent(type, payload) { // Forwarding that is harmless; storing it would put the page back in front // of the next visitor after this relay decided to drop it. if (humanEnded) return - if (type === "frame") lastFrame = payload - else if (type === "state") lastState = payload - else if (type === "focus") lastFocus = payload + if (type === MSG.FRAME) lastFrame = payload + else if (type === MSG.STATE) lastState = payload + else if (type === MSG.FOCUS) lastFocus = payload } /** One line per relay at most: a hostile client must not be able to fill the log. */ @@ -314,7 +359,7 @@ function logDrop(type) { dropLogged = true log("human message dropped", { type: String(type).slice(0, 32), - mode: MODE, + mode: HANDOFF_MODE, ended: humanEnded, }) } @@ -353,7 +398,7 @@ function route(peer, payload, opcode) { return } const type = messageType(payload) - if (type === "ping") { + if (type === MSG.PING) { sendText(peer, PONG) return } @@ -361,7 +406,7 @@ function route(peer, payload, opcode) { else if (!acceptFromHuman(type, payload)) return // Newest frame wins: drop a frame bound for a backpressured receiver rather // than queue it in memory. Control and terminal messages are never dropped. - if (type === "frame" && other?.backpressure) return + if (type === MSG.FRAME && other?.backpressure) return write(other, payload, opcode) } @@ -377,6 +422,19 @@ function log(event, detail) { ) } +/** + * The mobile page, with this relay's two facts substituted in: which mode it + * is serving, and the wire vocabulary. The page never spells a message type + * itself — it reads \`MSG\` out of the same object the router above uses, so a + * type that is renamed in one place cannot survive in the other. + */ +function renderPage() { + return PAGE.replace("__HANDRAISE_MODE__", HANDOFF_MODE).replace( + "__HANDRAISE_VOCAB__", + JSON.stringify({ msg: MSG, mode: MODE }), + ) +} + const server = createServer((req, res) => { const url = new URL(req.url || "/", "http://relay") if (url.pathname === "/healthz") { @@ -392,9 +450,9 @@ const server = createServer((req, res) => { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", }) - // MODE is one of two literals, so this substitution can only produce the - // two pages this file was written for. - res.end(PAGE.replace("__HANDRAISE_MODE__", MODE)) + // HANDOFF_MODE is one of two literals, so this substitution can only + // produce the two pages this file was written for. + res.end(renderPage()) return } res.writeHead(404, { @@ -503,7 +561,7 @@ server.listen(PORT, "0.0.0.0", () => { // Report the bound port, not the requested one: port 0 asks the OS to pick a // free one, which is how the local test suite avoids fighting for 3000. const bound = server.address() - log("relay listening", { port: bound?.port ?? PORT, mode: MODE }) + log("relay listening", { port: bound?.port ?? PORT, mode: HANDOFF_MODE }) }) const PAGE = \` @@ -1005,16 +1063,37 @@ const PAGE = \` var overlayNote = document.getElementById("overlay-note") var actionEl = document.getElementById("action") + /** + * The wire vocabulary, injected by the relay that served this page from the + * one definition at the top of server.js. Not a copy: a message type renamed + * up there is renamed here in the same edit, and relay.test.ts asserts both + * against the TypeScript protocol. + */ + var VOCAB = __HANDRAISE_VOCAB__ + var MSG = VOCAB.msg + var MODE = VOCAB.mode + /** * Takeover or approval, decided by the relay that served this page. In * approval mode the human is answering a question about one screenshot, not * driving anything: the input row and the key bar are not on the page, and * the two messages below are the only ones this side can produce. */ - var APPROVAL = document.body.dataset.mode === "approval" - var SENDABLE = APPROVAL - ? { approve: 1, deny: 1, ping: 1 } - : { tap: 1, char: 1, key: 1, clear: 1, scroll: 1, handback: 1, abort: 1, ping: 1 } + var APPROVAL = document.body.dataset.mode === MODE.APPROVAL + var SENDABLE = {} + ;(APPROVAL + ? [MSG.APPROVE, MSG.DENY, MSG.PING] + : [ + MSG.TAP, + MSG.CHAR, + MSG.KEY, + MSG.CLEAR, + MSG.SCROLL, + MSG.HANDBACK, + MSG.ABORT, + MSG.PING + ] + ).forEach(function (type) { SENDABLE[type] = 1 }) var ws = null var retries = 0 @@ -1096,7 +1175,7 @@ const PAGE = \` return } // A heartbeat is only worth anything now. Replaying it later says nothing. - if (message.type === "ping") return + if (message.type === MSG.PING) return if (outbox.length >= MAX_QUEUED) { outbox.shift() dropped++ @@ -1544,7 +1623,7 @@ const PAGE = \` // the wheel delta the agent forwards is the inverse of the finger movement. // Divided by the zoom, or a magnified page would scroll magnified too. var fdy = Math.round((-stepped * frameH) / (box.h * view.scale)) - if (fdy !== 0) send({ type: "scroll", fdy: fdy }) + if (fdy !== 0) send({ type: MSG.SCROLL, fdy: fdy }) } canvas.addEventListener("pointerdown", function (e) { @@ -1640,7 +1719,7 @@ const PAGE = \` if (APPROVAL) return var point = toFrame(e.clientX, e.clientY) if (!point) return - send({ type: "tap", fx: point.x, fy: point.y }) + send({ type: MSG.TAP, fx: point.x, fy: point.y }) markTap(e.clientX, e.clientY) }) canvas.addEventListener("pointercancel", function (e) { @@ -1658,8 +1737,8 @@ const PAGE = \` while (shared < mirrored.length && shared < next.length && mirrored[shared] === next[shared]) { shared++ } - for (var back = mirrored.length; back > shared; back--) send({ type: "key", key: "Backspace" }) - for (var i = shared; i < next.length; i++) send({ type: "char", ch: next[i] }) + for (var back = mirrored.length; back > shared; back--) send({ type: MSG.KEY, key: "Backspace" }) + for (var i = shared; i < next.length; i++) send({ type: MSG.CHAR, ch: next[i] }) mirrored = next }) // The mirror and the field are one state: writing kbd.value fires no input @@ -1672,13 +1751,13 @@ const PAGE = \` kbd.addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault() - send({ type: "key", key: "Enter" }) + send({ type: MSG.KEY, key: "Enter" }) resetMirror() return } // An empty field fires no input event, so this is the only signal that the // human wants to delete a character the remote page still holds. - if (e.key === "Backspace" && kbd.value === "") send({ type: "key", key: "Backspace" }) + if (e.key === "Backspace" && kbd.value === "") send({ type: MSG.KEY, key: "Backspace" }) }) /** @@ -1721,20 +1800,20 @@ const PAGE = \` kbd.value = kbd.value.slice(0, -1) mirrored = kbd.value } - send({ type: "key", key: "Backspace" }) + send({ type: MSG.KEY, key: "Backspace" }) }) var clearKey = keyButton("key-clear", function () { - send({ type: "clear" }) + send({ type: MSG.CLEAR }) resetMirror() }) // Tab moves to another field, Enter usually submits: either way what the // human types next belongs to a different context than what is mirrored here. keyButton("key-tab", function () { - send({ type: "key", key: "Tab" }) + send({ type: MSG.KEY, key: "Tab" }) resetMirror() }) keyButton("key-enter", function () { - send({ type: "key", key: "Enter" }) + send({ type: MSG.KEY, key: "Enter" }) resetMirror() }) @@ -1841,11 +1920,11 @@ const PAGE = \` // is; approve is the hold, because it is the one that cannot be undone. // That is the takeover's rule with the sides swapped, for the same reason. document.getElementById("deny").addEventListener("click", function () { - send({ type: "deny" }) + send({ type: MSG.DENY }) finish(ENDINGS.denied[0], ENDINGS.denied[1]) }) holdButton(document.getElementById("approve"), function () { - send({ type: "approve" }) + send({ type: MSG.APPROVE }) finish(ENDINGS.approved[0], ENDINGS.approved[1]) }) } else { @@ -1853,11 +1932,11 @@ const PAGE = \` // spirit: the agent looks, fails and asks again. Confirming the happy path // is the classic mistake, so this stays a single tap. document.getElementById("handback").addEventListener("click", function () { - send({ type: "handback" }) + send({ type: MSG.HANDBACK }) finish(ENDINGS.resolved[0], ENDINGS.resolved[1]) }) holdButton(document.getElementById("abort"), function () { - send({ type: "abort" }) + send({ type: MSG.ABORT }) finish("Thanks for looking", "The agent knows it can't be done here and will stop. You can close this tab.") }) } @@ -1866,14 +1945,14 @@ const PAGE = \` var message try { message = JSON.parse(raw) } catch (err) { return } if (!message) return - if (message.type === "frame") showFrame(message.data, message.meta) - else if (message.type === "state") { + if (message.type === MSG.FRAME) showFrame(message.data, message.meta) + else if (message.type === MSG.STATE) { reason.textContent = message.reason // textContent, never innerHTML: the action is the agent's own sentence, // and it goes on the screen a decision is made from. if (message.action) actionEl.textContent = message.action } - else if (message.type === "focus") { + else if (message.type === MSG.FOCUS) { focus = readFocus(message) applyKind(focus.rect ? focus.kind : "text") placeRing() @@ -1881,7 +1960,7 @@ const PAGE = \` setHint() setClearEnabled() } - else if (message.type === "ended") { + else if (message.type === MSG.ENDED) { var ending = ENDINGS[message.outcome] || ["Session ended", "You can close this tab."] finish(ending[0], ending[1]) } @@ -1944,7 +2023,7 @@ const PAGE = \` setHint() } applyTransform(false) - setInterval(function () { send({ type: "ping" }) }, 20000) + setInterval(function () { send({ type: MSG.PING }) }, 20000) window.addEventListener("resize", render) // The stage also changes size without the window doing so: a longer reason // takes the header to its second line. The letterbox has to follow. diff --git a/src/relay/guest/server.js b/src/relay/guest/server.js index 7cb7bae..e6f5e15 100644 --- a/src/relay/guest/server.js +++ b/src/relay/guest/server.js @@ -34,28 +34,73 @@ const PORT = Number(process.argv[2] || process.env.HANDRAISE_RELAY_PORT || 3000) */ const AGENT_KEY = process.argv[3] || process.env.HANDRAISE_AGENT_KEY || "" +/** + * Every message type on the wire, in both directions, named once. + * + * This file is plain JavaScript, so comparing a message against a bare quoted + * string is unchecked: a typo is not a compile error, it is a message that is + * silently never matched. The literal unions in src/relay/protocol.ts do that + * work for the rest of the codebase; these constants are their counterpart + * here, and `relay.test.ts` asserts the two sets against each other so neither + * can grow a member alone. + * + * The mobile page below gets this same object injected at serve time — one + * definition for the relay and the page it serves, never two that can drift. + */ +const MSG = { + // agent -> human + FRAME: "frame", + STATE: "state", + FOCUS: "focus", + ENDED: "ended", + // human -> agent + TAP: "tap", + CHAR: "char", + KEY: "key", + CLEAR: "clear", + SCROLL: "scroll", + HANDBACK: "handback", + ABORT: "abort", + APPROVE: "approve", + DENY: "deny", + // either direction + PING: "ping", + PONG: "pong", +} + +/** The two things a handoff can ask of a human. */ +const MODE = { TAKEOVER: "takeover", APPROVAL: "approval" } + /** * What this handoff asks of the human: `takeover` (drive the page) or * `approval` (answer one question about one screenshot). It arrives as argv * and never as a message, so no client can talk the relay into the other set. */ -const MODE = - (process.argv[4] || process.env.HANDRAISE_MODE) === "approval" - ? "approval" - : "takeover" +const HANDOFF_MODE = + (process.argv[4] || process.env.HANDRAISE_MODE) === MODE.APPROVAL + ? MODE.APPROVAL + : MODE.TAKEOVER /** The human messages this relay forwards. Everything else from that side is dropped. */ const HUMAN_MESSAGES = new Set( - MODE === "approval" - ? ["approve", "deny"] - : ["tap", "char", "key", "clear", "scroll", "handback", "abort"], + HANDOFF_MODE === MODE.APPROVAL + ? [MSG.APPROVE, MSG.DENY] + : [ + MSG.TAP, + MSG.CHAR, + MSG.KEY, + MSG.CLEAR, + MSG.SCROLL, + MSG.HANDBACK, + MSG.ABORT, + ], ) /** * The human messages that end a handoff, in either mode. They are held for an * agent that is not connected at the moment, and they stop the frame replay. */ -const TERMINAL_HUMAN = new Set(["handback", "abort", "approve", "deny"]) +const TERMINAL_HUMAN = new Set([MSG.HANDBACK, MSG.ABORT, MSG.APPROVE, MSG.DENY]) /** Must equal HEARTBEAT_INTERVAL_MS in src/relay/protocol.ts (asserted in relay.test.ts). */ const HEARTBEAT_INTERVAL_MS = 20000 @@ -73,7 +118,7 @@ const MAX_MESSAGE_BYTES = 8 * 1024 * 1024 /** Grace before a replaced/closed socket is force-destroyed if it hangs on. */ const CLOSE_GRACE_MS = 1000 -const PONG = JSON.stringify({ type: "pong" }) +const PONG = JSON.stringify({ type: MSG.PONG }) /** role -> peer. At most one connection per role; a new one replaces the old. */ const peers = new Map() @@ -279,7 +324,7 @@ function messageType(payload) { /** Keep what a human who joins late has to be shown, and drop what they must not. */ function rememberFromAgent(type, payload) { - if (type === "ended") { + if (type === MSG.ENDED) { // Terminal: keep the ending for a late human, drop everything that could // show the logged-in page to whoever opens the link next. lastEnded = payload @@ -292,9 +337,9 @@ function rememberFromAgent(type, payload) { // Forwarding that is harmless; storing it would put the page back in front // of the next visitor after this relay decided to drop it. if (humanEnded) return - if (type === "frame") lastFrame = payload - else if (type === "state") lastState = payload - else if (type === "focus") lastFocus = payload + if (type === MSG.FRAME) lastFrame = payload + else if (type === MSG.STATE) lastState = payload + else if (type === MSG.FOCUS) lastFocus = payload } /** One line per relay at most: a hostile client must not be able to fill the log. */ @@ -305,7 +350,7 @@ function logDrop(type) { dropLogged = true log("human message dropped", { type: String(type).slice(0, 32), - mode: MODE, + mode: HANDOFF_MODE, ended: humanEnded, }) } @@ -344,7 +389,7 @@ function route(peer, payload, opcode) { return } const type = messageType(payload) - if (type === "ping") { + if (type === MSG.PING) { sendText(peer, PONG) return } @@ -352,7 +397,7 @@ function route(peer, payload, opcode) { else if (!acceptFromHuman(type, payload)) return // Newest frame wins: drop a frame bound for a backpressured receiver rather // than queue it in memory. Control and terminal messages are never dropped. - if (type === "frame" && other?.backpressure) return + if (type === MSG.FRAME && other?.backpressure) return write(other, payload, opcode) } @@ -368,6 +413,19 @@ function log(event, detail) { ) } +/** + * The mobile page, with this relay's two facts substituted in: which mode it + * is serving, and the wire vocabulary. The page never spells a message type + * itself — it reads `MSG` out of the same object the router above uses, so a + * type that is renamed in one place cannot survive in the other. + */ +function renderPage() { + return PAGE.replace("__HANDRAISE_MODE__", HANDOFF_MODE).replace( + "__HANDRAISE_VOCAB__", + JSON.stringify({ msg: MSG, mode: MODE }), + ) +} + const server = createServer((req, res) => { const url = new URL(req.url || "/", "http://relay") if (url.pathname === "/healthz") { @@ -383,9 +441,9 @@ const server = createServer((req, res) => { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", }) - // MODE is one of two literals, so this substitution can only produce the - // two pages this file was written for. - res.end(PAGE.replace("__HANDRAISE_MODE__", MODE)) + // HANDOFF_MODE is one of two literals, so this substitution can only + // produce the two pages this file was written for. + res.end(renderPage()) return } res.writeHead(404, { @@ -494,7 +552,7 @@ server.listen(PORT, "0.0.0.0", () => { // Report the bound port, not the requested one: port 0 asks the OS to pick a // free one, which is how the local test suite avoids fighting for 3000. const bound = server.address() - log("relay listening", { port: bound?.port ?? PORT, mode: MODE }) + log("relay listening", { port: bound?.port ?? PORT, mode: HANDOFF_MODE }) }) const PAGE = ` @@ -996,16 +1054,37 @@ const PAGE = ` var overlayNote = document.getElementById("overlay-note") var actionEl = document.getElementById("action") + /** + * The wire vocabulary, injected by the relay that served this page from the + * one definition at the top of server.js. Not a copy: a message type renamed + * up there is renamed here in the same edit, and relay.test.ts asserts both + * against the TypeScript protocol. + */ + var VOCAB = __HANDRAISE_VOCAB__ + var MSG = VOCAB.msg + var MODE = VOCAB.mode + /** * Takeover or approval, decided by the relay that served this page. In * approval mode the human is answering a question about one screenshot, not * driving anything: the input row and the key bar are not on the page, and * the two messages below are the only ones this side can produce. */ - var APPROVAL = document.body.dataset.mode === "approval" - var SENDABLE = APPROVAL - ? { approve: 1, deny: 1, ping: 1 } - : { tap: 1, char: 1, key: 1, clear: 1, scroll: 1, handback: 1, abort: 1, ping: 1 } + var APPROVAL = document.body.dataset.mode === MODE.APPROVAL + var SENDABLE = {} + ;(APPROVAL + ? [MSG.APPROVE, MSG.DENY, MSG.PING] + : [ + MSG.TAP, + MSG.CHAR, + MSG.KEY, + MSG.CLEAR, + MSG.SCROLL, + MSG.HANDBACK, + MSG.ABORT, + MSG.PING + ] + ).forEach(function (type) { SENDABLE[type] = 1 }) var ws = null var retries = 0 @@ -1087,7 +1166,7 @@ const PAGE = ` return } // A heartbeat is only worth anything now. Replaying it later says nothing. - if (message.type === "ping") return + if (message.type === MSG.PING) return if (outbox.length >= MAX_QUEUED) { outbox.shift() dropped++ @@ -1535,7 +1614,7 @@ const PAGE = ` // the wheel delta the agent forwards is the inverse of the finger movement. // Divided by the zoom, or a magnified page would scroll magnified too. var fdy = Math.round((-stepped * frameH) / (box.h * view.scale)) - if (fdy !== 0) send({ type: "scroll", fdy: fdy }) + if (fdy !== 0) send({ type: MSG.SCROLL, fdy: fdy }) } canvas.addEventListener("pointerdown", function (e) { @@ -1631,7 +1710,7 @@ const PAGE = ` if (APPROVAL) return var point = toFrame(e.clientX, e.clientY) if (!point) return - send({ type: "tap", fx: point.x, fy: point.y }) + send({ type: MSG.TAP, fx: point.x, fy: point.y }) markTap(e.clientX, e.clientY) }) canvas.addEventListener("pointercancel", function (e) { @@ -1649,8 +1728,8 @@ const PAGE = ` while (shared < mirrored.length && shared < next.length && mirrored[shared] === next[shared]) { shared++ } - for (var back = mirrored.length; back > shared; back--) send({ type: "key", key: "Backspace" }) - for (var i = shared; i < next.length; i++) send({ type: "char", ch: next[i] }) + for (var back = mirrored.length; back > shared; back--) send({ type: MSG.KEY, key: "Backspace" }) + for (var i = shared; i < next.length; i++) send({ type: MSG.CHAR, ch: next[i] }) mirrored = next }) // The mirror and the field are one state: writing kbd.value fires no input @@ -1663,13 +1742,13 @@ const PAGE = ` kbd.addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault() - send({ type: "key", key: "Enter" }) + send({ type: MSG.KEY, key: "Enter" }) resetMirror() return } // An empty field fires no input event, so this is the only signal that the // human wants to delete a character the remote page still holds. - if (e.key === "Backspace" && kbd.value === "") send({ type: "key", key: "Backspace" }) + if (e.key === "Backspace" && kbd.value === "") send({ type: MSG.KEY, key: "Backspace" }) }) /** @@ -1712,20 +1791,20 @@ const PAGE = ` kbd.value = kbd.value.slice(0, -1) mirrored = kbd.value } - send({ type: "key", key: "Backspace" }) + send({ type: MSG.KEY, key: "Backspace" }) }) var clearKey = keyButton("key-clear", function () { - send({ type: "clear" }) + send({ type: MSG.CLEAR }) resetMirror() }) // Tab moves to another field, Enter usually submits: either way what the // human types next belongs to a different context than what is mirrored here. keyButton("key-tab", function () { - send({ type: "key", key: "Tab" }) + send({ type: MSG.KEY, key: "Tab" }) resetMirror() }) keyButton("key-enter", function () { - send({ type: "key", key: "Enter" }) + send({ type: MSG.KEY, key: "Enter" }) resetMirror() }) @@ -1832,11 +1911,11 @@ const PAGE = ` // is; approve is the hold, because it is the one that cannot be undone. // That is the takeover's rule with the sides swapped, for the same reason. document.getElementById("deny").addEventListener("click", function () { - send({ type: "deny" }) + send({ type: MSG.DENY }) finish(ENDINGS.denied[0], ENDINGS.denied[1]) }) holdButton(document.getElementById("approve"), function () { - send({ type: "approve" }) + send({ type: MSG.APPROVE }) finish(ENDINGS.approved[0], ENDINGS.approved[1]) }) } else { @@ -1844,11 +1923,11 @@ const PAGE = ` // spirit: the agent looks, fails and asks again. Confirming the happy path // is the classic mistake, so this stays a single tap. document.getElementById("handback").addEventListener("click", function () { - send({ type: "handback" }) + send({ type: MSG.HANDBACK }) finish(ENDINGS.resolved[0], ENDINGS.resolved[1]) }) holdButton(document.getElementById("abort"), function () { - send({ type: "abort" }) + send({ type: MSG.ABORT }) finish("Thanks for looking", "The agent knows it can't be done here and will stop. You can close this tab.") }) } @@ -1857,14 +1936,14 @@ const PAGE = ` var message try { message = JSON.parse(raw) } catch (err) { return } if (!message) return - if (message.type === "frame") showFrame(message.data, message.meta) - else if (message.type === "state") { + if (message.type === MSG.FRAME) showFrame(message.data, message.meta) + else if (message.type === MSG.STATE) { reason.textContent = message.reason // textContent, never innerHTML: the action is the agent's own sentence, // and it goes on the screen a decision is made from. if (message.action) actionEl.textContent = message.action } - else if (message.type === "focus") { + else if (message.type === MSG.FOCUS) { focus = readFocus(message) applyKind(focus.rect ? focus.kind : "text") placeRing() @@ -1872,7 +1951,7 @@ const PAGE = ` setHint() setClearEnabled() } - else if (message.type === "ended") { + else if (message.type === MSG.ENDED) { var ending = ENDINGS[message.outcome] || ["Session ended", "You can close this tab."] finish(ending[0], ending[1]) } @@ -1935,7 +2014,7 @@ const PAGE = ` setHint() } applyTransform(false) - setInterval(function () { send({ type: "ping" }) }, 20000) + setInterval(function () { send({ type: MSG.PING }) }, 20000) window.addEventListener("resize", render) // The stage also changes size without the window doing so: a longer reason // takes the header to its second line. The letterbox has to follow. diff --git a/src/relay/relay.test.ts b/src/relay/relay.test.ts index 5f8e112..1a00d1b 100644 --- a/src/relay/relay.test.ts +++ b/src/relay/relay.test.ts @@ -17,7 +17,10 @@ import WebSocket from "ws" import type { HandoffMode } from "../types" import { GUEST_SERVER_JS } from "./guest-source" import { + type AgentToHuman, HEARTBEAT_INTERVAL_MS, + type Heartbeat, + type HumanToAgent, RELAY_PORT, type RelayMessage, } from "./protocol" @@ -384,6 +387,78 @@ test("the guest server honours the protocol constants", () => { expect(GUEST_SERVER_JS).toContain(`|| ${RELAY_PORT})`) }) +// --- the wire vocabulary: one set, three places ---------------------------- + +/** + * The constant name `guest/server.js` must give every message type on the + * wire, keyed by the protocol's own unions. + * + * The annotation is a mapped type over all three, so a member added to + * `AgentToHuman`, `HumanToAgent` or `Heartbeat` does not compile here until it + * is listed — and does not go green until the relay names it too. That is the + * point: the relay is untyped JavaScript, where a mistyped `"framme"` is not a + * compile error but a message that is silently never matched. + */ +const WIRE_NAMES = { + frame: "FRAME", + state: "STATE", + focus: "FOCUS", + ended: "ENDED", + tap: "TAP", + char: "CHAR", + key: "KEY", + clear: "CLEAR", + scroll: "SCROLL", + handback: "HANDBACK", + abort: "ABORT", + approve: "APPROVE", + deny: "DENY", + ping: "PING", + pong: "PONG", +} satisfies { + [K in AgentToHuman["type"] | HumanToAgent["type"] | Heartbeat["type"]]: string +} + +/** The relay's own `MSG` object, read back out of the source that defines it. */ +function guestVocabulary(): Map { + const block = /const MSG = \{([\s\S]*?)\n\}/.exec(GUEST_SERVER_JS)?.[1] + if (!block) throw new Error("guest/server.js no longer defines MSG") + const found = new Map() + for (const [, name, value] of block.matchAll(/(\w+): "([^"]+)"/g)) { + if (name && value) found.set(name, value) + } + return found +} + +test("the relay names every message type the protocol defines, and no others", () => { + const found = guestVocabulary() + for (const [type, name] of Object.entries(WIRE_NAMES)) { + expect(found.get(name)).toBe(type) + } + // No extras either: a constant the relay carries but the protocol has never + // heard of is a message nobody on the TypeScript side can send or receive. + expect([...found.keys()].sort()).toEqual(Object.values(WIRE_NAMES).sort()) +}) + +test("neither the relay nor the page it serves spells a message type by hand", () => { + for (const type of Object.keys(WIRE_NAMES)) { + expect(GUEST_SERVER_JS).not.toContain(`type: "${type}"`) + expect(GUEST_SERVER_JS).not.toContain(`type === "${type}"`) + } +}) + +test("the served page carries the relay's own vocabulary, not a copy", async () => { + const html = await (await fetch(`http://127.0.0.1:${relay.port}/`)).text() + + // The placeholder is gone, which is the only proof the substitution ran. + expect(html).not.toContain("__HANDRAISE_VOCAB__") + for (const [name, type] of guestVocabulary()) { + expect(html).toContain(`"${name}":"${type}"`) + } + expect(html).toContain(`"TAKEOVER":"takeover"`) + expect(html).toContain(`"APPROVAL":"approval"`) +}) + // --- B3: the agent role is a secret, not a claim --------------------------- test("role=agent is refused without the secret and accepted with it", async () => { From ca6be9d96ac1988bc163254f4f1231977cc4a679 Mon Sep 17 00:00:00 2001 From: Sy-D <8460326+Sy-D@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:18:24 +0200 Subject: [PATCH 2/5] feat: read the QR code on the page and hand the human the link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A growing class of wall asks for a second device. reCAPTCHA's scan-to-verify, a WhatsApp Web login, an authenticator enrolment: the site draws a QR code and says "scan this with your phone". handraise's whole answer to a wall is to put a human on a phone in front of the page — and the code is on that phone's screen. A phone cannot scan itself, so until now this needed a second device, which defeats a handoff that was supposed to take twenty seconds. The phone gains one key, `Scan QR`, in takeover mode. It sends `scanqr`; the agent takes a fresh full-resolution `page.screenshot({ type: "png" })`, decodes it, and answers `links`. The phone shows a sheet with the whole payload of each code, an "Open in new tab" button for the schemes that may be opened, and Copy for everything else. Measured before it was built (docs/measurements/05-qr.md, reproducible with scripts/measure-qr-decode.ts): - `BarcodeDetector` does not exist in Solari's Chromium, with or without stealth. So the decode is `jsqr` plus a PNG decoder written against `node:zlib` — one pure-JS dependency, and nothing of ours running in the realm of whatever site the agent got stuck on. - The live cast frame is not good enough to decode from: at 800px and JPEG quality 60 it fails on symbols the screenshot reads, with a luck-dependent band in between. The screenshot costs 239ms p50 and buys the difference. - Two codes on one screen defeat jsQR's locator outright — it returns neither. A tiled second pass over four overlapping corners finds both. And one thing that was measured only because the live e2e failed on it, after every offline test had passed. A code the page draws at a resampled size can be large, centred and perfectly sharp and still not be *located*: jsQR thresholds in fixed 8x8 blocks, and a 4.9-pixel module grid straddles them. A tight crop of the same pixels decodes; the same crop with twelve pixels of padding does not. So a scan now looks up to three times — as it came, at 2x, then the four corners — each only when the one before found nothing. The screenshot that failed is kept verbatim as src/core/fixtures/qr-centred.png, and the test around it was watched failing before the second look was added. Security is two locks on one door. Only `http`, `https`, `tel:`, `mailto:` and `otpauth:` get an anchor, as an allowlist rather than a blocklist; the agent classifies each link and the phone checks the scheme again before building the anchor, because `kind` crosses a socket anybody holding the handoff URL can write to. The anchor carries `rel="noopener noreferrer"`. The agent process never fetches any of it. Rate-limited to one scan per 2s in the core, not on the phone. The wide event grows `qrScans` and `qrHits`. reCAPTCHA itself is untested: its demo never served the QR variant, which Google shows at its own discretion. The README and ADR 0008 say so rather than implying more; the mechanism is proven end to end in the live e2e against the test app's new `/qr` page, whose code leads to a token-gated `/verified`. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 59 +++++++ README.md | 64 +++++-- bun.lock | 65 +++++++ docs/adr/0008-qr-passthrough.md | 138 +++++++++++++++ docs/adr/README.md | 1 + docs/measurements/05-qr.md | 176 +++++++++++++++++++ docs/measurements/README.md | 13 +- e2e/handoff.e2e.ts | 107 +++++++++++- e2e/human-sim.ts | 21 +++ e2e/ui.spec.ts | 215 ++++++++++++++++++++++- package.json | 3 + scripts/dist-smoke.mjs | 20 ++- scripts/measure-qr-decode.ts | 227 ++++++++++++++++++++++++ src/core/fixtures/qr-centred.png | Bin 0 -> 57384 bytes src/core/fixtures/qr-page.png | Bin 0 -> 43734 bytes src/core/handoff.test.ts | 246 +++++++++++++++++++++++++- src/core/input.ts | 5 +- src/core/png.ts | 180 ++++++++++++++++++++ src/core/qr-scan.test.ts | 238 ++++++++++++++++++++++++++ src/core/qr-scan.ts | 284 +++++++++++++++++++++++++++++++ src/core/raise-hand.ts | 60 +++++++ src/core/socket.test.ts | 12 +- src/core/socket.ts | 1 + src/events.ts | 11 ++ src/index.ts | 6 + src/relay/guest-source.ts | 274 ++++++++++++++++++++++++++++- src/relay/guest/server.js | 274 ++++++++++++++++++++++++++++- src/relay/protocol.ts | 22 +++ src/relay/relay.test.ts | 16 ++ test-app/app.test.ts | 49 ++++++ test-app/deploy.ts | 36 ++++ test-app/guest-source.ts | 101 ++++++++++- test-app/guest/app.js | 101 ++++++++++- 33 files changed, 2988 insertions(+), 37 deletions(-) create mode 100644 docs/adr/0008-qr-passthrough.md create mode 100644 docs/measurements/05-qr.md create mode 100644 scripts/measure-qr-decode.ts create mode 100644 src/core/fixtures/qr-centred.png create mode 100644 src/core/fixtures/qr-page.png create mode 100644 src/core/png.ts create mode 100644 src/core/qr-scan.test.ts create mode 100644 src/core/qr-scan.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe6913a..b10211d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] — 0.6.0 +QR passthrough, and typed errors on everything `raiseHand` throws. + +A device-change check draws a QR code and asks for a phone — and handraise's +human is holding that phone, looking at the code on its screen. A phone cannot +scan itself, so until now this needed a second device. The agent reads the code +off the page instead and hands the human the link. + +The QR half is additive: no existing call, type or outcome changes. The errors +half is not entirely — a page that is already dead now throws instead of +returning `disconnected`. Both are under **Changed**. + ### Added +- **A `Scan QR` key on the phone, in takeover mode.** It asks the agent to read + the QR codes on the page; the answer arrives as a sheet showing what each one + said, with **Open in new tab** and **Copy**. The button is disabled while a + scan is in flight and releases itself if no answer comes. +- **Two protocol messages**: human→agent `{ type: "scanqr" }`, and agent→human + `{ type: "links", links: ScannedLink[], source: "qr" }` — always sent, with + an empty list when nothing decoded, because silence reads as a broken button. + The relay routes `scanqr` in takeover mode only. +- **`scanQrLinks(png)` and `OPENABLE_SCHEMES` are exported.** The decoder is + usable without a human: hand it a PNG screenshot, get back up to two + `{ text, kind }`. `kind` is `"url"` only for `http:`, `https:`, `tel:`, + `mailto:` and `otpauth:`; everything else is `"text"` with a Copy button and + no anchor, and the phone re-checks the scheme itself rather than trusting a + label that crossed a socket a stranger holding the link can write to. +- **`HandoffEvent.qrScans` and `HandoffEvent.qrHits`.** Two new **required** + number fields on the wide event — additive for callers, who receive the + event rather than construct it, but a TypeScript consumer that builds a + `HandoffEvent` literal in a test will need them. Both are 0 in approval mode, + which offers no scan. `qrScans - qrHits` is the number worth watching. +- **`jsqr` as a runtime dependency** (pure JavaScript, no dependencies of its + own), and a PNG decoder written against `node:zlib` in `src/core/png.ts`. + `BarcodeDetector` does not exist in Solari's Chromium, so the decode happens + in the agent process — never in the remote page, whose JavaScript belongs to + whoever the agent got stuck on. +- **[ADR 0008](docs/adr/0008-qr-passthrough.md)** and + **[measurement 05](docs/measurements/05-qr.md)**, reproducible with + `bun --env-file=.env scripts/measure-qr-decode.ts`. - **Typed errors: `HandraiseError`, `HandraiseErrorCode`, `isHandraiseError`.** Everything `raiseHand` throws now carries a `code` you can branch on — `missing_api_key`, `invalid_mode`, `empty_action`, `browser_unusable`, @@ -59,6 +97,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `node20`). It always was the floor: `@solarisdk/browser` and the patchright runtime it wraps require Node 20, so a Node 18 install never worked; the package just did not say so. +- **`src/relay/guest/server.js` names its wire vocabulary.** `MSG` and `MODE` + replace the bare strings the untyped relay compared against, and the mobile + page it serves is handed the same object at serve time instead of keeping its + own copy. `relay.test.ts` asserts `MSG` against the TypeScript protocol's own + unions, so neither side can grow a message alone. No behaviour change. - **A page that is already dead is now refused instead of handed off.** `raiseHand` used to create a relay, fail on the first CDP call and *return* `{ outcome: "disconnected" }`. It now throws a `HandraiseError` @@ -83,6 +126,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Apart from the page check above, nothing throws that did not throw before, and no outcome became an exception. +### Known limits + +- A scan takes a fresh full-resolution `page.screenshot()`, 293 ms p50 measured + from Germany. It is rate-limited to one per 2 s in the core. +- A symbol drawn below about 120 CSS pixels does not decode. The live cast + frame — 800 px, JPEG quality 60 — fails well before that, which is why the + scan does not reuse it. +- A code the page drew at a resampled size can be sharp and still not be found + on the first pass, so a scan looks again at 2x and then at four overlapping + corners. A page with no code at all pays all three, about 320 ms of CPU. +- Two codes on one screen need a tiled second pass to be found at all; three or + more are not attempted. +- **reCAPTCHA itself is untested.** Its demo never served the scan-to-verify + variant, which Google shows at its own discretion. The mechanism is proven + end to end in the live e2e against a page that behaves the same way. + ## [0.5.1] - 2026-09-02 Republish of 0.5.0 with no code change. 0.5.0 was published to npm and diff --git a/README.md b/README.md index 536b567..9b35458 100644 --- a/README.md +++ b/README.md @@ -206,8 +206,8 @@ zoom and pan yourself; double-tap toggles between zoomed and fit. Typing goes straight into the focused field, character by character — and if that field is a one-time code, the phone offers the SMS code it just received. -Four keys under the input, because a phone's virtual keyboard cannot be trusted -to send them: +Under the input, four keys a phone's virtual keyboard cannot be trusted to +send, and one that asks the agent a question about the page: | Key | What it does | |---|---| @@ -215,6 +215,25 @@ to send them: | ⇥ | Move to the next field | | ⏎ | Submit / press Enter | | Clear | Empty the focused field (select-all + backspace; disabled while nothing is focused, and kept well away from ⌫) | +| Scan QR | Read the QR codes on the page and show what they say | + +### QR codes on the page + +Some walls ask for a *second device*: reCAPTCHA's "scan to verify", a WhatsApp +Web login, an authenticator enrolment. The human is holding the phone the site +wants — and the code is on that phone's screen, so it cannot be scanned. + +**Scan QR** asks the agent instead. It takes a fresh full-resolution screenshot +of the page, decodes it, and sends back what each code said. The phone shows the +link in full and offers **Open in new tab** — so the link is opened on the +phone, which is the device the site was asking for. Only `http`, `https`, +`tel:`, `mailto:` and `otpauth:` are openable; anything else is shown as text +with a Copy button, and the agent itself never fetches any of it. Takeover mode +only, one scan per 2 seconds, and a symbol below about 120 CSS pixels will not +decode — scroll or zoom the remote page and scan again. + +Measured: [`docs/measurements/05-qr.md`](docs/measurements/05-qr.md); the +decisions are in [ADR 0008](docs/adr/0008-qr-passthrough.md). Below that, two ways out. **✋ Hand back** ends the handoff as `resolved` — one tap, the agent continues. **I can't do this** ends it as `aborted` — the agent @@ -289,6 +308,21 @@ await raiseHand(page, { | `timeout` | both | Nobody answered within `timeoutMs`. | | `disconnected` | both | The browser session died mid-handoff. | +### `scanQrLinks(png): ScannedLink[]` + +The decoder behind the phone's **Scan QR** button, exported so an agent can +read a code without asking a human. Takes the bytes of a PNG screenshot, +returns up to two `{ text, kind }` — `kind: "url"` only for a scheme in +`OPENABLE_SCHEMES`, which is also exported. It reads and classifies; it never +opens anything. + +```ts +import { scanQrLinks } from "handraise" + +const codes = scanQrLinks(await page.screenshot({ type: "png" })) +if (codes[0]?.kind === "url") console.log(codes[0].text) +``` + ### Errors `raiseHand` throws only before the handoff URL exists — while nobody has been @@ -369,8 +403,10 @@ handraise brings the same handoff to Solari browsers, which have no native live view (Solari's VNC is desktop-only), as a portable library instead — less polished, and it works where those don't. What the hosted live views do not have is the second mode: an approval is a yes-or-no on one screenshot, no -live session exposed at all, answerable from a chat channel. Its scope stops -at the handoff, not wall detection +live session exposed at all, answerable from a chat channel. Nor do they have +an answer to a device-change check — a QR code a phone is asked to scan, on +the phone's own screen — which handraise reads off the page and hands over as +a link. Its scope stops at the handoff, not wall detection ([`docs/adr/0005`](docs/adr/0005-handoff-not-wall-detection.md)). ## Security @@ -444,14 +480,17 @@ a 2FA, and each handoff consumes one sandbox, destroyed when it ends. ## Verified how -Benchmark method and raw data: [`benchmarks/`](benchmarks/README.md). The four +Benchmark method and raw data: [`benchmarks/`](benchmarks/README.md). The five platform measurements the design rests on — transport, screencast, input -injection, session lifetime — are in +injection, session lifetime, QR decoding — are in [`docs/measurements/`](docs/measurements/README.md). The e2e test drives the whole loop with no mocks: a Solari browser signs into a TOTP-protected demo app ([`test-app/`](test-app/), deployed into a sandbox), hits the 2FA wall, raises its hand, a scripted "human" types the code through the real handoff UI, and -the test asserts the signed-in page — ~6s end to end. Injected events arrive +the test asserts the signed-in page — ~6s end to end. The same run then drives +the QR passthrough: the app shows a device-change code, the human asks for a +scan, and the link that comes back is fetched from outside the browser to reach +the confirmation page. Injected events arrive with `isTrusted: true`. ## Limitations (v1) @@ -464,11 +503,12 @@ with `isTrusted: true`. - An approval shows the page as it was when the agent asked. If the page changes underneath (a session expiring, a redirect), the human is deciding on a stale picture — the frame is not refreshed. -- A verification that shows a QR code to scan (reCAPTCHA's "scan to verify - you're human") needs a second screen today: open the handoff link on a - laptop and scan it with the phone — the phone cannot scan its own display. - Decoding the QR from the live frame and handing the phone the link is - planned. +- The QR passthrough is **untested against reCAPTCHA itself**: its demo never + served the scan-to-verify variant, which Google shows at its own discretion. + The mechanism is proven end to end against a page that behaves the same way + ([measurement 05 §6](docs/measurements/05-qr.md)). A code drawn below ~120 + CSS pixels does not decode, and three or more codes on one screen are not + attempted. - TypeScript/Node only for now. ## Contributing diff --git a/bun.lock b/bun.lock index 7e6d36e..20705bf 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@solarisdk/browser": "^0.1.2", "@solarisdk/sdk": "^0.1.2", + "jsqr": "^1.4.0", "qrcode-terminal": "^0.12.0", "ws": "^8.21.3", }, @@ -15,12 +16,14 @@ "@ai-sdk/openai-compatible": "^3.0.41", "@biomejs/biome": "^2.0.0", "@oxlint/plugins": "1.80.0", + "@types/qrcode": "^1.5.6", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.5.13", "ai": "^7.0.87", "bun-types": "^1.4.0", "oxlint": "1.80.0", "playwright-core": "^1.62.1", + "qrcode": "^1.5.4", "tsup": "^8.5.1", "typescript": "^5.7.2", }, @@ -226,6 +229,8 @@ "@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="], + "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], + "@types/qrcode-terminal": ["@types/qrcode-terminal@0.12.2", "", {}, "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -238,6 +243,10 @@ "ai": ["ai@7.0.87", "", { "dependencies": { "@ai-sdk/gateway": "4.0.70", "@ai-sdk/provider": "4.0.9", "@ai-sdk/provider-utils": "5.0.34" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/hrT7toRx8vLIyr/lTKOOPDxCGdxk2tVs5viHDwIPlUsge5FBaLe9h3BhPAr7cOmEAXkN/Cf2+2N8HYGCjJbHg=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], @@ -246,8 +255,16 @@ "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], @@ -256,26 +273,42 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "fix-dts-default-cjs-exports": ["fix-dts-default-cjs-exports@1.0.1", "", { "dependencies": { "magic-string": "^0.30.17", "mlly": "^1.7.4", "rollup": "^4.34.8" } }, "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "jsqr": ["jsqr@1.4.0", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="], + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "load-tsconfig": ["load-tsconfig@0.2.5", "", {}, "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg=="], + "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], @@ -288,8 +321,16 @@ "oxlint": ["oxlint@1.80.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.80.0", "@oxlint/binding-android-arm64": "1.80.0", "@oxlint/binding-darwin-arm64": "1.80.0", "@oxlint/binding-darwin-x64": "1.80.0", "@oxlint/binding-freebsd-x64": "1.80.0", "@oxlint/binding-linux-arm-gnueabihf": "1.80.0", "@oxlint/binding-linux-arm-musleabihf": "1.80.0", "@oxlint/binding-linux-arm64-gnu": "1.80.0", "@oxlint/binding-linux-arm64-musl": "1.80.0", "@oxlint/binding-linux-ppc64-gnu": "1.80.0", "@oxlint/binding-linux-riscv64-gnu": "1.80.0", "@oxlint/binding-linux-riscv64-musl": "1.80.0", "@oxlint/binding-linux-s390x-gnu": "1.80.0", "@oxlint/binding-linux-x64-gnu": "1.80.0", "@oxlint/binding-linux-x64-musl": "1.80.0", "@oxlint/binding-openharmony-arm64": "1.80.0", "@oxlint/binding-win32-arm64-msvc": "1.80.0", "@oxlint/binding-win32-ia32-msvc": "1.80.0", "@oxlint/binding-win32-x64-msvc": "1.80.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA=="], + "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + + "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + "patchright-core": ["patchright-core@1.62.2", "", { "bin": { "patchright-core": "cli.js" } }, "sha512-XDl3HB/Kjm9ZBFn4ygD5mxdsqK4aIWNGXdea46XAGd0hvHuJJ84w1M83JqofpXIbD2aJfQ2Rtz4YcqPVrxjSzw=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -302,18 +343,32 @@ "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "qrcode-terminal": ["qrcode-terminal@0.12.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="], "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], "rollup": ["rollup@4.63.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.1", "@rollup/rollup-android-arm64": "4.63.1", "@rollup/rollup-darwin-arm64": "4.63.1", "@rollup/rollup-darwin-x64": "4.63.1", "@rollup/rollup-freebsd-arm64": "4.63.1", "@rollup/rollup-freebsd-x64": "4.63.1", "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", "@rollup/rollup-linux-arm-musleabihf": "4.63.1", "@rollup/rollup-linux-arm64-gnu": "4.63.1", "@rollup/rollup-linux-arm64-musl": "4.63.1", "@rollup/rollup-linux-loong64-gnu": "4.63.1", "@rollup/rollup-linux-loong64-musl": "4.63.1", "@rollup/rollup-linux-ppc64-gnu": "4.63.1", "@rollup/rollup-linux-ppc64-musl": "4.63.1", "@rollup/rollup-linux-riscv64-gnu": "4.63.1", "@rollup/rollup-linux-riscv64-musl": "4.63.1", "@rollup/rollup-linux-s390x-gnu": "4.63.1", "@rollup/rollup-linux-x64-gnu": "4.63.1", "@rollup/rollup-linux-x64-musl": "4.63.1", "@rollup/rollup-openbsd-x64": "4.63.1", "@rollup/rollup-openharmony-arm64": "4.63.1", "@rollup/rollup-win32-arm64-msvc": "4.63.1", "@rollup/rollup-win32-ia32-msvc": "4.63.1", "@rollup/rollup-win32-x64-gnu": "4.63.1", "@rollup/rollup-win32-x64-msvc": "4.63.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg=="], + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], @@ -338,8 +393,18 @@ "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + "y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + + "yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + + "yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], } } diff --git a/docs/adr/0008-qr-passthrough.md b/docs/adr/0008-qr-passthrough.md new file mode 100644 index 0000000..e0c7a4e --- /dev/null +++ b/docs/adr/0008-qr-passthrough.md @@ -0,0 +1,138 @@ +# 0008 — QR passthrough: the agent reads the code, the phone gets the link + +- **Status:** accepted +- **Date:** 2026-09-02 + +## Context + +A growing class of walls asks for a *second device*. reCAPTCHA's scan-to-verify +variant, a WhatsApp Web login, an authenticator enrolment, a payment code: the +site draws a QR code and says "scan this with your phone". + +handraise's whole answer to a wall is to put a human on a phone in front of the +page. That answer breaks here, and it breaks in a way that is funny once and +then expensive: the human is holding the phone the site is asking for, and the +code is on that phone's screen. **A phone cannot scan itself.** Before this, +the only way through was a second device — which defeats the point of a handoff +that was supposed to take twenty seconds. + +The information is right there. The code is a string, usually a URL, and the +agent has the page it is drawn on. + +## Decision + +**A human-initiated scan.** The phone gains one control in the key bar, +`Scan QR`. It sends `{ "type": "scanqr" }`; the agent takes a fresh +full-resolution `page.screenshot({ type: "png" })`, decodes it, and answers +`{ "type": "links", "links": [...], "source": "qr" }`. The phone shows a sheet +with what each code said, an **Open in new tab** button for the schemes below, +and **Copy** for everything. + +Five decisions inside that, each one measured or argued rather than assumed +([Measurement 05](../measurements/05-qr.md)). + +**Decode in the agent process, not in the page.** `BarcodeDetector` does not +exist in Solari's Chromium (measured, §1), so "let the browser do it" was not +available. It would have been the wrong shape anyway: running the decode via +`page.evaluate` puts handraise's code in the realm of whatever site the agent +got stuck on, where the page can replace `BarcodeDetector` and answer with a +link of its choosing — a link a human is then invited to open on their phone. +The decode is `jsqr` plus a PNG decoder written against `node:zlib` +(`src/core/png.ts`, ~180 lines): one new pure-JavaScript dependency, no native +build on any platform an agent runs on. + +**A fresh screenshot, not the cast frame.** The phone is already looking at a +picture of the page, and reusing it would cost nothing. It is not good enough: +the cast is 800 px wide at JPEG quality 60, chosen for a form field, and it +fails on symbols the screenshot reads (measured, §3 — 180 px and 120 px fail +from the frame and decode from the screenshot, with a luck-dependent band in +between). The screenshot costs 239 ms p50, which is the price of the difference +between "works" and "works when the site draws its code large". + +**On request, and rate-limited.** No auto-scan of every frame: a scan is a +screenshot plus a decode on a stream that already paces itself to a phone's +link, and 99% of pages have no code on them. One scan per 2 s, enforced in the +core rather than on the phone — the handoff URL is a bearer credential and the +socket behind it is reachable from any HTTP client, so the phone's own floor is +a courtesy, not a limit. + +**An allowlist of openable schemes, checked twice.** `http:`, `https:`, `tel:`, +`mailto:`, `otpauth:` may be opened; everything else is shown as text with a +Copy button and no anchor. An allowlist rather than a blocklist because the +interesting half is the half nobody thinks of: `javascript:` and `data:` are +the two everyone remembers, and `intent:`, `file:`, `content:` and whatever a +phone browser ships next year are the ones a blocklist would have let through. +The agent classifies, and the phone checks the scheme again before it builds +the anchor — the `kind` field crosses a socket a stranger holding the link can +write to, so the page must not have to trust it. The anchor carries +`rel="noopener noreferrer"`: the opened site gets no handle on the tab holding +a live handoff, and is not told the handoff URL it came from. + +**The agent never opens anything.** It reads and classifies; the fetching is +the human's, in their own browser. An agent process that followed a URL out of +a hostile page would be an SSRF primitive with the agent's own network position. + +Takeover only. An approval is one screenshot of a moment, not a live page — +there is nothing to scan and nothing a scan could change. + +## Alternatives + +**Auto-scan every cast frame.** Rejected on cost and on false positives: a +decode per frame on a stream that runs at ~14 fps, to answer a question almost +every page answers "no" to, and a sheet that opens itself while the human is +typing. + +**Decode the cast frame on the phone.** Attractive — no protocol change, no +agent work, and the phone already has `BarcodeDetector` on iOS. Rejected on the +same measurement that killed reusing the frame in the agent (§3): the picture +the phone holds is the one that does not decode. It would also have shipped a +feature whose reliability depended on which phone the human happened to hold. + +**Send a screenshot to the phone and let it decode.** The phone would need the +full-resolution PNG — 43.7 KB here, more on a real page — over a link that is +already pacing a live cast, to run a decode that costs 54 ms in Node. All of +the bandwidth, none of the control, and still phone-dependent. + +**Let the agent open the link itself.** It is the obvious shortcut and it is +wrong twice: the site is asking for a *different device* on purpose, so opening +it from the browser that showed the code defeats the check it is making; and it +turns any page the agent lands on into a request the agent will make. + +**`pngjs` instead of a hand-written PNG decoder.** A second dependency to read +four colour types at one bit depth. The decoder is ~180 lines against +`node:zlib`, refuses everything it has not been fed, and is tested against a +real cloud-browser screenshot. + +## Consequences + +- One new runtime dependency: `jsqr` (pure JavaScript, no dependencies of its + own). `qrcode` is added as a **dev** dependency, for the test app's page and + for generating test images. +- The protocol grows one message in each direction (`scanqr`, `links`). The + wide event grows `qrScans` and `qrHits`; `qrScans - qrHits` is the number + worth watching, because it is either a page with no code or a decode that + failed. +- `scanQrLinks(png)` and `OPENABLE_SCHEMES` are exported: an agent that wants + to read a code without a human can, and the dist smoke uses it to prove the + CommonJS interop survives bundling — the failure mode that once broke + `qrcode-terminal` in `dist` while every bun test stayed green. +- **A symbol below ~120 CSS px will not decode** (§3). The sheet says nothing + was found; the human can zoom or scroll the remote page and scan again. +- **One decode is not enough.** A code the page drew at a resampled size can be + large, centred and sharp and still not be *located*, because `jsQR` + thresholds in fixed 8x8 blocks and a 4.9-pixel module grid straddles them + (§5). `scanImage` therefore looks up to three times — as it came, at 2x, then + four overlapping corners — and only when the previous look found nothing. A + page with no code pays all three, about 320 ms of CPU. This was found by the + live e2e after every offline test had passed, which is the argument for + having one. +- **Two codes on one screen** need the tiled second pass to be found at all + (§4). Three or more are not attempted: `MAX_CODES` is 2. +- **reCAPTCHA itself is untested.** Its demo never served the QR variant + (§6). The mechanism is proven end to end against the test app's `/qr` page in + the live e2e, and the README says as much rather than implying more. +- A hostile page can put any string in a QR code and have a human read it on a + phone. That is the residual risk, and it is bounded by the allowlist, by the + scheme re-check on the page, by `noreferrer`, and by the fact that opening it + is an explicit act by a person who can see the whole link — which is why the + sheet never truncates it. diff --git a/docs/adr/README.md b/docs/adr/README.md index ea076d9..a7e0fd3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -15,6 +15,7 @@ pre-publish security review — they document the history, they do not invent it | [0005](0005-handoff-not-wall-detection.md) | handraise is the handoff mechanism, not the wall detection | accepted | Scope decision | | [0006](0006-approval-mode.md) | Approval mode: one screenshot, a hold on yes | accepted | Scope decision | | [0007](0007-channels.md) | Channels: an in-process hook, not a second WebSocket client | accepted | Scope decision | +| [0008](0008-qr-passthrough.md) | QR passthrough: the agent reads the code, the phone gets the link | accepted | [Measurement 05](../measurements/05-qr.md) | ## Format diff --git a/docs/measurements/05-qr.md b/docs/measurements/05-qr.md new file mode 100644 index 0000000..45b2a42 --- /dev/null +++ b/docs/measurements/05-qr.md @@ -0,0 +1,176 @@ +# 05 — Reading a QR code off the page an agent is stuck on + +**What was measured, and why.** A device-change check — reCAPTCHA's +scan-to-verify, a WhatsApp Web login, an authenticator enrolment — draws a QR +code and asks for a phone. handraise puts a human on a phone in front of that +page, and a phone cannot scan its own screen, so today it takes a second +device. Passing the link through instead needs three facts settled before any +code is written: can Chromium decode the code itself, is the live cast frame +good enough to decode from, and what does a scan cost. Measured 2026-09-02, +against a Solari cloud browser (Chrome 151, Linux x86_64) and local Chromium. + +Reproduce with: + +``` +bun --env-file=.env scripts/measure-qr-decode.ts --recaptcha # A, one browser session +bun scripts/measure-qr-decode.ts --local # B, no API key needed +``` + +**Short answer.** `BarcodeDetector` does not exist in Solari's Chromium, so the +decode happens in the agent process. A fresh full-resolution `page.screenshot()` +decodes a symbol the live cast frame cannot, and costs 293 ms end to end. Two +codes on one screen defeat the decoder outright unless the image is tiled. +A code the page resampled can be sharp and still not decode, which needs a +second look at 2x. reCAPTCHA's own demo never showed the QR variant, so that +specific site remains untested — see §6. + +--- + +## 1. `BarcodeDetector` in Solari's Chromium: absent + +```json +{"event":"barcode_detector","present":false, + "ua":"Mozilla/5.0 (X11; Linux x86_64) … Chrome/151.0.0.0 Safari/537.36"} +``` + +Checked with `stealth: true` and `stealth: false`; absent in both, and +`HeadlessChrome/151.0.7922.34` without stealth. This is the expected outcome — +Chromium implements the Shape Detection API on top of platform frameworks +(macOS, Android, ChromeOS) and ships nothing for Linux — but it was worth one +API call, because a positive would have meant zero new dependencies. + +**Consequence:** the decode is a `jsqr` call in the agent process, fed by a PNG +decoder written against `node:zlib` (`src/core/png.ts`). One new runtime +dependency, pure JavaScript, no native build. + +## 2. Screenshot and decode: 293 ms p50 + +Ten runs, 1280×800 viewport, a 200-character payload drawn at 420 CSS px +(version 10, 57 modules, ~7 px per module): + +| | p50 | samples (ms) | +|---|---|---| +| `page.screenshot({ type: "png" })` | **239 ms** | 455, 232, 240, 234, 319, 252, 235, 238, 246, 232 | +| decode (PNG unfilter + `jsQR`) | **54.5 ms** | 92, 45, 49, 56, 46, 58, 53, 56, 57, 44 | +| total | **293.5 ms** | | + +The screenshot is the cost, and it is network: a CDP round trip from Germany to +the session's region. The decode is local and cheap enough to ignore. The first +sample of each column is a warm-up (455 ms, 92 ms) and is in the median like +everything else. + +Screenshot size: 43.7 KB PNG. Decoded payload: 200 characters, exactly. + +**Consequence:** a scan feels immediate on a phone, and there is no case for +scanning speculatively. It is rate-limited to one per 2 s in the core. + +## 3. The cast frame is not good enough, and this is where it stops + +The phone is already looking at a picture of the page: the screencast frame, at +`DEFAULT_PROFILE` — 800 px wide, JPEG quality 60. Decoding *that* would cost no +screenshot at all. It was tested against the real thing rather than an +approximation: the frame comes from this repo's own `startFramePump`, and it is +handed back to Chromium to be decoded by Chromium's own JPEG decoder before +`jsQR` sees the pixels. + +Same payload, drawn at six sizes: + +| symbol on the page | from `page.screenshot()` (1280 px PNG) | from the cast frame (800 px JPEG q60) | frame bytes | +|---|---|---|---| +| 420 px | decoded | decoded | 27.8 KB | +| 260 px | decoded | decoded | 16.2 KB | +| 180 px | decoded | **failed** | 11.8 KB | +| 150 px | decoded | decoded | 10.5 KB | +| 120 px | decoded | **failed** | 8.9 KB | +| 100 px | **failed** | **failed** | 7.8 KB | + +Note the non-monotonicity at 150 px. Below about 260 px the cast frame is at the +edge of what survives a 0.625 downscale plus quality-60 quantisation, and +whether a given size lands is a matter of how the module grid aligns to the +pixel grid — which is another way of saying it is luck. The screenshot has no +such band: it decodes down to 120 px and stops at 100 px. + +**Consequence:** every scan takes a fresh `page.screenshot({ type: "png" })`. +The 239 ms buys the difference between "works" and "works when the site happens +to draw its code large". + +**Known limit of the feature:** a symbol below roughly 120 CSS px, at +`deviceScaleFactor: 1`, will not decode. A human can scroll or zoom the remote +page and scan again, and the sheet says so when nothing is found. + +## 4. Two codes on one screen defeat the decoder + +`jsQR` locates a symbol by its three finder patterns. Two symbols put six in +front of it, and it does not pick one — it returns nothing at all: + +``` +1280×800 page, two 261 px codes, 80 px and 700 px from the left + whole image -> null + four 60% corners -> both codes, 63 ms +``` + +Reading the whole image and then painting out the symbol just found (which is +how a second code is reached at all) only helps once the first has been found. +So when the whole-image pass comes back empty, `scanImage` looks again at four +overlapping corner tiles. That costs a second decode only on a pass that had +already failed, and it turns "no QR code found" on a page that visibly has two +into both of them. + +## 5. A sharp code that will not decode, and the second look that fixes it + +Found by the first live run of the e2e, which failed with "0 codes" on a page +whose QR was large, centred and perfectly sharp to the eye. The screenshot is +kept as `src/core/fixtures/qr-centred.png`; everything below is measured +against exactly those pixels. + +``` +whole 1280x800 image -> null +tight crop of the symbol -> decoded +same crop plus 12px padding -> null +2x nearest-neighbour -> decoded, 209 ms +3x nearest-neighbour -> decoded, 462 ms +``` + +A tight crop decodes and the same pixels with twelve more pixels of margin do +not. That rules out the image and points at `jsQR`'s binarizer, which +thresholds in fixed 8x8 blocks: the page drew a 534-pixel image at 420 CSS +pixels, so a module is 4.9 pixels wide and the block grid straddles the module +boundaries. Shifting the crop shifts that alignment, which is why padding +changes the answer. Magnifying puts about ten pixels under each module and the +blocks line up again — nearest-neighbour, so not one new pixel of information. + +Tiling was tried first and rejected as a fix for this: four 60% corners miss a +symbol that straddles the centre, and the geometries that happened to work +(0.6 centre, 0.7 bottom-left) did so by the same alignment luck, with 0.5 and +0.8 finding nothing. Tuning tile sizes would have been fitting to one image. + +**Consequence:** `scanImage` looks three times, and the second and third run +only when the one before found nothing — the image as it came, the image at 2x, +then the four corners for the two-code case in §4. A page with no code at all +therefore costs all three, about 320 ms of CPU against a 239 ms screenshot. + +**This is why the live e2e exists.** Every offline test passed, the decoder read +a real cloud-browser screenshot, the phone UI worked in real Chromium, and the +feature did not work. + +## 6. reCAPTCHA: not reproducible, and therefore not claimed + +The feature exists because of reCAPTCHA's scan-to-verify variant. It could not +be reproduced: + +```json +{"event":"recaptcha_demo","url":"https://www.google.com/recaptcha/api2/demo", + "codes":0,"first":""} +``` + +The demo at `google.com/recaptcha/api2/demo`, loaded in a Solari browser with +stealth on and again without it, served the checkbox and an image challenge. The +device-change variant is served at Google's discretion, on signals nobody +outside Google controls, and no amount of retrying makes it appear on demand. + +**What this means, stated plainly:** the mechanism is proven end to end against +a site that behaves the way reCAPTCHA's variant behaves — the test app's `/qr` +page, driven by the live e2e — and it is **untested against reCAPTCHA itself**. +The README says the same thing. If it turns out reCAPTCHA's code is drawn below +the ~120 px floor in §3, or is not a link at all, this will need revisiting with +a real sample. diff --git a/docs/measurements/README.md b/docs/measurements/README.md index 15e1a14..5aabf53 100644 --- a/docs/measurements/README.md +++ b/docs/measurements/README.md @@ -1,9 +1,9 @@ # Platform measurements Four questions had to be answered against the live Solari API before handraise -could be designed at all. These are the write-ups, kept as evidence: the -numbers are unchanged from the day they were taken, and the ADRs in -[`../adr/`](../adr/) cite them. +could be designed at all, and one more before the QR passthrough was built. +These are the write-ups, kept as evidence: the numbers are unchanged from the +day they were taken, and the ADRs in [`../adr/`](../adr/) cite them. | # | What was measured | When | How | |---|---|---|---| @@ -11,11 +11,14 @@ numbers are unchanged from the day they were taken, and the ADRs in | [02](02-cdp-screencast.md) | CDP screencast over the SDK: framerate, frame size, bandwidth, delivery lag | 2026-09-01 | Four runs, three browser sessions, six scenarios | | [03](03-cdp-input-injection.md) | CDP input injection: mouse, text, keys, touch, scroll, `isTrusted`, coordinate mapping | 2026-09-01 | Every result read back from page state, never from "no error was thrown" | | [04](04-browser-session-lifetime.md) | Whether a browser session survives a multi-minute human pause | 2026-09-01 | Six browser sessions and five sandboxes, idle vs. pinged vs. streaming | +| [05](05-qr.md) | Reading a QR code off the page: `BarcodeDetector`, screenshot vs. cast frame, decode latency | 2026-09-02 | One cloud browser session and local Chromium; reproducible with `scripts/measure-qr-decode.ts` | **04 is the one to read first.** It is why the default wait is five minutes, why there is no keep-alive pinger, why `disconnected` is an outcome rather than an exception, and why `storageState` is captured on handback. -The probe scripts behind these documents were throwaway experiments and are not -carried in the tree; the repository history has them. Timing benchmarks of the +The probe scripts behind 01-04 were throwaway experiments and are not carried +in the tree; the repository history has them. 05 is reproducible from +[`scripts/measure-qr-decode.ts`](../../scripts/measure-qr-decode.ts), which also +regenerates the decoder's test fixture. Timing benchmarks of the shipped library live in [`../../benchmarks/`](../../benchmarks/). diff --git a/e2e/handoff.e2e.ts b/e2e/handoff.e2e.ts index 17d26a6..0a6c8e0 100644 --- a/e2e/handoff.e2e.ts +++ b/e2e/handoff.e2e.ts @@ -26,7 +26,7 @@ import type { Page } from "playwright-core" import type { HandoffEvent } from "../src/events" import { raiseHand } from "../src/index" -import { startTestApp } from "../test-app/deploy" +import { previewPath, startTestApp } from "../test-app/deploy" import { msUntilNextStep, totp } from "../test-app/totp" import { openHandoffPage } from "./human-sim" @@ -243,6 +243,111 @@ try { ) await relayGone.text() + // --- QR passthrough: the code on the page, opened on the phone --------- + // + // The device-change check, which the human on a phone cannot answer by + // scanning their own screen. The agent reads the code off a full-resolution + // screenshot and hands the human the link; the human opens it, and the site + // is satisfied on a device that has never seen it before. + const qrAt = Date.now() + await page.goto(previewPath(app.url, "/qr"), { + waitUntil: "domcontentloaded", + timeout: 30_000, + }) + await page.waitForSelector('[data-testid="qr-code"]', { timeout: 15_000 }) + + // The selector only says the element is there. A scan that finds nothing is + // then two different bugs — a code that never drew, or a decoder that could + // not read it — and this is what tells them apart. + const drawn = await page.evaluate(() => { + const image = document.querySelector("img") + if (!image) return null + const rect = image.getBoundingClientRect() + return { + complete: image.complete, + natural: image.naturalWidth, + css: Math.round(rect.width), + src: image.src.length, + } + }) + log("qr_page", drawn ?? { drawn: false }) + check( + (drawn?.natural ?? 0) > 0, + `the code is drawn on the page (${JSON.stringify(drawn)})`, + ) + + let qrUrl = "" + let qrEvent: HandoffEvent | undefined + const scanning = raiseHand(page, { + reason: "Aurora Bank wants this code scanned with your phone", + qr: false, + timeoutMs: 60_000, + onUrl: (url) => { + qrUrl = url + }, + onEvent: (raised) => { + qrEvent = raised + }, + }) + pending = scanning + + while (qrUrl === "") await Bun.sleep(50) + const scanner = await openHandoffPage(qrUrl) + await scanner.waitForFrame() + + const scanAt = Date.now() + const links = await scanner.scanqr() + timings.qrScanMs = Date.now() - scanAt + log("qr_scanned", { + ms: timings.qrScanMs, + count: links.length, + kind: links[0]?.kind, + }) + if (links.length === 0) { + // Keep the pixels the agent was looking at. Reading a failure off a + // screenshot beats guessing at it from a count. + const evidence = "/tmp/handraise-qr-e2e-failure.png" + await Bun.write(evidence, await page.screenshot({ type: "png" })) + log("qr_evidence", { path: evidence }) + } + check( + links.length === 1, + `the agent found exactly one code (${links.length})`, + ) + check( + links[0]?.text === app.verifyUrl, + "the link the human got is the one inside the code on the page", + ) + check(links[0]?.kind === "url", "an https link is offered as openable") + + // The human "opens" it. A phone, not this browser: no session cookie, no + // preview cookie, nothing but the link itself. + const visited = await fetch(links[0]?.text ?? "", { cache: "no-store" }) + const visitedBody = await visited.text() + check(visited.status === 200, `the link opens (${visited.status})`) + check( + visitedBody.includes('data-testid="verified"'), + "opening it reached the confirmation page", + ) + + await scanner.handback() + const scanned = await scanning + pending = null + timings.qrCaseMs = Date.now() - qrAt + log("qr_done", { + outcome: scanned.outcome, + scans: qrEvent?.qrScans, + hits: qrEvent?.qrHits, + ms: timings.qrCaseMs, + }) + check(scanned.outcome === "resolved", "the QR handoff resolved") + check( + qrEvent?.qrScans === 1, + `the wide event counts one scan (${qrEvent?.qrScans})`, + ) + check(qrEvent?.qrHits === 1, `and one hit (${qrEvent?.qrHits})`) + await scanner.close() + // --- Approval: the human answers a question, and drives nothing -------- // // The other half of the product. No screencast, no input path: one diff --git a/e2e/human-sim.ts b/e2e/human-sim.ts index 1c63ac1..7a88603 100644 --- a/e2e/human-sim.ts +++ b/e2e/human-sim.ts @@ -12,6 +12,7 @@ */ import WebSocket from "ws" +import type { ScannedLink } from "../src/core/qr-scan" import { type AgentToHuman, type FrameMeta, @@ -42,7 +43,11 @@ export interface SimulatedHuman { action(): string /** How the agent said the handoff ended, if it has. */ ending(): HandoffOutcome | null + /** The links from the newest answered scan, or null before the first one. */ + links(): ScannedLink[] | null waitForFrame(timeoutMs?: number): Promise + /** Ask the agent to read the QR codes on the page, and wait for its answer. */ + scanqr(timeoutMs?: number): Promise tap(fx: number, fy: number): Promise /** Type one character per message, the way the mobile UI does. */ type(text: string, delayMs?: number): Promise @@ -101,6 +106,7 @@ export async function openHandoffPage( let reason = "" let action = "" let ending: HandoffOutcome | null = null + let links: ScannedLink[] | null = null const waiters: (() => void)[] = [] socket.on("message", (data: Buffer) => { @@ -117,6 +123,7 @@ export async function openHandoffPage( reason = message.reason action = message.action ?? "" } + if (message.type === "links") links = message.links if (message.type === "ended") ending = message.outcome }) @@ -146,6 +153,7 @@ export async function openHandoffPage( reason: () => reason, action: () => action, ending: () => ending, + links: () => links, async waitForFrame(timeoutMs = 30_000) { const deadline = Date.now() + timeoutMs @@ -166,6 +174,19 @@ export async function openHandoffPage( tap: (fx, fy) => send({ type: "tap", fx, fy }), + async scanqr(timeoutMs = 30_000) { + links = null + await send({ type: "scanqr" }) + const deadline = Date.now() + timeoutMs + while (links === null) { + if (Date.now() > deadline) { + throw new Error(`no links answer within ${timeoutMs}ms`) + } + await Bun.sleep(100) + } + return links + }, + async type(text, delayMs = 60) { for (const ch of text) { await send({ type: "char", ch }) diff --git a/e2e/ui.spec.ts b/e2e/ui.spec.ts index 586d156..6e1f022 100644 --- a/e2e/ui.spec.ts +++ b/e2e/ui.spec.ts @@ -618,7 +618,7 @@ test("the clear key is offered only while a remote field is focused", async () = expect(consoleErrors).toEqual([]) }) -const KEY_IDS = ["#key-back", "#key-tab", "#key-enter", "#key-clear"] +const KEY_IDS = ["#key-back", "#key-tab", "#key-enter", "#key-clear", "#key-qr"] /** Every key button's box, in the order the ids are given. */ async function keyBoxes(): Promise { @@ -1387,3 +1387,216 @@ test("the reconnect loop gives up flushing once its deadline passes", async () = ) expect(unexpected).toEqual([]) }, 20000) + +// --- QR passthrough: the button, the sheet, and what it refuses to open ---- + +const QR_LINK = "https://verify.example.com/device?token=abc123" + +/** Wait for the result sheet to be on screen (or gone). */ +async function waitForSheet(visible: boolean): Promise { + await page.waitForFunction((want: boolean) => { + const sheet = document.getElementById("sheet") + return sheet ? !sheet.hidden === want : false + }, visible) +} + +/** The text of every link card in the sheet, in order. */ +function sheetTexts(): Promise { + return page + .locator("#sheet-links .link-text") + .allTextContents() + .then((texts) => texts.map((text) => text.trim())) +} + +test("the scan button is offered in a takeover and not in an approval", async () => { + await showFrame() + expect(await page.locator("#key-qr").isVisible()).toBe(true) + expect(await page.locator("#key-qr").isEnabled()).toBe(true) + + // The whole input bar is gone in an approval: the human is answering a + // question about one screenshot, and there is no live page to scan. + await reopenFixture("approval") + agent.send({ type: "frame", data: frameData, meta: META }) + await page.waitForTimeout(150) + expect(await page.locator("#key-qr").isVisible()).toBe(false) + + // The page will not even put it on the wire: `scanqr` is not in an approval's + // vocabulary. (The relay refuses it too — relay.test.ts "approval mode drops + // every takeover message the human sends" — because a hidden control is not + // a restriction.) + await page.evaluate(() => { + document.getElementById("key-qr")?.click() + }) + await page.waitForTimeout(200) + expect(agent.received.some((message) => message.type === "scanqr")).toBe( + false, + ) + expect(consoleErrors).toEqual([]) +}) + +test("pressing scan asks the agent once and waits for the answer", async () => { + await showFrame() + + await page.locator("#key-qr").click() + await page.waitForFunction(() => { + const button = document.getElementById("key-qr") + return button instanceof HTMLButtonElement && button.disabled + }) + const scans = agent.received.filter((message) => message.type === "scanqr") + expect(scans).toHaveLength(1) + expect(await page.locator("#hint").textContent()).toBe("Reading the page…") + + // A second press while the first is in flight must not reach the agent: the + // core would drop it anyway, and a dropped scan is an answer that never comes. + await page.locator("#key-qr").click({ force: true }) + await page.waitForTimeout(100) + expect( + agent.received.filter((message) => message.type === "scanqr"), + ).toHaveLength(1) + + agent.send({ + type: "links", + links: [{ text: QR_LINK, kind: "url" }], + source: "qr", + }) + await waitForSheet(true) + expect(await page.locator("#key-qr").isEnabled()).toBe(true) + expect(consoleErrors).toEqual([]) +}) + +test("the sheet shows the link and opens it in a new tab, never in this one", async () => { + await showFrame() + agent.send({ + type: "links", + links: [{ text: QR_LINK, kind: "url" }], + source: "qr", + }) + await waitForSheet(true) + + expect(await sheetTexts()).toEqual([QR_LINK]) + const open = page.locator("#sheet-links a.link-action") + expect(await open.isVisible()).toBe(true) + expect(await open.getAttribute("href")).toBe(QR_LINK) + // This tab is holding a live handoff. The opened site must not get a handle + // on it, and must not be told the handoff URL it came from. + expect(await open.getAttribute("target")).toBe("_blank") + expect(await open.getAttribute("rel")).toBe("noopener noreferrer") + + // Copy is offered whatever the link is, and the sheet is dismissible. + expect( + await page.locator("#sheet-links button.link-action").textContent(), + ).toBe("Copy") + await page.locator("#sheet-close").click() + await waitForSheet(false) + expect(consoleErrors).toEqual([]) +}) + +test("a scan that found nothing says so rather than showing an empty sheet", async () => { + await showFrame() + await page.locator("#key-qr").click() + agent.send({ type: "links", links: [], source: "qr" }) + await waitForSheet(true) + + expect(await page.locator("#sheet-title").textContent()).toBe( + "No QR code found", + ) + expect(await sheetTexts()).toEqual([]) + expect(await page.locator("#sheet-links .empty").textContent()).toContain( + "Nothing on this screen decoded", + ) + // And the button is usable again, or the human cannot try after scrolling. + expect(await page.locator("#key-qr").isEnabled()).toBe(true) + expect(consoleErrors).toEqual([]) +}) + +test("a scheme the page may not open gets no anchor, whatever the agent called it", async () => { + await showFrame() + // Every one of these arrives labelled `kind: "url"`, which is the lie the + // page has to survive: the handoff URL is a bearer credential and the socket + // behind it is reachable from any HTTP client, so this side re-checks the + // scheme itself instead of trusting the label. + const hostile = [ + "javascript:alert(document.cookie)", + "data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==", + "file:///etc/passwd", + "intent://scan/#Intent;scheme=zxing;end", + ] + agent.send({ + type: "links", + links: hostile.map((text) => ({ text, kind: "url" as const })), + source: "qr", + }) + await waitForSheet(true) + + expect(await sheetTexts()).toEqual(hostile) + // No anchor at all — not a disabled one, and not one with a neutered href. + expect(await page.locator("#sheet-links a").count()).toBe(0) + expect(await page.locator("#sheet-links button.link-action").count()).toBe( + hostile.length, + ) + expect( + await page.locator("#sheet-links .link-note").first().textContent(), + ).toContain("Not a link this page will open") + expect(consoleErrors).toEqual([]) +}) + +test("a QR payload reaches the sheet as text, never as markup", async () => { + await showFrame() + const payload = '' + agent.send({ + type: "links", + links: [{ text: payload, kind: "text" }], + source: "qr", + }) + await waitForSheet(true) + + expect(await sheetTexts()).toEqual([payload]) + expect(await page.locator("#sheet-links img").count()).toBe(0) + expect(consoleErrors).toEqual([]) +}) + +test("two codes are both listed, and the sheet says there are two", async () => { + await showFrame() + agent.send({ + type: "links", + links: [ + { text: QR_LINK, kind: "url" }, + { text: "WIFI:S:GuestNet;T:WPA;P:hunter2;;", kind: "text" }, + ], + source: "qr", + }) + await waitForSheet(true) + + expect(await page.locator("#sheet-title").textContent()).toBe( + "2 codes on the page", + ) + expect(await sheetTexts()).toEqual([ + QR_LINK, + "WIFI:S:GuestNet;T:WPA;P:hunter2;;", + ]) + expect(await page.locator("#sheet-links a").count()).toBe(1) + expect(consoleErrors).toEqual([]) +}) + +test("a scan the agent never answers releases the button and says why", async () => { + await reopenFixture("takeover", false, true) + await showFrame() + await page.locator("#key-qr").click() + await page.waitForFunction(() => { + const button = document.getElementById("key-qr") + return button instanceof HTMLButtonElement && button.disabled + }) + + // The agent drops a scan that came too soon, or has gone away entirely. + // Without a deadline the button would stay dead for the rest of the session. + await page.clock.fastForward(12_000) + await page.waitForFunction(() => { + const button = document.getElementById("key-qr") + return button instanceof HTMLButtonElement && !button.disabled + }) + expect(await page.locator("#hint").textContent()).toBe( + "The agent didn't answer — try again", + ) + expect(await page.locator("#sheet").isHidden()).toBe(true) + expect(consoleErrors).toEqual([]) +}) diff --git a/package.json b/package.json index 19178bc..90c88fb 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "dependencies": { "@solarisdk/browser": "^0.1.2", "@solarisdk/sdk": "^0.1.2", + "jsqr": "^1.4.0", "qrcode-terminal": "^0.12.0", "ws": "^8.21.3" }, @@ -60,12 +61,14 @@ "@ai-sdk/openai-compatible": "^3.0.41", "@biomejs/biome": "^2.0.0", "@oxlint/plugins": "1.80.0", + "@types/qrcode": "^1.5.6", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.5.13", "ai": "^7.0.87", "bun-types": "^1.4.0", "oxlint": "1.80.0", "playwright-core": "^1.62.1", + "qrcode": "^1.5.4", "tsup": "^8.5.1", "typescript": "^5.7.2" }, diff --git a/scripts/dist-smoke.mjs b/scripts/dist-smoke.mjs index bf8305b..33583bd 100644 --- a/scripts/dist-smoke.mjs +++ b/scripts/dist-smoke.mjs @@ -1,10 +1,14 @@ // Runs the SHIPPED artifact under node — the consumer's runtime, not bun's. // Exists because a CJS-interop difference made the QR silently break in dist // while every bun-driven test stayed green. +import { readFileSync } from "node:fs" + const m = await import("../dist/index.js") const expected = [ "raiseHand", "handoffQr", + "scanQrLinks", + "OPENABLE_SCHEMES", "consoleLogger", "quietLogger", "noopLogger", @@ -42,6 +46,20 @@ if (!qr || !qr.includes("▄")) { console.error("dist smoke: QR did not render under node") process.exit(1) } +// The other direction, and the same trap: `jsqr` is a CommonJS UMD bundle, so +// its default import is exactly the shape that broke `qrcode-terminal` in dist +// while bun stayed green. Decode a real screenshot to prove it survived. +const shot = readFileSync( + new URL("../src/core/fixtures/qr-page.png", import.meta.url), +) +const links = m.scanQrLinks(shot) +if (links.length !== 1 || links[0].kind !== "url") { + console.error( + "dist smoke: the QR decoder did not read the fixture under node", + ) + process.exit(1) +} + console.log( - `dist smoke ok — ${expected.length} exports, QR ${qr.split("\n").length} rows`, + `dist smoke ok — ${expected.length} exports, QR ${qr.split("\n").length} rows, decoded ${links[0].text.length} chars`, ) diff --git a/scripts/measure-qr-decode.ts b/scripts/measure-qr-decode.ts new file mode 100644 index 0000000..18c96cb --- /dev/null +++ b/scripts/measure-qr-decode.ts @@ -0,0 +1,227 @@ +/** + * Measurement 05: reading a QR code off the page an agent is stuck on. + * + * bun --env-file=.env scripts/measure-qr-decode.ts + * bun --env-file=.env scripts/measure-qr-decode.ts --recaptcha + * bun scripts/measure-qr-decode.ts --local # part B only, no API key + * + * Answers the questions plan 04 asked before the feature was built: + * + * A, on a real Solari cloud browser: + * 1. Is `BarcodeDetector` available? If it were, the decode could happen + * inside Chromium and cost no dependency. + * 2. What does a full-resolution `page.screenshot()` plus a decode cost, + * over ten runs? + * Also writes the unit tests' fixture — a real screenshot from a real + * cloud browser — to src/core/fixtures/qr-page.png. + * + * B, on local Chromium, because it needs no cloud and is deterministic: + * 3. Would the live cast frame do instead of a screenshot? The frame is + * produced by this repo's own `startFramePump` at `DEFAULT_PROFILE`, + * and decoded through Chromium's own JPEG decoder — not an + * approximation of one — at three symbol sizes. + * + * Results: docs/measurements/05-qr.md. + */ + +import { writeFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { Solari } from "@solarisdk/browser" +import { chromium, type Page } from "playwright-core" +import QRCode from "qrcode" + +import { decodePng } from "../src/core/png" +import { scanImage, scanQrLinks } from "../src/core/qr-scan" +import { DEFAULT_PROFILE, startFramePump } from "../src/core/screencast" + +const RUNS = 10 +const VIEWPORT = { width: 1280, height: 800 } +/** The sizes a device-change prompt draws its code at, in CSS pixels. */ +const SYMBOL_SIZES = [420, 260, 180, 150, 120, 100] +const FIXTURE = fileURLToPath( + new URL("../src/core/fixtures/qr-page.png", import.meta.url), +) + +/** A payload the length of a real device-handoff link, so the symbol is dense. */ +const PAYLOAD = `https://verify.example.com/device?token=${"a1b2c3d4".repeat(20)}` + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + if (sorted.length % 2 === 1) return sorted[middle] ?? 0 + return ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2 +} + +/** One wide JSON line per measurement, the way the e2e and the relay log. */ +type LogDetail = Record + +function log(event: string, detail: LogDetail): void { + console.log(JSON.stringify({ event, ...detail })) +} + +/** The page both parts measure: a heading, a sentence, and one QR code. */ +async function drawPage(page: Page, cssWidth: number): Promise { + const image = await QRCode.toDataURL(PAYLOAD, { scale: 6, margin: 2 }) + await page.setContent( + ` +

Confirm on another device

+

Scan this code with the phone you registered.

+ code + `, + ) +} + +/** What the symbol actually measures on the page, so the numbers can be read. */ +function symbolWidth(page: Page): Promise { + return page.evaluate( + () => document.getElementById("code")?.getBoundingClientRect().width ?? 0, + ) +} + +/** + * Hand a JPEG back to Chromium and take the pixels out as a PNG. + * + * The point is that Chromium decodes its own JPEG: a downscale written here + * would be an approximation of the cast frame, and this is the frame. + */ +async function jpegToPng(page: Page, base64: string): Promise { + const png = await page.evaluate(async (data: string) => { + const image = new Image() + image.src = `data:image/jpeg;base64,${data}` + await image.decode() + const canvas = document.createElement("canvas") + canvas.width = image.naturalWidth + canvas.height = image.naturalHeight + const context = canvas.getContext("2d") + if (!context) throw new Error("no 2d context") + context.drawImage(image, 0, 0) + return canvas.toDataURL("image/png") + }, base64) + return Buffer.from(png.replace(/^data:image\/png;base64,/, ""), "base64") +} + +/** One live cast frame at the profile the handoff actually uses. */ +async function castFrame(page: Page): Promise<{ data: string; width: number }> { + const cdp = await page.context().newCDPSession(page) + let resolve: (frame: { data: string; width: number }) => void = () => + undefined + const first = new Promise<{ data: string; width: number }>((done) => { + resolve = done + }) + const pump = await startFramePump( + cdp, + DEFAULT_PROFILE, + async (data, meta) => { + resolve({ data, width: meta.jpegWidth }) + }, + ) + const frame = await first + await pump.stop() + await cdp.detach().catch(() => undefined) + return frame +} + +// --------------------------------------------------------------- part A ---- + +if (!process.argv.includes("--local")) { + const apiKey = process.env.SOLARI_API_KEY + if (!apiKey) throw new Error("SOLARI_API_KEY missing — use --env-file=.env") + const solari = new Solari({ apiKey }) + try { + const browser = await solari.launch({ stealth: true }) + const context = browser.contexts()[0] ?? (await browser.newContext()) + const opened = context.pages()[0] ?? (await context.newPage()) + await opened.setViewportSize(VIEWPORT) + + log( + "barcode_detector", + await opened.evaluate(() => ({ + present: "BarcodeDetector" in window, + ua: navigator.userAgent, + })), + ) + + // SAFETY: `@solarisdk/browser` returns patchright-core's Page, whose + // runtime surface is the one used here (setContent, evaluate, screenshot); + // the two declarations differ only in optional-property variance. The same + // assertion the e2e makes. + const page = opened as Page + await drawPage(page, 420) + + const shotMs: number[] = [] + const decodeMs: number[] = [] + let decoded = "" + let bytes = 0 + for (let run = 0; run < RUNS; run++) { + const shotAt = Date.now() + const shot = await page.screenshot({ type: "png" }) + shotMs.push(Date.now() - shotAt) + bytes = shot.length + const decodeAt = Date.now() + decoded = scanQrLinks(shot)[0]?.text ?? "" + decodeMs.push(Date.now() - decodeAt) + if (run === 0) writeFileSync(FIXTURE, shot) + } + log("full_resolution", { + ok: decoded === PAYLOAD, + symbolPx: await symbolWidth(page), + bytes, + shotP50: median(shotMs), + decodeP50: median(decodeMs), + totalP50: median(shotMs) + median(decodeMs), + shot: shotMs.join(","), + decode: decodeMs.join(","), + }) + + if (process.argv.includes("--recaptcha")) { + await page.goto("https://www.google.com/recaptcha/api2/demo", { + waitUntil: "domcontentloaded", + timeout: 45_000, + }) + await new Promise((done) => setTimeout(done, 4_000)) + const shot = await page.screenshot({ type: "png", fullPage: true }) + const links = scanQrLinks(shot) + log("recaptcha_demo", { + url: page.url(), + codes: links.length, + first: links[0]?.text.slice(0, 120) ?? "", + }) + } + + await browser.close() + } finally { + await solari.close().catch(() => undefined) + } +} + +// --------------------------------------------------------------- part B ---- + +const local = await chromium.launch({ headless: true }) +try { + const page = await local.newPage({ + viewport: VIEWPORT, + deviceScaleFactor: 1, + }) + for (const size of SYMBOL_SIZES) { + await drawPage(page, size) + const symbolPx = await symbolWidth(page) + + const shot = await page.screenshot({ type: "png" }) + const fromScreenshot = scanImage(decodePng(shot))[0] === PAYLOAD + + const frame = await castFrame(page) + const fromCast = + scanImage(decodePng(await jpegToPng(page, frame.data)))[0] === PAYLOAD + + log("cast_versus_screenshot", { + symbolPx, + screenshot: `${VIEWPORT.width}px`, + fromScreenshot, + cast: `${frame.width}px jpeg q${DEFAULT_PROFILE.quality}`, + fromCast, + castBytes: Buffer.from(frame.data, "base64").length, + }) + } +} finally { + await local.close() +} diff --git a/src/core/fixtures/qr-centred.png b/src/core/fixtures/qr-centred.png new file mode 100644 index 0000000000000000000000000000000000000000..28d8710e4ed89848dc2ac210f1373950cfbc9b06 GIT binary patch literal 57384 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 F{}&v^6afGL literal 0 HcmV?d00001 diff --git a/src/core/handoff.test.ts b/src/core/handoff.test.ts index d00c582..8f497d7 100644 --- a/src/core/handoff.test.ts +++ b/src/core/handoff.test.ts @@ -15,6 +15,7 @@ import { spawn } from "node:child_process" import { readFileSync } from "node:fs" import type { AddressInfo } from "node:net" import { fileURLToPath } from "node:url" +import { deflateSync } from "node:zlib" import type { Browser, BrowserContext, CDPSession, Page } from "playwright-core" import WebSocket, { WebSocketServer } from "ws" @@ -167,11 +168,63 @@ const CLOSED_PORT = "http://127.0.0.1:1" const SAMPLE_JPEG = readFileSync( fileURLToPath(new URL("./fixtures/sample-frame.jpg", import.meta.url)), ) + +/** + * A real screenshot of a real page carrying a real QR code, so a `scanqr` is + * answered by the decoder rather than by a stub that always agrees. + * `page.screenshot({ type: "png" })` is what a scan asks for; an approval's one + * frame asks for a JPEG, and `fakePage` answers each with its own bytes. + */ +const QR_PAGE_PNG = readFileSync( + fileURLToPath(new URL("./fixtures/qr-page.png", import.meta.url)), +) +const QR_PAGE_LINK = `https://verify.example.com/device?token=${"a1b2c3d4".repeat(20)}` + +/** A white PNG: a page with nothing on it to find. */ +function blankPng(width: number, height: number): Buffer { + const raw = Buffer.alloc(height * (width * 3 + 1), 0xff) + for (let y = 0; y < height; y++) raw[y * (width * 3 + 1)] = 0 + const chunk = (type: string, body: Buffer): Buffer => { + const head = Buffer.alloc(8) + head.writeUInt32BE(body.length, 0) + head.write(type, 4, "ascii") + const crc = Buffer.alloc(4) + crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), body])), 0) + return Buffer.concat([head, body, crc]) + } + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(width, 0) + ihdr.writeUInt32BE(height, 4) + ihdr[8] = 8 + ihdr[9] = 2 + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(raw)), + chunk("IEND", Buffer.alloc(0)), + ]) +} + +function crc32(bytes: Buffer): number { + let crc = 0xffffffff + for (const byte of bytes) { + crc ^= byte + for (let bit = 0; bit < 8; bit++) { + crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1 + } + } + return (crc ^ 0xffffffff) >>> 0 +} + +const BLANK_PNG = blankPng(64, 40) const VIEWPORT = { width: 1280, height: 800 } /** CDP sessions opened on the fake page since the last reset. */ let cdpSessions = 0 +/** Screenshots the newest `fakePage()` has been asked for. */ +let screenshots = 0 + /** Kills the browser session behind the newest `fakePage()`. */ let killSession: () => void = () => undefined @@ -185,8 +238,10 @@ function fakePage( cdp: CDPSession, screenshotDelayMs = 0, storageStateDelayMs = 0, + pngScreenshot: Buffer = QR_PAGE_PNG, ): Page { cdpSessions = 0 + screenshots = 0 let browser: Browser let connected = true const gone = new Set<() => void>() @@ -232,10 +287,12 @@ function fakePage( // A live page, which is what `raiseHand` checks before it starts anything. isClosed: () => false, // SAFETY: approval mode calls screenshot() for its one frame and reads the - // viewport for that frame's metadata; neither result is used as anything else. - screenshot: (async () => { + // viewport for that frame's metadata; a QR scan calls it for a PNG. Neither + // result is used as anything else. + screenshot: (async (options?: { type?: "png" | "jpeg" }) => { if (screenshotDelayMs > 0) await Bun.sleep(screenshotDelayMs) - return SAMPLE_JPEG + screenshots += 1 + return options?.type === "png" ? pngScreenshot : SAMPLE_JPEG }) as Page["screenshot"], viewportSize: () => VIEWPORT, // SAFETY: as the browser's, above — an unused chaining emitter. @@ -1618,3 +1675,186 @@ test("a logger whose methods reject does not break the handoff either", async () process.off("unhandledRejection", record) } }) + +test("a logger that throws does not break the handoff", async () => { + // `logger` is a public option and it is the caller's object: a pino instance + // over a closed transport throws. Every call handraise makes to it sits on a + // failure path or inside a promise callback, so a throw would either lose + // the outcome (the wide event is logged before it is returned) or reject a + // promise nobody awaits, and node ends the process for that. + const port = await startRelayProcess() + const human = await connectHuman(port) + const cdp = fakeCdp() + const down = (): never => { + throw new Error("logger is down (EPIPE)") + } + const hostile: Logger = { debug: down, info: down, warn: down, error: down } + const events: HandoffEvent[] = [] + + const handoff = runHandoff({ + page: fakePage(cdp.cdp), + agentWsUrl: `ws://127.0.0.1:${port}/ws?role=agent`, + options: { + reason: "the logger is hostile", + logger: hostile, + onEvent: (event) => events.push(event), + }, + timeoutMs: 5000, + url: "https://relay.example/?pt_token=x", + handoffId: "hostile-logger", + relayColdStartMs: 5, + logger: hostile, + }) + + await until("the phone to connect", () => human.inbox.length >= 0) + human.send({ type: "handback" }) + + const end = await handoff + expect(end.outcome).toBe("resolved") + // The wide event still reaches the caller: `logger.info` throwing must not + // take `onEvent` with it. + expect(events).toHaveLength(1) +}) + +// --- QR passthrough -------------------------------------------------------- + +/** + * A takeover with a phone attached, ready to press Scan QR. Returns the pieces + * the tests below drive; each one ends the handoff itself. + */ +async function scannableHandoff(pngScreenshot: Buffer = QR_PAGE_PNG): Promise<{ + human: Awaited> + events: HandoffEvent[] + handoff: ReturnType +}> { + const port = await startRelayProcess() + const human = await connectHuman(port) + const cdp = fakeCdp() + const events: HandoffEvent[] = [] + const handoff = runHandoff({ + page: fakePage(cdp.cdp, 0, 0, pngScreenshot), + agentWsUrl: `ws://127.0.0.1:${port}/ws?role=agent`, + options: { + reason: "The site wants this code scanned with a phone", + logger: noopLogger, + onEvent: (event) => events.push(event), + }, + timeoutMs: 5000, + url: "https://relay.example/?pt_token=x", + handoffId: "qr-handoff", + relayColdStartMs: 12, + logger: noopLogger, + }) + await until("the phone to see the reason", () => + human.inbox.some((message) => message.type === "state"), + ) + return { human, events, handoff } +} + +/** Every `links` message the phone has been sent so far. */ +function linksSeen( + inbox: RelayMessage[], +): Extract[] { + return inbox.filter( + (message): message is Extract => + message.type === "links", + ) +} + +test("a scan reads the page and sends the human the link it carries", async () => { + const { human, events, handoff } = await scannableHandoff() + + human.send({ type: "scanqr" }) + await until( + "the phone to be sent links", + () => linksSeen(human.inbox).length === 1, + ) + + const answer = linksSeen(human.inbox)[0] + expect(answer?.source).toBe("qr") + expect(answer?.links).toEqual([{ text: QR_PAGE_LINK, kind: "url" }]) + + human.send({ type: "handback" }) + await handoff + expect(events[0]?.qrScans).toBe(1) + expect(events[0]?.qrHits).toBe(1) +}) + +test("a page with no code answers nothing found, and still counts as a scan", async () => { + const { human, events, handoff } = await scannableHandoff(BLANK_PNG) + + human.send({ type: "scanqr" }) + await until( + "the phone to be sent links", + () => linksSeen(human.inbox).length === 1, + ) + expect(linksSeen(human.inbox)[0]?.links).toEqual([]) + + human.send({ type: "handback" }) + await handoff + // A scan that found nothing still happened, and the gap between these two is + // the number worth watching. + expect(events[0]?.qrScans).toBe(1) + expect(events[0]?.qrHits).toBe(0) +}) + +test("a second scan inside the rate limit is dropped, not queued", async () => { + const { human, events, handoff } = await scannableHandoff() + + // A held button, a double tap, or a second holder of the handoff link. The + // limit is enforced here and not on the phone, because the socket behind + // that link is reachable from any HTTP client. + human.send({ type: "scanqr" }) + human.send({ type: "scanqr" }) + human.send({ type: "scanqr" }) + await until( + "the phone to be sent links", + () => linksSeen(human.inbox).length >= 1, + ) + // Long enough for a queued scan to have answered, and well inside the 2s floor. + await Bun.sleep(400) + + expect(linksSeen(human.inbox)).toHaveLength(1) + human.send({ type: "handback" }) + await handoff + expect(events[0]?.qrScans).toBe(1) + // One screenshot for the scan and no other: a takeover casts, it does not + // screenshot, so this is the whole count. + expect(screenshots).toBe(1) +}) + +test("an approval never scans, whatever the phone sends", async () => { + const port = await startRelayProcess("approval") + const human = await connectHuman(port) + const cdp = fakeCdp() + const events: HandoffEvent[] = [] + const handoff = runHandoff({ + page: fakePage(cdp.cdp), + agentWsUrl: `ws://127.0.0.1:${port}/ws?role=agent`, + options: { + mode: "approval", + reason: "The agent may not move money without a human", + action: "Transfer EUR 12,430.00 to Acme GmbH", + logger: noopLogger, + onEvent: (event) => events.push(event), + }, + timeoutMs: 5000, + url: "https://relay.example/?pt_token=x", + handoffId: "qr-approval", + relayColdStartMs: 12, + logger: noopLogger, + }) + await until("the phone to see the screenshot", () => + human.inbox.some((message) => message.type === "frame"), + ) + + human.send({ type: "scanqr" }) + await Bun.sleep(300) + expect(linksSeen(human.inbox)).toHaveLength(0) + + human.send({ type: "approve" }) + await handoff + expect(events[0]?.qrScans).toBe(0) + // The one screenshot is the approval's own frame; the scan added none. + expect(screenshots).toBe(1) +}) diff --git a/src/core/input.ts b/src/core/input.ts index 14e5881..4a5e915 100644 --- a/src/core/input.ts +++ b/src/core/input.ts @@ -298,9 +298,12 @@ export function createInputTarget(cdp: CdpChannel): InputTarget { appliedCount += 1 return } + case "scanqr": case "handback": case "abort": - // Lifecycle, not input. raiseHand handles these. + // Not input: nothing about these touches the page through this module. + // raiseHand handles them — the first by taking a screenshot, the other + // two by ending the handoff. return } } diff --git a/src/core/png.ts b/src/core/png.ts new file mode 100644 index 0000000..9c2c2ac --- /dev/null +++ b/src/core/png.ts @@ -0,0 +1,180 @@ +/** + * Just enough PNG to hand a screenshot to a QR decoder. + * + * `jsQR` wants what a canvas gives a browser: four bytes per pixel, row-major, + * RGBA. Node has no canvas and no image decoder — but it does have zlib, and a + * PNG is a zlib stream of filtered scanlines. That is the whole of this file: + * about a hundred lines instead of a native dependency that has to build on + * every platform a handraise user runs an agent on. + * + * It decodes the subset that is actually produced here, and refuses the rest + * loudly rather than guessing: + * + * - 8 bits per channel, non-interlaced. Chromium's `Page.captureScreenshot` + * emits colour type 2 (RGB) for a screenshot and 6 (RGBA) when the page has + * transparency; measured, docs/measurements/05-qr.md. + * - No palette (colour type 3) and no 16-bit depth. Nothing in this repo + * produces either, and a decoder path with no input is a decoder path + * nobody has ever run. + */ +import { inflateSync } from "node:zlib" + +const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + +/** Bytes per pixel by PNG colour type: grey, RGB, grey+alpha, RGBA. */ +const CHANNELS = new Map([ + [0, 1], + [2, 3], + [4, 2], + [6, 4], +]) + +/** Length + type + CRC around every chunk's payload. */ +const CHUNK_OVERHEAD = 12 + +/** A decoded image in the one layout `jsQR` and `ImageData` agree on. */ +export interface RgbaImage { + data: Uint8ClampedArray + width: number + height: number +} + +interface PngHeader { + width: number + height: number + channels: number +} + +function fail(what: string): never { + throw new Error(`handraise: ${what}`) +} + +/** IHDR is mandatory and always the first chunk, so it is read by position. */ +function readHeader(bytes: Buffer): PngHeader { + if (bytes.length < 33 || !bytes.subarray(0, 8).equals(SIGNATURE)) { + fail("the screenshot is not a PNG") + } + if (bytes.subarray(12, 16).toString("ascii") !== "IHDR") { + fail("the PNG does not start with IHDR") + } + const depth = bytes[24] + const colourType = bytes[25] + const interlace = bytes[28] + const channels = + colourType === undefined ? undefined : CHANNELS.get(colourType) + if (depth !== 8 || interlace !== 0 || channels === undefined) { + fail( + `unsupported PNG (colour type ${colourType}, ${depth} bits, interlace ${interlace})`, + ) + } + return { + width: bytes.readUInt32BE(16), + height: bytes.readUInt32BE(20), + channels, + } +} + +/** The image data, which a PNG may split over any number of IDAT chunks. */ +function collectImageData(bytes: Buffer): Buffer { + const parts: Buffer[] = [] + let offset = 8 + while (offset + CHUNK_OVERHEAD <= bytes.length) { + const length = bytes.readUInt32BE(offset) + const type = bytes.subarray(offset + 4, offset + 8).toString("ascii") + if (type === "IEND") break + if (type === "IDAT") { + parts.push(bytes.subarray(offset + 8, offset + 8 + length)) + } + offset += length + CHUNK_OVERHEAD + } + if (parts.length === 0) fail("the PNG carries no image data") + return Buffer.concat(parts) +} + +/** PNG's own predictor, from the spec's Filter type 4. */ +function paeth(left: number, above: number, corner: number): number { + const estimate = left + above - corner + const dLeft = Math.abs(estimate - left) + const dAbove = Math.abs(estimate - above) + const dCorner = Math.abs(estimate - corner) + if (dLeft <= dAbove && dLeft <= dCorner) return left + return dAbove <= dCorner ? above : corner +} + +/** + * Undo one scanline's filter, in place. + * + * Every filter is a difference against the byte to the left, the byte above, + * or both, so a row can only be reconstructed after the row above it. Writing + * into a `Uint8Array` is what makes the arithmetic wrap at 256 the way the + * spec's modulo does. + */ +function unfilterRow( + row: Uint8Array, + above: Uint8Array, + filter: number, + bpp: number, +): void { + for (let i = 0; i < row.length; i++) { + const value = row[i] ?? 0 + const left = i >= bpp ? (row[i - bpp] ?? 0) : 0 + const up = above[i] ?? 0 + const corner = i >= bpp ? (above[i - bpp] ?? 0) : 0 + if (filter === 1) row[i] = value + left + else if (filter === 2) row[i] = value + up + else if (filter === 3) row[i] = value + ((left + up) >> 1) + else if (filter === 4) row[i] = value + paeth(left, up, corner) + } +} + +/** Filtered scanlines (one filter byte each) to raw samples. */ +function unfilter(raw: Buffer, header: PngHeader): Uint8Array { + const stride = header.width * header.channels + if (raw.length < (stride + 1) * header.height) { + fail("the PNG's image data is shorter than its dimensions claim") + } + const pixels = new Uint8Array(stride * header.height) + const firstAbove = new Uint8Array(stride) + let offset = 0 + for (let y = 0; y < header.height; y++) { + const filter = raw[offset] + offset += 1 + if (filter === undefined || filter > 4) fail(`unknown PNG filter ${filter}`) + const row = pixels.subarray(y * stride, (y + 1) * stride) + row.set(raw.subarray(offset, offset + stride)) + offset += stride + const above = + y === 0 ? firstAbove : pixels.subarray((y - 1) * stride, y * stride) + unfilterRow(row, above, filter, header.channels) + } + return pixels +} + +/** Widen whatever channels the file has to the RGBA a decoder expects. */ +function toRgba(pixels: Uint8Array, header: PngHeader): Uint8ClampedArray { + if (header.channels === 4) return new Uint8ClampedArray(pixels) + const count = header.width * header.height + const rgba = new Uint8ClampedArray(count * 4) + const grey = header.channels < 3 + for (let i = 0; i < count; i++) { + const from = i * header.channels + const to = i * 4 + const first = pixels[from] ?? 0 + rgba[to] = first + rgba[to + 1] = grey ? first : (pixels[from + 1] ?? 0) + rgba[to + 2] = grey ? first : (pixels[from + 2] ?? 0) + rgba[to + 3] = header.channels === 2 ? (pixels[from + 1] ?? 255) : 255 + } + return rgba +} + +/** Decode a PNG to RGBA. Throws with a readable message on anything else. */ +export function decodePng(bytes: Buffer): RgbaImage { + const header = readHeader(bytes) + const raw = inflateSync(collectImageData(bytes)) + return { + data: toRgba(unfilter(raw, header), header), + width: header.width, + height: header.height, + } +} diff --git a/src/core/qr-scan.test.ts b/src/core/qr-scan.test.ts new file mode 100644 index 0000000..1559ef6 --- /dev/null +++ b/src/core/qr-scan.test.ts @@ -0,0 +1,238 @@ +/** + * The decoder, against images rather than mocks. + * + * bun test src/core/qr-scan.test.ts + * + * Two kinds of input, on purpose. `src/core/fixtures/qr-page.png` is a real + * 1280x800 screenshot taken by a real Solari cloud browser — colour type 2 + * (RGB) with the whole range of PNG scanline filters in it, which is the shape + * production actually feeds this code, and which nothing synthetic reproduces. + * Regenerate it with `bun --env-file=.env scripts/measure-qr-decode.ts`. The + * rest are generated here from the `qrcode` dev dependency, so the small, dense, + * rotated, absent and doubled cases cost no binary in the repository. + */ +import { expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import jsQR from "jsqr" +import QRCode from "qrcode" + +import { decodePng, type RgbaImage } from "./png" +import { + classifyLink, + MAX_LINK_CHARS, + OPENABLE_SCHEMES, + scanImage, + scanQrLinks, +} from "./qr-scan" + +const FIXTURE = fileURLToPath( + new URL("./fixtures/qr-page.png", import.meta.url), +) + +/** The screenshot that failed the first live e2e run; see the test that uses it. */ +const CENTRED_FIXTURE = fileURLToPath( + new URL("./fixtures/qr-centred.png", import.meta.url), +) + +/** The payload the fixture's page was drawn with; see the measurement script. */ +const FIXTURE_PAYLOAD = `https://verify.example.com/device?token=${"a1b2c3d4".repeat(20)}` + +async function qrImage(text: string, scale = 6): Promise { + return decodePng(await QRCode.toBuffer(text, { scale, margin: 2 })) +} + +/** A white canvas, the quiet zone every QR code needs around it. */ +function blank(width: number, height: number): RgbaImage { + const data = new Uint8ClampedArray(width * height * 4) + data.fill(255) + return { data, width, height } +} + +function paste(into: RgbaImage, image: RgbaImage, x: number, y: number): void { + for (let row = 0; row < image.height; row++) { + const from = row * image.width * 4 + into.data.set( + image.data.subarray(from, from + image.width * 4), + ((y + row) * into.width + x) * 4, + ) + } +} + +/** Quarter turn clockwise, exactly — no resampling, so nothing else changes. */ +function rotate90(image: RgbaImage): RgbaImage { + const turned = blank(image.height, image.width) + for (let y = 0; y < image.height; y++) { + for (let x = 0; x < image.width; x++) { + const from = (y * image.width + x) * 4 + const to = (x * turned.width + (image.height - 1 - y)) * 4 + turned.data.set(image.data.subarray(from, from + 4), to) + } + } + return turned +} + +// --- the images ----------------------------------------------------------- + +test("a real cloud-browser screenshot decodes to the link it carries", () => { + const links = scanQrLinks(readFileSync(FIXTURE)) + expect(links).toEqual([{ text: FIXTURE_PAYLOAD, kind: "url" }]) +}) + +test("a symbol the page resampled decodes, though the plain pass cannot", () => { + // This screenshot failed the first live run of the e2e, and it is kept + // exactly as it came off the browser. The code in it is large, centred and + // perfectly sharp to the eye — but the page drew a 534px image at 420 CSS + // px, so a module is 4.9 pixels wide and `jsQR`'s fixed 8x8 binarizer blocks + // straddle the module boundaries. The plain pass finds nothing; a tight crop + // of the very same pixels decodes. See docs/measurements/05-qr.md. + const shot = readFileSync(CENTRED_FIXTURE) + const image = decodePng(shot) + expect(jsQR(image.data, image.width, image.height)).toBeNull() + + const links = scanQrLinks(shot) + expect(links).toHaveLength(1) + expect(links[0]?.kind).toBe("url") + expect(links[0]?.text).toContain("/verified?pt_token=") +}) + +test("a small symbol decodes", async () => { + expect(scanImage(await qrImage("https://example.com/a", 3))).toEqual([ + "https://example.com/a", + ]) +}) + +test("a dense symbol decodes", async () => { + // 900 characters is version 27 or so: far past anything a login page draws, + // and the case where a wrong scanline filter shows up as garbage rather than + // as a failure to find the code at all. + const long = `https://example.com/?q=${"x".repeat(900)}` + expect(scanImage(await qrImage(long, 5))).toEqual([long]) +}) + +test("a symbol turned on its side decodes", async () => { + const upright = await qrImage("https://example.com/rotated", 6) + expect(scanImage(rotate90(upright))).toEqual(["https://example.com/rotated"]) +}) + +test("a page with no code decodes to nothing", () => { + expect(scanImage(blank(600, 400))).toEqual([]) +}) + +test("two codes on one screen are both reported", async () => { + // Six finder patterns in one image defeat jsQR's locator outright: the + // whole-page pass below finds neither of these. What rescues it is the tiled + // second look, and this is the test that holds that fallback in place — see + // docs/measurements/05-qr.md and the comment on `readTiles`. + const first = await qrImage("https://example.com/first", 9) + const second = await qrImage("https://example.com/second", 9) + const page = blank(1280, 800) + paste(page, first, 80, 200) + paste(page, second, 700, 200) + + const found = scanImage(page) + expect(found.sort()).toEqual([ + "https://example.com/first", + "https://example.com/second", + ]) +}) + +test("the same code is not reported twice", async () => { + // Masking a symbol that has been read is what stops the second pass from + // finding the first one again and calling it a second code. + expect(scanImage(await qrImage("https://example.com/once", 6))).toEqual([ + "https://example.com/once", + ]) +}) + +// --- what the phone may open ---------------------------------------------- + +test("the openable schemes are the ones a device-change code uses", () => { + expect([...OPENABLE_SCHEMES].sort()).toEqual([ + "http:", + "https:", + "mailto:", + "otpauth:", + "tel:", + ]) +}) + +test("a link in an openable scheme is a url", () => { + for (const text of [ + "https://example.com/verify?token=abc", + "http://192.168.0.4:8080/pair", + "tel:+4915112345678", + "mailto:help@example.com", + "otpauth://totp/Example:ada?secret=JBSWY3DPEHPK3PXP&issuer=Example", + ]) { + expect(classifyLink(text)).toEqual({ text, kind: "url" }) + } +}) + +test("everything else is text, and stays readable as text", () => { + for (const text of [ + "javascript:alert(document.cookie)", + "JavaScript:alert(1)", + "data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==", + "file:///etc/passwd", + "intent://scan/#Intent;scheme=zxing;end", + "vbscript:msgbox(1)", + "WIFI:S:GuestNet;T:WPA;P:hunter2;;", + "BEGIN:VCARD\nVERSION:3.0\nFN:Ada\nEND:VCARD", + "just a sentence someone printed on a poster", + "", + ]) { + expect(classifyLink(text).kind).toBe("text") + } +}) + +test("a scheme hidden behind whitespace is not a url", () => { + // The URL parser drops tabs and newlines, so it would report `https:` for a + // string whose visible first line says something else entirely. What the + // parser validated and what the phone would show have to be one string. + expect(classifyLink("https://exa\tmple.com/x").kind).toBe("text") + expect(classifyLink("https://example.com/x evil").kind).toBe("text") +}) + +test("surrounding whitespace is trimmed rather than making a link untouchable", () => { + expect(classifyLink(" https://example.com/x\n")).toEqual({ + text: "https://example.com/x", + kind: "url", + }) +}) + +test("an absurdly long payload is capped and demoted to text", () => { + const huge = `https://example.com/?q=${"y".repeat(4000)}` + const link = classifyLink(huge) + expect(link.text).toHaveLength(MAX_LINK_CHARS) + expect(link.text).toBe(huge.slice(0, MAX_LINK_CHARS)) + // Still a valid URL after the cut, and deliberately still openable: the cap + // is about what a phone can render, not about what a scheme may do. + expect(link.kind).toBe("url") +}) + +// --- the PNG decoder ------------------------------------------------------ + +test("a PNG the decoder cannot read says so instead of guessing", async () => { + // Colour type 3 (palette). Nothing here produces one, and a decoder path + // with no input is a path nobody has run — so it is refused by name. + const palette = await QRCode.toBuffer("https://example.com", { + scale: 4, + margin: 2, + // SAFETY: `qrcode` writes a palette PNG when told to use two colours; the + // option is not in its published types, and this test exists to prove the + // decoder rejects exactly that file. + type: "png", + }) + // The generated file is RGBA, so build the refusal case by hand instead: + // take its header and claim colour type 3. + const forged = Buffer.from(palette) + forged[25] = 3 + expect(() => decodePng(forged)).toThrow(/unsupported PNG/) +}) + +test("something that is not a PNG at all says so", () => { + expect(() => decodePng(Buffer.from("not a png, just some bytes"))).toThrow( + /not a PNG/, + ) +}) diff --git a/src/core/qr-scan.ts b/src/core/qr-scan.ts new file mode 100644 index 0000000..aa1a680 --- /dev/null +++ b/src/core/qr-scan.ts @@ -0,0 +1,284 @@ +/** + * Reading the QR codes off the page the agent is stuck on. + * + * The case this exists for is a device-change check: reCAPTCHA, a WhatsApp Web + * login, an authenticator enrolment, a payment code. The site draws a QR code + * and says "scan this with your phone" — and the human handraise put in front + * of it *is* on a phone, looking at the code through their own screen. A phone + * cannot scan itself. Today that needs a second device. + * + * So the agent reads the code instead and sends the human the link. + * + * Three decisions, all argued in docs/adr/0008-qr-passthrough.md: + * + * - It decodes in the agent process, from a fresh full-resolution + * `page.screenshot()`. Not from the cast frame, which is scaled to 800px + * and JPEG-compressed until a dense symbol is mush; and not in the remote + * page, whose JavaScript belongs to whoever the agent got stuck on. + * - Only on request. A scan is a screenshot plus a decode, so scanning every + * frame would cost that on a stream that already paces itself to a phone. + * - It classifies, it never opens. The agent process fetches nothing; the + * phone offers an "Open" button, and only for the schemes below. + */ +import jsQR, { type QRCode } from "jsqr" +import type { Page } from "playwright-core" + +import { decodePng, type RgbaImage } from "./png" + +/** + * Whether the phone may offer to open this, or only to copy it. + * + * A QR code is an arbitrary string from a page the agent did not choose. Most + * of them are links, and the useful ones are; the rest are a wifi credential, a + * vCard, a plain sentence — worth showing, never worth handing to a browser. + */ +export type LinkKind = "url" | "text" + +export interface ScannedLink { + /** What the code carried, trimmed and capped. Shown as text either way. */ + text: string + kind: LinkKind +} + +/** + * The schemes a phone is offered an "Open" button for. + * + * An allowlist and not a blocklist, because the interesting half of this list + * is the half nobody thinks of: `javascript:` and `data:` are the two everyone + * remembers, and `intent:`, `file:`, `content:` and whatever a phone browser + * ships next year are the ones that would have got through a blocklist. The + * four here are the ones a device-change QR actually uses. + * + * The phone checks this again before it builds the anchor. Two locks on one + * door on purpose: the agent's `kind` travels over a socket the human's link + * can reach, so it is a hint the page must not have to trust. + */ +export const OPENABLE_SCHEMES: ReadonlySet = new Set([ + "http:", + "https:", + "tel:", + "mailto:", + "otpauth:", +]) + +/** + * A QR code holds up to 4296 characters. Past a couple of thousand it is not a + * link any more, and the phone has to render it as one line of text. + */ +export const MAX_LINK_CHARS = 2048 + +/** + * How many codes one scan reports. + * + * Pages that show a QR code show one. Two is what makes "there is more than + * one here" sayable instead of silently picking; past that the sheet is a list + * nobody reads on a phone, and the human can scroll the page and scan again. + */ +export const MAX_CODES = 2 + +/** Cap on the screenshot itself: a page that cannot paint must not hang a scan. */ +const SCREENSHOT_TIMEOUT_MS = 5_000 + +/** + * Whitespace, and everything below and around it in the code space. + * + * A link never needs any of it, and a payload that carries it is one trying to + * look like something else: the URL parser silently drops a tab or a newline, + * so what it validated and what the phone would show are two different + * strings. + */ +function hasUnsafeCharacter(text: string): boolean { + for (const character of text) { + const code = character.codePointAt(0) ?? 0 + if (code <= 0x20 || code === 0x7f) return true + } + return false +} + +function isOpenable(text: string): boolean { + if (text.length === 0 || hasUnsafeCharacter(text)) return false + try { + return OPENABLE_SCHEMES.has(new URL(text).protocol) + } catch { + // Not a URL at all: a wifi credential, a vCard, a sentence. + return false + } +} + +/** Decide what one code's payload is, and what the phone may do with it. */ +export function classifyLink(payload: string): ScannedLink { + const text = payload.trim().slice(0, MAX_LINK_CHARS) + return { text, kind: isOpenable(text) ? "url" : "text" } +} + +/** + * Paint over a symbol that has already been read. + * + * `jsQR` returns the first code it finds and has no way to ask for the next + * one, so the only way to know whether the page holds a second is to remove the + * first and look again. White, because that is the quiet zone every QR code is + * already surrounded by. + */ +function maskOut(image: RgbaImage, location: QRCode["location"]): void { + const xs = [ + location.topLeftCorner.x, + location.topRightCorner.x, + location.bottomLeftCorner.x, + location.bottomRightCorner.x, + ] + const ys = [ + location.topLeftCorner.y, + location.topRightCorner.y, + location.bottomLeftCorner.y, + location.bottomRightCorner.y, + ] + const left = Math.max(0, Math.floor(Math.min(...xs)) - 1) + const right = Math.min(image.width - 1, Math.ceil(Math.max(...xs)) + 1) + const top = Math.max(0, Math.floor(Math.min(...ys)) - 1) + const bottom = Math.min(image.height - 1, Math.ceil(Math.max(...ys)) + 1) + for (let y = top; y <= bottom; y++) { + const row = y * image.width + for (let x = left; x <= right; x++) { + image.data.fill(255, (row + x) * 4, (row + x) * 4 + 4) + } + } +} + +/** Read up to `MAX_CODES` payloads, painting each out before looking again. */ +function readRepeatedly(image: RgbaImage): string[] { + const found: string[] = [] + for (let pass = 0; pass < MAX_CODES; pass++) { + const code = jsQR(image.data, image.width, image.height) + if (!code) break + // A payload seen twice is the same code found again, not a second one. + if (code.data.length > 0 && !found.includes(code.data)) + found.push(code.data) + maskOut(image, code.location) + } + return found +} + +/** + * How much bigger the second look is. Two is enough and three costs 4x the + * pixels for nothing (both decode the fixture; measured in docs/measurements/05-qr.md). + */ +const MAGNIFY = 2 + +/** + * Look again, twice the size. + * + * Nearest-neighbour, so not one new pixel of information — and that is the + * point. `jsQR` binarizes in fixed 8x8 blocks, and a page that draws its code + * at a size the browser has to resample lands a module boundary in the middle + * of a block. A symbol that is perfectly sharp to the eye then fails to be + * *located* while a tight crop of the same pixels decodes: the failure is the + * block grid, not the image. Doubling it puts about ten pixels under each + * module and the blocks line up again. + * + * Found the hard way. `src/core/fixtures/qr-centred.png` is the screenshot that + * failed the first live run of the e2e, kept exactly as it came off the browser. + */ +function magnify(image: RgbaImage, factor: number): RgbaImage { + const width = image.width * factor + const height = image.height * factor + const data = new Uint8ClampedArray(width * height * 4) + for (let y = 0; y < height; y++) { + const row = Math.floor(y / factor) * image.width + for (let x = 0; x < width; x++) { + const from = (row + Math.floor(x / factor)) * 4 + data.set(image.data.subarray(from, from + 4), (y * width + x) * 4) + } + } + return { data, width, height } +} + +/** Each tile's share of a dimension. Four of them, anchored at the corners. */ +const TILE_SHARE = 0.6 + +function cropTile( + image: RgbaImage, + x: number, + y: number, + width: number, + height: number, +): RgbaImage { + const data = new Uint8ClampedArray(width * height * 4) + for (let row = 0; row < height; row++) { + const from = ((y + row) * image.width + x) * 4 + data.set(image.data.subarray(from, from + width * 4), row * width * 4) + } + return { data, width, height } +} + +/** + * The fallback, and it is not a nicety. + * + * `jsQR` locates a symbol by its three finder patterns, and two symbols on one + * screen put six of them in front of it: on a 1280x800 page with two 260px + * codes it finds *neither*, where each quarter on its own decodes cleanly + * (docs/measurements/05-qr.md). So when the whole image comes back empty, look + * again at four overlapping corners. It costs a second decode only on the pass + * that already failed, and it is what makes "two codes on the page" a result + * rather than "no QR code found" on a page that visibly has two. + */ +function readTiles(image: RgbaImage): string[] { + const width = Math.floor(image.width * TILE_SHARE) + const height = Math.floor(image.height * TILE_SHARE) + if (width < 1 || height < 1) return [] + const found: string[] = [] + for (const x of [0, image.width - width]) { + for (const y of [0, image.height - height]) { + if (found.length >= MAX_CODES) return found + const tile = cropTile(image, x, y, width, height) + const code = jsQR(tile.data, tile.width, tile.height) + if (code && code.data.length > 0 && !found.includes(code.data)) { + found.push(code.data) + } + } + } + return found +} + +/** + * Every QR payload in an image, in the order they are found. + * + * Three looks, each one earning its place, and the second and third only run + * when the one before found nothing: + * + * 1. the image as it came, which is the answer almost every time; + * 2. the image at 2x, for a symbol the page drew at a resampled size — the + * failure that is invisible to the eye (see `magnify`); + * 3. four overlapping corners, for two codes on one screen, which defeat the + * locator outright (see `readTiles`). + * + * The image is modified in place — each symbol is painted out before the next + * pass — so pass a decode that is not needed afterwards. + */ +export function scanImage(image: RgbaImage): string[] { + const whole = readRepeatedly(image) + if (whole.length > 0) return whole + const bigger = readRepeatedly(magnify(image, MAGNIFY)) + if (bigger.length > 0) return bigger + return readTiles(image) +} + +/** Read the QR codes in a PNG screenshot and say what the phone may do with each. */ +export function scanQrLinks(screenshot: Buffer): ScannedLink[] { + return scanImage(decodePng(screenshot)).map(classifyLink) +} + +/** + * Take a fresh screenshot of the page and read its QR codes. + * + * A new screenshot rather than the newest cast frame: the cast is scaled to + * 800px wide and encoded at JPEG quality 60, a profile chosen for reading a + * login form and one that destroys a dense symbol's modules. PNG rather than JPEG for + * the same reason — the decoder wants edges, not a small file. + */ +export async function scanPageForLinks(page: Page): Promise { + const shot = await page.screenshot({ + type: "png", + timeout: SCREENSHOT_TIMEOUT_MS, + }) + return scanQrLinks(shot) +} diff --git a/src/core/raise-hand.ts b/src/core/raise-hand.ts index 9556739..82cd7ab 100644 --- a/src/core/raise-hand.ts +++ b/src/core/raise-hand.ts @@ -39,6 +39,7 @@ import type { import { notifyWebhook } from "../webhook" import { NO_FOCUS, probeFocus } from "./focus" import { createInputTarget } from "./input" +import { scanPageForLinks } from "./qr-scan" import { DEFAULT_PROFILE, type FramePump, startFramePump } from "./screencast" import { type ApprovalFrame, captureApprovalFrame } from "./snapshot" import { connectRelay, type RelayConnection } from "./socket" @@ -61,6 +62,14 @@ const RELAY_SLACK_MS = 5 * 60_000 */ const STORAGE_STATE_TIMEOUT_MS = 5_000 +/** + * Floor between two QR scans. Two seconds is the shortest gap at which a + * second scan can say something new — the human has to move the page for it + * to — and it is long enough that a stuck button cannot turn a live cast into + * a screenshot loop. + */ +const QR_SCAN_INTERVAL_MS = 2_000 + /** Resolve `promise`, or reject with `label` if it has not settled in `ms`. */ function withTimeout( promise: Promise, @@ -376,6 +385,49 @@ export async function runHandoff(run: HandoffRun): Promise { }) } + // What the phone asked for and what came back, for the wide event. + let qrScans = 0 + let qrHits = 0 + // One scan at a time, and never two inside the interval. A scan costs a + // full-resolution screenshot of a page that is already casting to a phone, + // so a held button must not be able to turn into a screenshot loop. The + // phone enforces the same floor; this is the one that counts, because the + // socket behind the handoff URL is reachable from any HTTP client. + let scanning = false + let lastScanAt = 0 + + /** + * Read the QR codes on the page and send the human what they carry. + * + * Off the critical path, like the focus probe: nothing awaits it, and a scan + * still in flight when the handoff ends is dropped rather than delivered to + * a phone that has already been told it is over. + */ + const scanQr = (): void => { + const now = Date.now() + if (scanning || over || now - lastScanAt < QR_SCAN_INTERVAL_MS) return + lastScanAt = now + scanning = true + qrScans += 1 + void scanPageForLinks(page) + .then((links) => { + if (links.length > 0) qrHits += 1 + if (over) return undefined + return link?.send({ type: "links", links, source: "qr" }) + }) + // A screenshot fails when the page is closing, which is a handoff that + // is about to end as `disconnected` — the phone gets that, and its own + // deadline releases the button. Answering with an empty list here would + // tell the human the page has no code on it, which is a different and + // untrue thing. + .catch((error) => { + logger.warn("qr_scan_failed", { error: String(error) }) + }) + .finally(() => { + scanning = false + }) + } + const onHuman = (message: HumanToAgent): void => { const ending = endingFor(mode, message.type) if (ending) { @@ -386,6 +438,12 @@ export async function runHandoff(run: HandoffRun): Promise { // abandoned, so no further input may run against it. An approval never // injects anything at all: the human is answering, not driving. if (terminal || mode === "approval") return + // The one message that asks the agent about the page instead of changing + // it, so it is answered before the input path and needs no frame metadata. + if (message.type === "scanqr") { + scanQr() + return + } // Input can only be mapped once a frame has defined the coordinate space. const meta = pump?.lastMeta() if (!meta || !input) return @@ -565,6 +623,8 @@ export async function runHandoff(run: HandoffRun): Promise { framesSent, bytesSent, inputsApplied: input?.applied() ?? 0, + qrScans, + qrHits, reconnects: connection.stats().reconnects, storageStateCaptured: storageState !== undefined, } diff --git a/src/core/socket.test.ts b/src/core/socket.test.ts index 34aef59..cc1333d 100644 --- a/src/core/socket.test.ts +++ b/src/core/socket.test.ts @@ -356,6 +356,7 @@ const SAMPLES = { key: { type: "key", key: "Enter" }, clear: { type: "clear" }, scroll: { type: "scroll", fdy: 40 }, + scanqr: { type: "scanqr" }, handback: { type: "handback" }, abort: { type: "abort" }, approve: { type: "approve" }, @@ -364,7 +365,16 @@ const SAMPLES = { /** Which mode's relay routes which of them (`HUMAN_MESSAGES` in guest/server.js). */ const ROUTED_BY = { - takeover: ["tap", "char", "key", "clear", "scroll", "handback", "abort"], + takeover: [ + "tap", + "char", + "key", + "clear", + "scroll", + "scanqr", + "handback", + "abort", + ], approval: ["approve", "deny"], } satisfies Record diff --git a/src/core/socket.ts b/src/core/socket.ts index 3b0ad22..9c75f93 100644 --- a/src/core/socket.ts +++ b/src/core/socket.ts @@ -144,6 +144,7 @@ export function connectRelay(options: RelayConnectionOptions): RelayConnection { case "key": case "clear": case "scroll": + case "scanqr": case "handback": case "abort": case "approve": diff --git a/src/events.ts b/src/events.ts index 724e450..870ebd0 100644 --- a/src/events.ts +++ b/src/events.ts @@ -47,6 +47,17 @@ export interface HandoffEvent { * in approval mode, which injects nothing. */ inputsApplied: number + /** + * QR scans the human asked for and the agent performed. Requests dropped by + * the rate limit are not counted — they cost nothing and happened only in + * the sense that a button was pressed twice. Always 0 in approval mode, + * which offers no scan. + */ + qrScans: number + /** Of those, the ones that found at least one code. `qrScans - qrHits` is + * how often the human was told "nothing here", which is the number worth + * watching: it is either a page that has no code or a decode that failed. */ + qrHits: number /** Agent-socket reconnects during the handoff (the 60 s idle cut, drops). */ reconnects: number /** Whether cookies + localStorage were captured after a handback. */ diff --git a/src/index.ts b/src/index.ts index a87f5ef..6d7a7d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,12 @@ export type { HandoffChannel, TakeoverChannelHandoff, } from "./channels" +export { + type LinkKind, + OPENABLE_SCHEMES, + type ScannedLink, + scanQrLinks, +} from "./core/qr-scan" export { raiseHand } from "./core/raise-hand" export { HandraiseError, diff --git a/src/relay/guest-source.ts b/src/relay/guest-source.ts index f6d535f..ec09abc 100644 --- a/src/relay/guest-source.ts +++ b/src/relay/guest-source.ts @@ -61,9 +61,11 @@ const MSG = { FRAME: "frame", STATE: "state", FOCUS: "focus", + LINKS: "links", ENDED: "ended", // human -> agent TAP: "tap", + SCANQR: "scanqr", CHAR: "char", KEY: "key", CLEAR: "clear", @@ -80,6 +82,18 @@ const MSG = { /** The two things a handoff can ask of a human. */ const MODE = { TAKEOVER: "takeover", APPROVAL: "approval" } +/** + * The URL schemes the page may offer an "Open" button for, from a QR code the + * agent read off whatever site it got stuck on. + * + * The agent classifies each link before it sends it, and the page checks the + * scheme again against this list. Both locks are needed: the human's link is a + * bearer URL and the socket behind it is reachable from any HTTP client, so + * \`kind: "url"\` is a hint the page must not have to trust. Asserted equal to + * \`OPENABLE_SCHEMES\` in src/core/qr-scan.ts by relay.test.ts. + */ +const OPENABLE_SCHEMES = ["http:", "https:", "tel:", "mailto:", "otpauth:"] + /** * What this handoff asks of the human: \`takeover\` (drive the page) or * \`approval\` (answer one question about one screenshot). It arrives as argv @@ -100,6 +114,7 @@ const HUMAN_MESSAGES = new Set( MSG.KEY, MSG.CLEAR, MSG.SCROLL, + MSG.SCANQR, MSG.HANDBACK, MSG.ABORT, ], @@ -431,7 +446,7 @@ function log(event, detail) { function renderPage() { return PAGE.replace("__HANDRAISE_MODE__", HANDOFF_MODE).replace( "__HANDRAISE_VOCAB__", - JSON.stringify({ msg: MSG, mode: MODE }), + JSON.stringify({ msg: MSG, mode: MODE, schemes: OPENABLE_SCHEMES }), ) } @@ -841,14 +856,18 @@ const PAGE = \` or one step and sit together in typing order; clear destroys the whole field with no undo, so it is a word rather than a glyph a stranger has to guess at, and it sits behind a gutter the thumb has to reach for. A missed - backspace can no longer empty the field. */ - #key-clear { + backspace can no longer empty the field. + + Scan QR is past the gutter with it — not because it is destructive, but + because it is not a key. It asks the agent a question about the page + instead of typing into it, and the three glyphs keep their own group. */ + #key-clear, #key-qr { flex: 0 0 auto; min-width: 44px; - margin-left: 18px; padding: 0 8px; font-size: 13px; } + #key-clear { margin-left: 18px; } .key:active:not(:disabled) { color: var(--text); border-color: oklch(0.44 0 0); @@ -956,6 +975,81 @@ const PAGE = \` #overlay[hidden] { display: none; } #overlay h1 { margin: 0; font-size: 20px; letter-spacing: -0.02em; } #overlay p { margin: 0; color: var(--muted); font-size: 14px; } + /* What the QR code said. + A sheet and not a second overlay: the overlay ends the session, this one is + an answer the human reads and dismisses, and the frame stays behind it + because the next thing they do is usually on the page. It rises from the + bottom edge, where the thumb already is. */ + #sheet { + position: fixed; + inset: 0; + z-index: 9; + display: flex; + align-items: flex-end; + background: oklch(0.11 0 0 / 0.72); + } + #sheet[hidden] { display: none; } + #sheet-card { + width: 100%; + max-height: 80%; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 12px; + padding: 18px calc(16px + env(safe-area-inset-right)) calc(16px + env(safe-area-inset-bottom)) calc(16px + env(safe-area-inset-left)); + border-top: 1px solid var(--line); + border-radius: var(--radius) var(--radius) 0 0; + background: var(--surface); + transform: translateY(0); + transition: transform 220ms cubic-bezier(0.23, 1, 0.32, 1); + } + @starting-style { + #sheet-card { transform: translateY(100%); } + } + #sheet-title { margin: 0; font-size: 17px; letter-spacing: -0.02em; } + #sheet-links { display: flex; flex-direction: column; gap: 12px; } + .link { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--bg); + } + /* The link is the thing being decided on, so it is shown in full and it + wraps. anywhere, because a token has no spaces to break at — a truncated + URL is exactly how somebody is talked into opening the wrong one. */ + .link-text { + margin: 0; + font-size: 13px; + line-height: 1.35; + color: var(--text); + overflow-wrap: anywhere; + } + .link-note { margin: 0; font-size: 12px; color: var(--muted); } + .link-actions { display: flex; gap: 8px; } + /* Same box for the anchor and the button, so the row does not shift by a + pixel between a link that can be opened and one that can only be copied. */ + .link-action { + flex: 1 1 0; + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 12px; + border: 1px solid var(--field); + border-radius: var(--radius); + background: transparent; + color: var(--text); + font: inherit; + font-size: 15px; + font-weight: 500; + text-decoration: none; + } + .link-action:active { background: oklch(0.26 0 0 / 0.4); } + #sheet-close { min-height: 44px; } + .empty { margin: 0; color: var(--muted); font-size: 14px; } /* One page, two jobs. The relay bakes the mode into the body, and the controls belonging to the other job are gone rather than disabled: the relay refuses to route what they would have sent anyway. */ @@ -992,6 +1086,10 @@ const PAGE = \` .ghost::before { display: none; } .ghost[data-holding] { background: oklch(0.65 0.2 25 / 0.14); } #approve[data-holding] { background: oklch(0.985 0 0 / 0.12); } + #sheet-card { transition: none; } + @starting-style { + #sheet-card { transform: none; } + } #overlay { transition: opacity 150ms linear; } @starting-style { #overlay { opacity: 0; transform: none; } @@ -1025,6 +1123,7 @@ const PAGE = \` +

@@ -1045,6 +1144,13 @@ const PAGE = \`

+