Conversation
`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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Round 1 of the adversarial review. Every finding was a hole in a lock this feature already claimed to have shut, and each fix has a test that was watched failing first. The ending overlay buried an open result sheet. Both are `position: fixed; inset: 0`, the overlay is opaque, and it sat one z-index higher — so a human who read the link and then handed back lost it, with the button disabled and no way to scan again. That is the feature's own happy path. The sheet now sits above every overlay, which is what its own comment already claimed; the ui.spec case sends `links` then `ended` and asserts `elementFromPoint` over the Open anchor is still the anchor. The sheet showed a link that was not the link it opened. `textContent` took the payload verbatim while the anchor let the URL parser resolve it, and the difference is invisible: `https://аpple.com` with a Cyrillic а reads as apple.com and lands on xn--pple-43d.com; a U+202E reverses the visible tail of a path. ADR 0008 argued the residual risk was bounded by "a person who can see the whole link", which was not true. An openable link is now shown, anchored and copied as `new URL(text).href` — one string, punycode host, nothing truncated — with a note when that differs from what the code said. The phone's `openable()` was not an independent second lock. It checked the scheme and the agent checked three things, so a forged `kind: "url"` on `https://good.example\t@evil.example/` got an anchor: the URL parser deletes the tab without a word. The page now applies the whole rule — control characters and credentials in the authority as well as the scheme — and the list of payloads that must never become a button lives in `qr-fixtures.ts`, asserted against `classifyLink` in the unit test and against the real page in the browser, so the two locks cannot drift into checking different things. The authority check is new on both sides. `decodePng` inflated without a bound: 815 KB of IDAT claiming an 8x8 image allocated 873 MB and then decoded happily, because the first 200 bytes were a valid 8x8 picture. The header says exactly how many bytes a PNG's image data comes to, so the inflate is capped by it, and a header claiming more pixels than any screen is refused before a byte is decompressed. `scanPageForLinks` only ever sees Chromium's own screenshot, but `scanQrLinks` is exported and documented as taking a PNG from anywhere. A malformed `links` array threw out of the message handler, which skipped the code that releases the Scan QR button — leaving it dead for the full twelve-second deadline with no sheet and no reason. The wire's array is parsed into cards now, and the test sends the malformed payload as bytes rather than as a cast, because the protocol has no way to describe it. Also: `qrHits` counts after the `over` check rather than before it, so a scan whose answer is discarded is not a hit anybody was told about; `renderPage` substitutes with function replacements, so a `$&` in an injected value stays a literal; and ADR 0008 and the README now name the three things they were quiet about — `tel:` accepts USSD and `otpauth:` enrols a secret, the decode blocks the event loop for about 320 ms on the path that finds nothing, and `jsqr` is an archived dependency taken as a deliberate bet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Round 2, from the GPT-5.6 Sol security review. No blocker, four majors, four minors; every finding fixed red-first. **The decode no longer runs on the agent's event loop.** It is pure synchronous CPU, and on a 3840x2160 screenshot it held the loop for 2132 ms — measured with a 5 ms heartbeat that did not tick once in that window. That loop is the frame pump, the handback the human is about to send, the timeout and the browser's disconnect. The work moved to a worker thread (`dist/qr-worker.js`, a second build entry, started at the first scan of a handoff and terminated when it settles) and the same decode now costs the loop one millisecond. The worker also buys the only lever there is over a decode that will not finish: a three-second deadline and `terminate()`. `scripts/measure-qr-block.ts` reproduces both numbers with no API key. **Every size is bounded before anything is allocated.** A header is the only part of a PNG that is cheap to believe and everything downstream is sized from it, so it is checked first: 8192 pixels a side, 33 megapixels total, 32 MB of compressed data, and the exact inflated length it implies passed to `inflateSync` as `maxOutputLength` and then required to match. Chunk lengths are walked against the end of the file and IEND is required, so a malformed file with a usable zlib stream inside it is a refusal rather than pixels handed to a security-sensitive classifier. CRCs are deliberately not checked and the file says why. The 2x retry allocates four times the source, so it is refused above 40 megapixels rather than taking a half-gigabyte step. **`tel:` and `otpauth:` are no longer openable, and that is a reversal.** They were on the allowlist because device-change flows use them. They are off it because both are actions rather than pages and both are one tap: `tel:*21*1234567890%23` is a call-forwarding sequence handed to a dialler, and an `otpauth:` URI enrols an attacker-chosen TOTP secret in the human's authenticator. Neither is worth a tap taken from a page nobody vetted. They are still decoded, shown in full and copyable, under a label that names them. **The phone shows the address it opens, with the host as the loud part**, and refuses the invisible characters that make that a lie — the bidi overrides and isolates and the zero-width joiners, on both sides of the wire. Credentials in the authority are refused on both sides too. **The relay bounds its own ingress.** Four kilobytes per human message, enforced in the reader before anything is parsed — every message that side can send is a handful of small fields, and the megabyte cap exists for the agent's frames. The scan floor is enforced there as well as in the core, because a burst of accepted scans costs a forward and a parse per message whatever the core then does with it. And a human socket is held while the agent's is backpressured, so the relay's write queue cannot be grown by input nobody has asked for — with the message already accepted written first, so a handback is never the thing that waits. Minors: the in-flight scan is retained and awaited at teardown, settlement is checked between the screenshot and the answer, and `qrScans`/`qrHits` count delivered results and are frozen before the event is built. `jsqr` is pinned to exactly 1.4.0, with the fork trigger written down next to the bet. Live e2e green with the worker in the path: 58 assertions, 65 s, scan round trip 1.5 s. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…clock The five lows from the verification pass. **The "wrote this address differently" note fired on ordinary links.** It compared `new URL(text).href` against the payload, so `https://example.com` — one of the commonest shapes a QR code has — drew the same warning as a Cyrillic homograph, because the parser adds a trailing slash. A capital letter anywhere in a scheme or a domain did it too. A warning that goes off on ordinary input is one a human learns to tap past, which is exactly the day it needs to land. It now compares the authority the code *wrote* against the host the browser will connect to, and fires on nothing else. Written without a regular expression on purpose: the page is a template literal in server.js, which eats the backslash a regex needs — that bug was live for one commit and the ui.spec caught it by taking the whole page down. **The decode deadline and the decoder's own caps did not fit, and the gap was bigger than the review found.** Measured across sizes, the cost is not the pixel count but whether the 2x retry runs: a 24 MP image too large to magnify decodes in 1.2 s, while a 10 MP one that magnifies to 40 MP takes 2.8 to **3.7 s** — past the 3 s deadline, on a legitimate screenshot, killed on the way to an answer it already had and reported to the human as "the agent didn't answer". The three numbers are now derived from each other: `MAX_SCAN_PIXELS` is 34 MP, which is exactly what a 4K screenshot needs to magnify and nothing more; that input measured 2.1 s; the deadline is 6 s, near three times it, and 5 s of screenshot plus 6 s of decode still fits inside the phone's 12 s wait. `MAX_PIXELS` is 24 MP, and png.ts now says out loud that a 4K viewport at device scale 2 is 33.2 MP and is refused, instead of claiming headroom the cap does not have. The table is in measurement 05 §6. **A worker that errored stayed cached.** `onExit` and the deadline cleared the handle and `onError` did not, so the next scan posted into a dead thread and burned the whole deadline finding out. The listener that clears it is now attached at `start()` rather than per scan, which also closes the second half of that finding: between scans the worker had no `'error'` listener at all, and an EventEmitter `'error'` with no handler throws. **The backpressure comment oversold what the pause promises.** The message that triggers it is delivered — that part holds — but anything already behind it in the human's socket buffer, a handback among them, waits for the agent to drain or to go away. Bounded and never lost, but not instant. The comment and ADR 0008 now say that instead of implying a human who has answered is unaffected. And two citations went stale when round 2 inserted a measurement section: README and ADR 0008 cited §6 for reCAPTCHA, which is §7. The `scanqr` docstring in a file named `src/relay/` still said "rate-limited in the core, not here" after round 2 put a token bucket in the relay; it now names all three floors and what each one bounds. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A verification that shows a QR code to scan (reCAPTCHA's scan-to-verify, WhatsApp Web, authenticator enrolment) could not be finished from the phone: a phone cannot scan its own screen. Now the phone has a Scan QR key: the agent takes a full-resolution screenshot, decodes it (jsqr + a node:zlib PNG decoder; BarcodeDetector is absent in Solari's Chromium), and hands the phone the decoded content as a link it can open or copy. Openable schemes are whitelisted (http, https, tel, mailto, otpauth); everything else is text. Rate-limited to one scan per 2 s in the core. Takeover only.
First commit names the relay's wire vocabulary (MSG/MODE constants in server.js, asserted against the protocol types).
Measured: screenshot 239 ms p50, decode 55 ms; the cast frame is too small to decode (rejected on measurement). The first live run found jsqr failing on a 4.9 px module grid; the fix retries at 2x and on corners, the failing screenshot is a committed fixture. reCAPTCHA itself could not be reproduced and is documented as untested. 255 tests, live e2e green twice.
🤖 Generated with Claude Code