Skip to content

feat(9gag): add high-fidelity mirror - #92

Open
sarendis56 wants to merge 1 commit into
aiming-lab:mainfrom
sarendis56:add-9gag-mirror
Open

feat(9gag): add high-fidelity mirror#92
sarendis56 wants to merge 1 commit into
aiming-lab:mainfrom
sarendis56:add-9gag-mirror

Conversation

@sarendis56

Copy link
Copy Markdown
Contributor

Summary

Adds a deterministic 9GAG mirror as the next WebHarbor site on port 40024.

  • Implements feed and category browsing, scored search, post details, authentication, voting, saving, comments, reporting, submission, account settings, and notifications.
  • Adds 20 contributor-format WebVoyager tasks spanning public discovery and persistent account actions.
  • Seeds 80 varied posts, four benchmark users, comments, saved posts, and votes.
  • Registers 9GAG in the startup script, control server, Docker image, and project documentation.

Fidelity

The UI was refined against recorded desktop and mobile references. It includes the three-column desktop shell, navigation/sidebar hierarchy, topic chips, content controls, real harvested post imagery, a captured advertisement asset, and responsive feed behavior at 390px and 320px.

Screenshots

Desktop — 1440px Mobile — 390px
9GAG homepage at 1440px 9GAG homepage at 390px

Assets

Validation

  • Docker build completed successfully.
  • 20/20 benchmark tasks passed the reset-before-each-task browser walkthrough.
  • Responsive checks passed at 1440px, 390px, and 320px with no broken images or horizontal overflow.
  • Runtime and seed databases were byte-identical after reset.

Seeded state

  • Posts: 80
  • Users: 4
  • Comments: 4
  • Saved posts: 16
  • Votes: 12

Detailed audit report

@JeremyJC67

Copy link
Copy Markdown

Taking ownership of the review for this PR (Track B). Plan: build and drive the mirror per the review-env pipeline, post a structured review here (mechanical / visual / functional / task-quality, with evidence), and open a separate Review: PR that keeps @sarendis56's commits intact and adds the grading contract (deterministic verifiers + judge rubrics for every accepted task, validated with no-op / pass / shortcut / wrong-answer / state-mismatch runs). Expect the review within the next day or two.

JeremyJC67 added a commit to JeremyJC67/WebHarbor that referenced this pull request Sep 11, 2026
@JeremyJC67

Copy link
Copy Markdown

Review — 9gag (PR #92) — site validation + grading contract

Reviewer: JeremyJC67 · Site author: @sarendis56 · Head reviewed: 58501984 (branch add-9gag-mirror, 0 commits behind main) · Assets: HF discussion #65 (9gag.tar.gz)

Verdict: APPROVE, conditional on the four required fixes in section 6 (two are release mechanics, two are site-quality items; none affects grading). Mechanical checks pass, byte-identical reset holds across two boots and a full read-only drive, every auth/CRUD/form flow persists correctly, and all 20 tasks are solvable through the UI with page-specific (fictional) facts that cannot be recalled from prior knowledge. The reviewer PR adds the deterministic verifier suite (sites/9gag/verify/, 20 verifiers + shared lib), verifier_path + judge_rubric on every task row, a 244-case unit matrix and a Playwright live matrix that drives every task end-to-end; both matrices are green. One task wording was tightened (T10, "original"), no task was rejected.

Method: the site was run standalone (Flask on 127.0.0.1:45001, instance/ recreated from instance_seed/, assets extracted from the HF tarball) and driven with a real Chromium via Playwright (agent_demo env). The full 25-site Docker environment was then built from the review branch on the same host and passed every control-plane check (last row of §1).


1. Mechanical checks — PASS (standalone + full Docker environment)

Check Result
Syntax python3 -m py_compile app.py seed_data.py _health.py ok
bash -n websyn_start.sh, git diff --check ok
Registration websyn_start.sh SITES == control_server.py SITES (25 entries); 9gag index 24 → port 40024; Dockerfile EXPOSE 8101 40000-40024
tasks.jsonl (contributor rows) 20 rows, exactly web_name,id,ques,web,upstream_url; ids 9GAG--0..19; web = http://localhost:40024/; no answer keys
Cross-site imports grep -rn "from sites\.|import sites" sites/9gag none
HF tarball validate_asset_archive.py ../tarball/9gag.tar.gz 9gag validated 73 managed members; extract ok
Seed DB instance_seed/9gag.db md5 44768940829bf4a8bec07d7bdf072d97 (matches the author's report); 80 posts / 4 users / 4 comments / 12 votes / 16 saved / 0 hidden / 0 reports
Images 67 files (38 post images + 27 brand/interest references + 1 rail ad); all 11 images on the homepage load (0 broken, naturalWidth > 0)
Boot GET / 200; /_health{"ok":true,"posts":80,"site":"9gag"}
Byte-identical seed md5 equal after boot 1, after boot 2 (instance kept), after the full read-only Playwright drive, and after a fresh reset — see evidence
Idempotent seeding seed_database() gated on Post.query.count(), seed_benchmark_users() gated on alice's row — confirmed by the md5 results above
Stateful drive control md5 diverges only after the stateful drive (6eb5f5cf…) and returns to the seed after a fresh copy — i.e. writes really hit instance/ and a reset restores it
Docs / integration tests FAIL on the PR head as submitted: README/AGENTS/CONTRIBUTING/CLAUDE/agent_demo README still say 24 sites / 40000-40023, the Dockerfile comment says "24 Flask mirror sites", and sites/walmart_careers/tests/test_integration.py + sites/rotten_tomatoes/tests/test_environment_quality.py assert the old range/list — fixed in reviewer commit d5ebfdd (same sweep PR #95 does)
Tarball hygiene 73 AppleDouble ._* members (9gag/._instance_seed, 9gag/static/images/posts/._aO86yA2-…jpg, …) — 146 members total, half are junk; the validator/extractor skip them, but please repack (COPYFILE_DISABLE=1 tar …) — same nit raised on #53
.assets-revision unchanged (65c479f8…, does not contain 9gag.tar.gz); needs the merged HF sha after discussion #65 lands
Port 40024 is also claimed by open PR #95 (webmd_doctor); whichever merges second needs re-indexing — maintainer call
Docker (from review/pr-92-9gag, assets refs/pr/65) ok — docker build 4.72 GB; alt ports 44000-44024: 25/25 return 200; /health ok, 25/25 alive+ready, 9gag on internal 40024; POST /reset/9gag → md5 44768940… == seed; GET+POST /login then reset → still 44768940…; docker restart → still 44768940…; POST /reset-all ok, 25/25 ready in 1.65 s; verifier no-op matrix in the container (--container, DBs via docker cp): 20/20 FAIL, 0 errors; registry tests 4 passed; sites/9gag test suite 246 passed

2. Visual fidelity — PASS with notes

Side-by-side at 1440px (shots/home_1440_fold.png vs shots/upstream_9gag_home_1440.png, the real site fetched from the server): the three-column shell, top bar (menu, wordmark, Shuffle/Get App links, search, Sign Up/Log In, blue Post button), the "New to 9GAG?" sign-up card, Home/Top/Trending/Fresh/News/Ask 9GAG sidebar, the topic chips row, the yellow "Want fewer political content? Block tags" notice, right-rail advertisement and the post-card layout (section · @author, title, media, points · comments, vote/comment/save row) all match the upstream page closely. White background and Arial typography match. Detail pages (shots/detail_12.png) render title, media, description, tag chips, stats, action row, comments and a "More from " rail.

Notes (not blocking, see section 6):

  • Image/caption mismatch. Only the 10 captured posts show their own media; the 35 curated posts and their 35 "Community remix" clones rotate through 38 unrelated real-9GAG images (e.g. "Tiny lighthouse office with the best ocean view" shows a dash-cam self-driving clip, "Students launch a weather balloon" shows a construction site). A vision agent sees captions that contradict the picture, and the same image repeats every ~2 cards.
  • Tablet width overflows. At 768px the document is 857px wide (scrollWidth 857 > clientWidth 768, shots/home_768_full.png): the @media(max-width:1000px) grid is 220px + minmax(0,640px) + 34px gap + 40px padding. 1440px and 390px are clean (1440/1440, 390/390).
  • The feed tab strip always highlights "Home" (templates/feed.html hardcodes class="active" on the Home tab), so /top, /trending, /fresh, /news show the wrong active tab.
  • The sidebar Interests list omits awesome (6 posts), art (4), comic (2), books (2), motorvehicles (2) — 16 posts reachable only via a card's section link.

3. Functional depth — PASS

Driven with Playwright (scripts/audit_functional.py, results in audit_functional.json); DB after-state checked with sqlite after each action.

Flow Result
Login wrong password → flash "Incorrect email, username, or password."; email login → /home, header shows alice_j; username login (bob_c) works
Account /account shows display name, @alice_j, bio, location, joined date
Register duplicate email → "already registered"; valid → auto-login, header new_person, row in user; re-login works; server rejects a 5-char password (client minlength bypassed via POST: no row created)
Search OR-scored, case-insensitive, multi-word ok (bridge sound rain → 20; LIGHTHOUSE OFFICE → 10); no stemming (lighthouse offices matches only on lighthouse); unknown term → "No posts found"; empty q returns the full feed
Browse all feeds/interests/tags return 200; /interest/memes and 6/6 topic chips (spider man, usa, trump, elon musk, politics, latest news) and #animals are empty pages (see section 6)
Save / unsave Save on detail → button "★ Saved", flash, saved_post row, persists on reload and on /saved; unsave from the /saved card → row gone, count back to 4
Vote upvote 8388→8389 (button selected); switch to downvote → 8388 points / 58 downvotes; undo → 8388 / 57; vote rows consistent
Comment 1-char body rejected ("at least 2 characters"); real comment rendered with author + "just now", comment_count +1
Report /gag/<slug>/report form with Spam/Harassment/Misinformation/Graphic content; submit → flash + report row; empty reason rejected
Hide detail Hide → redirect to /, hidden_post row, post gone from feeds and search, detail URL still 200 (by design)
Settings display name / bio / location persist to user and render on /account; empty display name rejected
Submit new post created (slug quiet-victories-deserve-confetti, section, tags, author, source_id local-81), redirect to its page, first on /fresh, listed on /account; 3-char title rejected
Auth gating /saved, /settings, /submit, /notifications, /account, /gag/<slug>/report and POST /gag/<slug>/save redirect to /login?next=… when logged out
404s /u/nobody, /gag/does-not-exist → 404

4. Task quality — PASS (20/20 accepted, 1 rewording)

All facts are fictional, curated descriptions (a 4.2-metre lighthouse office, a 440-hertz bridge, a cat named Miso) — no knowledge-shortcut risk. Search/list cards show only section, author, title, points and comment count, so no task fact leaks onto a results page; every read task needs the detail page. Pitfall check: no count labels, no pre-sorted "answer first" lists except T8 (see below), search is not strict-AND.

Catalog caveat that shapes the whole audit: 35 of the 80 posts are verbatim "Community remix N: <title>" clones carrying the same description as the original, with different points/authors. They act as distractors for the tasks that say "original" (and as a trap for the T9 points comparison), but they are not near-misses for fact retrieval, and they inflate the feed to 80 rows from 45 unique posts. Depth per interest is thin: science 17, animals 13, wholesome 7, awesome 6, sports 5, gaming/humor/food/music/art 4, movies/comic/books/motorvehicles 2, wtf/politics/random/news 1, memes 0.

id solvable leak distractors (results / target position) steps verdict note
0 yes no 8 lighthouse posts (4 originals + 4 clones) / #8 4 accept "title mentions the best ocean view" also matches the clone (same facts) — verifier accepts either detail
1 yes no 9 / #2 (shelter cat, foster kitten, rescue dog near-misses) 4 accept
2 yes no 10 / #4 (solar lantern, solar mat, rainy tarp near-misses) 4 accept
3 yes no science feed 17 / #13 (page 2); 3 other bridge posts 5 accept needs pagination
4 yes no 40 / #7 (community hits every clone's tag) 4 accept noisy but fine; 3 other library posts
5 yes no sports 5 / #3 3 accept thin section
6 yes no 2 / #1 (original + clone only) 4 accept "original" disambiguates; distractor count below the ≥6 guideline
7 yes no gaming 4 / #3 (clone ranks #1 with more points) 3 accept thin section
8 yes no 12 / #1 4 accept target is the first result; still needs the detail page
9 yes by design (points on cards) animals 13; clone cat has 8561 points vs originals 2506/3717/4236 5+ accept good trap: only originals count; fox → "Copper"
10 yes n/a search 10 / original #2, clone #1 6 accept (reworded) added "original" so the clone (id 47) is not a valid target — mirrors T11–T13/T18/T19 wording
11 yes n/a animals 13 / #9 6 accept carol has no vote on post 20 in the seed
12 yes n/a science page 2 7 accept exact comment text
13 yes n/a 6 / #1 6 accept hide redirects to /
14 yes n/a news 19 / #19 (page 2) 8 accept needs pagination + report form
15 yes n/a 8 accept slug quiet-victories-deserve-confetti
16 yes n/a 7 accept
17 yes n/a 6 accept header shows the username after sign-up
18 yes n/a 4 saved cards 5 accept post 11 is in alice's seeded saves
19 yes no 3 detail pages required (numbers only in descriptions) 12 accept 120 W vs 440 Hz vs 27 km → bridge; david has not saved it

Difficulty: ≥5 actions for 12 tasks; multi-page comparison in T9 and T19; T19 (three detail reads, unit-agnostic comparison, then a save) and T9 (clone trap) are the frontier-challenging ones. No task is solvable from the first card without a detail visit.

5. Grading contract (reviewer deliverable, on the Review: PR)

sites/9gag/verify/verify_lib.py (merriam API family + walmart hardening) and verify_0.py … verify_19.py; ground truth hardcoded in the verifiers only. Every task row now carries verifier_path and an English judge_rubric (rules and fact-checkpoints, no answers). Contract per task is summarised in sites/9gag/verify/README.md; in short:

  • Package identity: task_id, terminated/agent_done, non-empty answer, all URLs http:// loopback on the same port as start_url, every referenced screenshot decodes as PNG.
  • Navigation gates: /search?q= with a task-relevant whole token, or the named /interest/<x> / /news / /saved / /settings / /submit / /register page, plus the exact /gag/<slug> detail page (T19: all three compared details; T14: the /report form too), with login-before-action ordering for the stateful tasks and the typed account identity checked against the trajectory's input steps.
  • Answers: affirmative-only matchers (negated mentions do not count), standalone counts or number words, decimal-exact 4.2, clock-time variants for 6 a.m..
  • Snapshot contract: seven tables, pinned schema hash, frozen seed counts and benchmark identities, seed post catalog columns immutable; read-only tasks require all 7 tables row-identical; stateful tasks require the exact row delta (one added/removed/changed row with the expected values, counters bumped by exactly one on the right post, password hash verified for T17) and no_collateral_writes elsewhere; stale-state preconditions (initial_state_requires_action).
  • LLM helpers exist for parity but only emit [INFO]; every verdict is decided with --no_llm True.

6. Required fixes before approval

  1. Release mechanics — .assets-revision: after HF discussion Add TED task verifiers (site by @shanjiaming, verifiers by reviewer) #65 merges, pin the merged commit sha (the current pin 65c479f8… has no 9gag.tar.gz).
  2. Tarball hygiene — 9gag.tar.gz (HF Add TED task verifiers (site by @shanjiaming, verifiers by reviewer) #65): strip the 73 AppleDouble ._* members (COPYFILE_DISABLE=1 tar czf … on macOS, or tar --exclude='._*'), then re-upload; tar tzf 9gag.tar.gz | grep -c '/\._' should print 0.
  3. Responsive — sites/9gag/static/css/site.css line 2 (@media(max-width:1000px)): the 220px + 640px grid overflows 768px viewports by 89px; use grid-template-columns:200px minmax(0,1fr) (or collapse the sidebar earlier).
  4. Empty entry points — sites/9gag/templates/_sidebar.html lines 14–16 / 19, templates/base.html toplinks, templates/feed.html topic row + "Popular tags": "Memes" (sidebar interest, top-nav link and the Recents "Old Meme" chip), all six topic chips and #animals land on "No posts found". Either seed posts for memes/wtf/politics/random (≥ ~10 each) or remove the dead chips/links.

Recommended (non-blocking) follow-ups:
5. templates/feed.html line 1: compute the active feed tab from active instead of hardcoding it on Home.
6. Add awesome, art, comic, books, motorvehicles to the sidebar Interests (16 posts are otherwise unreachable from navigation).
7. Fidelity: pair captions with matching media (or fewer, topical images); the current rotation puts unrelated memes under 70 of 80 titles and repeats each image about twice per feed.
8. Depth: replace the verbatim "Community remix" clones with genuinely different posts so search families have real near-misses; T6/T7/T5 currently sit in 2–5-item pools.
9. Docs/tests sweep for 25 sites / 40000-40024 — already applied in reviewer commit d5ebfdd on the Review: PR (the two registry integration tests fail on the PR head as-is).

7. Validation of the grading contract (CONTRIBUTING §C)

Live matrix (sites/9gag/verify/tests/live_matrix.py; every task driven for real in Chromium with UI clicks only — sidebar links, search box, card titles, pagination, form buttons; agent.py-shaped run dirs with real screenshots; initial.db = seed, after.db = live instance/9gag.db; site reset from the seed before each task). Cell = verifier verdict (first failing check):

task genuine no-op (home only, empty answer, clean DB) shortcut (correct answer, no navigation) wrong answer / wrong DB target state mismatch (clean DB)
9GAG--0 PASS FAIL (final_answer_nonempty) FAIL (visited_search_results) FAIL (answer_has_room_width_meters)
9GAG--1 PASS FAIL (final_answer_nonempty) FAIL (visited_search_results) FAIL (answer_has_arrival_month)
9GAG--2 PASS FAIL (final_answer_nonempty) FAIL (visited_search_results) FAIL (answer_has_panel_wattage)
9GAG--3 PASS FAIL (final_answer_nonempty) FAIL (visited_science_interest_feed) FAIL (answer_has_frequency_hertz)
9GAG--4 PASS FAIL (final_answer_nonempty) FAIL (visited_search_results) FAIL (answer_has_cabinet_colour)
9GAG--5 PASS FAIL (final_answer_nonempty) FAIL (visited_sports_interest_feed) FAIL (answer_has_kilometre)
9GAG--6 PASS FAIL (final_answer_nonempty) FAIL (visited_search_results) FAIL (answer_has_attempt_count)
9GAG--7 PASS FAIL (final_answer_nonempty) FAIL (visited_gaming_interest_feed) FAIL (answer_has_switch_type)
9GAG--8 PASS FAIL (final_answer_nonempty) FAIL (visited_search_results) FAIL (answer_has_platform_number)
9GAG--9 PASS FAIL (final_answer_nonempty) FAIL (visited_animals_interest_feed) FAIL (answer_has_animal_name)
9GAG--10 PASS FAIL (final_answer_nonempty) FAIL (visited_login_page) FAIL (saved_post_exact_delta: remix clone saved) FAIL (saved_post_exact_delta)
9GAG--11 PASS FAIL (final_answer_nonempty) FAIL (visited_login_page) FAIL (vote_exact_delta: downvote) FAIL (vote_exact_delta)
9GAG--12 PASS FAIL (final_answer_nonempty) FAIL (visited_login_page) FAIL (comment_exact_delta: other text) FAIL (comment_exact_delta)
9GAG--13 PASS FAIL (final_answer_nonempty) FAIL (visited_login_page) FAIL (hidden_post_exact_delta: clone hidden) FAIL (hidden_post_exact_delta)
9GAG--14 PASS FAIL (final_answer_nonempty) FAIL (visited_login_page) FAIL (report_exact_delta: reason Spam) FAIL (report_exact_delta)
9GAG--15 PASS FAIL (final_answer_nonempty) FAIL (visited_login_page) FAIL (new_post_exact_delta: wrong interest) FAIL (new_post_exact_delta)
9GAG--16 PASS FAIL (final_answer_nonempty) FAIL (visited_login_page) FAIL (profile_fields_updated: wrong location) FAIL (profile_fields_updated)
9GAG--17 PASS FAIL (final_answer_nonempty) FAIL (visited_register_page) FAIL (new_user_password_verifies: wrong password) FAIL (new_user_exact_delta)
9GAG--18 PASS FAIL (final_answer_nonempty) FAIL (visited_login_page) FAIL (saved_post_exact_delta: other post removed) FAIL (saved_post_exact_delta)
9GAG--19 PASS FAIL (final_answer_nonempty) FAIL (visited_login_page) FAIL (saved_post_exact_delta: solar post saved) FAIL (saved_post_exact_delta)

Result: 20/20 genuine PASS, 90/90 negative cells FAIL, cells wrong: none. The genuine runs double as the solvability proof — recorded UI paths, e.g. T3 / → /interest/science → /interest/science?page=2 → /gag/an-engineer-explains-why-this-bridge-hums-in-the-wind-19; T14 / → /login → /home → /news → /news?page=2 → /gag/a-mysterious-press-conference-moment-from-2009-6 → …/report; T19 / → /login → /home → /interest/science → (page 2) → solar detail → bridge detail → balloon detail → bridge detail (Save), 17 steps.

Unit matrix (sites/9gag/verify/tests/, synthetic trajectories over copies of the frozen seed, no site/docker/LLM): uv run python -m unittest discover -s sites/9gag/verify/tests -p 'test_*.py'Ran 246 tests … OK (skipped=10) (the 10 skips are the abstract read-task base class). Covers run-dir snapshot discovery, wrong task id, unterminated runs, mixed origins, corrupt screenshots, read-only writes, schema/catalog tampering (snapshot_contract_invalid), stale-state preconditions, wrong account (entered_expected_account_identity), out-of-order login (workflow_login_before_save), duplicate actions, counter bumps without vote/comment rows, collateral writes, clone-instead-of-original targets, negated answers, number/time spelling variants, and the real werkzeug scrypt hashes of the four seeded accounts.

Entry point: agent_demo/eval_judge.py --run_dir <run> --verifier True on the genuine T19 run dir → pass: True … reason: all checks passed; on the shortcut run dir → pass: False … reason: visited_login_page (eval.json written both times). Every verifier was run with --no_llm True; no LLM key exists on the review host.

8. Evidence (exact commands and outputs, server carcinoma, standalone site on 127.0.0.1:45001)

$ python3 scripts/validate_asset_archive.py ../tarball/9gag.tar.gz 9gag
[fetch] validated 73 managed members for 9gag
$ tar tzf ../tarball/9gag.tar.gz | wc -l ; tar tzf ../tarball/9gag.tar.gz | grep -c '\._'
146
73
$ md5sum sites/9gag/instance_seed/9gag.db
44768940829bf4a8bec07d7bdf072d97  sites/9gag/instance_seed/9gag.db
$ find sites/9gag/static/images -type f | wc -l ; ls sites/9gag/static/images/posts | wc -l
67
38

$ python3 -m py_compile app.py seed_data.py _health.py && echo ok        # ok
$ bash -n ../../websyn_start.sh && echo ok                                # ok
$ grep -rn "from sites\.\|import sites" .                                 # (no output)
$ python3 ~/wh-review/9gag/scripts/check_registration.py
websyn_start.sh SITES == control_server.py SITES: True
site count: 25 | 9gag index: 24 | port: 40024
Dockerfile: EXPOSE 8101 40000-40024 | # 24 Flask mirror sites + control plane on :8101.   <- comment stale on PR head (fixed in d5ebfdd)
tasks: 20 ; key sets: [('id','ques','upstream_url','web','web_name')] ; ids sequential: True ; web set: ['http://localhost:40024/']

$ rm -rf instance && cp -a instance_seed instance && nohup venv/bin/python -c "from app import app; app.run(host='127.0.0.1', port=45001, debug=False, use_reloader=False)" &
$ curl -so /dev/null -w '%{http_code}\n' http://127.0.0.1:45001/ ; curl -s http://127.0.0.1:45001/_health
200
{"ok":true,"posts":80,"site":"9gag"}
$ md5sum instance/9gag.db instance_seed/9gag.db          # boot 1
44768940829bf4a8bec07d7bdf072d97  instance/9gag.db
44768940829bf4a8bec07d7bdf072d97  instance_seed/9gag.db
$ uv run python ~/wh-review/9gag/scripts/audit_readonly.py ; md5sum instance/9gag.db instance_seed/9gag.db   # after read-only Playwright drive
44768940829bf4a8bec07d7bdf072d97  instance/9gag.db
44768940829bf4a8bec07d7bdf072d97  instance_seed/9gag.db
$ bash stop_site.sh; bash start_site.sh; md5sum instance/9gag.db instance_seed/9gag.db   # boot 2, instance kept
44768940829bf4a8bec07d7bdf072d97  instance/9gag.db
44768940829bf4a8bec07d7bdf072d97  instance_seed/9gag.db
$ uv run python ~/wh-review/9gag/scripts/audit_functional.py ; md5sum instance/9gag.db instance_seed/9gag.db   # after stateful drive (control)
6eb5f5cf2d661c2e15a24b81c0a6b21f  instance/9gag.db
44768940829bf4a8bec07d7bdf072d97  instance_seed/9gag.db
$ bash stop_site.sh; bash start_site.sh fresh; md5sum instance/9gag.db instance_seed/9gag.db   # fresh copy = reset
44768940829bf4a8bec07d7bdf072d97  instance/9gag.db
44768940829bf4a8bec07d7bdf072d97  instance_seed/9gag.db

# read-only audit (audit_readonly.json), excerpts
home_images => {'count': 11, 'broken': []}
home_scrollWidth_vs_client => [1440, 1440] ; home_768 => [857, 768] ; home_390 => [390, 390]
interests => humor 4, memes 0, gaming 4, wtf 1, animals 13, science 17, wholesome 7, sports 5, movies 2, food 4, music 4, politics 1, random 1, awesome 6, art 4, comic 2, books 2, motorvehicles 2
/tag/spider-man 0, /tag/usa 0, /tag/trump 0, /tag/elon-musk 0, /tag/politics 0, /tag/latest-news 0, /tag/animals 0 (all "No posts found")
search 'lighthouse' 8 ; 'rescue cat shelter' 9 ; 'bridge sound rain' 20 ; 'LIGHTHOUSE OFFICE' 10 ; 'nonexistentterm' 0 ; 'Boston Celtic players' 0
task 0: results_total 8, target_position 8, card_text_leaks {} ; task 3: results_total 17, target_page 2 ; task 14: results_total 19, target_position 19 (page 2)
remix_duplicates: count 35, all_same_facts True
t9 points: cat 2506 / dog 3717 / fox 4236 (clone cat 8561, clone dog 1372, clone fox 1891)

# functional audit (audit_functional.json), excerpts
login_wrong_pw flash 'Incorrect email, username, or password.' ; login_email header 'alice_j'
register_ok header 'new_person', db [('new_person','new.person@test.com','new_person')] ; register_server_validation created 0
save button_before '☆ Save' -> button_after '★ Saved', persists_after_reload '★ Saved' ; unsave db_count 4
vote '8388 points · 57 downvotes' -> up '8389 … 57' -> down '8388 … 58' -> undo '8388 … 57'
comment short_flash 'Comment must contain at least 2 characters.' ; stats '… 27 comments'
report db [('Misinformation', 47)] ; report_empty_reason db_count 1
hide db [(1,)], in_search_after_hide False, detail_status_after_hide 200
settings db [('Alice J.', 'Memes, trail photos, and excellent tiny libraries.', 'Tacoma, WA')] ; settings_empty_name unchanged
submit db slug 'quiet-victories-deserve-confetti', section 'wholesome', tags 'community|education|wholesome', author 'alice_j' ; first_on_fresh True
auth_gate: /saved,/settings,/submit,/notifications,/account,/report -> /login?next=… ; POST /save logged out -> /login?next=…

Screenshots (server ~/wh-review/9gag/shots/, cited ones copied to the review bundle): home_1440_fold.png, home_1440_full.png, upstream_9gag_home_1440.png, home_768_full.png, home_390_full.png, detail_12.png, search_lighthouse.png, page_interest_memes.png, task{N}_results.png / task{N}_detail.png (N = 0–14, 19), func_login_ok.png, func_register_done.png, func_saved_after_save.png, func_vote.png, func_comment.png, func_report_form.png, func_settings_after.png, func_submit_done.png; live-matrix run dirs with per-step screenshots under ~/wh-review/9gag/runs/matrix/task{NN}_{genuine,noop,shortcut,wrong,mismatch}/.

Screenshots

All cited screenshots: https://github.com/JeremyJC67/WebHarbor/tree/pr-screenshots/reviews/9gag

mirror homepage 1440 px real 9gag.com homepage 1440 px (captured from the review host)
mirror homepage 1440 px real 9gag.com homepage 1440 px (captured from the review host)
768 px — horizontal overflow (857 px document) /interest/memes — empty entry point
768 px — horizontal overflow (857 px document) /interest/memes — empty entry point

@JeremyJC67

Copy link
Copy Markdown

Second-pass audit — 9gag (PR #92) — merge-blocker checklist

Reviewer: JeremyJC67 · Head audited: cf4fcff on review/pr-92-9gag (= PR head 5132fa4
plus five reviewer commits) · Site author: @sarendis56

Verdict: APPROVE stands. This pass ran the maintainer's merge-blocker checklist — the classes that
survive a normal review — against the site: form prefill, static-text leaks, answer-identifier grading,
distractor/ordering defects, verifier-vs-recorder drift, application robustness, asset provenance, test
hygiene and accessibility. It found 1 blocker, 6 major, 9 minor, 3 nit. Thirteen are already fixed
on the review branch; six are report-only because they need a Hugging Face re-upload or a maintainer
action. The four required fixes from the first review are unchanged; two of them (768px overflow, empty
navigation targets) are now done in-branch rather than left to you.

Everything below has a command and its output. All 20 tasks were re-driven end to end after every
change: 20/20 genuine PASS, 70/70 negative cells FAIL, cells wrong: none, unit matrix
258 passed, and the seed stays byte-identical (44768940829bf4a8bec07d7bdf072d97).


1. The blocker: invalid input was accepted and written to the database

This is the only finding that can change a graded outcome, so it is the only one I would call a
blocker. Four routes accepted values the UI cannot produce; one silently inverted intent.

request before after
POST /gag/<slug>/vote value=garbage 302 — stored as a downvote (vote row value=-1) 400
POST /submit section=NOT_AN_INTEREST 302post.section='NOT_AN_INTEREST' 400
POST /submit title=<5000 chars> 302 — 5000 chars into a String(300) column 400
POST /submit image=../../etc/passwd 302 — stored verbatim 400
POST /gag/<slug>/report reason=NOT_AN_OPTION 302report.reason='NOT_AN_OPTION' 400
post rows after all of the above 83 (three junk posts) 80
$ sqlite3 instance/9gag.db "select id,source_id,section,length(title) from post where id>80"
81|local-81|humor|16
82|local-82|humor|5000
83|local-83|NOT_AN_INTEREST|19
$ sqlite3 instance/9gag.db "select * from vote where post_id=23"
13|1|23|-1        <- "garbage" became a downvote

value = 1 if request.form.get("value") == "1" else -1 treats every unrecognised value as a
downvote, and section / image / reason were passed straight through.

Fixed in 5bedd61: interests, report reasons and preview images are now one tuple each in app.py,
rendered by the template and validated by the handler, so the form and the server cannot drift
apart; bounded_text() rejects over-long fields. Empty-form submissions keep the existing
flash-and-re-render behaviour (that is UX, and it never succeeds silently) — only out-of-vocabulary
values, which the UI cannot generate, return 400.

2. Application robustness — the rest

item evidence after parity across the 25 sites
Fixed SECRET_KEY minted a cookie from the tracked literal: GET /account -> 200, rendered @alice_j / Alice Johnson / Seattle, WA; a forged POST .../save wrote a row 302 to /login, identity not leaked 17/25 hardcode a key; 7 use env-or-random, which 9gag now does
No CSRF POST .../save with Origin: http://evil.example -> 302, saved_post 16 -> 17 400, saved_post 16 -> 16 9gag and ikea were the only 2 of 25 without CSRFProtect
GET /logout 302 (prefetchers fire it) 405 7/25 already POST-only
No MAX_CONTENT_LENGTH 60 MB multipart accepted in full (uploaded 62914887 bytes) 413 5/25 set it
No error handlers /nope rendered the bare Werkzeug page 404 with the 9GAG shell 17/25 have handlers
source_id = COUNT+1 latent: collides after any delete (no delete route today) MAX(suffix)+1
Flask-WTF undeclared site refused to boot: ModuleNotFoundError: No module named 'flask_wtf' declared 8/25 already declare it

On parity: SECRET_KEY, CSRF, GET /logout and MAX_CONTENT_LENGTH are common in the repo and this
is a loopback-only sandbox, so I would not block a merge on them — but 9gag was the outlier on CSRF,
and all five were cheap to close, so they are done rather than filed.

3. Accessibility — 16 distinct contrast failures, and a bug in my own instrument

Measured with Playwright plus per-element pixel sampling, 4 widths x 25 routes.

ratio required colour where
2.548 3.0 #999 on #fff2c7 notice-band dismiss x (26px)
2.849 4.5 #999 on #fff .stats points/comments, vote buttons, feed tabs
3.545 4.5 #888 on #fff sidebar headings, Clear, ADVERTISEMENT
4.068 4.5 #fff on #087bf5 Post button, all .primary submit buttons
4.478 4.5 #777 on #fff .muted-link, post meta, footer, topic chips

Worth flagging honestly: my first contrast pass was wrong about the Post button. It sampled the
four corners of each element box, and on a rounded pill the corners are page background — so it
reported white-on-white at ratio 1.0. Re-sampling the modal colour across the whole box gives
rgb(8,123,245) and 4.068:1, which matches the CSS-derived value. Only failures confirmed by both
the cascade walk and the pixel sample are listed.

Fixed in 532ee09 with a --muted (#767676, 4.54:1) and --action (#0a74e6, 4.52:1) token,
keeping the brand blue for decoration. 51 -> 0 failing elements.

4. Responsive — the 768px overflow, with a corrected diagnosis

The first review attributed the 857px document to the 220px + minmax(0,640px) grid. That was
incomplete: fixing the grid alone would not have cleared it.

OVERFLOW 768px /: 857 > 768
  offenders=[{'tag':'A','right':857,'width':77,'clipped_by':None},
             {'tag':'A','right':804,'width':72,'clipped_by':'HIDDEN:topic-row'}]

The offender with clipped_by: None is the six-tab .feed-tabs strip (~628px needed against a
narrower main column); the topic-row overflow is deliberately clipped and correctly ignored.
532ee09 makes the grid track flexible under a 934px cap (preserving the intended 640px reading
column at 1000px) and tightens the tab strip so it scrolls itself between 681px and 1000px.

11 -> 0 failing route/width cells; 1440 / 390 / 320 were clean before and after. The fix had to be
appended as a final layer — the stylesheet is built from successive override layers, so the same
rules placed earlier are defeated by the later .feed-tabs / .stats / .actions redefinitions.

5. Empty navigation targets — fixed without moving any task

Eight entry points landed on "No posts found": the Memes interest (sidebar, top-nav and the Recents
chip), all six topic chips, and #animals. The seed has 0 memes posts and no post carries those tags.

Seeding them needs an HF re-upload, so the dead links are repointed at feeds the sidebar already
exposes
— nothing becomes newly reachable, so no task gains a navigation path its verifier would
reject. Re-measured afterwards:

DEAD ENDS: none
T0/T10 lighthouse 10   T1 rescue cat 9   T2 solar camping 10   T3 science 17
T4 community lib 30    T5 sports 5       T6 sourdough 2        T7 gaming 4
T8 rain delay 12       T9/T11 animals 13 T13 office plant 6    T14 news 19

Every count is identical to the first review, and the live matrix then re-drove all 20 tasks green.

I did not add the missing awesome / art / comic / books / motorvehicles interests to the sidebar
(first review, follow-up 6), on purpose: awesome holds T0's target, so a sidebar entry would create
a browse path to it that verify_0's mandatory /search?q= gate rejects — a "fix" that could fail a
correctly solved task. Worth doing only together with relaxing that gate.

6. A defect in our own grading contract

Tasks 6 and 8 are read-only fact questions, but their verifiers accepted only the original post's
slug. The seed's Community remix N: clones copy the description verbatim, so an agent that
opened the clone, read the right number and answered correctly was graded FAIL — on a rule stated
nowhere it could see. It was also inconsistent: the other seven read-only clone tasks (0-5, 7)
already accepted either page.

Provenance is now enforced only where it changes the graded outcome:

tasks policy why
0-8 (read-only facts) original or clone identical description; the answer is the same
9 original only it compares point counts, which differ between original and clone
10-13, 18, 19 (stateful) original only the clone is a different row to save/vote/hide/comment

Every task that still depends on provenance now says so in its ques ("not its 'Community remix'
copy"), so the requirement is visible to the agent. Widening a gate can silently disable it, so each
of tasks 6 and 8 gained two cases: the clone now passes, and an unrelated detail page still fails on
visited_post_detail.

Separately, on distractor quality: the 35 verbatim clones add search-result noise but little
discriminative difficulty, since they never contradict the original on any queried fact. Replacing
them with genuinely different posts (first review, follow-up 8) remains the higher-value change, and
needs an HF re-upload.

7. Verifier-vs-recorder contract

ast over agent_demo/agent.py gives step keys {action, params, screenshot_before, screenshot_after, step, thought, title, url} and the action vocabulary click / input / scroll / navigate / go_back / done with params {index, text} — all matched by verify_lib
(INPUT_ACTIONS includes input; it reads params["text"]). The recorder writes no url_after,
and the verifiers already use the next-step-url convention.

One drift: agent.py never writes final_url, but both our unit fixtures and live_matrix.Recorder
did — so the verifiers had only ever been proven against a shape the recorder does not emit. Not a
live failure (final_url() already falls back to the last step's url, and Recorder.done() appends
a done step carrying the landing page exactly as agent.py does). Fixed in cf4fcff: the live
matrix omits it so its evidence is shape-identical to a real run, and every read task gained a case
that nulls it.

8. Report-only

  • 27 zero-reference assets ship into the image. Every file under static/images/reference/
    (26 .jpg, 1 .png, 1 .ico) is referenced by no template, stylesheet or handler
    (grep -rn "reference/" returns nothing), yet .dockerignore ships static/images/. Please prune
    them in the same re-upload that strips the 73 AppleDouble ._* members already filed. 9gag
    ships no asset_inventory.json, so the inventory contract cannot catch this (parity: only compass
    and walmart_careers have one).
  • review-reports/9GAG-FINAL-AUDIT.md does not describe the uploaded artifact. It records
    sha256 6ba9ca46... (the tarball on HF Add TED task verifiers (site by @shanjiaming, verifiers by reviewer) #65 is 9f003eee...), "Managed members: 74" (the validator
    reports 73), and "no horizontal document overflow" across "1440, 390, and 320 px" — true at those
    widths, but 768px was never tested and overflows on 11 routes. Historical audit reports should not
    be retro-edited; noting it so the numbers are not carried forward as verified.
  • Two nits. submit.html's section and image selects have no empty first option (not a leak —
    T15 needs wholesome, which is not first; report.html already does this correctly). /shuffle
    uses db.func.random(), the one nondeterministic route in a byte-deterministic mirror; no task
    uses it and it performs no write.
  • Correction to my own first review: it said "20/20 genuine PASS, 90/90 negative cells FAIL". The
    matrix has 90 cells in total — 20 genuine and 70 negative. The measurement was right; the phrasing
    double-counted.

9. What changed in your code

Five reviewer commits on top of 5850198; your commit is untouched.

commit files concern
d2ddecc templates/base.html, _sidebar.html, feed.html repoint the 8 dead navigation targets
532ee09 static/css/site.css 768px overflow + WCAG AA contrast
5bedd61 app.py, requirements.txt, templates/error.html + 7 form templates input validation, CSRF, secret key, logout, upload cap, error pages, id allocator
0efbedf tasks.jsonl, verify/verify_6.py, verify_8.py, their tests, verify/README.md the hidden provenance rule
cf4fcff verify/tests/_support.py, live_matrix.py recorder trajectory shape (our artifact only)

Contributor-code diff: 14 files, +187 / -54. Please review d2ddecc, 532ee09 and 5bedd61 in
particular — they change your templates, stylesheet and app.py.

10. Validation after every change

unit matrix   : Ran 258 tests ... OK (skipped=10)            # 246 before, +12 added by this pass
live matrix   : 20/20 genuine PASS, 70/70 negative FAIL, 90 cells, cells wrong: none
reset md5     : instance == seed == 44768940829bf4a8bec07d7bdf072d97   (after the full matrix)
responsive    : 0/100 route x width cells overflow           # 11 before, same instrument
contrast      : 0 failing elements                           # 51 before, same instrument
hygiene       : 0 broken images, 0 console errors, 0 page errors, 0 external requests

Both UI numbers are mutation-proofed in both directions: the contributor's stylesheet was restored on
the server and the same corrected instrument re-run, reproducing 11 and 51; with the fix, 0 and 0.

Still open, and not something I can do: repin .assets-revision to the merged HF sha once
discussion #65 lands (the current pin 65c479f8... contains no 9gag.tar.gz); repack the tarball to
drop the 73 AppleDouble members and the 27 unreferenced reference images; and the 40024 port
collision with PR #95. The full 25-site image was green in the first review, but that predates these
five commits — the container chain should be re-run against cf4fcff before merge, since
CSRFProtect, MAX_CONTENT_LENGTH and the new error handlers are exactly the kind of change that
can behave differently in the image.

Container re-verification of this branch (cf4fcff, full 25-site image)

Docker was unavailable to the audit pass itself; it has now been run, which matters here because
CSRFProtect, MAX_CONTENT_LENGTH and the new error handlers are exactly the kind of change that can
behave differently in the image. Assets from HF refs/pr/65.

check result
docker build ok (the new Flask-WTF dependency resolves)
HTTP sweep, alt ports 44000-44024 25/25 return 200
GET /health ok, 25/25 alive and ready, 9gag on internal 40024
POST /reset/9gag → md5 instance vs instance_seed 44768940829bf4a8bec07d7bdf072d97 both
dirty request then reset still 44768940…
docker restart then md5 still 44768940…
POST /reset-all ok, 25/25 ready, 1.58 s
verifier no-op matrix, in-container DBs via docker cp 20/20 FAIL, 0 errors
walmart_careers / rotten_tomatoes registry + docs tests 4 passed, 3,343 subtests
sites/9gag suite inside the image 258 passed, 130 skipped, 25 subtests

The blocker fix and the hardening hold in the image:

POST /gag/<slug>/vote  value=NOT_AN_OPTION      400   (was: 302, and it wrote a downvote row)
POST /gag/<slug>/save  without a CSRF token     400
GET  /logout                                    405
GET  /no-such-page                              404, 1175-byte branded page
POST /register, 300 KB body                     413
GET  /login?next=https://evil.example/pwn       redirect_url empty (not followed)
md5 before / after 7 read-only GETs (/, /top, /trending, /fresh, /news, /search, /interest/science)
  44768940829bf4a8bec07d7bdf072d97 / 44768940829bf4a8bec07d7bdf072d97   -> byte-identical

One reading to be precise about, so it is not mistaken for a regression: /interest/memes still answers
200 and renders zero cards. That is intended. The fix removed the dead entry points — the sidebar
interest, the top-nav link, the Recents chip, the six topic chips and the #animals tag — rather than
seeding content or making the route 404. /interest/<section> is a generic route, so any section with no
posts renders an empty feed if a URL is typed directly; nothing in the UI leads there any more, and
memes remains a valid choice in the submit form, so a post created during a task still lands somewhere
coherent.

Everything else in this comment was measured standalone before the container run and is unchanged.

@JeremyJC67

Copy link
Copy Markdown

Tooling disclosure

Added after the fact, because it belongs in the public record and not only in our internal notes.

The second-pass audit had a general-purpose LLM available to it as a judgment aid — used for
adjudication calls (does this string leak an answer, is this task wording ambiguous) and as a second
opinion on our own checks. It produced no verdicts: every finding in the comment above is backed by a
command and its output, and the shipped verifiers decide with --no_llm, so nothing in the grading
contract depends on a model.

For this site I have no record of it changing any conclusion; the findings above came from the probes and
their outputs.

No call log was kept, so I cannot enumerate every consultation — that is the honest limit of this
disclosure.

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