phase: 01_runnable_service_scaffold

**Phase 01 final verification pass — all criteria green**

- Verified (nothing left to implement; all 4 task files already in `complete/`): toolchain lock, typed `Settings`+`.env.example`, debugpy bootstrap, app factory, CLI (`--host/--port/--reload/--levels-dir`, `python -m undercooked`), static shell + `/static`, security headers, no-CDN/doc guards, `validate.sh`.
- `uv sync --extra dev` → exit 0; `uv.lock` present (untracked → harness commits it with the phase).
- `uv run undercooked --host 127.0.0.1 --port 8000` → `curl /healthz` = 200 `{"status":"ok","version":"0.1.0","rooms":0}` (superset of the criterion's body — now documented in README); `curl /` = 200 shell HTML; static css/js 200; CSP/nosniff/no-referrer/DENY on 200, 404 and 405 too.
- `DEBUGPY=1`: port 5678 is held on this machine by the user's own podman dev-container debugpy adapter — **no live change made/killed**; verified via `DEBUGPY=1 DEBUGPY_PORT=5679` → listener opens, `/healthz` still 200; the occupied-port case degraded gracefully as designed (warning, server serves). Bad config → one line + exit 2 ✓.
- `bash .agents/validate.sh` (root **and** `.agents/`) → exit 0: `ruff check .` 0 errors, `pyright` 0 errors, levels-CLI notice-skip (matches README prose), **284 passed** full suite incl. 10 Playwright e2e, coverage **100.00%** (floor 90), repo hygiene clean. `uv run pytest -m e2e` → 10 passed.
- Docs fixed: README Status → phase 01 complete + next pending 02 (was pointing at `todo/01…`), documented real `/healthz` body, precedence now "CLI flags > env > .env > default", added the PLAN §13-promised **How to play** section (honest about shell-only today), Layout rows marked what exists vs phases 02/07. AGENTS.md: recorded the `data:`-URL exemption in the no-CDN rule (test allows it) and matched the session-token rule to the browser's WebCrypto/base64url implementation. Also corrected a stale "session-wide chromium" docstring in `tests/e2e/conftest.py` (fixtures are function-scoped).
- Deviations: no container build run (needs an image pull; not a phase criterion — `Containerfile`/`compose.yaml` reviewed and consistent with `docs/DEPLOYMENT.md`); `HEAD /` is 405 (routes are GET-only by design; health checks use GET). No git index/history commands run.
- Next pending phase: `02_level_content_and_validation`.
This commit is contained in:
2026-10-06 01:51:20 -04:00
parent f84c6ebaef
commit 6fd83446ec
43 changed files with 5478 additions and 30 deletions
+61 -8
View File
@@ -15,7 +15,10 @@
(`00_phase.md` + `NN_task.md`). Finished work: `.agents/phases/complete/`.
- Execution is owned by the `phased-execution` skill (`run-task.sh`, `run-phase.sh`,
`auto-phase.sh`). Each task runs in a fresh subprocess and must pass
`.agents/validate.sh` before it moves to `complete/`.
`.agents/validate.sh` before it moves to `complete/`. That gate fails fast in this
order: `ruff check .` → `pyright` → `undercooked-levels validate` (skipped with a
notice until phase 02 creates the loader) → the **full** `pytest` suite with the
coverage floor → repo-hygiene checks. It runs from the repo root or from `.agents/`.
- New work is authored by the `phase-authoring` skill as a phase directory — not
implemented straight from chat.
- **Never** modify `.agents/PLAN.md` or anything under `.agents/phases/complete/`.
@@ -60,16 +63,52 @@
## 5. Hard rules
- **Configuration is read once, in `config.py`.** `src/undercooked/config.py` is
the only module that touches `os.environ`. To add a knob: add a `Settings`
field + `ENV_KEYS` entry + a line in `.env.example` + a row in the README table
(a test fails when those disagree), and read it from `get_settings()`. Values
are validated at startup: an impossible one raises `ConfigError` rather than
half-configuring the server (`undercooked` prints one line and exits `2`).
Precedence is **CLI flag > environment > built-in default**: `__main__.py`
resolves it purely, then hands the flags to `config.apply_cli_overrides()`,
which is the only writer of `os.environ` (so a uvicorn `--reload` child
re-reads the same values). `DEBUGPY=1` binds a debugger listener on `0.0.0.0`
— dev-only, never on a public host.
- **No external CDNs or remote assets.** `tests/unit/test_no_external_assets.py`
fails the build on `http://`/`https://`/`//cdn` references in `app/static/` and
templates. CSP is `default-src 'self'` (plus `ws:`/`wss:` same-origin).
fails the build on `http://`/`https://`/`//host` references, `@import url(`,
`srcset`, an absolute-URL `fetch()`/`import`, an absolute-URL `WebSocket`, or
`EventSource` anywhere in `app/static/**` (html/css/js/svg), and requires every
`<script src>`/`<link href>`/`<img src>` in `index.html` to be a same-origin
`/static/...` path. It scans the same `STATIC_DIR` `server/app.py` mounts, so it
cannot pass by scanning the wrong folder; the exemptions are XML `xmlns` URIs, which
are identifiers the browser never fetches, and a `data:` URL in an `index.html`
element attribute where the CSP already allows it (`img-src 'self' data:`) — the
app's own bytes either way. The US-01 e2e suite additionally
fails if loading the page ever issues an off-origin request. CSP is
`default-src 'self'` (plus `ws:`/`wss:` same-origin) and is applied by
`server/headers.py` to **every** response, including errors. FastAPI's `/docs`,
`/redoc` and `/openapi.json` stay disabled because their bundled HTML pulls Swagger
assets from `cdn.jsdelivr.net`; the wire contract lives in `docs/PROTOCOL.md`
(written by phase 06) instead.
- **Docs are guarded like code.** `tests/unit/test_docs_commands.py` requires every
command line in a `bash` fence of `README.md`/`docs/*.md` to start with a known-safe
prefix (`uv `, `podman `, `curl `, `cd `, `bash .agents/validate.sh`, `#`, or a
documented `VAR=value` lead) and to launch only a program declared in
`pyproject.toml`, and keeps the `UNDERCOOKED_*`/`DEBUGPY*` names in the docs and the
ones in `config.py`/`.env.example` equal in both directions. So: never document a
command that does not run yet — describe it in prose until the phase that ships it.
`tests/unit/test_docs_index.py` keeps PLAN §13's documentation index and the `docs/`
tree identical (no missing doc for a shipped phase, no doc nothing promises), and
`docs/TESTING.md` is the reference for test layout, markers and artifact policy.
- **No new technology** (dependency, framework, service) without explicit written
user permission recorded in the phase's `00_phase.md` and signed off into PLAN.md.
- **The simulation is the single source of truth.** Client-side values are guesses.
Never trust inbound coordinates, scores, timers, or level ids — validate every
inbound field against the room's state (`server/protocol.py`).
- **Anonymous by design.** No accounts, no emails, no cookies with personal data.
Identity = a random per-browser token (`secrets.token_urlsafe`) held in
Identity = 24 random bytes per browser tab (`secrets.token_urlsafe(24)` when the
server mints one, `crypto.getRandomValues` + base64url in `app/static/js/app.js`,
which is the same entropy and the only option in a browser) held in
`sessionStorage`. Names are sanitized, length-capped, non-authoritative.
- **Frame and rate limits are enforced, not suggested**: `max_message_bytes`,
`max_inputs_per_s`, room caps, and message-type whitelists per room state.
@@ -92,14 +131,28 @@
| Change | Location |
|---|---|
| Configuration (env vars → typed `Settings`), debugpy bootstrap | `src/undercooked/config.py`, `.env.example`, `src/undercooked/debug.py` |
| Pure game rules (movement, stations, orders, scoring, hazards) | `src/undercooked/sim/` |
| Level/recipe content | `levels/*.yaml` |
| Rooms, lobby lifecycle, session records | `src/undercooked/server/rooms.py`, `room.py` |
| Wire messages + validation | `src/undercooked/server/protocol.py`, `docs/PROTOCOL.md` |
| HTTP app, static serving, headers | `src/undercooked/server/app.py` |
| HTTP app (`/`, `/healthz`, `/static`), lifespan | `src/undercooked/server/app.py` |
| Security headers (CSP `default-src 'self'`, no-sniff, frame-ancestors) | `src/undercooked/server/headers.py` |
| CLI entry point (`undercooked`): flags → `--help` → serve | `src/undercooked/__main__.py` |
| Renderer, HUD, input, interpolation | `app/static/js/`, `app/static/assets/` |
| Documentation (design / testing / deployment; per-phase docs) | `docs/*.md`, indexed by PLAN §13 and guarded by `tests/unit/test_docs_index.py` |
| The pipeline gate itself | `.agents/validate.sh` |
| User-visible flow contract | `.agents/user_stories/US-XX_*.md` |
Tests mirror that: `tests/unit/`, `tests/server/` (HTTP routes and stores),
`tests/integration/` (live uvicorn + WebSocket clients), `tests/sim/` (headless
determinism and replays), `tests/e2e/` (Playwright, named `test_usNN_*.py`, marked `e2e`).
Tests mirror that: `tests/unit/` (pure, no-I/O logic incl. HTTP-route unit tests),
`tests/integration/` (in-process app boot, live uvicorn + WebSocket clients), `tests/sim/`
(headless determinism and replays), `tests/e2e/` (Playwright, named `test_usNN_*.py`,
marked `e2e`). `tests/` is not a package; shared fixtures live in `tests/conftest.py`.
- The browser suites use the **`playwright` sync API directly with fixtures written in
`tests/e2e/conftest.py`** — `pytest-playwright` is deliberately not a dependency, so
adding it needs the usual permission. Those `browser`/`context`/`page` fixtures are
**function-scoped on purpose**: a long-lived sync driver keeps an asyncio loop flagged
as running in the main thread and breaks every later `async def` test.
- `live_server` (session-scoped, `tests/conftest.py`) is the only fixture that binds a
socket: an ephemeral `127.0.0.1` port, never a routable interface.