Skip to content

dashboard: /stand-card Stand Card page (re-land of #3765 on #3604's head, with the script-escape fix) - #4033

Closed
qingyun-wu wants to merge 8 commits into
rescue/pool-uncommitted-2026-08-26from
feat/stand-card-dashboard-r2
Closed

qingyun-wu wants to merge 8 commits into
rescue/pool-uncommitted-2026-08-26from
feat/stand-card-dashboard-r2

Conversation

@qingyun-wu

@qingyun-wu qingyun-wu commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Re-lands the Stand Card dashboard from #3604's local line as its own reviewable PR, stacked on rescue/pool-uncommitted-2026-08-26 (base = 6c0b416e, #3604's current head). Supersedes #3765, which carried the same six commits on an older base and was closed after @sonichi's review asked for one change before landing.

What is in here

Six original stand-card commits, cherry-picked with -x in order (tree identical to #3765's tip cfea8948d; git diff cfea8948d fcd49c7b2 -- src/dashboard.py src/dashboard-stand-card.html is empty), plus two new commits — the review fix, and a coverage follow-up after the gate's first run reported the route branch uncovered because its tracer did not follow the loopback server thread (the same lines are now also driven on the calling thread; locally every changed line reports covered):

  • 10e49e052 dashboard: /stand-card serves the owner's Stand Card design with live data
  • a3638fafe stand-card: drop the three design-notes sections (owner request)
  • f0067be5a stand-card: rail swaps proof/snapshot blocks for a workers roster
  • e9e5d8574 stand-card: Workers inspector section replaces Devices; rail roster removed
  • fe5c9f290 stand-card: reorder sections, worker drawers, live identity intake
  • fcd49c7b2 stand-card: in-place avatar pick, identity chips, owner-only view
  • ae4606032 stand-card: escape </ in the JSON inlined into the page script ← new
  • 96c2a0326 test(stand-card): cover the 404 path and drive /stand-card on the calling thread ← new

Deliberately not included: the pin-honoring leaderless fallback (3726b6d1 + follow-ups) and docs/interaction-semantics.md — the former sits in the per-worker loop #3604 has been asked to remove; the latter documents the A2UI interaction vocabulary, not this page.

The review finding (#3765) and its fix

Before (fcd49c7b2, src/dashboard.py:891) — a </script> inside any stand field closes the inlined element:

inject = "<script>window.SUTANDO_STAND_RAW = %s;</script>\n<script>" % json.dumps(data)
-> <script>window.SUTANDO_STAND_RAW = {"stand": {"name": "Echo</script><img src=x onerror=alert(1)

After (ae4606032):

payload = json.dumps(data).replace("</", "<\\/")
-> <script>window.SUTANDO_STAND_RAW = {"stand": {"name": "Echo<\/script><img src=x onerror=alert(1
json.loads(payload) == data: True

<\/ is the same JSON to the parser, so the page receives an identical payload; there is no longer a token that can end the script element. Unconditional, not gated on which fields are attacker-influenced today (per the review).

Evidence at HEAD

python3 tests/dashboard-stand-card-escape.test.py (new; covers the escape, both fallback paths, and /stand-card through the real Handler — the lines the #3765 coverage gate listed as uncovered):

  ok   live payload is injected
  ok   no literal </script> from the payload survives
  ok   injected text is still valid JSON decoding to the same payload
stand-card: runtime absent -> baked sample, no injection
  ok   still 200 when bin/sutando is missing
  ok   no injection on failure
stand-card: runtime answers with an error object -> no injection
  ok   200 on error payload
  ok   error payload is not injected
stand-card: /stand-card route through the real handler
  ok   route returns 200 text/html
  ok   route body carries the escaped payload

all passed

All 13 tests/dashboard-*.test.py suites pass on this head. Repo gate on the cumulative diff vs 6c0b416e:

prose-cap: PASS (comment blocks within cap 2, exts .py)
review-checks: PASS (hardcoded-paths + root-artifacts + prose-cap clean)

Not addressed here

The except Exception: pass nit from the same review (a live-data outage leaves no trace) is left as-is — it was explicitly a nit, and the page labels itself sample snapshot on fallback, which the review confirmed holds.

Stacked on #3604; merge order: #3604 first, then this.

🤖 Generated with Claude Code

… data

Vendors the owner's reference (sutando-stand-card_1.html) as
src/dashboard-stand-card.html with two additive patches: an
adaptStandJson() bridge from the current `bin/sutando stand --json`
shape (stand_id / owners[] / evidence blocks) to the card's VIEW shape,
and a source badge that says live vs sample. The /stand-card route runs
the CLI with a 6s timeout and injects the JSON; on any failure the page
falls back to its baked Aug-23 sample and labels itself so — the route
never 500s on a dead daemon.

(cherry picked from commit fadd9f1499aee0f72a3b2b7a5bb524f514e76410)
The matrix, CLI-gaps and JSON-view sections were the mockup's prototype
commentary; the served card now ends at Resolve. Their render JS and the
introducing divider go with them.

(cherry picked from commit 96763bc9a0b596f93d04215e5e80bf05238715df)
Owner request: the Stand-proof note and Snapshot chips leave the rail;
a Workers block lists the four named workers (name, seat, runtime, live
dot), from window.SUTANDO_WORKERS when injected, baked names otherwise.

(cherry picked from commit 9eaf35e7d5a6539a21fc836d4c8b6a9c8c3080b8)
…emoved

Owner iteration: workers render as a full inspector section in the
Channels row grammar rather than a rail block; the Devices section,
its tile renderer and its tally slot go with it.

(cherry picked from commit c40c3163a2dce74b149599fcc4275fce5f08b5bf)
Workers -> Owner -> Channels; Resolve section + demo resolver removed.
Worker rows open the drawer; a stand-profile postMessage from the
hosting client applies names, avatar, base color and About from the
identity document, and the avatar tap posts an edit-profile hop back
up — the card renders, the client owns writes.

(cherry picked from commit d9dc420c55995ecc96674787f63c21249fe59001)
Avatar tap asks the host to open a picker (host owns the API write) and
toasts upload status; style/decorators/DM chips render from the posted
identity; the visibility switcher, its wiring and the channel advisory
are removed; profile posts merge instead of replace.

(cherry picked from commit 64ae7d4d253c29269af36e718efefe1f3bb17346)
`json.dumps` leaves `/` alone, so a `</script>` inside any stand field
ended the inlined <script> element early and the rest of the value ran
as markup. `<\/` is the same JSON to the parser, so the page receives
an identical payload with no token that can close the element.

Adds the route's first test: the escape, the two fallback paths (runtime
missing, error object), and /stand-card through the real handler.
@github-actions

github-actions Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 88%.

Diff Coverage

Diff: origin/rescue/pool-uncommitted-2026-08-26...HEAD, staged and unstaged changes

  • src/dashboard.py (100%)

Summary

  • Total: 21 lines
  • Missing: 0 lines
  • Coverage: 100%

…ling thread

The coverage gate reported the route branch uncovered even though the
loopback round trip exercised it: its tracer did not follow the server
thread. Serving one request through Handler on an in-memory socket runs
the same lines on the test's own thread, so they count under any core.
Also covers the 404 when the page file is absent.
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested at 96c2a032. COMMENT is used because this review account is also the author. The JSON </script> escape is repaired, but the complete new page has three blockers:

  1. [P1] Authenticate profile messages and avoid the avatar HTML sink — src/dashboard-stand-card.html:794–803. The message listener accepts stand-profile from any origin/source, then interpolates profile.avatarHttp into innerHTML. I loaded the actual page HTML produced by stand_card_response() in Chrome, framed it from a different origin, and sent avatarHttp: 'x" onerror="document.body.dataset.reviewXss=location.origin'. The frame's marker changed from absent to its own dashboard origin. No owner interaction or backend mutation was needed. Restrict messages to the trusted hosting client (source AND configured origin) and assign a validated avatar URL through DOM properties. The server's new script escape does not cover this second sink. Add a foreign-origin rejection test and an accepted-profile hostile-URL test.

  2. [P1] Do not report verification/link/revocation without performing it — src/dashboard-stand-card.html:845–876. With injected live channel data explicitly lacking a credential, clicking Verify and then approving Link produces ✓ 1 verified entrance; Revoke then shows Revoked. Chrome recorded zero network requests during those actions. These handlers only change VIEW and fabricate verification time/subject. On a page labeled live state, an owner can believe access was revoked while durable authorization is untouched. Bind the actions to the real governed APIs and update from their results, or disable them until implemented. Test success, refusal, and persistence after reload.

  3. [P2] Render live identity and workers instead of mixing in the sample — src/dashboard-stand-card.html:313–325,521–529,552–563. I supplied stand @review-fixture:example.test, display name Review Stand, inactive status, and owner Fixture Owner. The page announces live state but its main card still says Sutando / Qingyun Wu / @sutando-qingyun-001:ag2.space, Active, and four sample workers all active. The copy/QR code uses the supplied ID while the printed QR URIs remain the sample. Populate every visible identity/share field from one validated view; show unreported/empty workers until actual worker data is available. Add a non-sample identity and empty-worker browser case.

Browser proof used actual production-rendered HTML with only the runtime JSON response substituted, two distinct HTTP origins, and all network requests intercepted; it touched no live services or accounts. Output:

input Review Stand / Fixture Owner -> card Sutando / Qingyun Wu; four sample active workers
verify + link -> 1 verified entrance; requests=0
revoke -> Verified / Revoked; requests=0
foreign-origin avatar message -> marker=http://127.0.0.1:19401

Reviewed the full three-file child diff and surrounding dashboard mutation gates, response routing, CLI identity source, and frontend interactions. The parent #3604 head 6c0b416e is an ancestor. All 13 dashboard suites pass; the late 96c2a032 commit adds test-only 404/inline-handler coverage, and its expanded suite passes. Production HTML/server code is unchanged from the browser-tested ae460603. Added-path/root/Python-prose/whitespace checks pass with a forbidden-path positive control; the scan does not catch the clone-specific ~/Documents/github/sutando/... strings in the new adapter, which should also be derived from the runtime rather than guessed.

Worst-case disruption is attacker-controlled execution in the dashboard origin plus misleading authorization and identity state. Those are blocking despite the green route tests. Latest CI is still running on the new head. Keep merge order #3604 then this PR, rebase/recheck after the parent lands, and provide the required real deployment/restart witness before approval. No live restart or provider send was performed for this review.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

CLA check did not register on 96c2a03 (SHA-bound stall); close+reopen to retrigger it.

@qingyun-wu qingyun-wu closed this Sep 8, 2026
@qingyun-wu qingyun-wu reopened this Sep 8, 2026
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Closing per owner: the stand card is being removed. Branch deleted; commits remain reachable through this PR's history.

@qingyun-wu qingyun-wu closed this Sep 8, 2026
@qingyun-wu
qingyun-wu deleted the feat/stand-card-dashboard-r2 branch September 8, 2026 04:33
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