Repository navigation
Private content sets for BodyCaptcha - #145
Merged
Merged
Conversation
Some collaborations supply artwork that cannot be published, but every background image today lives in this public repo under Git LFS and there is only one fixed level-set path. Specs a private companion repo cloned into an ignored images/private/, a --levels launch argument, and the guard rails that keep unpublishable content out of public history. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six tasks: module-level levels path override, --levels in the app and the editor, ignore rule plus pre-commit guard, optional deploy sync, and operator docs. Migration of in-progress content is left as manual steps. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_reload_data() re-reads the levels file at every arc start, so the override lives on the module and is resolved per call — an instance-level override would revert to the default set at the first arc boundary.
Applied inside _load_games, because importlib gives each load a distinct module object and the override must land on the one create() runs against.
_read_levels() returning None at construction silently fell back to a single placeholder level with zero diagnostics — a typo'd --levels path collapsed the show at load-in with nothing in the logs to explain why. Matches the existing _reload_data() warning's voice.
_save follows the override too, so editing an alternate set cannot overwrite the default levels file.
*.jpg is LFS-tracked and pushed LFS objects are permanent, so the ignore rule gets a backstop that fails the commit on any staged path under images/private/. Installed by symlink to leave the Git LFS hooks intact.
Skipped silently when unconfigured so a plain public clone still installs. Clones as the service user — install.sh runs under sudo, and a root-owned tree could not be pulled later.
A configured-but-failing sync (unreadable key, network down, diverged history) previously tripped set -e and aborted install.sh before the Python deps and systemd unit steps ran, so a misconfigured private-content machine ended up worse off than an unconfigured one — no service at all. Now a failed clone/pull warns loudly and falls back to the default level set. Also gate on the service user actually being able to read the key (sudo -u ... test -r) rather than trusting root's own [ -f ] check, since root can see a key the service user cannot read, which would otherwise fail hard inside the sudo -u ssh invocation.
- pre-commit: anchor the private-path match so it also catches the directory staged as a bare gitlink (git add -f on a nested repo), no longer over-matches an unrelated backup/<prefix>/x path, and fails closed (refuses the commit) if git diff or grep error unexpectedly instead of silently passing. - pre-commit: add a second, content-based check that refuses a commit where the tracked bodycaptcha-levels.json gains a "private/..." image reference — closes the gap where the editor's picker lists private images regardless of which levels file is open, so a mistaken save while the public file is loaded never touched a path under images/private/ and slipped past every path-based guard. - bodycaptcha_editor.py: put the active levels path in the window caption so which set is open is visible at a glance, and make docs/FundingCAPTCHA.md's "check the window title" advice actually true. - bodycaptcha_editor.py: _save() creates a missing parent directory instead of raising FileNotFoundError, and _do_save() catches any remaining OSError and reports it via the flash message rather than crashing out of run() (which used to skip both the unsaved-changes warning and pygame.quit()). - Tests: scripts/test_git_hooks.py covers the gitlink case and the new content check (both the rejection and an ordinary edit that must still pass); test_bodycaptcha_editor.py covers the caption and both save-failure paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ent rule - test_bodycaptcha_levels.py: add a regression test that constructs a real BodyCaptchaGame under a levels override and calls reset() twice, asserting the private set stays loaded. Verified it catches the exact regression it guards against (reset() reverting to _LEVELS_DEFAULT). - docs/FundingCAPTCHA.md, install.sh: correct the failure-mode description — a failed private-content sync does not mean the show falls back to the default level set (bodycaptcha-levels.json) when a systemd drop-in already points --levels at the private path; it falls back to app.py's single built-in placeholder level instead. - AGENTS.md: add the private-content rule to the critical-constraints list so it's visible to whoever is about to run `git add -f` or edit `.gitignore`, not just readers of docs/FundingCAPTCHA.md. - docs/agents/git-workflow.md: same rule, plus the per-clone hook install command. - Minors: log the full levels path instead of get_levels_path().name (every private set's file is named levels.json, so the bare name told an operator nothing); unquote "Path | str | None" / "Path | None" annotations now that all three modules import `from __future__ import annotations`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…kiosk start - GIT_SSH_COMMAND now adds -o BatchMode=yes -o StrictHostKeyChecking=accept-new. Without these, a fresh Pi with no known_hosts entry (or a passphrase- protected deploy key) makes ssh print an interactive prompt and wait — an unattended install at load-in would hang indefinitely instead of failing loudly like every other misconfiguration this block handles. - Moved the entire private-content sync block to after systemctl enable/start, so nothing about the optional sync can delay or prevent the kiosk service coming up. Internal logic (SYNC_OK chaining, the four outcome messages, sudo -u "$SERVICE_USER", --preserve-env=GIT_SSH_COMMAND, scoped export/unset, non-fatal behavior under set -e) is unchanged — only the block's position and the ssh flags moved. - Updated the block's own comment and the closing summary to match. Verified with bash -n and a scratch harness (/tmp) that fakes sudo/git and drives all six branches (unset repo, missing key, unreadable key, clone success, clone/pull failure, pull-path on an existing clone) — every branch reaches the end of the block under `set -e` without aborting, and the block is confirmed to sit after `systemctl start` in the file. Did not run the real installer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pre-commit hook is opt-in per clone and skippable with --no-verify, so it is not a guarantee. Add a step to the Tests workflow, running before `make test`, that fails the PR when its diff (against the PR base SHA) either touches ShowControl/FundingCAPTCHA/images/private/ or gives the tracked bodycaptcha-levels.json a new "private/..." image reference — the same two leak channels Critical 1 and Important 3 cover locally, but enforced on every PR regardless of whether the contributor installed or bypassed the hook. Checkout now uses fetch-depth: 0 so the base commit is present locally to diff against; the previous shallow checkout would not have had it. The failure message names the offending paths/lines and tells the contributor exactly how to fix it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mode The CI runner has no renderer, so pygame.SCALED in Editor.__init__ raises "failed to create renderer" there even though it only warns locally. Extract the caption text into _caption() and cover it directly (default and overridden levels path), plus a monkeypatched-set_mode variant that still proves __init__ applies _caption() to the window title without touching set_mode. 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.
Some shows use artwork that cannot be published. This adds a way to run a level set whose images and level definitions live outside this public repo, without that content ever entering public history.
Spec:
docs/superpowers/specs/2026-09-06-private-content-sets-design.mdPlan:
docs/superpowers/plans/2026-09-06-private-content-sets.mdHow it works
A private companion repo is cloned into
ShowControl/FundingCAPTCHA/images/private/, which.gitignoreexcludes. Because the clone lands inside the existing image root, level entries reference backgrounds exactly as public levels do —"image": "private/<show-name>/<file>.jpg"— so image loading is unchanged. That repo's own remote provides the backup and version history; images and their level definitions version together, so a restore is a single clone.--levels PATHselects a set at launch, in both the game and the editor. Omitting it resolvesbodycaptcha-levels.jsonexactly as before.Why the levels path override is module-level
_reload_data()re-reads the levels file at every arc start. An override stored on the instance, or bound as a default argument, would play the alternate set for one arc and then silently revert mid-show._read_levels()resolves the path per call instead, andtest_reset_keeps_the_override_across_repeated_arc_boundariesfails if that regresses.Keeping content out of public history
*.jpgis LFS-tracked and pushed objects are effectively permanent, so there are three independent guards:.gitignoreexcludes the private path.scripts/git-hooks/pre-commitrefuses any commit that stages a path under it or whose stagedbodycaptcha-levels.jsongains aprivate/image reference — the editor lists every image underimages/in its picker, so a private path can reach the tracked levels file without any private file being staged. The hook fails closed: an unexpected error in its own pipeline refuses the commit rather than allowing it. Install per clone withbash scripts/install-git-hooks.sh.--no-verify.The editor's window caption now shows the active levels path, so which set is open is visible at a glance.
Deployment
deploy/install.shoptionally syncs the private repo whenPRIVATE_ASSETS_REPOand a deploy key are configured (seedeploy/private-assets.example.env); otherwise it announces the skip and continues, so a plain public clone still installs and runs.The sync runs after
systemctl enable/start, and usesBatchMode=yes, so nothing about optional content can delay or block the kiosk service coming up. A failed sync warns loudly and the install continues. The four outcomes — not configured, key missing, key unreadable, sync failed — each print a distinct message, so a misconfigured machine never reads like an unconfigured one.A show Pi selects its set with a systemd drop-in rather than the committed unit, which stays on the default set.
Failure behaviour
A missing or malformed levels file falls back, logs, and keeps running — it never raises at startup, because that would take a show down at load-in. A level whose background is absent renders without one.
Testing
249 tests pass (
make test), 8 of them new. The hook tests drive the real hook through real staged indexes, including the gitlink case wheregit add -fon the private directory stages a bare name with no trailing slash.