fix: four defects the U4 bug-hunt panel found in code it did not add

All four pre-date U4 and sit in files it touched, which is why a diff-scoped
robustness lens saw them. They are separated from the unit's own commit so the
feature history stays readable; the release tags both.

* A booth name reached a JS string context. The confirm dialogs interpolated
  the name into a string literal inside `onsubmit`. Jinja's autoescape is
  HTML-attribute escaping, not JS-string escaping: the browser decodes the
  entity back to a quote before the JS parser sees it, so a name crafted to
  close the string executed on submit. Booth names are agent-authored — making
  a folder under the data dir is the whole API — so this was a live path, not a
  theoretical one. The name now travels as a data attribute to a delegated
  handler, where escaping is escaping.

* An unreadable `links.md` returned 500 for the whole booth page. `is_file()`
  then an unguarded `read_text()`. The board is one tile on that page, and a
  page that will not load is worse than one missing a tile — the posture
  `read_blurred`, `marks_for` and `read_manifest` already take.

* The index order had no tie-breaker, which violates the deterministic-order
  invariant. Equal-mtime booths fell back to whatever `iterdir()` yielded, and
  two booths landed by one `rsync` batch share an mtime exactly. Now
  `(mtime, name)` reverse: newest first, then name. The operator refers to
  cards positionally, so a sequence that moves between renders misfiles his
  judgment rather than crashing.

* `/b/<n>/marks.json` reported damage as empty success. `booth marks` exits 3
  on an unreadable file precisely so a caller can tell "not yet" from "broken";
  the HTTP mirror — the only reader a remote session has — returned the same
  empty list for both. It now carries `error` and `detail`. The status stays
  200 deliberately: reads are lenient here, and a pinned status code is a
  promise to remote clients this fix has no business breaking.

Each has a regression test. 410 tests.
This commit is contained in:
vh
2026-09-22 09:51:14 -07:00
parent c3a97c1b64
commit c75d7a2797
6 changed files with 195 additions and 25 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
# The Booth — roadmap # The Booth — roadmap
Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md). Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md).
Current version: `0.3.0` (U1, U2, U4 and U5 landed; extracted from eshpfi 2026-09-21). Current version: `0.4.0` (U1, U2, U4 and U5 landed; extracted from eshpfi 2026-09-21).
## v1 target ## v1 target
@@ -13,7 +13,7 @@ defect — not a wish. The measurements are in the IA doc.
| 1 | ~~**One item record**~~ — **landed `ce598b3`** | captions never reach the zoom view (never sent, not lost) | U1 | | 1 | ~~**One item record**~~ — **landed `ce598b3`** | captions never reach the zoom view (never sent, not lost) | U1 |
| 2 | ~~**Marks**~~ — **landed `c7f9437`, released `v0.2.0`** | 5 mechanisms for 1 job; operator→session loop runs through chat | U2 | | 2 | ~~**Marks**~~ — **landed `c7f9437`, released `v0.2.0`** | 5 mechanisms for 1 job; operator→session loop runs through chat | U2 |
| 3 | **Declared embed seam** — `/_booth/embed.js`, chrome mounts via DOM | 6 regexes injected into arbitrary author HTML, load-bearing for asks | U3 | | 3 | **Declared embed seam** — `/_booth/embed.js`, chrome mounts via DOM | 6 regexes injected into arbitrary author HTML, load-bearing for asks | U3 |
| 4 | ~~**Derived lifetime**~~ — **landed** | 70% of booths on the `.forever` escape hatch (54% when first counted) | U4 | | 4 | ~~**Derived lifetime**~~ — **landed `c3a97c1`, released `v0.4.0`** | 70% of booths on the `.forever` escape hatch (54% when first counted) | U4 |
| 5 | ~~**Self-announcing booths**~~ — **landed `c015a91`, released `v0.3.0`** | job 5 had no home, so it lived on the link board as 145 dead rows | U5 | | 5 | ~~**Self-announcing booths**~~ — **landed `c015a91`, released `v0.3.0`** | job 5 had no home, so it lived on the link board as 145 dead rows | U5 |
| 6 | **Benches** — registry, identity, enforced rule, migration | 69% link-board rot; the same bench posted 5× | U6 | | 6 | **Benches** — registry, identity, enforced rule, migration | 69% link-board rot; the same bench posted 5× | U6 |
| 7 | **Navigation at 270 items** — sections, rail, filters, grid keyboard | one flat wall; subfolder structure discarded at render | U7 | | 7 | **Navigation at 270 items** — sections, rail, filters, grid keyboard | one flat wall; subfolder structure discarded at render | U7 |
+48 -11
View File
@@ -487,7 +487,12 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
"mtime": mtime, "mtime": mtime,
} }
) )
booths.sort(key=lambda b: b["mtime"], reverse=True) # Newest first, NAME as the tie-break. Sorting on mtime alone left equal-mtime
# booths ordered by whatever `iterdir()` yielded, which is not a rule — and
# invariant 6 is not "usually stable", it is a sentence you can write down.
# Two booths created by one `rsync` batch share an mtime exactly, and the
# operator refers to cards positionally.
booths.sort(key=lambda b: (b["mtime"], b["name"]), reverse=True)
return booths return booths
@@ -914,13 +919,13 @@ def create_app(
# Ordered pinned-first then newest-first, each row stamped with a # Ordered pinned-first then newest-first, each row stamped with a
# `pinned` flag. Empty list for every other booth, so the template # `pinned` flag. Empty list for every other booth, so the template
# branch simply does not fire. # branch simply does not fire.
"board": ( # `is_file()` then an UNGUARDED read was a 500 waiting on a
order_for_display( # mode change or an EIO: the board is one tile on this page, and
parse_link_entries((booth / LINKS_FILE).read_text()), # a page that will not load is worse than one missing a tile —
read_pins(booth), # the same posture `read_blurred`, `marks_for` and
) # `read_manifest` already take. A booth whose `links.md` cannot
if (booth / LINKS_FILE).is_file() else [] # be read renders as a booth with no board.
), "board": _board_rows(booth),
# Marks: operator judgment attached to this booth or to one of # Marks: operator judgment attached to this booth or to one of
# its items — a session's question (`pick`), the operator's own # its items — a session's question (`pick`), the operator's own
# remark (`note`), the operator's selection (`flag`). Rendered # remark (`note`), the operator's selection (`flag`). Rendered
@@ -947,6 +952,21 @@ def create_app(
}, },
) )
def _board_rows(booth: Path) -> list[dict]:
"""The link board's rows, or [] for a board that cannot be read.
NEVER RAISES, for the reason every other read on this page does not:
one damaged file must cost its own tile, not the booth page."""
try:
if not (booth / LINKS_FILE).is_file():
return []
return order_for_display(
parse_link_entries((booth / LINKS_FILE).read_text()),
read_pins(booth),
)
except (OSError, ValueError, UnicodeDecodeError):
return []
def _mark_redirect(name: str, form, anchor: str) -> RedirectResponse: def _mark_redirect(name: str, form, anchor: str) -> RedirectResponse:
"""Land where the form was: the standalone marks page for a verbatim """Land where the form was: the standalone marks page for a verbatim
booth (its own index.html cannot show the recorded judgment), else the booth (its own index.html cannot show the recorded judgment), else the
@@ -1197,12 +1217,29 @@ def create_app(
the whole booth instead of one question at a time. the whole booth instead of one question at a time.
""" """
booth = resolve_booth(name) booth = resolve_booth(name)
marks = marks_for(booth) marks, read_err = hold_read(booth)
return JSONResponse({ body = {
"booth": name, "booth": name,
"marks": [as_dict(m) for m in marks], "marks": [as_dict(m) for m in marks],
"open": [m.id for m in open_marks(marks)], "open": [m.id for m in open_marks(marks)],
}) }
# A DAMAGED file used to come back as an empty list and nothing else,
# which is indistinguishable from "you were never asked anything" — and
# this endpoint is the ONLY reader a remote session has. Its filesystem
# sibling has told the truth since U2: `booth marks` exits 3 on an
# unreadable file precisely so a caller can tell "not yet" from
# "broken". One question, two surfaces, two answers.
#
# The STATUS stays 200 and that is deliberate. Reads are lenient here —
# the same rule that keeps a poisoned booth from 500ing the index — and
# a pinned status code is a promise to remote clients this fix has no
# business breaking. The information goes in the body instead: a client
# that wants the CLI's exit-3 parity reads `error`, and one that does
# not behaves exactly as it does today.
if read_err is not None:
body["error"] = "this booth's .marks.json cannot be read"
body["detail"] = read_err
return JSONResponse(body)
@app.get("/b/{name}/view", response_class=HTMLResponse) @app.get("/b/{name}/view", response_class=HTMLResponse)
def booth_view_file(request: Request, name: str, f: str): def booth_view_file(request: Request, name: str, f: str):
+37 -3
View File
@@ -70,11 +70,11 @@
a label changes width. #} a label changes width. #}
<div class="kept-actions"> <div class="kept-actions">
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep" <form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
onsubmit="return confirm('Release \u201c{{ b.name }}\u201d?\n\nIt moves to the ephemeral lane so you can wipe it from there. Nothing is deleted by this step.')"> data-booth="{{ b.name }}" data-confirm="release">
<button title="release this board so it can be wiped">release</button> <button title="release this board so it can be wiped">release</button>
</form> </form>
<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete" <form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
onsubmit="return confirm('WIPE the KEPT booth \u201c{{ b.name }}\u201d?\n\nThis deletes it and its files immediately. Kept booths are the ones nothing else will clean up, so nobody else is going to do this for you — and nothing brings it back.')"> data-booth="{{ b.name }}" data-confirm="wipe-kept">
<button title="wipe this KEPT booth now" aria-label="wipe kept booth">×</button> <button title="wipe this KEPT booth now" aria-label="wipe kept booth">×</button>
</form> </form>
</div> </div>
@@ -124,7 +124,7 @@
<button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★</button> <button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★</button>
</form> </form>
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete" <form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
onsubmit="return confirm('Wipe booth “{{ b.name }}”?')"> data-booth="{{ b.name }}" data-confirm="wipe">
<button title="wipe now" aria-label="wipe booth">×</button> <button title="wipe now" aria-label="wipe booth">×</button>
</form> </form>
</article> </article>
@@ -161,5 +161,39 @@
} }
}); });
})(); })();
/* Destructive-action confirmation, delegated and DATA-DRIVEN.
These were an inline onsubmit calling confirm() with the booth NAME
interpolated straight into the JS string literal. Jinja's autoescape is
HTML-attribute escaping, not JS-string escaping: the browser decodes the
entity back to a quote before the JS parser ever sees it, so a booth name
crafted to close that string executed on submit. Booth names are
agent-authored — making a folder under the data dir is the whole API — so
that is a live path, not a theoretical one.
The name now travels as a DATA ATTRIBUTE, where escaping is escaping, and
never reaches a JS string literal. Same pattern the board controls already
use. With JS off the form submits without a prompt, which is what every
no-JS browser here already did. */
(function () {
var WORDS = {
release: function (n) {
return 'Release \u201c' + n + '\u201d?\n\nIt moves to the ephemeral lane so you '
+ 'can wipe it from there. Nothing is deleted by this step.';
},
'wipe-kept': function (n) {
return 'WIPE the KEPT booth \u201c' + n + '\u201d?\n\nThis deletes it and its files '
+ 'immediately. Kept booths are the ones nothing else will clean up, so nobody '
+ 'else is going to do this for you \u2014 and nothing brings it back.';
},
wipe: function (n) { return 'Wipe booth \u201c' + n + '\u201d?'; }
};
document.addEventListener('submit', function (ev) {
var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null;
if (!form) return;
var word = WORDS[form.getAttribute('data-confirm')];
if (word && !confirm(word(form.getAttribute('data-booth') || ''))) ev.preventDefault();
}, true);
})();
</script> </script>
{% endblock %} {% endblock %}
+7 -8
View File
@@ -25,14 +25,13 @@ _As of 2026-09-22:_
`5e41108` → `v0.2.1`, `026a1fc` → `v0.2.2`; U5 `c015a91` + `95beede` → `5e41108` → `v0.2.1`, `026a1fc` → `v0.2.2`; U5 `c015a91` + `95beede` →
`v0.3.0`. **U4 landed 2026-09-22** — 396 tests green (341 → 396), deployed and `v0.3.0`. **U4 landed 2026-09-22** — 396 tests green (341 → 396), deployed and
verified live, 24/24 booth pages 200, layout probe clean. verified live, 24/24 booth pages 200, layout probe clean.
- **U4 IS NOT YET RELEASED — the version bump is an open operator decision.** - **U4 released as `v0.4.0`** (operator approved the minor on 2026-09-22).
Recommended **v0.3.0 → v0.4.0 (minor)**: U4 changes what `keep` MEANS for 17 `c3a97c1` is the unit; the release commit carries the pre-existing fixes the
agent handles ("stop pressing keep when you are only waiting"), which is bug-hunt panel surfaced in touched files. The tag waited for the last gate to
release-note-worthy at the pre-1.0 bar and the same tier U2 and U5 took. The close, per the `v0.2.0` lesson — see Tried and abandoned.
defensible alternative is v0.3.1 if U4 reads as internal plumbing, since no - ⚠ **The 17 consuming handles have NOT been told** that `keep` no longer means
CLI verb or URL changed shape. **Commits are not releases**, so the work is "waiting on an answer". That is the one coordination this release genuinely
committed unbumped and untagged; the tag waits on his word and nothing is warrants, and a fleetwide post needs operator approval before it is sent.
blocked by the wait.
- **THE NEXT UNIT IS THE OPERATOR'S CALL.** U3 (declared embed seam) and U6 - **THE NEXT UNIT IS THE OPERATOR'S CALL.** U3 (declared embed seam) and U6
(benches) are both unblocked; U7 waits on the rest. U6 is independent of (benches) are both unblocked; U7 waits on the rest. U6 is independent of
everything and was conceptually unblocked by U5 giving job 5 a home; U3 is everything and was conceptually unblocked by U5 giving job 5 a home; U3 is
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "booth" name = "booth"
version = "0.3.0" version = "0.4.0"
description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator." description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator."
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+100
View File
@@ -14,6 +14,7 @@ test names are that contract's.
import json import json
import os import os
import pathlib import pathlib
import re
import stat import stat
import subprocess import subprocess
import time import time
@@ -996,3 +997,102 @@ def test_a_VALID_but_empty_marks_document_does_NOT_hold(tmp_path):
assert hold_reason(*hold_read(booth)) is None assert hold_reason(*hold_read(booth)) is None
assert sweep_once(tmp_path, ttl_seconds=3600) == ["b"] assert sweep_once(tmp_path, ttl_seconds=3600) == ["b"]
# ---- pre-existing defects the bug-hunt panel surfaced ----------------------
def test_marks_json_says_so_when_the_file_is_damaged(tmp_path):
"""Gróa's strongest solo. `booth marks` exits 3 on an unreadable file so a
caller can tell "not yet" from "broken"; the HTTP mirror — the ONLY reader a
remote session has — reported the same damage as an empty success. One
question, two surfaces, two answers, which is the thing U4 exists to stop.
The status stays 200 on purpose: reads are lenient here, and a pinned
status is a promise to remote clients this fix has no business breaking.
`test_a_corrupt_marks_file_gives_the_browser_a_409_not_a_500` pins it.
"""
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
c = TestClient(app)
booth = tmp_path / "b"
_touch(booth / "a.png")
clean = c.get("/b/b/marks.json").json()
assert "error" not in clean
(booth / ".marks.json").write_text("{{{ not json")
damaged = c.get("/b/b/marks.json")
assert damaged.status_code == 200
assert "cannot be read" in damaged.json()["error"]
assert damaged.json()["open"] == []
def test_an_unreadable_links_file_does_not_take_down_the_booth_page(client):
"""Hulda, pre-existing. `is_file()` then an unguarded `read_text()` was a
500 waiting on a mode change or an EIO. The board is one tile on that page,
and a page that will not load is worse than one missing a tile — the same
posture `read_blurred`, `marks_for` and `read_manifest` already take."""
c, data = client
booth = data / "links"
_touch(booth / "a.png")
(booth / "links.md").write_text("- [x](http://x/) <sub>· y · 2026-01-01 00:00</sub>\n")
os.chmod(booth / "links.md", 0)
try:
assert c.get("/b/links/").status_code == 200
finally:
os.chmod(booth / "links.md", 0o644)
def test_the_index_order_has_a_tie_breaker(tmp_path):
"""CLAUDE.md invariant 6. Sorting on mtime alone left equal-mtime booths in
whatever order `iterdir()` yielded, which is not a rule — and two booths
landed by one `rsync` batch share an mtime exactly. The operator refers to
cards positionally, so a sequence that moves between renders misfiles his
judgment rather than crashing."""
when = time.time() - 100
for n in ("charlie", "alpha", "bravo"):
_touch(tmp_path / n / "a.png", when=when)
os.utime(tmp_path / n, (when, when))
runs = {tuple(b["name"] for b in list_booths(tmp_path, ttl_seconds=86400))
for _ in range(5)}
assert len(runs) == 1, "the same state must render the same sequence"
assert runs.pop() == ("charlie", "bravo", "alpha"), "newest first, then name"
def test_a_booth_name_cannot_reach_a_js_string_context(client):
"""Hulda, solo, pre-existing and live. The confirm dialogs interpolated the
booth NAME into a JS string literal inside `onsubmit`. Jinja's autoescape is
HTML-attribute escaping, not JS-string escaping: the browser decodes `&#39;`
back to `'` before the JS parser sees it, so a booth named `'+alert(1)+'`
executed on submit. Booth names are agent-authored — making a folder is the
whole API — so that is a live path.
"""
c, data = client
# A canary spelled so that nothing in the repo's prose can collide with it —
# the first version of this test matched the fix's OWN comment explaining
# what it fixed, which is a passing test measuring the wrong thing.
hostile = "'+xssCanary7+'"
_touch(data / hostile / "a.png")
_touch(data / "kept-one" / "a.png")
_touch(data / "kept-one" / KEEP_MARKER)
html = c.get("/").text
# The ATTRIBUTE form, not the bare word — the replacement's own comment
# explains what it replaced and says "onsubmit" while doing so.
assert "onsubmit=" not in html, "no inline handler may carry a name at all"
assert "onclick=" not in html
# The name DOES appear as visible text, correctly escaped, and that is the
# point: HTML-escaping is the right escaping for an HTML text node. What
# must not happen is the name reaching a place where the JS parser reads it.
scripts = re.findall(r"<script\b[^>]*>(.*?)</script>", html, re.S)
assert scripts, "the page does ship script, so this check is not vacuous"
for block in scripts:
assert "xssCanary7" not in block.replace("&#39;", "'"), "the name is in a script"
assert 'data-confirm="wipe"' in html, "the name travels as data, where escaping is escaping"
assert ">&#39;+xssCanary7+&#39;<" in html, "and still renders as the name it is"