Skip to content

Private content sets for BodyCaptcha - #145

Merged
charliehuge merged 17 commits into
mainfrom
private-content-sets
Sep 6, 2026
Merged

charliehuge merged 17 commits into
mainfrom
private-content-sets

Conversation

@ii-impy

@ii-impy ii-impy commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

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.md
Plan: docs/superpowers/plans/2026-09-06-private-content-sets.md

How it works

A private companion repo is cloned into ShowControl/FundingCAPTCHA/images/private/, which .gitignore excludes. 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 PATH selects a set at launch, in both the game and the editor. Omitting it resolves bodycaptcha-levels.json exactly as before.

cd ShowControl/FundingCAPTCHA
python3 app.py --camera --levels images/private/<show-name>/levels.json
python3 bodycaptcha_editor.py --levels images/private/<show-name>/levels.json

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, and test_reset_keeps_the_override_across_repeated_arc_boundaries fails if that regresses.

Keeping content out of public history

*.jpg is LFS-tracked and pushed objects are effectively permanent, so there are three independent guards:

  1. .gitignore excludes the private path.
  2. scripts/git-hooks/pre-commit refuses any commit that stages a path under it or whose staged bodycaptcha-levels.json gains a private/ image reference — the editor lists every image under images/ 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 with bash scripts/install-git-hooks.sh.
  3. A CI step, since the hook is opt-in and skippable with --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.sh optionally syncs the private repo when PRIVATE_ASSETS_REPO and a deploy key are configured (see deploy/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 uses BatchMode=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 where git add -f on the private directory stages a bare name with no trailing slash.

ii-impy and others added 17 commits September 6, 2026 03:58
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>
@charliehuge
charliehuge merged commit 8963038 into main Sep 6, 2026
1 check passed
@charliehuge
charliehuge deleted the private-content-sets branch September 6, 2026 09:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants