Repository navigation
fix(cli): declare content-type for .md and other text assets on R2 upload - #249
Merged
Merged
Conversation
…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
marked this pull request as ready for review
July 3, 2026 15:05
Brooooooklyn
approved these changes
Jul 3, 2026
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>
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.
Static deploys (e.g. VitePress with the
vitepress-plugin-llmsplugin) uploaded.mdbuild outputs to R2 withapplication/octet-stream, so they were served with the wrong content-type (e.g.viteplus.dev/guide/*.md).ASSET_MIME_TYPESinpackages/void/src/cli/package.tssets the content-type on the presigned R2 PUT. It lacked markdown/yaml/toml entries, so those extensions fell through to theapplication/octet-streamdefault. Addedmd,markdown,yaml,yml,toml, matching the dispatch serve-time table and the platform APImimelookup.Note: because assets are content-addressed and never re-uploaded, this only fixes newly deployed files. Already-deployed
.mdfiles are healed at serve time by the dispatch-side change in voidzero-dev/void-platform#215.Testing
getAssetContentTypeunit tests for the added extensions.packages/void/test/unit/asset-hashing.test.ts: 13 passed.tsgo --noEmitclean.