Skip to content

fix(nav): survive a partial Navigation API, and score agent readiness against a real agent browser - #353

Merged
oddharsh merged 5 commits into
mainfrom
kitesurf-agent-check
Aug 13, 2026
Merged

oddharsh merged 5 commits into
mainfrom
kitesurf-agent-check

Conversation

@oddharsh

Copy link
Copy Markdown
Owner

Cloudflare's Kitesurf is the browser an agent actually runs when it drives Cloudflare's stack. Pointing it at aadhar.sh found two defects that every existing check was structurally unable to see, because both live in what an ENGINE does with bytes that are themselves correct.

The public playground at kitesurf.cloudflare.app takes no account and no token, so all of this costs zero of the 10 browser-minutes a day that /lens/shot and /lens/browser share.

The two defects

nav.js threw on every page. Kitesurf ships a navigation stub carrying entries() and currentEntry and nothing else: no canGoBack, no addEventListener. Every if (window.navigation) guard passed and the listener line threw, inside a rAF, taking out the rest of that function. The maximize control was dead on all six pages tested and wired on all six in a Chrome control.

Verified by reproducing the stub in Chrome and A/B-ing the two files against the same page:

A: stub + PRODUCTION nav.js   maximize=DEAD   errors=TypeError: navigation.addEventListener is not a function
B: stub + PATCHED nav.js      maximize=WIRED  errors=none

12 of 12 photo tiles fail to decode. /photos/grid.html serves a bare <img src="/i/<stem>-400.<hash>.avif"> with no fallback and Kitesurf cannot decode AVIF. The requests return 200, so this is a decode gap. Not fixed here, deliberately: lib/photo-grid.js dropped <picture> on 2026-08-11 on a measured premise ("every browser this site targets decodes AVIF") and every tile carries real alt text, so a text-reading agent loses nothing while a screenshotting one loses the grid. That tradeoff is the owner's call, not this PR's.

pnpm run agent:check

Drives Kitesurf over the routes and scores what comes back. --all for every manifest page, --control to run the identical script in Chrome, --json for machine output.

--control is not decoration. Gotcha 15 and gotcha 33 are the same lesson: an instrument reporting a browser feature as missing is usually broken itself. A Kitesurf-only defect means nothing until Chrome passes, and without the flag the report says the control did not run rather than implying one did.

Not a CI gate. It reaches a third-party playground on every run, so as a required check it would redden PRs on somebody else's outage. Workstation control, same idiom as kitesurf:check and bun:check.

The rubric

lib/agent-rubric.js is pure for the reason lib/tui.js is pure: /lens will score arbitrary URLs out of the evidence its machine tabs already collect, and one definition has to answer a Node script, an HTTP response and node --test without caring which is asking.

Two rules keep the number defensible, both pinned in contract tests:

  • An unmeasured dimension is unmeasured, never zero. max shrinks instead of a missing probe becoming an accusation the scored site cannot answer.
  • The browser dimension carries the heaviest weight (25). The declared-surfaces layer everyone builds first scored full marks on the same run that turned up both defects.

The rubric's own first bug is worth reading in the diff: it counted non-empty alt against ALL images, scoring /garage 1/10 when 14 of its 16 images are taskbar sprites carrying a deliberate alt="". Three states now, and an all-decorative page is unmeasured rather than failed.

Measured against production

route score browser notes
/ 79% 7/25 12/26 images decoded, 1 script error
/garage 87% 13/25 1 script error
/writing /lens /coffee /around 80% 13/25 no Markdown twin (new finding)

Every declared surface answered 200 throughout: llms.txt, agent card, both MCP cards, sitemap, tools/list. That layer was already perfect and was not the problem.

247/247 contract tests, typecheck clean.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 12, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
aadhar-sh 951303d Aug 12 2026, 10:54 PM

@github-actions

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Wire-size diff vs merge base

9eb993f → 1b9108e

Brotli q11, the quality the edge serves. Every number here is deterministic:
an unchanged file produces no row. Advisory — this check fails on nothing.

Client assets

Total Brotli: 68.07 KiB → 68.08 KiB (+0.01 KiB, +0.0%) across 19 files, 1 changed.

file base head Δ
nav.js 5.72 KiB 5.73 KiB +0.01 KiB (+0.2%)

Pages

Total Brotli: 494.19 KiB → 494.22 KiB (+0.04 KiB, +0.0%) across 48 files, 45 changed.

No single file moved by 0.13 KiB or more.

Plus 45 files under the 0.13 KiB floor, net +0.04 KiB — usually content-hash churn in /a/ references, counted in the total above.

Worker bundle

276.59 KiB → 278.56 KiB gzip (+1.97 KiB, +0.7%)

Largest module deltas (raw bytes in bundle):

file base head Δ
.build/www/_worker.js/lib/agent-execution.js — 2.32 KiB new
.build/www/_worker.js/lens-wire.js 12.74 KiB 14.54 KiB +1.80 KiB (+14.2%)
.build/www/_worker.js/lens.js 164.19 KiB 165.53 KiB +1.34 KiB (+0.8%)

Dictionary deltas (pd/)

144 → 144 deltas, 452.96 KiB → 453.49 KiB (+0.52 KiB).

Comment thread scripts/check-agent.mjs Fixed
@oddharsh

Copy link
Copy Markdown
Owner Author

Folded into /lens, and two things deliberately not built

Two collisions turned up while wiring this in, and both changed the shape.

/lens already had a readiness rubric. lensReadiness() runs 20 checks across five categories, renders a hero number and a level, and drives the head-to-head compare. I had been about to ship a second definition of the same score, which is the exact drift this repo works hardest against. The standalone agent-rubric.js from the first two commits is gone.

Reading the existing rubric against the Kitesurf findings made the gap precise: all 20 checks are declaration audits. robots.txt, sitemap, Link headers, DNS-AID, Markdown negotiation, Web Bot Auth, the MCP cards, the OAuth doors. Existence and shape. Nothing asks what an engine did with the page, which is why it would have scored this site near-perfect on the day the homepage rendered 12 blank squares.

So: a sixth Execution category, two checks, riding the CDP session /lens/wire already opens. No new browser instance, no new minute off the 10-a-day ceiling.

Neutral until measured is the load-bearing rule and it deliberately inverts how unknown works everywhere else here. An unknown declared check is the site's fact, we asked and it did not answer. An unmeasured execution check is ours, and docking a stranger because our render budget was spent would make the number dishonest. Verified through the real scorer:

NO RENDER    execution: total 0, checkCount 2   counted 15
WITH RENDER  execution: total 2, checkCount 2   counted 17

The client already draws a zero-total category as skipped with a dash, so this needed no client change at all.

The header band was cut. verdictStrip() in www/lens.js already renders a score that opens Agent-ready?, so a band above the panes would have been a third copy of a number on one screen. The honesty it was meant to buy arrives anyway through the skipped category and the two checks stating why they are neutral.

Also in this push

/lens/wire returns execution and records three new span attributes, skipping them entirely when there is no evidence rather than writing a zero that reads like a clean page.

agent:check now reports the same two shared checks and publishes no competing score. Current production, unfixed:

8 route(s): 8 with script failures, 1 with undecodable media, 4 with no Markdown twin
declared surfaces: llms.txt yes, agent card yes, MCP yes, sitemap yes

249/249 contract tests, typecheck clean, route oracle 130/130.

oddharsh added a commit that referenced this pull request Aug 12, 2026
… tag

CodeQL, on #353: js/bad-tag-filter against httpWords(). An end tag may carry
attributes and may hold whitespace, so `</script >` and `</script bar>` close
a script element as surely as `</script>` does. A stripper spelled the short
way hands the whole body through.

Same class and same fix as #347, which closed this across the tree. The
closers are `<\/tag\b[^>]*>` now, matching what lens.js already uses.

Not cosmetic. The control, on `<p>keep</p><script >var leak=1;</script >tail`:

    OLD: ["keep","var","leak=1;","tail"]
    NEW: ["keep","tail"]

Those three leaked words land in the HTTP side of the legible-without-
JavaScript comparison, so a page carrying an inline script closed with a
space reads as MORE legible than it is. A measurement tool that quietly
inflates the number it exists to report is worse than one that fails.

httpWords is exported and main() now runs behind `import.meta.main`, so the
assertion is behavioural instead of a regex-shape match on the source. That
matters here specifically: the short spelling LOOKS right, and a source-shape
test would have passed on the broken version.
oddharsh added a commit that referenced this pull request Aug 12, 2026
… tag

CodeQL, on #353: js/bad-tag-filter against httpWords(). An end tag may carry
attributes and may hold whitespace, so `</script >` and `</script bar>` close
a script element as surely as `</script>` does. A stripper spelled the short
way hands the whole body through.

Same class and same fix as #347, which closed this across the tree. The
closers are `<\/tag\b[^>]*>` now, matching what lens.js already uses.

Not cosmetic. The control, on `<p>keep</p><script >var leak=1;</script >tail`:

    OLD: ["keep","var","leak=1;","tail"]
    NEW: ["keep","tail"]

Those three leaked words land in the HTTP side of the legible-without-
JavaScript comparison, so a page carrying an inline script closed with a
space reads as MORE legible than it is. A measurement tool that quietly
inflates the number it exists to report is worse than one that fails.

httpWords is exported and main() now runs behind `import.meta.main`, so the
assertion is behavioural instead of a regex-shape match on the source. That
matters here specifically: the short spelling LOOKS right, and a source-shape
test would have passed on the broken version.
@oddharsh
oddharsh force-pushed the kitesurf-agent-check branch 2 times, most recently from 7e023e5 to a485f97 Compare August 12, 2026 21:48
Kitesurf, Cloudflare's agent browser, ships a `navigation` stub carrying
entries() and currentEntry and nothing else. No canGoBack, no
addEventListener. Every `if (window.navigation)` guard in the history-nav
block therefore passed, and the listener line threw:

    TypeError: navigation.addEventListener is not a function

It throws inside a requestAnimationFrame pass, so it takes out the rest of
that function, which is where the maximize/restore control gets wired.

Measured 2026-08-12 against the public Kitesurf CDP: the control was dead on
all six pages tested (/, /writing, /terminal, /lens, /coffee, /garage) and a
Chrome control wired it on all six. Verified the fix by reproducing the stub
in Chrome and A/B-ing the two files against the same page: production nav.js
gives maximize=DEAD plus the TypeError, this one gives maximize=WIRED and a
clean console.

Asking for the member each branch actually uses means a partial
implementation degrades to the history fallback rather than crashing past it.
Every agent check here reads a declaration. The route oracle sweeps routes,
gen-mcp-cards diffs a card against tools/list, build.mjs counts the Markdown
twins. All of them were green on 2026-08-12 while an agent browser was
getting a homepage with 12 blank squares and a script that threw on every
page, because the defect is in what an ENGINE does with bytes that are
themselves correct.

`pnpm run agent:check` drives Cloudflare's Kitesurf over the routes and
scores what comes back. It is free: the public playground at
kitesurf.cloudflare.app takes no account and no token, so it spends none of
the 10 browser-minutes a day that /lens/shot and /lens/browser share. The
same sweep through the account binding would black out the browser lenses on
its first run.

lib/agent-rubric.js is the scoring, kept pure for the reason lib/tui.js is
pure: /lens will score arbitrary URLs out of the evidence its machine tabs
already collect, and one definition has to answer a Node script, an HTTP
response and `node --test` without caring which is asking.

Two rules make the number defensible, both pinned in contract tests. A
dimension nobody measured is unmeasured rather than zero, so max shrinks
instead of the score becoming an accusation the scored site cannot answer.
And the browser dimension carries the heaviest weight at 25, because the
declared-surfaces layer everyone builds first scored full marks on the same
run that turned up both real defects.

--control is the other half. gotcha 15 and gotcha 33 are both the same
lesson: an instrument reporting a browser feature as missing is usually
broken itself, so a Kitesurf-only defect means nothing until the identical
script passes in Chrome. Without the flag the report says the control did not
run rather than implying one did.

Not a CI gate. It reaches a third-party playground on every run, so as a
required check it would redden PRs on somebody else's outage.
…ription

The rubric's own first bug, and it defamed the site it was written to check.
It counted images with non-empty alt against ALL images, so /garage scored
1/10 for describability when 14 of its 16 images are taskbar sprites
carrying a deliberate alt="", which is the right markup for a decorative
icon. The homepage scored 5/10 on the same mistake.

Three states, not two. A non-empty alt describes, an alt="" is a decision
that the image owes no description, and a missing alt attribute is the only
omission. Decorative images leave both sides of the ratio, which is the whole
point of writing one. A page whose images are all decorative is now
unmeasured on this dimension rather than failed.

After the repair, measured against production: / scores 12/12 content images
described with 14 decorative, /garage 2/2 with 14 decorative. Both 10/10.

Also probes the Markdown twin per route, since it is the one discovery
surface that is not site-wide. That immediately found four of the eight
sampled routes serving no twin: /writing, /lens, /coffee and /around.
…eclares

/lens has scored agent readiness since it was built, across twenty checks in
five categories. Every one of them is a declaration audit: does robots.txt
exist, does the sitemap parse, does the MCP server card have the right shape.

That layer was perfect on aadhar.sh on 2026-08-12, on a day an agent browser
was getting a homepage with twelve blank squares and a script that threw on
every page. A declaration cannot catch either defect, because both live in
what an ENGINE does with bytes that are themselves correct.

So this adds a sixth category, Execution, with two checks a declaration
structurally cannot answer:

  agentScripts  did the page's own JavaScript survive the engine?
  agentMedia    could the engine decode the images the page served?

Both ride the CDP session /lens/wire already opens, so they cost no extra
browser instance and no extra minute of the 10-a-day account ceiling.
Runtime and Log are enabled before the navigation, because an exception
thrown during load is the interesting case, and the DOM census runs after the
settle window because an image that has not finished loading yet reports
naturalWidth 0 and is not broken.

NEUTRAL UNTIL MEASURED is the load-bearing rule, and it is deliberately the
opposite of how `unknown` is treated everywhere else in this rubric. An
unknown declared check is the SITE's fact: we asked and it did not answer. An
unmeasured execution check is OURS, and docking a stranger's score because
our render budget was spent would make the number dishonest. Verified through
the real scorer: an unrendered scan leaves `counted` at 15 and renders the
category as skipped in the existing grid, a render takes it to 17.

lib/agent-execution.js holds the checks and the in-page probe so the rubric
and `pnpm run agent:check` cannot drift into two definitions of one claim.
The CLI now publishes no overall score of its own for the same reason.

Two things deliberately NOT built, both because the surface already exists.
No standalone rubric module: /lens owns the score. No header band above the
panes: verdictStrip() already renders a score that opens Agent-ready?, and a
third copy of a number on one screen is worse than none. The honesty the band
was for lands anyway, since the client already draws a zero-total category as
skipped and both checks state why they are neutral.
… tag

CodeQL, on #353: js/bad-tag-filter against httpWords(). An end tag may carry
attributes and may hold whitespace, so `</script >` and `</script bar>` close
a script element as surely as `</script>` does. A stripper spelled the short
way hands the whole body through.

Same class and same fix as #347, which closed this across the tree. The
closers are `<\/tag\b[^>]*>` now, matching what lens.js already uses.

Not cosmetic. The control, on `<p>keep</p><script >var leak=1;</script >tail`:

    OLD: ["keep","var","leak=1;","tail"]
    NEW: ["keep","tail"]

Those three leaked words land in the HTTP side of the legible-without-
JavaScript comparison, so a page carrying an inline script closed with a
space reads as MORE legible than it is. A measurement tool that quietly
inflates the number it exists to report is worse than one that fails.

httpWords is exported and main() now runs behind `import.meta.main`, so the
assertion is behavioural instead of a regex-shape match on the source. That
matters here specifically: the short spelling LOOKS right, and a source-shape
test would have passed on the broken version.
@oddharsh
oddharsh force-pushed the kitesurf-agent-check branch from a485f97 to 951303d Compare August 12, 2026 22:53
@oddharsh
oddharsh merged commit f69df39 into main Aug 13, 2026
7 checks passed
@oddharsh
oddharsh deleted the kitesurf-agent-check branch August 13, 2026 11:38
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.

2 participants