Skip to content

feat: read a QR code on the page and hand the human the link - #9

Merged
Sy-D merged 5 commits into
mainfrom
qr-links
Sep 2, 2026
Merged

feat: read a QR code on the page and hand the human the link#9
Sy-D merged 5 commits into
mainfrom
qr-links

Conversation

@Sy-D

@Sy-D Sy-D commented Sep 2, 2026

Copy link
Copy Markdown
Owner

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

Sy-D and others added 4 commits September 2, 2026 22:01
`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>
@Sy-D
Sy-D merged commit 3e65625 into main Sep 2, 2026
2 checks passed
@Sy-D
Sy-D deleted the qr-links branch September 2, 2026 20:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant