Tutorial: 2026-07-12

2026-07-12

Newest on top.

Released 2.60.1 — the backend could be made to run arbitrary commands

Retrospective note: the changelog dates 2.60.1 2026-07-13, but git authored these commits late on 2026-07-12 local time — a timezone artifact, the same work, not a second day. The session log lives under 07-12 (when the work happened); the release entry under 07-13 (the changelog's date).

CodeQL flagged it on the 2.60.0 release PR. It was right, and so were the three findings beside it.

/api/image/open and /api/image/reveal built a shell string around a request-derived path (cmd /c start "" "<fp>"). resolveOutputFile() blocks path traversal and nothing else — so an image in the output folder named x" & calc & ".png closes the quote and everything after it runs. ImageMagick's convert/resize had the same shape. The fix is structural, not a better escape function: no shell. {cmd, args}execFile, argv straight to the OS (new backend/osCommands.js, pure, so the invariant is testable). Also fixed: prototype pollution through a request-supplied sidecar patch, a predictable cache file in the world-writable os.tmpdir(), three check-then-use fs races, and an upstream manifest that was cached and trusted without ever being validated.

And then the same bug bit me one layer up. Writing the commit message — which quotes the payload — through PowerShell's -m executed it and spawned calc. PowerShell parses & inside double-quoted arguments; text is not text. Commit messages go through a file now (working-agreements §A1). Nothing has ever taught me the lesson faster than my own tooling doing it to me mid-sentence.

Three dead CI runs, and my diagnosis was wrong twice. The Android system-image download kept failing with "Error on ZipFile unknown archive", and I called it a flaky CDN and cached around it. It wasn't flaky — it was deterministic. One line earlier, every single time: "This version only understands SDK XML versions up to 3 but an SDK XML file of version 4 was encountered." The runner's sdkmanager was too old to read Google's current metadata. Pinned cmdline-tools 19.0. "Flaky infrastructure" is the most comfortable diagnosis available; it should be the last one reached for, not the first — and the log had already said why, which is the same lesson as the 1000-prompt "defect" below.

The review bot also caught that my AVD cache wouldn't have prevented the failure it was added for (it cached AVD state, not the SDK system-images directory) and that actions/cache only saves on success, so a red Detox run would have thrown the snapshot away. Both fixed. That review earned its keep.

Also measured, since it was the owner's open worry: the engine is 0.179 ms/prompt in Node with the phone's own loader — flat and linear (scripts/profile-engine.mjs). No hidden quadratic. So the device's 23–30 ms/prompt is a ~150× runtime gap (Hermes has no JIT, on an emulated CPU), not a code defect. The remaining question needs a real handset, and is written up as such.

Released 2.60.0 — and the two things this session got wrong before it got them right

Shipped: v2.60.0 on main (tagged, released, deployed). The on-device Android gate is now a required check on main, so a release physically cannot merge without it going green.

The 1000-prompt promise, measured on a phone: 1000 prompts → render 272 ms, memory flat (116 MB vs a 112 MB baseline), cost per prompt falling with N. The list virtualizes exactly as advertised.

The two mistakes, both mine, both instructive

  1. I believed my test over the app's own telemetry. The gate's first runs said the app missed its headline promise; I wrote that into status.md as a P1 and went hunting for a render bug. The device log had been saying committed 1220 result rows, 34 ms after the roll the whole time. The real bug: results accumulate across rolls, so the test waited for "200 generated" while the label read "220 generated" — then sat in a ten-minute timeout and called it an app failure. Read the instrument you built before you distrust the thing it measures.
  2. I paid a 40-minute gate on every commit. The on-device job ran on every push to dev, so each one-line fix cost 40 minutes — and I spent them spin-polling CI, which from the outside looked like a hang. The owner called it: "why don't we CI gate only on release to main." Done — CI (plus CodeQL and Sonar) now runs on the release path only; npm test is the per-commit gate, and it is the same gate. CodeQL keeps its weekly scan, because a security scan that only runs when you ship isn't one.

Also fixed on the way out: CodeQL's five findings on the release PR (all correct — a duplicated JSX attribute on the palette FAB, a check-then-use fs race in the registry generator, an unanchored host regex in a test), and the SonarCloud org key, which the previous session's owner-rename had swept up (a GitHub login rename does not rename a third party's identifiers).

Left open, honestly: the engine costs 23–30 ms/prompt under Hermes vs 0.16 ms in Node. The render is provably fine, so on a phone the engine is the whole cost. That is not "emulators are slow" — it's a tracked question (plans/next-steps.md), to be measured on a real device before anyone theorises. Plus CodeQL's pre-existing critical command-injection finding in the local backend.

The exception closes: the 1000-prompt promise, verified on a real Android runtime — plus a red CI nobody saw

Continuation of the mobile campaign, at the owner's instruction to "look in the notes… use them by default" — which is now a hard rule rather than a habit (below).

1. CI was RED on dev, and every local gate said green

Before any new work: gh run list showed CI failing since the last session's final push. Not a flake — Format check. Nine files the previous session wrote were never Prettier'd, and npm test doesn't run format:check (CI does). So a session can end "verified green" on a commit CI rejects. A gate you don't run is not a gate — and a local gate that is a subset of the CI gate is a lie about what green means. format:check now runs inside npm test.

Two of the nine were generated files, which exposed a genuinely nasty knot: formatting them made check:registry call them STALE (it compares byte-for-byte against what the generator emits), while not formatting them kept format:check red — two gates each demanding the other be broken. Fixed at the source: the generator now runs its rendered output through Prettier before writing and before comparing, so the file it writes is the file both gates expect. Never hand-format a generated file.

2. The 1000-prompt promise is no longer unverified (Detox + a real Android runtime)

This was the open exception in the whole mobile testing mandate. The react-native-web proxy could not check it honestly — FlashList's web renderer doesn't recycle like the native one, so the number it produces describes react-native-web, not the phone. The test was left skipped, with the evidence.

It's now checked where the claim is made: targets/mobile/e2e/ + .detoxrc.js build the release APK from the Expo-CNG native project and drive it with Detox (local: the rap_phone AVD; CI: a new android-device job with KVM).

What it asserts is deliberately not "1000 prompts in under N ms" — that benchmarks the runner. It rolls 20 prompts, then 1000 on the same device in the same session, and compares: 50× the rows must not cost 50× the memory or collapse the frame rate. The numbers come from the platform's own accounting (dumpsys gfxinfo janky-frame % + p50/p90/p95/p99, dumpsys meminfo PSS), so the app can't flatter itself, and emulator noise cancels out because it's identical in both rolls. It also asserts the app produced all 1000 (the wait is on the literal "1000 generated", so a re-introduced cap times out instead of passing quietly) and is still interactive afterwards.

Release, not debug, on purpose: a debug build runs the dev bundle with dev-mode React checks on. Its numbers are pessimistic fiction, and the promise is about what a user runs.

3. The last hand-port is gone — engine/dplInsertCatalog.js

The de-duplication campaign's final row. The DPL insert catalog describes what engine/core/dpl/dpl.js compiles, so it is engine domain — it was never the web's to own, and the phone's 262-line copy existed only because the web owned it. The knot the plan flagged as "entangled" was the labels (web = react-intl descriptors, mobile = inline English). Resolved by splitting grammar from presentation: the engine holds ids/syntax/templates/examples; each target hangs its own label layer on via buildInsertMenu(). Label keys are derived from the catalog ids, so a construct added to the grammar with no string in a target fails a test rather than rendering undefined on a phone. checkDplInserts deleted — six of seven drift checks are now gone, each because the thing it guarded can no longer differ.

4. Notes-first is now a hard rule (§A0)

The owner's instruction, written into both places an assistant actually reads: CLAUDE.md's Start Here is now an ordered session-start ritual (status → latest session log → the plans/systems page for the area), and working-agreements.md gains §A0 — notes read first, used as the default source of truth over any private/AI memory, and written back in the same change.

Where it runs, stated honestly. The release APK builds here (27 MB app + 2.9 MB androidTest, BUILD SUCCESSFUL), but this machine's AVD cannot run it: the instant the app renders, the emulator's own graphics stack aborts — surfaceflinger dies inside GoldfishMapper::readFromHost (mapper.ranchu.so), takes system_server with it, and every subsequent adb install fails with a StorageManagerService NPE. Reproduced under all three GPU modes (swiftshader_indirect, host, angle_indirect) — the emulator, not the app; the APK CI installs is byte-identical. So the gate runs in CI's android-device job (Ubuntu + KVM + a google_apis image), which is the Android runner this exception always asked for. Saying "verified" without saying where would be the same dishonesty the skipped proxy test was written to avoid.

Scars

  • The Android NDK download is a 700 MB, multi-hour stall on a home line (AGP fetches the exact pinned version). The app's own C++ is only RN's codegen, so any same-major NDK compiles it — hence gradle/ndk-override.init.gradle (opt-in via ANDROID_NDK_VERSION), a no-op in CI where the pinned NDK is a fast cached download. Don't let a toolchain fetch become the reason a promise stays unverified.
  • MAX_PATH kills the RN C++ codegen build on Windows — ninja: "Filename longer than 260 characters", because the object path embeds the full source path under node_modules. Build from a short path (subst R: <repo>).
  • sys.boot_completed=1 does not mean the emulator is ready. Installs kept failing with a StorageManagerService NPE until adb shell service check package said found. Wait for the service, not the property.

The mobile app was never in CI — plus press tests, coverage gates, and the block catalog → engine

Continuation of the 2026-07-11 campaign (de-duplication + the full mobile testing mandate). Four releases: 2.57.1 (broken build repair), 2.58.0 (CI + press tests + coverage), 2.59.0 (blockCatalog → engine).

The three findings that mattered

1. I shipped four broken commits, and every gate said green. engine/listEditorOps.js was moved out of the web target and the old file deleted, but ManageListEditor.jsx still imported the deleted path — dev did not compile. A multi-path git add had silently staged 13 of the 17 files I listed, so the importer fixes stayed on disk. Every check I ran — build, 430 web tests, parity — read the working tree; CI and every other clone read the commit. Found it by checking out HEAD into a clean worktree and building. New gate: npm run check:committed (fails when tracked source differs from HEAD). Rule: a working tree is not evidence (working-agreements §B3a).

2. The mobile app was not in CI at all. Not the jest suite, not the web⇄mobile parity gate, not capability gating, not no-caps, not the metro engine-parity check. Everything built during this campaign was enforced only on my machine, when I remembered. A gate you don't run is not a gate. There is now a mobile CI job (metro parity → mobile parity → jest + coverage → Codecov), and check:registry joined the check job.

3. The app was CAPPING the user (owner's correction, 2.57.0). The web silently truncated every roll to 50; mobile clamped at 1000 in five places; the web's count input had max={50}. Those numbers are the load the app supports with no performance loss — a promise about behaviour, not permission. And the tests asserted the caps (expect(len(999)).toBe(50) // capped), which is how it survived: a test that asserts a bug is the bug's best defender. All removed; a negative gate (checkNoCaps) now fails on any re-introduced cap. Measured: 1000 → 257 ms, 5000 → 869 ms, 25 000 → 3.9 s, all produced in full, linear (0.16 ms/prompt — cheaper per prompt at 25k than at 1k).

The rest

  • Press tests (§B2). Single had 25 controls / 3 presses; Gallery 9 / 2. Now 122 mobile tests, with Gallery's Select → Select all → Delete asserted on the storage call (a Delete that only clears the UI passes any render test and loses nothing… until the user reopens the app).
  • Two a11y defects fell out of writing them: gallery cells had no accessible name (a screen-reader user heard "button, button, button" and couldn't tell one image from another — axe had missed it because the export's gallery is empty), and Single's action buttons were bare glyphs announced as "⤢".
  • 60 axe tests (5 sizes × both colour schemes) found the app was largely unusable by screen reader: 119 of 121 TouchableOpacity had no role, so react-native-web emitted plain <div>s carrying aria-label — invalid and unannounced. Fixed; 60/60.
  • blockCatalog → engine/blockCatalog.js. Web promptEngine.js 411 → 219; mobile's hand-port 218 → 33. That copy had no drift check at all. Its replacement test asserts the phone never invents content the engine doesn't have.
  • Repo rename junebug128511fairyfox (current-state files only; dated history left intact).
  • 43 coverage artifacts had been committed in 2.58.0 — targets/mobile/coverage/ was missing from .gitignore. Untracked.

Scars worth remembering

  • Never junction node_modules into a throwaway worktree: git worktree remove --force follows it and empties the real one (it did; root + web + mobile all had to be reinstalled).
  • Two dead ends when driving react-native-web from Playwright: element.click() and synthetic PointerEvent both silently do nothing (RN-web uses its own responder). Only real browser input works.
  • A TDZ error (useRef(settings) above const settings = useMemo(...)) blanked the entire app while all 108 unit tests passed — they mock the engine and never exercise the real declaration order. Only the screenshot saw it. That's twice now that looking beat the suite.