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:
+2
-2
@@ -1,7 +1,7 @@
|
||||
# The Booth — roadmap
|
||||
|
||||
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
|
||||
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
+48
-11
@@ -487,7 +487,12 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
||||
"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
|
||||
|
||||
|
||||
@@ -914,13 +919,13 @@ def create_app(
|
||||
# Ordered pinned-first then newest-first, each row stamped with a
|
||||
# `pinned` flag. Empty list for every other booth, so the template
|
||||
# branch simply does not fire.
|
||||
"board": (
|
||||
order_for_display(
|
||||
parse_link_entries((booth / LINKS_FILE).read_text()),
|
||||
read_pins(booth),
|
||||
)
|
||||
if (booth / LINKS_FILE).is_file() else []
|
||||
),
|
||||
# `is_file()` then an UNGUARDED read was a 500 waiting on a
|
||||
# mode change or an EIO: the board is one tile on this 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. A booth whose `links.md` cannot
|
||||
# be read renders as a booth with no board.
|
||||
"board": _board_rows(booth),
|
||||
# Marks: operator judgment attached to this booth or to one of
|
||||
# its items — a session's question (`pick`), the operator's own
|
||||
# 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:
|
||||
"""Land where the form was: the standalone marks page for a verbatim
|
||||
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.
|
||||
"""
|
||||
booth = resolve_booth(name)
|
||||
marks = marks_for(booth)
|
||||
return JSONResponse({
|
||||
marks, read_err = hold_read(booth)
|
||||
body = {
|
||||
"booth": name,
|
||||
"marks": [as_dict(m) for m in 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)
|
||||
def booth_view_file(request: Request, name: str, f: str):
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
a label changes width. #}
|
||||
<div class="kept-actions">
|
||||
<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>
|
||||
</form>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
@@ -124,7 +124,7 @@
|
||||
<button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★</button>
|
||||
</form>
|
||||
<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>
|
||||
</form>
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -25,14 +25,13 @@ _As of 2026-09-22:_
|
||||
`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
|
||||
verified live, 24/24 booth pages 200, layout probe clean.
|
||||
- **U4 IS NOT YET RELEASED — the version bump is an open operator decision.**
|
||||
Recommended **v0.3.0 → v0.4.0 (minor)**: U4 changes what `keep` MEANS for 17
|
||||
agent handles ("stop pressing keep when you are only waiting"), which is
|
||||
release-note-worthy at the pre-1.0 bar and the same tier U2 and U5 took. The
|
||||
defensible alternative is v0.3.1 if U4 reads as internal plumbing, since no
|
||||
CLI verb or URL changed shape. **Commits are not releases**, so the work is
|
||||
committed unbumped and untagged; the tag waits on his word and nothing is
|
||||
blocked by the wait.
|
||||
- **U4 released as `v0.4.0`** (operator approved the minor on 2026-09-22).
|
||||
`c3a97c1` is the unit; the release commit carries the pre-existing fixes the
|
||||
bug-hunt panel surfaced in touched files. The tag waited for the last gate to
|
||||
close, per the `v0.2.0` lesson — see Tried and abandoned.
|
||||
- ⚠ **The 17 consuming handles have NOT been told** that `keep` no longer means
|
||||
"waiting on an answer". That is the one coordination this release genuinely
|
||||
warrants, and a fleetwide post needs operator approval before it is sent.
|
||||
- **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
|
||||
everything and was conceptually unblocked by U5 giving job 5 a home; U3 is
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
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."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -14,6 +14,7 @@ test names are that contract's.
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
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 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 `'`
|
||||
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("'", "'"), "the name is in a script"
|
||||
|
||||
assert 'data-confirm="wipe"' in html, "the name travels as data, where escaping is escaping"
|
||||
assert ">'+xssCanary7+'<" in html, "and still renders as the name it is"
|
||||
|
||||
Reference in New Issue
Block a user