Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions admin/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,14 @@ function scrubReason(reason: any): string {
}

/**
* The control-byte class for text this pane renders, C0 + DEL + C1 (issue #337).
* The control-byte class for text this pane renders (issue #337, widened under #402).
*
* WHICH CLASS, because the project has two and the first version of this picked the wrong one. The
* narrow one is `triggers.mjs`'s VALIDATOR, C0 + DEL, which decides whether an operator-authored file
* is acceptable. The wider one is `panel.mjs`'s `CONTROL_CHARS`, C0 + DEL + C1, whose own comment calls
* it a "defensive strip of C0/C1 control chars from untrusted input" and which already backs `clip`,
* is acceptable. The wider one is `panel.mjs`'s `interpreted`, which since issue #402 asks what a code
* point DRAWS rather than listing shapes -- what a terminal executes, plus what breaks a line, draws
* nothing or draws a blank a reader cannot tell from a space, minus what COMPOSES a neighbouring glyph --
* and which already backs `clip`,
* so the PLAIN and ASCII render paths have stripped C1 out of these same rows all along. A themed row
* and a plain row of the same record going through two different classes is the drift this must not be,
* and the wider one is the right one for the job: this is text on its way to a terminal, not a file
Expand Down Expand Up @@ -2336,11 +2338,26 @@ function renderRunList(rows: any[], selected: number, w: number): string[] {

/** The indices of tail lines containing `query` (case-insensitive substring) -- the LIVE_TAIL search
* model, computed over the held tail bytes alone, so search reads nothing the view does not already. */
function tailMatches(lines: any[], query: string): number[] {
const q = String(query).toLowerCase();
// Exported for its own pin, as `targetUrl` is: what an operator can SEARCH FOR has to stay tied to what
// the pane DRAWS, and that relation is not visible from a rendered frame.
export function tailMatches(lines: any[], query: string): number[] {
// SEARCH WHAT THE PANE DRAWS, not the raw bytes behind it (issue #402). The pane substitutes the whole
// control class, and the search box DELETES it from what the operator types, so once the class grew to
// hold the invisible characters that real log output carries, a line drawn as `repo -prod` could not be
// found by any query at all: typing what is on screen missed the raw byte, and pasting the original
// missed because the box had dropped it. Scrubbing both sides is the only arrangement where what an
// operator reads is what they can search for.
// ONE READING, THE DRAWN ONE, on both sides. The pane substitutes the class and the search box now
// substitutes it too, so typing what is on screen and pasting the original bytes produce the same query
// and both find the line. A first repair compared the drawn reading OR the deleted one, and a review pass
// showed what that costs: the deleted reading RE-MERGES the collision this class was widened to expose,
// so a search for `deploy-prod` matched both it and `deploy` plus a zero-width space plus `-prod`. The
// pane tells those two rows apart; the search must not put them back together.
const drawn = (v: string) => scrubControls(v).toLowerCase();
const q = drawn(query);
const out: number[] = [];
for (let i = 0; i < lines.length; i++) {
if (String(lines[i]).toLowerCase().includes(q)) out.push(i);
if (drawn(String(lines[i])).includes(q)) out.push(i);
}
return out;
}
Expand Down
220 changes: 209 additions & 11 deletions admin/src/panel.mjs

Large diffs are not rendered by default.

271 changes: 260 additions & 11 deletions admin/test/control-bytes.test.mjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion admin/test/dashboard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3043,7 +3043,7 @@ test("the deps layer projects a failed Job to five host-chosen fields -- .data n
const line = JSON.stringify(snap.failed);
assert.ok(!line.includes("SECRET TITLE") && !line.includes("SECRET BODY") && !line.includes("secret-login") && !line.includes("secretFrame"), "payload and stacktrace stay out of the snapshot");
// C1 is in the class too since issue #337, because `scrubReason` shares `scrubControl` with the
// renderer and that moved to `panel.mjs`'s wider `CONTROL_CHARS`. Nothing this project produces puts a
// renderer and that moved to `panel.mjs`'s wider class, now its `interpreted` predicate. Nothing this project produces puts a
// C1 code point in a worker throw's message, so this pins a contract rather than a behaviour.
for (const code of [0x1b, 0x07, 0x9b, 0x80]) {
assert.ok(!row.failedReason.includes(String.fromCharCode(code)), `control byte ${code} is scrubbed`);
Expand Down
14 changes: 9 additions & 5 deletions admin/test/panel.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -263,15 +263,19 @@ test("makeLineInput edits round-trip: insert, cursor moves, backspace, del, setV
assert.equal(li.cursor(), 5, "setValue parks the cursor at the end");
});

test("makeLineInput strips control characters on the way in, typed or pasted", () => {
test("makeLineInput substitutes control characters on the way in, typed or pasted", () => {
// SUBSTITUTES, where it used to delete (issue #402). This box is the LIVE_TAIL search, the pane
// substitutes the class, and deleting here produced a query matching neither what is drawn nor what is
// stored. What has NOT changed is the invariant this test was written for: a sentinel can never enter
// the value, because `render` adds them rather than the value holding them.
const li = makeLineInput("a\x00b");
assert.equal(li.value(), "ab", "the initial value is stripped too");
assert.equal(li.value(), "a b", "the initial value is scrubbed too");
li.insert("\x07");
assert.equal(li.value(), "ab", "a lone control char inserts nothing");
assert.equal(li.value(), "a b ", "a lone control char becomes a space");
li.insert("cd\x1bef");
assert.equal(li.value(), "abcdef", "a pasted string is stripped, then inserted whole");
assert.equal(li.value(), "a b cd ef", "a pasted string is scrubbed, then inserted whole");
li.insert(LINE_INPUT_CURSOR[0] + "x" + LINE_INPUT_CURSOR[1]);
assert.equal(li.value(), "abcdefx", "the cursor sentinels are C0 controls and can never enter the value");
assert.doesNotMatch(li.value(), /[\x01\x02]/, "the cursor sentinels are in the class and can never enter the value");
});

test("makeLineInput render is exactly width columns and windows around the cursor", () => {
Expand Down
2 changes: 1 addition & 1 deletion admin/test/width.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,7 @@ test("a keycap is consumed whole, and never duplicates its own enclosing mark (#

test("the editor's value never admits half a character, through any door (#401)", () => {
// `backspace` and `del` were fixed to step by character; a review pass found the three doors left open.
// `stripControls` removes C0 and C1, not half a character, and `insert` takes a whole PASTE. The argument
// `stripControls` removes the whole class, and it removes half a character too, and `insert` takes a whole PASTE. The argument
// that made the edit-side fix necessary is that `value()` is the string that gets SAVED, and it applies
// here unchanged.
const lone = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
Expand Down
49 changes: 43 additions & 6 deletions specs/design.md

Large diffs are not rendered by default.

16 changes: 12 additions & 4 deletions specs/open-questions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1385,13 +1385,17 @@ adversarial passes did.
should not depend on every future write site holding the enum, when one scrub inside one `show()` holds
it structurally. The `failedReason` cap is unchanged; its CLASS is not, and that is recorded here rather
than left in a diff: `scrubReason` shares `scrubControl`, so widening the renderer's class widened this
belt's too, from C0 + DEL to C0 + DEL + C1. No `failedReason` this project can produce contains a C1
belt's too, from C0 + DEL to C0 + DEL + C1 (and again under issue #402). No `failedReason` this project can produce contains a C1
code point (it is a worker throw's message, decoded as UTF-8), so nothing observable moved -- but a
contract that moved without a row is the gap this project's own rule exists to close.
**Issue #337 asked whether the class needs C1, and the answer turned out to be already written down.**
This project has TWO control-byte classes, not one: `triggers.mjs`'s VALIDATOR is C0 + DEL and decides
whether an operator-authored file is acceptable, while `panel.mjs`'s `CONTROL_CHARS` is C0 + DEL + C1,
calls itself "a defensive strip of C0/C1 control chars from untrusted input", and already backs `clip`
whether an operator-authored file is acceptable, while `panel.mjs`'s class was C0 + DEL + C1
(WIDER SINCE, under issue #402, where it also became a PREDICATE rather than a regex: `interpreted` asks
what a code point draws, so it holds the bidi controls, everything invisible, every blank a reader cannot
tell from a space and the line and paragraph separators, and it excludes what composes a neighbouring
glyph),
is derived from properties since issue #402, and already backs `clip`
-- so the PLAIN and ASCII render paths have been stripping C1 out of these same rows all along. The
renderer's own scrub was therefore the outlier rather than the convention, and a themed row and a plain
row of the same record went through different classes depending on which file the pane used. It is now
Expand All @@ -1403,7 +1407,10 @@ adversarial passes did.
- **Position**: the FAILED panel section renders `failedReason` -- the message of the error the WORKER
itself threw. The known payload-bearing sources were fixed at their sites in the same slice
(`branch.mjs` answers with a type instead of the forge-payload value; `prepare-local.mjs` basenames
its path), and the display path wears a belt (control bytes REPLACED WITH SPACES -- one column each, so
its path), and the display path wears a belt (control bytes REPLACED WITH SPACES -- one column each for
the C0/DEL/C1 members this said when it was written, and NOT for every one issue #402 added: the renderer
draws all but 19 of that class as nothing, so a substituted line is wider than the raw one and every
measurement site scrubs BEFORE it measures rather than relying on the counts matching; so
a belted pane and a plain one clip identically -- a 120-char cap, and the `job_failed`
line's own bound). But the channel itself stays free-form: `InfraRetry` messages and any future
untagged throw's `error.message` become `failedReason` verbatim, so the no-payload property is held by
Expand Down Expand Up @@ -1639,3 +1646,4 @@ adversarial passes did.
| 2026-09-22 | Issue #367. **`OQ-038` AMENDED**, three rows, all of them measurements this round took rather than questions it opened. The two genuine pre-start refusals that exit **1** and are therefore silent (`-i -t` with no TTY -- BOTH flags, since either alone exits 0, which is the argv the sandbox passes -- and a `DOCKER_CONTEXT` that does not resolve, where the `DOCKER_HOST` spelling of the same mistake exits 125 and IS covered), with the reason widening the code table is NOT the answer and what would be: pre-launch refusals on the model `sandbox-cli.mjs` already uses for a missing TTY. The fixed 2.5 s pause a healthy shell buys when its last command was a typo, since `--entrypoint bash -i` makes `docker run` return bash's 127, which is the stated trade for not letting a real `exec bash failed` go silent. And the fourth manifest-only refusal, `decideSandboxJobUser`, which is deliberately not folded into `sandboxSyncRefusal`: measured across twelve platform strings, one malformed stamp refuses on every one of them EXCEPT darwin and win32, so folding it in would make the panel's `b` depend on the operator's own OS for an identical run. That is the whole argument: "it is async and asks the daemon" is true of the function and NOT of this refusal, which is reached with zero calls to the endpoint resolver, the facts reader and the image preflight. **`OQ-035` AMENDED**, not "unchanged": its text said the belt is what RUN_DETAIL applies, and the reach is now every pane that prints a record, framed and plain, through one `cellOf` in the panel and one `cell` in the plain renderers. #337 recorded the identical widening as an amendment and this row first called it unchanged, which is the same mistake one surface over. The reason it gives for leaving the record's own enum fields alone is untouched and still why this is belt-and-braces rather than a boundary. **`OQ-016` UNCHANGED, checked**: nothing in the suspend-and-hand-over pair moves. |
| 2026-09-22 | Issue #375. **`OQ-007` AMENDED** with one event name and one shape that MOVED between the two greps, and it is the naming rule from #337 applied rather than an exception to it: the session reaper now leaves an entry that is not a real directory and says `session_not_reaped {key, reason}`, a VERDICT from a look that succeeded, where `session_reaper_skipped` stays what it has been, the name for something a pass could not establish. Its sibling `sandbox_network_not_reaped` is the shape this follows. Worth the row on its own: a stray FILE in the store used to reach the per-entry catch as an ENOTDIR and appear under `session_reaper_skipped` on every pass, so this narrows that grep as well as widening the verdict one. **`OQ-037` UNCHANGED, checked**: rootless Podman is unaffected, since every file in a key directory is still worker-written. **Code evidence**: `worker/src/session-store.mjs` -> `reapSessions`. |
| 2026-09-23 | Issue #382. **`OQ-035` AMENDED**, in its Position bullet: the control-byte class is written ONCE, in `admin/src/panel.mjs`, and every renderer substitutes through it -- `scrubReason` included. The bullet said "stripped" where the operation is a SUBSTITUTION, which is the distinction the whole issue turns on: `clip` deletes and the record cells substitute, so a pane composing with `clip` clipped one column narrower than its twin for the same record. `clip` still deletes, deliberately and for a pinned reason (`LINE_INPUT_CURSOR`'s sentinels are in the same class), and `clipData` is the composition data paths use. What the belt is no longer asked to do alone: a GATE now runs over every finished pane line, so a field that reaches a pane without a belt is still substituted before it is printed. The validator question this could have been read as raising is settled in `DES-ONE-SHOT-DISARM-IN-THE-FILE` rather than here, because a refusal at that writer leaves a one-shot armed and costs a second paid run. |
| 2026-09-24 | Issue #402. **`OQ-035` AMENDED**, in the same Position bullet issue #382 corrected: the renderer's control-byte class is no longer C0 + DEL + C1. It draws its line at INTERPRETED against COMPOSING, so the bidi controls, the invisible break characters (U+200B, U+2060 and the word joiner range, U+FEFF, U+00AD, U+180E) and the line and paragraph separators are in it, while U+200D, U+200C and the variation selectors are deliberately out because they compose the character beside them. **The `failedReason` belt moves with it, and that is why this row exists**: `scrubReason` shares the class, exactly as #382 recorded, so widening the renderer widened this belt again. Nothing observable moves for the same reason as last time -- a `failedReason` is a worker throw's message decoded as UTF-8 and this project produces none carrying a bidi control -- but a contract that moves without a row is the gap this rule exists to close. **The validator is UNCHANGED, checked**: `triggers.mjs` still refuses on C0 + DEL, and widening it was rejected for the reason `DES-ONE-SHOT-DISARM-IN-THE-FILE` already records, that a refusal at that writer leaves a one-shot armed and costs a second paid run. **Code evidence**: admin/src/panel.mjs -> interpreted, mapInterpreted, scrubControls, hasControls; admin/test/control-bytes.test.mjs. |
Loading