Skip to content

fix(cli): declare content-type for .md and other text assets on R2 upload - #249

Merged
Brooooooklyn merged 1 commit into
mainfrom
fix-md-content-type
Jul 3, 2026
Merged

Brooooooklyn merged 1 commit into
mainfrom
fix-md-content-type

Conversation

@fengmk2

@fengmk2 fengmk2 commented Jul 3, 2026

Copy link
Copy Markdown
Member

Static deploys (e.g. VitePress with the vitepress-plugin-llms plugin) uploaded .md build outputs to R2 with application/octet-stream, so they were served with the wrong content-type (e.g. viteplus.dev/guide/*.md).

ASSET_MIME_TYPES in packages/void/src/cli/package.ts sets the content-type on the presigned R2 PUT. It lacked markdown/yaml/toml entries, so those extensions fell through to the application/octet-stream default. Added md, markdown, yaml, yml, toml, matching the dispatch serve-time table and the platform API mime lookup.

Note: because assets are content-addressed and never re-uploaded, this only fixes newly deployed files. Already-deployed .md files are healed at serve time by the dispatch-side change in voidzero-dev/void-platform#215.

Testing

  • New getAssetContentType unit tests for the added extensions.
  • packages/void/test/unit/asset-hashing.test.ts: 13 passed.
  • tsgo --noEmit clean.

…load

Add md/markdown/yaml/yml/toml to ASSET_MIME_TYPES so static deploys
(e.g. VitePress with the llms plugin) upload markdown to R2 with the
correct content-type instead of application/octet-stream.
@fengmk2
fengmk2 requested a review from Brooooooklyn July 3, 2026 15:05
@fengmk2
fengmk2 marked this pull request as ready for review July 3, 2026 15:05
@Brooooooklyn
Brooooooklyn merged commit 9b2c162 into main Jul 3, 2026
6 checks passed
@Brooooooklyn
Brooooooklyn deleted the fix-md-content-type branch July 3, 2026 15:18
Brooooooklyn added a commit that referenced this pull request Jul 4, 2026
…b CLI conflicts)

Unions #247 (executor/workflow prompts + void github status) with the
org-shared additions (void github join + non-owner connect-authorize
round-trip) across cli.ts, github-args.ts, github-cmd.ts, help.ts,
cli.md, and github-args.test.ts. Also picks up v0.10.3/v0.10.4 version
bumps and the #249 R2 content-type fix from main.
cpojer pushed a commit that referenced this pull request Sep 11, 2026
…ation fails (#216)

* fix(api): surface the real reason when migration v2 capability negotiation fails

When a manifest declares migration protocolVersion 2 but the worker
capability probe does not confirm a matching v2 capability, the deploy threw
a bare "migration protocol v2 capability negotiation failed" with no reason —
and the value-mismatch (skew) path logged nothing at all.

Make `probeWorkerMigrationCapabilities` return a discriminated
{ ok:true, caps } | { ok:false, reason } result that captures the HTTP status
plus the worker error body (or CF asset stub / non-JSON / request failure).
The caller composes a concrete reason (probe reason, or a field-by-field
capability-vs-manifest diff), logs `capability_negotiation_failed`, and throws
`...failed: <reason>`, which flows to both the deploy error event and the
persisted deployments.error.

Diagnostics only — this does not by itself fix a failing deploy. The SPA
asset-fallback bug it surfaced is fixed in #250.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump framework submodule to void#250 (SPA /__void routing fix)

Advances framework de1d581 -> 8fc0db3 (void main), which includes the
merged #250: SPA + API apps — and cron/queue-only SPAs with
D1 migrations — now route /__void/* to the worker before the SPA asset
fallback, so migration protocol v2 capability negotiation reaches the
migration handler instead of receiving index.html.

Also pulls in the already-merged #247, #249, and v0.10.3 / v0.10.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cpojer pushed a commit that referenced this pull request Sep 11, 2026
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
cpojer pushed a commit that referenced this pull request Sep 11, 2026
CI has been red on main since #249. `vp install --frozen-lockfile`
resolves two copies of @voidzero-dev/vite-plus-core and the rolldown
native binding then fails to load:

  Error: Cannot find native binding
  ERR_PACKAGE_PATH_NOT_EXPORTED: './binding' is not defined by "exports"
  Cannot find module '../rolldown-binding.linux-x64-gnu.node'

The catalog pins two things that must move together:

  overrides:  vite: 'catalog:'                          -> forces every
  catalog:    vite: npm:...vite-plus-core@0.2.6            vite to 0.2.6
              vite-plus: ^0.2.0                          -> floated to 0.2.8

vite-plus@0.2.8 depends on @voidzero-dev/vite-plus-core@0.2.8 exactly, so
bumping only `vite-plus` leaves both 0.2.6 and 0.2.8 in the tree. This is
the same trap the `hono` comment three lines above warns about.

After the bump the tree resolves to a single core (0.2.8); the lockfile
delta is purely that dedup (-112/+28).


Claude-Session: https://claude.ai/code/session_01Guv3pkFxhXReNWJ2Z2Luxg

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
cpojer pushed a commit that referenced this pull request Sep 11, 2026
…odes (#250)

* fix(deps): pin catalog vite to vite-plus-core 0.2.8

CI has been red on main since #249. `vp install --frozen-lockfile`
resolves two copies of @voidzero-dev/vite-plus-core and the rolldown
native binding then fails to load:

  Error: Cannot find native binding
  ERR_PACKAGE_PATH_NOT_EXPORTED: './binding' is not defined by "exports"
  Cannot find module '../rolldown-binding.linux-x64-gnu.node'

The catalog pins two things that must move together:

  overrides:  vite: 'catalog:'                          -> forces every
  catalog:    vite: npm:...vite-plus-core@0.2.6            vite to 0.2.6
              vite-plus: ^0.2.0                          -> floated to 0.2.8

vite-plus@0.2.8 depends on @voidzero-dev/vite-plus-core@0.2.8 exactly, so
bumping only `vite-plus` leaves both 0.2.6 and 0.2.8 in the tree. This is
the same trap the `hono` comment three lines above warns about.

After the bump the tree resolves to a single core (0.2.8); the lockfile
delta is purely that dedup (-112/+28).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Guv3pkFxhXReNWJ2Z2Luxg

* fix(api): render container build logs as text, not terminal control codes

Container builds never set `CI`, so `void deploy` took its interactive
path: @clack/prompts' spinner redraws one line in place with `ESC[1G
ESC[J` and no newline. `pipeStream` splits on CR/LF only, so every frame
the spinner ever drew arrived glued into a SINGLE captured line — one
real build produced an 11.5KB line holding 200+ erased frames, which is
what the completed check run then printed verbatim.

The framework already guards this: `createSpinner()` swaps in plain
`log.step` output when clack's `isCI` is true. That gate compares
`process.env.CI` to the exact string 'true', and `github_actions` builds
get it free from the runner — which is why only container builds
rendered broken.

- `nonInteractiveEnv()` hands `CI=true` to both build children.
- `sanitizeLogLineForMarkdown()` reduces each line to what a terminal
  would actually show (last non-empty frame, escapes dropped) and runs
  BEFORE `formatLogTailMarkdown`'s byte accounting, so an erased-frame
  flood can't spend the 60000-byte tail budget on invisible output.
  Render-time only: the live tail and `void build logs` keep their color.

Replaying the real capture from check run 92750142459 through the new
path: 15588 chars/664 escape bytes -> 3413 chars/0.

Also pins the fallback CLI version to the framework submodule (0.9.1 ->
0.10.10). That path only runs for repos with no local `void` dep, so the
drift was invisible; a test now asserts the equality the comment claimed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Guv3pkFxhXReNWJ2Z2Luxg

* fix(api): sync the agent's fallback CLI pin with the injected one

The version literal exists twice: BuildManager injects VOID_CLI_VERSION,
and build-agent.mjs falls back to DEFAULT_VOID_CLI_VERSION when the env
is absent. Both sat at 0.9.1, so bumping only the injected one would have
left the fallback path deploying through a different CLI.

The agent is plain JS copied into the container image and cannot import
the constant, so a test holds the two in step instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Guv3pkFxhXReNWJ2Z2Luxg

* fix(api): only treat prefix-destroying erases as frame boundaries

Addresses two Codex review comments on #250, both confirmed correct
against a real VT emulator (pyte) rather than by re-reading the regex.

1. Erase-FORWARD is not a frame boundary. `ESC[K` / `ESC[0K` / `ESC[J` /
   `ESC[0J` clear from the cursor onward and leave the prefix on screen —
   a terminal renders `Downloading\x1b[K done` as `Downloading done`, but
   the sanitizer returned ` done`, dropping diagnostic context. Only
   cursor-to-column-1 and the erases that reach BACK past the cursor
   (`1K`, `2K`, `1J`, `2J`, `3J`) destroy the prefix; the rest now fall
   through to the strip. A partial column move (`ESC[5G`) is likewise
   treated as prefix-preserving, which is the safer approximation.

2. The CSI parameter class was `[0-9;?]`, which does not match colon-form
   truecolor SGR (`ESC[38:2::255:0:0m`, ITU-T T.416) or private-parameter
   CSI (`ESC[>4;2m`). Neither alternation picked those up, so the whole
   sequence survived into the fence as garbage — worse than the color it
   was meant to strip. Widened to the full ECMA-48 range 0x30-0x3F.

Verified differentially: all 89 lines of the real voidzero.dev capture
now render byte-identical to pyte's output (0 mismatches).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Guv3pkFxhXReNWJ2Z2Luxg

* fix(api): consume C1 string controls whole, payload and all

`ANSI_ESCAPE` matched only OSC (`ESC ]`) as a complete string; DCS (`ESC P`),
SOS (`ESC X`), PM (`ESC ^`) and APC (`ESC _`) fell through to the two-byte
fallback branch, which strips the introducer and leaves the payload behind as
ordinary text.

That is not cosmetic. `pipeStream` splits on CR/LF only and caps one line at
MAX_PARTIAL_LINE_CHARS (128KB), and `formatLogTailMarkdown` sanitizes BEFORE
its CHECK_RUN_LOG_TAIL_MAX_BYTES (60KB) accounting — so one leaked payload
spends the whole tail budget on bytes no reader will ever see. Measured on a
100-row tail holding 99 real `[vite] error` lines plus one 120KB APC blob:

  before   kept  1 line   "Ga=T,f=100,s=800,v=600;QUJDRAQUJDRA..."
  after    kept 99 lines  "[vite] error detail line 0: TypeError at..."

`$` joins BEL and ST as an accepted terminator, and it is load-bearing rather
than defensive: a payload truncated at MAX_PARTIAL_LINE_CHARS, or split by an
embedded LF, arrives with its ST already gone — the 128KB case that costs the
most. A real VT parser stays in the string state to end of input, so ending
the match there is also what a terminal would show (nothing).

Ground-truthed against @xterm/headless, not pyte: pyte implements OSC only
and has no DCS/SOS/PM/APC handling, so it reproduces this exact bug and
cannot adjudicate it. Current regex agreed with xterm on 1 of 8 string-control
probes; this one agrees on 8 of 8. All 34 inputs from the existing suite
replay through both regexes with zero divergence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CeZ5KQp2TehVkNMrrABaah

* fix(api): unfreeze the no-lockfile yarn install, and stop treating 3J as an erase

Two independent defects, both introduced by earlier commits on this branch.

1. `CI=true` broke no-lockfile Yarn Berry builds. `resolveInstall`'s no-lockfile
   yarn plan deliberately omits a frozen flag because its whole job is to CREATE
   the missing lockfile — but Berry >= 3.0.0 defaults `enableImmutableInstalls`
   to true whenever it detects CI, and `nonInteractiveEnv()` now tells every
   child `CI=true`. Measured against real corepack on a no-lockfile fixture:

     yarn@4.14.1  CI=true                 -> exit 1  YN0028, no lockfile
     yarn@4.14.1  CI=true --no-immutable  -> exit 0   lockfile created
     yarn@4.14.1  (no CI)                 -> exit 0   lockfile created

   Reproduced on 3.0.0 / 3.6.4 / 4.14.1 / 4.18.0; 2.4.3 predates the default and
   Classic 1.22.22 ignores the flag (which matters, because a `yarn@1.x` pin with
   no lockfile lands on this same plan). pnpm needs nothing — it drops its
   implicit CI frozen-lockfile when the lockfile is absent. Yarn is the outlier.

   Scoped to that one plan. Not in `nonInteractiveEnv()` (shared with the deploy
   child, where a repo's own `yarn install --immutable` must stay immutable) and
   not on the lockfile plans, which would let a build mutate a committed lockfile
   and deploy resolutions the repo never pinned. A new test pins both halves.

   Only the container executor was affected, and only unreleased: `github_actions`
   scaffolds a hardcoded `yarn install --immutable`, so a no-lockfile yarn repo
   has always failed there via the explicit flag.

2. `ESC[3J` is not an erase. ED 3 is xterm's "Erase Saved Lines" extension —
   ECMA-48 defines params 0/1/2 only — and it touches zero visible cells: it
   drops scrollback rows ABOVE the viewport and leaves the cursor and its row
   alone. terminfo agrees, giving it a separate `E3` capability outside
   `clear=\E[H\E[2J`. Verified against @xterm/headless, where 6 lines written
   into a 4-row screen then `ESC[3J` moves baseY 2->0 and length 6->4 with the
   cursor row byte-identical.

   It was misfiled as prefix-erasing because 69f10d38 validated it against pyte,
   whose `erase_in_display` hardcodes `how == 2 or how == 3` and therefore cannot
   represent ED 3 at all — the same oracle-blindness that hid the DCS/APC case.
   `1J`/`2J` were re-checked and are correctly boundaries (2J clears the cursor's
   own row).

   Practical effect is narrow, which is worth saying plainly: every emitter in
   the dep tree (vite, tsc, playwright, /usr/bin/clear) pairs 3J with 2J, whose
   boundary still applies, so only a STANDALONE 3J changes — and that case was
   silently eating a line's prefix. A new test pins the three combos as unchanged.

The 3J expectation moves from the prefix-dropping test to the prefix-preserving
one rather than being deleted, so the parameter stays covered; reverting the
regex fails it (`expected ' done' to be 'Downloading done'`).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CeZ5KQp2TehVkNMrrABaah

* fix(api): terminate on BEL for OSC only, not for every string control

The string-control branch shared one `[^\x07\x1b]*` class across all five
introducers, so BEL ended the match for DCS/SOS/PM/APC too. BEL terminates OSC
only. In xterm's VT500 transition table it sits in OSC_STRING's exit set
(`OSC_END` -> GROUND) but is `IGNORE` in SOS_PM_APC_STRING and `IGNORE`/`DCS_PUT`
in every DCS state; only ST, 8-bit ST, ESC, CAN and SUB leave those. Confirmed
twice over: empirically against @xterm/headless, and by rebuilding the table out
of the shipped bundle and querying the BEL cell directly.

So `ESC P hidden BEL more-hidden ST visible` leaked `more-hidden` as text.
Reachable from real payloads, not just adversarial ones — BEL is an ordinary data
byte inside sixel, and an unterminated `ESC P abc BEL def` leaked `def` too.
Measured end to end: 9 diagnostic lines plus one DCS line carrying a 120KB
terminal-only suffix rendered 1 line and 0 of 9 diagnostics before, 10 lines and
9 of 9 after.

Split into two branches: OSC keeps `BEL | ST | $`, the other four take `ST | $`
and let BEL through as payload. Replayed all 209 unique inputs from the existing
suite plus the 19 recorded formatLogTailMarkdown calls through both regexes —
zero divergences, 37/37 tests pass either way, so no expectation changed. The
embedded-ESC abort still falls through to the two-byte branch structurally
(`[^\x1b]*` cannot cross an ESC and only shrinks on backtrack), and growth stays
linear on 128KB adversarial inputs.

Also documents two deliberate non-fixes in that docstring, both verified rather
than assumed: a non-ST ESC aborts the control but we keep the short payload
prefix xterm would hide, and a payload split across rows by a raw LF is not
reassembled. On the latter — do not make the sanitizer stateful. No real emitter
LF-splits (chafa's kitty output is thousands of self-terminated APC chunks on ONE
line; sixel and iterm are single payloads, zero bytes leaking today), the byte
cap drops oldest-first so a failing build's newest diagnostics are already safe,
and cross-line state cannot help once the introducer falls outside the 100-row
window or has been evicted. Meanwhile one stray unterminated `ESC _` would put a
stateful renderer into swallow-everything mode — about one stray introducer per
12KB of binary output — and on the oldest window row it returns null so the check
run omits `text` altogether. That trades a rare, self-announcing wall of
gibberish for a common, silent no-cause check run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CeZ5KQp2TehVkNMrrABaah

* fix(api): render the log line with a cell model, not a frame-splitting regex

`sanitizeLogLineForMarkdown` split on "rewrite" sequences and kept the last
non-empty frame. That model was wrong in four different ways across four rounds
of review, and each fix was written from the same mental picture that produced
the bug — so the tests agreed with the code and the code stayed wrong.

Replaced with what a terminal actually is: a row of cells and a cursor. Writes
overwrite in place, erases blank cells, and the visible row is whatever remains.
There is no picture left to be wrong about.

Three defects fall out as fixed, one of them a credential leak:

- A TRAILING ERASE BLANKS THE ROW. `◆ Deployed!ESC[1G ESC[J` now renders empty,
  because the writer returned to column 1 and erased forward. The old model
  walked BACK past the erase to the last non-empty frame and resurrected the
  text — so a build step that printed a token and erased it had the token
  persisted into the check-run output, which is world-readable for a public
  repo. `VOID_TOKEN` is in the build child's environment. Nothing legitimate is
  lost: clack erases BEFORE writing each frame, so a final message lands after
  the last erase and survives.
- A BARE COLUMN MOVE ONLY REPOSITIONS. `Downloading assetsESC[1GDone` shows
  `Doneloading assets`; the old model returned `Done` and silently dropped 14
  visible characters of diagnostics. Same for `ESC[G`, `ESC[0G` and bare CR.
- A PARTIAL COLUMN MOVE SPLICES. `DownloadingESC[5G done` shows `Down doneng`,
  not `Downloading done`.

`ESC[3J` needs no special case now — erase-saved-lines touches no visible cell,
so a model of visible cells ignores it for free. Same for erase-forward vs
erase-back: the difference is just which cells get blanked.

The real change is the ORACLE. `sanitize-log-line-oracle.test.ts` diffs the
renderer against `@xterm/headless` — the VT engine VS Code ships — over 37 fixed
cases plus 300 seeded escape soups. Assertions I write can only confirm the
model I already hold; xterm decides what correct means. It immediately caught
one of my own errors: a bare ESC inside a DCS/APC payload ABORTS the string
control and starts a new sequence, where my first cut swallowed to ST and ate
every remaining visible character on the row.

Single row by construction — the container agent has already split on CR/LF, so
nothing scrolls and no width is assumed (the oracle uses a 100k-column terminal
to keep wrapping out of the comparison).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(api): measure the log row in cells, not UTF-16 code units

The cell model stepped one UTF-16 code unit at a time, which is not what a
terminal cell holds. Three consequences, all reachable from ordinary build
output and all confirmed against @xterm/headless:

  'a' EMOJI ESC[1D 'b'   xterm "ab"   was "a<FFFD>b"   lone surrogate
  EMOJI ESC[2G 'X'       xterm "EMOJI X"  was "<FFFD>X"
  'e' U+0301 BS 'X'      xterm "X"    was "eX"         combining mark took a column
  CJK CJK ESC[3G 'X'     xterm CJK+" X"   was CJK+CJK+"X"  wide glyph took one column

The surrogate cases are corruption, not misalignment: half a pair reaches the
check run as U+FFFD. They need a cursor move or erase to land INSIDE a grapheme,
which is exactly what a spinner redraw does.

Iterate by code point, and give each glyph its terminal width: 0 for combining
marks and format characters (they attach to the cell on their left), 2 for the
standard wcwidth East Asian Wide/Fullwidth ranges, 1 otherwise. Emoji are
deliberately NOT wide — they are width 1 under the table xterm defaults to, and
widening them would desynchronise the column arithmetic this commit fixes. A
combining mark with nothing to attach to is drawn in its own cell, and writing
into either half of a wide glyph destroys the glyph and blanks the other half.

14 named oracle cases and four fuzz atoms (emoji, CJK, fullwidth, combining)
cover it; the fuzz is what caught the wide-glyph overwrite rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Guv3pkFxhXReNWJ2Z2Luxg

* fix(api): bound the row, model tab stops, and implement ECH

Three findings on the cell model, all from untrusted build output.

1. (P1) Cursor destinations were unbounded. A CSI parameter is just digits, so
   `ESC[999999999G` plus one printable byte asked `pad()` for a billion array
   pushes, and a long enough digit run parses to `Infinity`, which never
   terminates at all. Measured without the fix: `RangeError: Invalid array
   length` after ~876ms. This runs INSIDE the BuildManager DO while finalizing a
   check run, so one crafted log line could take the DO down before its
   scheduler cleanup ran.

   The clamp derives from the input — each code unit advances the cursor by at
   most one tab stop — so it cannot truncate a legitimately long line, which a
   fixed ceiling either would (too low) or would not bound usefully (too high).
   This is the one deliberate divergence from the oracle: a terminal clamps to
   its right margin, and answering with 99_999 spaces then the glyph is not
   better output for a check run.

2. HT was sent through the glyph path, advancing one column. A terminal moves to
   the next 8-column tab stop, so every cursor move after a tab operated on the
   wrong cells.

3. ECH (`ESC[X`) fell through the default branch as non-painting, so text the
   terminal had erased was resurrected into the tail: `TOKEN ESC[5D ESC[5X`
   rendered as `TOKEN` where a terminal shows a blank row.

10 new oracle cases (tabs at and off a stop, ECH default/partial/past-end) and
three fuzz atoms; 60 oracle cases now, and the suite still runs in under 2s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Guv3pkFxhXReNWJ2Z2Luxg

* fix(api): cap row expansion, drop a quadratic trim, model DCH/ICH

The row bound landed too loose and hid a much older problem.

1. Bounding at `line.length * TAB_WIDTH` let untrusted input amplify: tabs
   advance 8 columns per byte, so 128KB of tabs (the agent's per-line cap)
   expanded to a ~1M-cell row. `formatLogTailMarkdown` sanitized every retained
   line BEFORE trimming any, so a repo could hold ~100 of those live to publish
   60KB. Bound is now MIN(that, MAX_RENDERED_ROW_CELLS), and the trim moved
   inside the accumulation loop — identical oldest-first result, at most one
   over-cap row retained.

2. The trailing trim was QUADRATIC, and this is the real cost. `replace(/\s+$/,
   '')` on a row of trailing spaces followed by one glyph makes `\s+` match
   greedily from every start position and backtrack when `$` fails. Any wide
   cursor jump produces that row. Measured at 131072 cells: 5750ms, versus 3ms
   for `trimEnd()` — seconds of DO CPU per line, during check-run finalization.
   That predates the cell model; the amplification above is what made it easy to
   reach. Same strip set (WhiteSpace + LineTerminator).

3. DCH (`ESC[P`) fell through the default branch, so `TOKEN ESC[5D ESC[5P`
   returned `TOKEN` where a terminal shows an empty row — text the terminal had
   deleted, resurrected into a completed check run. Modelled with ICH alongside
   ECH.

4. The fuzz then caught that a row mutation landing on either half of a wide
   glyph destroys the whole glyph — true for writes, erases, inserts and
   deletes. Factored into `splitWideAt` and applied at every mutation site.

Fuzz raised 300 -> 900 cases; 67 oracle cases; the suite still runs in ~5s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Guv3pkFxhXReNWJ2Z2Luxg

* test(api): give the sanitizer fuzz an explicit timeout

Raising the fuzz from 300 to 900 cases pushed it past vitest's 5s default on
CI, which is slower than a laptop — it passed locally at ~2.9s and failed CI at
5004ms. Bounded at 60s rather than trimming coverage: this fuzz is what found
both the ED3 gap and the wide-glyph mutation rule.

Also reuses one Terminal across cases. Measured, that is NOT where the time
goes — 900 fresh terminals cost 4.11s versus 3.94s reused, because the cost is
xterm's parser at ~4ms per case. Kept for the reduced allocation, but the
timeout is what stops the flake.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Guv3pkFxhXReNWJ2Z2Luxg

* fix(api): bound ICH within the row, and model cursor save/restore

Both findings are on code from the previous commit.

1. (P1) ICH capped its own count but never truncated the row, so every command
   ADDED that many cells. A repo-controlled line of repeated `ESC[130000@` grew
   the array without bound, copying the whole tail each time. Worse, the insert
   spread an array into `splice`, so a count anywhere near the row bound throws
   `RangeError: Maximum call stack size exceeded` — measured, and it throws in
   5ms, i.e. it takes down check-run finalization immediately rather than
   slowly. It now shifts in place with `copyWithin`, and cells pushed past the
   row margin fall off, as on a terminal.

2. Every non-CSI escape was treated as inert, so DECSC/DECRC (`ESC 7`/`ESC 8`)
   and their SCO equivalents (`ESC[s`/`ESC[u`) were dropped. A tool repainting
   via save/restore then had its restore ignored and the following text landed
   at the wrong column: `abc ESC7 ESC[1G X ESC8 Y` renders `XbcY`, and we
   returned `XYc` — overwritten text retained, visible text lost.

Fixing (1) exposed that the wide-glyph rule is OPERATION-dependent, which the
fuzz caught twice in a row. Landing inside a glyph destroys it, but ICH inserts
BEFORE the head and the glyph shifts along intact; and an erase whose range ENDS
on a head must blank the orphaned second column. Split into `splitWideAt` and
`clearWideHeadAt`, with both applied inside `blank()` so no erase path can miss
one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Guv3pkFxhXReNWJ2Z2Luxg

* fix(api): parse 8-bit C1 controls, ESC intermediates, and RIS

Four of the five review findings on the sanitizer reproduced verbatim
against the xterm oracle; the fifth did not, and the fuzz found a sixth
nobody reported.

- 8-bit C1 controls (U+0080-U+009F) are the single-code-point form of
  `ESC <char>`. Build output reaches the sanitizer DECODED, so a tool
  emitting U+009B rather than `ESC [` produced a character that is
  neither printable nor matched by anything, and the whole sequence fell
  through as visible text -- for U+009F/U+0090, an entire APC/DCS
  payload, which then spends the 60000-byte check-run tail budget. The
  reported case was string controls; 8-bit CSI leaks too, and is worse.
  Normalised once before the loop, so there is a single shape to parse.

- A non-CSI escape is `ESC <intermediates 0x20-0x2F> <final>`, not two
  bytes. Assuming two consumed only `ESC (` of terminfo's `sgr0`
  (`ESC ( B` then `ESC [ m`) and printed its final `B` into the tail of
  every build whose toolchain colours its output. DECALN (`ESC # 8`) is
  consumed but deliberately not applied: it paints `E` across the full
  width and this row has no width -- the same reason cursor destinations
  are clamped rather than padded out to a margin.

- RIS (`ESC c`) clears the row. Dropped through the default branch it
  resurrected content the terminal had explicitly discarded, exactly as
  ignoring ECH or DCH did: `TOKEN ESC c failed` still showed the token.

- A combining mark following a WIDE glyph attaches to that glyph's
  LEADING cell. Appending it to the continuation marker destroyed the
  empty-string sentinel `splitWideAt` reads, so a later write into the
  trailing half kept a glyph xterm throws away.

- Found by the fuzz, not by review: a WIDE write covers two columns, so
  the head it orphans may start at the SECOND of them. `clearWideHeadAt`
  now runs at `cursor + width - 1`; clearing only at the cursor left a
  stray continuation marker whenever a wide glyph landed one column
  before another one's head.

Not reproduced: "preserve wide glyphs at exclusive mutation endpoints".
`splitWideAt` already guards on `cells[col] === ''`, so it fires only on
a trailing half -- which is the behaviour requested. The reviewer's own
example `a<wide> CR ESC[P` renders identically to xterm, as do the ECH
and mid-glyph variants now pinned in the oracle.

Oracle grows to 93 cases plus six new fuzz atoms.

* fix(api): bound cumulative shift work, and blank the row on SU/SD

- P1: bounding the ROW never bounded the WORK. DCH and ICH each shift
  the retained suffix, so N commands on an L-cell row cost N x L -- and
  both numbers come from the same repo-controlled line, making the total
  quadratic in the input even though nothing grows without bound.
  Measured: `'a' x 131072`, one ESC[G, then ESC[@ x 10000 is 157KB of
  input, inside the capture cap, and took 20.6 SECONDS inside the DO
  during check-run finalization, before its scheduler cleanup runs. The
  shifts now share one cumulative MAX_ROW_SHIFT_CELLS budget -- sixteen
  full-width rewrites of the largest row we render, orders of magnitude
  past any real emitter -- which brings that input to 39ms. Once spent,
  further shifts are dropped: the second deliberate divergence from the
  oracle, and the same trade as the first.

  Worth recording: this is the THIRD route to the same hazard, after the
  ESC[999999999G allocation and the quadratic trailing trim. Treat any
  new row-mutating command as a DoS surface until its cost is bounded in
  the input rather than in its result.

- P2: SU/SD (ESC[S / ESC[T) fell through as non-painting. This model is
  one row, as the oracle is, so either direction scrolls the only row
  out of view -- `SECRET_TOKEN ESC[S ESC[1G ERR` is `ERR` on a terminal
  and rendered `ERRRET_TOKEN` here, resurrecting scrolled-away content.

Oracle grows to 97 cases plus three scroll fuzz atoms.

* fix(api): harden terminal log rendering

* fix(api): fail closed on terminal mutations

* fix(api): harden terminal parser controls

* fix(api): complete terminal escape state

* fix(api): preserve terminal command identity

* fix(api): reset saved cursor on terminal soft reset

* fix(api): render build logs with xterm

* fix(api): preserve terminal state across log rows

* fix(api): preserve terminal log stream semantics

* fix(api): bound selective terminal erases

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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