diff --git a/ROADMAP.md b/ROADMAP.md index 36c79fc..18129bd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/booth/app.py b/booth/app.py index e8a20bb..892fcf0 100644 --- a/booth/app.py +++ b/booth/app.py @@ -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): diff --git a/booth/templates/index.html b/booth/templates/index.html index 1443505..b56647c 100644 --- a/booth/templates/index.html +++ b/booth/templates/index.html @@ -70,11 +70,11 @@ a label changes width. #}
+ data-booth="{{ b.name }}" data-confirm="release">
+ data-booth="{{ b.name }}" data-confirm="wipe-kept">
@@ -124,7 +124,7 @@
+ data-booth="{{ b.name }}" data-confirm="wipe">
@@ -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); + })(); {% endblock %} diff --git a/persistent-memory.md b/persistent-memory.md index 6ad1ac9..715e163 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 6f31bc9..d045e80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/tests/test_lifetime.py b/tests/test_lifetime.py index 0ad6dc5..6c2bf6e 100644 --- a/tests/test_lifetime.py +++ b/tests/test_lifetime.py @@ -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/) · y · 2026-01-01 00:00\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"]*>(.*?)", 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"