From e6db5428eb71f84a64c1d500bb4b6ea13bcc129a Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Sun, 6 Sep 2026 19:46:38 +0200 Subject: [PATCH] Add browser Serial/Admin terminal switching Keep the serial connection and lease intact while providing a separate, bounded admin terminal with explicit open and close controls. Fence retained terminal state across sessions and add fit-readiness retries with regression coverage. --- docs/agent/architecture.md | 4 + docs/agent/code-map.md | 1 + docs/agent/current-state.md | 10 +- docs/agent/design-decisions.md | 2 + docs/phase8d6_implementation.md | 75 +++++++++++ docs/phase8d_plan.md | 8 +- src/web_ui.c | 219 +++++++++++++++++++++++++------ tests/web_ui_session/README.md | 27 ++-- tests/web_ui_session/browser.cjs | 188 ++++++++++++++++++++++++-- 9 files changed, 471 insertions(+), 63 deletions(-) create mode 100644 docs/phase8d6_implementation.md diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index 05943e3..130923c 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -116,6 +116,10 @@ Web serial initialization is failure-isolated from the base HTTPS service: if th ### Browser admin backend +The 8D.6 document binds retained terminal state to its first validated username/role/session-stable CSRF tuple. Every later session adoption must match, otherwise both terminal hosts are hidden, both sockets/work are closed/fenced and a clean `/` document is required. Pagehide hides scrollback until same-session revalidation; no clearing is needed for unchanged-session restore or mode changes. Terminal-fit readiness uses successful-bounds caching and at most three generation-fenced animation-frame retries per external request, never unbounded polling. + +8D.6 `web_ui.c` now supplies the admin-only Serial/Admin selector using this backend. Selection leaves serial and any open admin socket connected and draining; only focus, displayed terminal and keyboard destination change. Broker identity/lease and writer controls remain serial-owned in both views. Admin opens/reopens explicitly and closes independently. Two page-lifetime terminals have separate 5,000-line scrollbacks and 64 KiB callback-accounted pending output each; browser overflow is dropped with visible counts. Admin input is bounded to 4 KiB admission and 512-byte frames. Logout/expiry/page exit closes both with generation fencing and socket-listener cleanup; bfcache revalidates serial/session but never automatically reopens admin. No server policy/capacity changes or 8D.7 lifecycle parity. See `docs/phase8d6_implementation.md`; the following paragraph describes the original backend boundary, before its UI entry was added. + 8D.5 additionally supplies `web_admin_transport` and `web_admin_tickets`: one optional admin socket, two 30-second digest-only tickets bound to current originating session/principal, the same two shared console slots, no serial broker client. Ticket POST requires cookie/Origin/CSRF/admin; ordinary GET upgrade requires cookie/Origin/admin/ticket and console admission before 101. Six total HTTPS sockets remain, LRU purge is disabled, and two routes bring the handler budget to 16. Optional admin registration/PSRAM allocation failures do not take down M1. A 20 ms ESP timer queues at most one HTTPD poll, with no new task; only HTTPD accesses the 1,552-byte PSRAM-only RX/TX payload or socket IO. Notifiers close the generation-qualified console and flag the socket. HTTPD shuts down the verified current fd directly and owns subsequent read cleanup, avoiding IDF's queued reusable `sock_db *` close race. Detach fences submissions; failed stop retains ownership, and queued state is retired only after successful HTTPD stop. Console dispatcher/prompt and owner input/output/idle checks enforce session and principal currentness. WEB supports deferred self-close only; parsed canonical policy denies unsupported lifecycle/network/account mutations before handler side effects. No normal UI entry, typed settings or lifecycle parity is included. See `docs/phase8d5_implementation.md` for validation limits and exact restrictions. ### SSH diff --git a/docs/agent/code-map.md b/docs/agent/code-map.md index 598beef..0373c8b 100644 --- a/docs/agent/code-map.md +++ b/docs/agent/code-map.md @@ -73,6 +73,7 @@ This is a semantic map, not a complete file inventory. Start here, then read the - 8D.3 HTTP policy: `web_cookie_auth` owns public login/challenge/login POST/session/logout routes and protected-route checks; `web_auth_parse` handles bounded values/JSON. `web_httpd_adapter` alone reads private IDF 5.5.0 header scratch, rejects duplicate fields, defers 101 until transport admission and wipes consumed scratch while preserving right-aligned pending bytes. No SDK patch. `src/CMakeLists.txt` supplies private includes and compiles HTTPD warning/debug logs out. Test with `python3 tests/web_cookie_auth/run.py` and `python3 tests/web_auth_parse/run.py`. - 8D.3 UI: `src/web_login_ui.{c,h}` serves standalone `/login`; `web_ui.c` validates session before serial connect/restore and handles logout/401 safely. Both scripts hash-bound, auth documents/app no-store. Tests: `python3 tests/web_login_ui/run.py` and `python3 tests/web_ui_session/run.py`. Live cutover host-tested/build-verified, M1 validated by user sign-off (numeric reserves open): `docs/phase8d3_implementation.md`. - Asset constraint: `web_assets_data.c` is checked-in generated input to the build; do not hand-edit or regenerate casually. +- 8D.6 UI: `web_ui.c` adds admin-only Serial/Admin selection and explicit admin open/close through existing endpoints. Serial socket/client/lease survives mode switches; hidden output drains into independent 5,000-line/64 KiB-pending terminals with visible browser-drop counts. Selected keyboard only; logout/expiry/pagehide closes both with handler cleanup. Session identity changes require a clean document before adopting the view; same-session restore retains hidden-until-validated buffers. Fit readiness retries are bounded to three and cache only success. Focused `tests/web_ui_session/run.py` now has 16 groups; target checks remain pending in `docs/phase8d6_implementation.md`. 8D.5 is user-validated; no 8D.7 restriction change. ### Browser admin backend (8D.5) diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md index 65f41ce..f7f4333 100644 --- a/docs/agent/current-state.md +++ b/docs/agent/current-state.md @@ -4,6 +4,12 @@ This file is working memory. Update it during active work and before handoff; do ## Development state +- **8D.6 final re-review complete (2026-09-06):** Independent reviewer confirmed session-identity isolation and bounded fit-readiness fixes and their regressions, with no remaining actionable findings in the reviewed changes. Final build/test results below stand; no further source change or build for this documentation update. Browser/target validation and numeric reserves remain pending, not phase sign-off or M2 completion. Stop before 8D.7 until separately requested after the validation decision. + +- **8D.6 independent review fixes implemented / host-tested / build-verified (2026-09-06), target pending:** Fixed both P2 findings without unrelated scope. Before session adoption, compare retained username/role/session-stable CSRF; mismatch hides old terminals, closes both routes/fences work and replaces the document at `/`, including live admin and logout paths. Pagehide keeps buffers hidden until same-session validation; unchanged identities retain scrollback. Fit caches bounds only after valid measurement/success, with three bounded readiness retries, teardown cancellation and generation fencing. Expanded UI harness passes **16 groups**; login eight groups, cookie/admin integration, parser 268 and store/serial reruns pass. Final finite `pio run` after all fixes: **21.35 s, 95,580 B RAM / 1,646,489 B flash**; review delta **0 / +1,376 B**, versus 8D.5 **0 / +9,216 B**, versus 8D.0 **+1,048 / +46,516 B**. `git diff --check` passes. See `docs/phase8d6_implementation.md` for exact regression cases and unchanged pending target checklist. Parent reviews fixes only; no target pass, reserve approval, sign-off or M2 completion. Existing worktree edits preserved; no server/protocol/assets/8D.7/upload/commit action. Supersedes initial 8D.6 counts/build below. + +- **8D.6 implemented / host-tested / build-verified (2026-09-06), target pending:** Admin-only Serial/Admin selector in authored `web_ui.c`, explicit Open/Close admin using existing protected endpoints, lazy separate 5,000-line terminals with 64 KiB pending writes each and visible browser-drop counts. Switching preserves serial socket/client/lease and drains hidden output; selected input only, writer controls visible in both. Admin closure is isolated; logout/expiry/pagehide cancel both with listener cleanup and stale-generation fencing. Existing 8D.5 restrictions/server/capacities unchanged; no assets/upload/commit. 13 UI groups, eight login groups, cookie/admin integration, parser 268, store/serial, admin transport 19/tickets 12, server lifecycle 11 and console boundary suites pass. Final finite `pio run`: **22.22 s, 95,580 B RAM / 1,645,113 B flash**, **0 / +7,840 B** versus 8D.5, **+1,048 / +45,140 B** versus 8D.0. Diff check passes. See `docs/phase8d6_implementation.md` for exact contracts, test limits/resource accounting and pending desktop/mobile/prompt/full-mix/soak checklist. Parent independent review pending; no target execution, numeric reserve approval, 8D.6 sign-off or M2 completion. Prior 8D.5 validation stands. Stop before separately requested 8D.7. This supersedes older planned-selector notes below. + - **8D.5 validated by explicit user sign-off (2026-09-06):** User supplied settled cold-boot telemetry and reports successful **15-minute full-client-mix active-use soak at 230400 baud**, with a few broker drops under extremely fast/dmesg output, then explicitly closes 8D.5. Supersedes older pending/incomplete notes below. Boot internal/DMA/PSRAM free **70,876 / 63,120 / 8,246,360 B**, minima **59,560 / 51,804 / 8,242,140 B**, largest **31,744 / 31,744 / 8,126,464 B**; SSH stack minimum-free **18,472 B**. Admin initialized/attached with ESP_OK and 167/240/1,552 B static/ticket/payload accounting; no active clients at boot. Full mix/soak is user-reported, not evidenced by the idle snapshot; exact drop count/client, flashed revision and loaded/post-soak/cleanup telemetry not supplied. See `docs/phase8d5_implementation.md`. These limits do not reopen sign-off or imply zero-drop operation. Numeric reserves/runtime socket cost remain open. No new build/device/source action for sign-off. Wait for separate **8D.6** request; M2 not yet complete. - **8D.5 backend implemented / host-tested / build-verified (2026-09-06), target pending:** Preserved interrupted tickets/transport/server/shared-console/SSH/policy/revocation work and inherited test tooling. One admin socket, two tickets, existing two shared console slots, 1,552 B PSRAM-only payload, 20 ms ESP timer/no new task, six HTTPS sockets/no LRU eviction, 16 URI handlers. Added real cookie/store/ticket/transport endpoint integration tests. Final IDF lifecycle review found queued `httpd_sess_trigger_close` retains a reusable socket-slot pointer; changed the admin path to HTTPD-owned `shutdown`, with HTTPD read cleanup and retry/reuse regressions. Transport 19 groups + tickets 12, server lifecycle 11, combined endpoints, both console suites, store/serial, cookie/parser (268), login UI (8), serial UI (9) all pass; diff check passes. **Parent reports sequential final `pio run` after the shutdown fix passed in 23.55 s at 95,580 B RAM / 1,637,273 B flash: +416/+9,224 versus prerequisite, +496/+10,100 versus 8D.4, +1,048/+37,300 versus 8D.0.** This supersedes the continuation's earlier pre-fix build; history remains in the implementation record. Parent's final independent security integration reviewer reported no actionable findings. Target ELF payload/slot/ticket sizes 1,552/80/96 B, ticket state+lock 240 B, transport static symbols 167 B before padding, ESP timer internal allocation 32 B before heap overhead. See `docs/phase8d5_implementation.md` for exact commands, inherited-versus-final fixes, resource accounting, restrictions and manual target/client checklist. Client `--help` only exercised in the continuation; this final evidence update changed documentation only, with no source/tests/build/device/network/asset/UI/upload/commit action. **Stop before 8D.6; target acceptance, runtime socket cost, numeric reserves and M2 remain open.** This supersedes the prerequisite-only pause below. @@ -66,8 +72,8 @@ Based on checked-in source plus `README.md` and `docs/roadmap.md`: ## Clearly incomplete or transitional areas - Phase 8C hardware validation passed, including route separation, shared command serialization, history/completion, prompts, output backpressure, revocation during queued work, deferred SSH lifecycle/reboot actions, and full concurrent transport operation. At 460800 baud with SSH and WebSocket clients in parallel, substantial packet drops and slow display controls were observed under load, without memory exhaustion; no baud-rate reduction is planned. -- Current shipped HTTPS UI gives both roles the same status/serial terminal. The 8D.5 admin backend is implemented without a UI entry; its temporary policy denies all user mutations and unsupported self-affecting lifecycle commands. -- Browser authentication uses cookie login/logout without Basic fallback. M1 target checkpoint is signed off; 8D.5 is implemented, host-tested and build-verified, with target gates recorded above. Browser selector/lifecycle parity remain future separately requested 8D.6/8D.7 work, not completed M2. +- Current HTTPS UI retains status/serial for both roles and exposes an admin-only selector in 8D.6. The 8D.5 backend's temporary policy still denies all user mutations and unsupported self-affecting lifecycle commands. +- Browser authentication uses cookie login/logout without Basic fallback. M1 and 8D.5 are signed off; 8D.6 selector target checks remain pending. Lifecycle parity remains separately requested 8D.7 work, not completed M2. - NVS encryption, secure boot/flash encryption review, production certificate/provisioning policy, and OTA are not implemented. HTTPS login has a bounded global five-verifications/60-second throttle, not comprehensive cross-transport DoS protection. ## Known inconsistencies diff --git a/docs/agent/design-decisions.md b/docs/agent/design-decisions.md index fccd5a6..6a7da64 100644 --- a/docs/agent/design-decisions.md +++ b/docs/agent/design-decisions.md @@ -100,6 +100,8 @@ Phase 8D.4 routes drain/lifecycle operations through a firmware-lifetime immutab ## Browser authentication has a narrow version-pinned HTTPD boundary +**8D.6 terminal separation:** Browser selection never reconnects serial or requests/releases a writer lease. Hidden connected terminals continue draining with separate bounded scrollback/pending writes and visible browser-drop accounting; only selected keyboard input is sent. Admin admission/reopen is explicit, close is isolated, and logout/expiry/pagehide tears down both routes. Keep the two page-lifetime input subscriptions stable across switches and remove socket callbacks on close. UI role hiding complements, never replaces, backend authorization. Existing unsupported WEB lifecycle/account-command restrictions remain for 8D.7. + **Decision:** 8D.3 uses `web_cookie_auth` plus digest-only session/challenge stores, mandatory Origin/CSRF mutations and no live session/challenge/ticket eviction. Four one-hour absolute sessions deliberately interrupt long serial connections at expiry. No Basic compatibility path remains. **Browser Origin serialization:** Authentication POST fetches use `mode: 'cors'` while retaining fixed same-origin URLs, `credentials: 'same-origin'`, redirect rejection and CSP `connect-src 'self'`. Under `no-referrer`, non-CORS POST mode can serialize Origin as `null` (confirmed in Firefox during M1 testing). Do not fix that by accepting null server-side or weakening CSP/referrer policy; no cross-origin server permission is added. diff --git a/docs/phase8d6_implementation.md b/docs/phase8d6_implementation.md new file mode 100644 index 0000000..e7cb23e --- /dev/null +++ b/docs/phase8d6_implementation.md @@ -0,0 +1,75 @@ +# Phase 8D.6 Implementation + +## Status and Scope + +Implemented, host-tested and build-verified on 2026-09-06. **Target/browser validation pending; no phase sign-off or M2 completion.** Prior 8D.5 user sign-off stands. Independent final re-review confirmed both session-isolation and fit-readiness findings resolved, with no remaining actionable findings in those fixes. Real-browser xterm and bfcache behavior remain target checks. + +The starting worktree was clean. Production changes are confined to authored `src/web_ui.c`; focused tests extend `tests/web_ui_session/`. No server/protocol/settings/8D.7 policy change, generated asset regeneration, commit, upload or erase. The change fits the work-unit guideline without a preparatory split. + +## Browser Contract + +- Validated `/api/session` role reveals Serial/Admin selection only for admins. Ordinary users retain serial-only navigation; existing server authorization remains authoritative. +- Selecting Admin lazily creates one additional xterm instance. Open admin explicitly POSTs `/api/admin/ws-ticket` with the existing CSRF/cookie policy, then connects `/ws/admin`. Selection alone does not open a socket or run a command. Failures and shell termination require explicit reopen, not automatic command/session replay. +- Mode switches only change visibility, focus, selected-terminal sizing and input gating. Both connected sockets continue receiving output. The same serial socket, broker identity and writer lease remain; broker status and Request control/Release control remain visible in both modes. +- Serial and admin have separate 5,000-line scrollbacks and separate 65,536-byte pending xterm-write limits, released through write callbacks. Excess frames are discarded while the socket continues draining, with cumulative per-terminal browser-drop byte counts visible in both modes. The UI explicitly states oldest scrollback lines expire. These counts are not firmware broker-drop telemetry or a byte-integrity guarantee. +- Keyboard/paste goes only to the selected terminal. Serial retains its writer check and 1,024-byte framing; admin sends binary frames of at most 512 bytes with a 4,096-byte input event/socket-buffer admission limit. Excess admin input closes only the shell rather than silently truncating a command. Earlier accepted input cannot be rolled back. +- Close admin, shell `exit` and admin failures do not disconnect serial. Explicit serial Disconnect still closes only serial and pauses its reconnect. Admin open has a 15-second handshake deadline in addition to the existing 15-second API deadline. Capacity/security errors remain safe-text/manual retry; server admission errors during upgrade appear as generic connection failure. +- Logout, session expiry/401 and pagehide cancel pending work and close both routes. Generation guards reject late responses/callbacks; socket handler properties are cleared before close. Resize listeners/observer are removed on exit and restored once for bfcache. Restored pages revalidate the session and preserve serial pause policy; admin requires explicit reopen. At most two terminals and one input subscription each persist for the page lifetime, not one per switch/reconnect. +- Scrollback belongs to the document's first validated username/role/session-stable CSRF tuple, retained separately from the active request CSRF value. Before adopting any later session view, a mismatch hides both terminals, closes both routes, fences pending work and replaces the document at `/`. This also prevents logout from mutating a replacement session. Pagehide hides both terminal hosts and blocks selection until successful same-session validation; same-session restore and mode switches retain scrollback. Failed or delayed restore validation never reveals the retained buffers. +- Terminal bounds are cached only after valid dimensions and successful resize (or an already matching size). An unavailable/invalid measurement gets at most three animation-frame retries per external fit request. Work cancellation removes the pending frame, resets the retry budget and generation-fences stale callbacks; teardown/logout cannot restart readiness retries. A later resize/selection can explicitly start a fresh bounded attempt. +- Existing dark panel/button visual language is retained, selected buttons expose `aria-pressed`, and narrow layouts can scroll rather than clipping the terminal below the dashboard. Real desktop/mobile rendering remains to be checked. The inline loader and its exact CSP hash are unchanged and tested; authored app/document remain no-store. + +## Executed Validation + +All commands ran successfully in this workspace: + +| Command | Result | +|---|---| +| `python3 tests/web_ui_session/run.py` | Production C renderer/header/failure/CSP checks and 16 Node groups pass | +| `python3 tests/web_login_ui/run.py` | C/HTML checks and eight Node groups pass | +| `python3 tests/web_cookie_auth/run.py --admin` | Cookie/session policy plus combined real store/ticket/admin endpoint tests pass | +| `python3 tests/web_auth_parse/run.py` | 268 cases, zero failures | +| `python3 tests/web_session_store/run.py --serial` | Store and serial binding/isolation/races pass | +| `python3 tests/web_admin_transport/run.py --tickets` | 19 transport groups and 12 ticket groups pass | +| `python3 tests/web_admin_transport/server_lifecycle.py` | 11 lifecycle groups pass | +| `python3 tests/admin_console_boundary/run.py` | Shared-owner/currentness/prompt/completion/SSH boundary suites pass | +| `pio run` | Final source build after review fixes succeeds in 21.35 seconds, 120-second finite timeout | +| `git diff --check` | Pass | + +New UI checks cover 20 switch cycles with unchanged serial socket/client/writer IDs, hidden binary output and independent terminals, selected input framing, user-only navigation, visible overflow and resumed draining, close/reopen/remote-close isolation, 401/logout/pagehide/expiry, cancelled admin ticket late 401, stale socket callbacks, selected resize, three bfcache listener cleanup cycles and handshake timeout. The harness executes production C-rendered JavaScript with DOM/xterm/WebSocket/timer doubles. It does not execute real terminal escape parsing, hidden prompts, CSS layout, browser CSP enforcement, TLS/HTTPD scheduling, target UART traffic or browser heap profiling. + +### Independent Review Fixes (2026-09-06) + +Both reported P2 findings are fixed, with parent re-review pending: + +- **Cross-session retained scrollback/live admin:** `loadSession` now compares identity before adopting CSRF, role, expiry or view. It requires a clean document on mismatch rather than reusing A's buffers or live admin socket under B's cookie. Regression coverage checks admin-to-admin, admin-to-user, same-account/new-CSRF, username-only and role-only changes, each during active restore, paused restore, live serial reconnect with admin still open, and logout. Tests assert clean `/` replacement, hidden old terminals, both sockets closed, no new ticket/logout request, unchanged old session view and fenced callbacks. Same-identity restore retains both scrollbacks and live same-session reconnect retains admin. +- **Failed initial fit cached forever:** Valid measurements alone populate the bounds cache; three bounded readiness retries recover from an initially undefined measurement at identical host bounds. Tests also exhaust retries, verify later external retry, and invoke stale callbacks after pagehide/restore, expiry and logout. The first test run exposed a fresh fit scheduled by logout's session check; suppressing fits while logging out fixed that case before the final passing rerun/build. + +After the fixes, reran `tests/web_ui_session/run.py` (16 groups), `tests/web_login_ui/run.py` (8 groups), `tests/web_cookie_auth/run.py --admin`, `tests/web_session_store/run.py --serial` and `tests/web_auth_parse/run.py` (268 cases), all with `python3`, all passing. The other unchanged-backend suites in the table passed during initial implementation and were not rerun for these UI-only review fixes. Final `pio run` and `git diff --check` pass. No target/browser result, phase sign-off or M2 completion is claimed. + +## Resource Accounting + +Final PlatformIO espressif32 6.12.0 / ESP-IDF 5.5.0 N16R8 release build: + +| Metric | Final | Versus signed-off 8D.5 build | Versus recorded 8D.0 | +|---|---:|---:|---:| +| Linked RAM | 95,580 B | 0 B | +1,048 B | +| Flash | 1,646,489 B | +9,216 B | +46,516 B | + +The delta is authored HTML/CSS/JavaScript read-only content. Review fixes add **0 B RAM / 1,376 B flash** versus the initial 8D.6 build (22.22 seconds, 95,580 / 1,645,113 B). An earlier pre-final-CSS build passed in 25.30 seconds at 95,580 / 1,644,905 B. The table is the final post-review-fix source result. Browser-only additions are one retained session identity tuple and a retry counter using the existing single animation-frame slot, not new firmware storage or a polling task. + +No firmware runtime allocations, tasks/stacks, queues, request scratch, route count or capacities are added. Existing six HTTPS socket slots, two serial sockets, one optional admin socket, two admin tickets and two shared console slots are unchanged. The UI now exercises the already-allocated backend with simultaneous serial/admin WebSockets plus periodic HTTPS requests; actual loaded TLS/socket/console heap headroom still requires target measurement. There is no LRU eviction/capacity increase to make the selector work. + +Browser cost adds a lazy terminal/fit addon and 5,000 lines of scrollback, plus at most 64 KiB pending output payload per terminal (128 KiB together) and callback/object overhead. This is not a total browser heap bound: xterm cell storage depends on dimensions, browser networking queues are implementation-owned, and JavaScript cannot securely wipe engine-managed strings. Numeric internal/DMA/largest-block/stack reserve gates remain open. + +## Target Checklist (Pending) + +1. Record exact flashed revision, browser versions and direct-IP/mDNS origin. Verify user serial-only login and admin selector on desktop and narrow/mobile screens, selected styling/focus, resize/orientation and usable terminal height. Exercise initially unavailable font/cell measurements: sizing must recover at unchanged bounds or after a later explicit resize, without unbounded retries. +2. With serial writer active and sustained UART output, perform 20 Serial/Admin switches. Confirm unchanged broker client/writer IDs, Request/Release from both modes, continued hidden output and no admin keystrokes on UART1. Repeat as observer. Distinguish browser-drop counts, firmware broker drops and expected scrollback rollover. +3. Exercise admin empty Enter, normal commands, history, Tab completion, Ctrl+C and hidden prompts while switching views. Confirm no input/output/prompt crossover with UART0/admin SSH. Close/reopen five times and use `exit`/empty Ctrl+D; serial must remain intact. Explicit serial Disconnect/reconnect must not close admin. +4. Test admin capacity rejection with existing console/socket occupancy, network failure, stale ticket and repeated manual reopen; retained serial must not be evicted. Confirm existing 8D.5 unsupported self-affecting command restrictions still reject without side effects; no 8D.7 parity is claimed. +5. Test logout, account revocation, absolute expiry, pagehide/bfcache restore, ordinary reload and interrupted logout with both routes open and with a pending ticket. No stale admin command/session replay; expired auth returns to usable login. Recheck unrelated-session isolation. Change the same-origin cookie via another tab/account login (admin-to-admin and admin-to-user), then restore/reconnect: no old scrollback may appear in the new document, and old admin must close. Same-session restore must retain both scrollbacks; delayed/failed revalidation must leave them hidden. +6. Run UART0 recovery, USB UART1, user/admin SSH and two-browser serial plus one browser-admin full mix with HTTPS polling. Capture `memory`, transport/broker counters and SSH stack margins at settled boot, serial-only, serial+admin, full load and after five open/close/logout cycles. Compare internal/DMA/PSRAM free/minimum/largest blocks without inventing reserve floors. +7. Run a bounded 15-minute mixed-client soak at the user's supported workload (record actual baud; prior 8D.5 was 230400), then disconnect and collect 60-second cleanup telemetry. Check no watchdog/stack faults, monotonic leaks, declining largest-block trend or unexplained lease loss. Profile browser memory across repeated switches and output overflow where feasible. + +Stop for target validation/user decision and independent parent review. Next implementation is separately requested **8D.7**, not settings or M2 sign-off. diff --git a/docs/phase8d_plan.md b/docs/phase8d_plan.md index 3cc537b..156bbc3 100644 --- a/docs/phase8d_plan.md +++ b/docs/phase8d_plan.md @@ -1,6 +1,6 @@ # Phase 8D — Incremental web administration plan -Status: **8D.0–8D.5 and M1 validated by explicit user sign-off. 8D.5 closed on 2026-09-06 after settled cold-boot evidence and a reported successful 15-minute full-client-mix soak at 230400 baud, with a few broker drops under heavy output. Numeric reserve gates remain open. 8D.6–8D.22 remain planned, each requiring a separate implementation request.** See the [8D.5 implementation record](phase8d5_implementation.md), [8D.4 implementation record](phase8d4_implementation.md), [8D.3 implementation record](phase8d3_implementation.md) and [8D.0 baseline/M1 contract](phase8d_baseline.md). +Status: **8D.0–8D.5 and M1 validated by explicit user sign-off. 8D.6 implemented, host-tested and build-verified; target validation and independent parent review pending, no phase sign-off. 8D.5 closure on 2026-09-06 stands. Numeric reserve gates remain open. 8D.7–8D.22 remain planned, each requiring a separate implementation request.** See the [8D.6 implementation record](phase8d6_implementation.md), [8D.5 implementation record](phase8d5_implementation.md), [8D.4 implementation record](phase8d4_implementation.md), [8D.3 implementation record](phase8d3_implementation.md) and [8D.0 baseline/M1 contract](phase8d_baseline.md). This is the execution plan for [roadmap Phase 8D](roadmap.md#phase-8--role-based-users-and-administrative-access). The roadmap retains the feature/security requirements; this document defines small work units, dependencies, and release gates. The [administration test matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration) remains the final acceptance checklist. @@ -126,6 +126,8 @@ If 8D.3 exceeds the work-unit limit, first split out inert login-page rendering ### 8D.6 — Browser terminal selector and serial-lease preservation +**Implementation checkpoint (2026-09-06, updated after review fixes):** [8D.6 implementation, accounting and target checklist](phase8d6_implementation.md). Implemented / host-tested / build-verified; target validation and parent re-review of fixes pending, no phase sign-off or M2 completion. Both P2 findings fixed: session identity changes require a clean document before adopting a new view, and fit caches only successful measurements with three bounded readiness retries. Sixteen production-rendered UI groups and focused UI/auth/store reruns pass; prior admin/console regression results remain recorded. Final finite `pio run`: **21.35 s, 95,580 B RAM / 1,646,489 B flash**, **0 / +9,216 B** versus 8D.5; cumulative **+1,048 / +46,516 B** versus 8D.0; review-only delta **0 / +1,376 B**. No backend/capacity/8D.7 restriction/asset change. Numeric reserves remain open; prior sign-offs stand. Stop for validation/user decision before separately requested 8D.7. This supersedes older planned-8D.6 status text in this document. + **Start in:** `src/web_ui.{c,h}`, using the completed serial/admin protocols. **Scope:** Add admin-only Serial/Admin selection and separate bounded terminal state; no new settings. Keep serial connected and drained/observed while hidden. Keep writer/observer identity and Request control/Release control visible in both modes. Mode changes route displayed output and keyboard input only. Closing the admin route or shell `exit` leaves serial intact; explicit serial Disconnect retains its documented cleanup/reconnect behavior. Bound scrollback and avoid leaking browser listeners/sockets across switches. @@ -175,11 +177,11 @@ Update the roadmap and user/command documentation to distinguish completed featu ## Progress and next-request template -Progress: **8D.0–8D.5 and M1 validated by user sign-off; numeric reserves remain open. 8D.6–8D.22 planned.** Record incremental results in `docs/agent/current-state.md`, retaining the [baseline](phase8d_baseline.md) and cumulative resource measurements as work proceeds. The baseline records user-provided evidence and sign-off; this does not imply completion of later browser-authentication acceptance checks. +Progress: **8D.0–8D.5 and M1 validated by user sign-off; numeric reserves remain open. 8D.6 implemented/host-tested/build-verified, target pending. 8D.7–8D.22 planned.** Record incremental results in `docs/agent/current-state.md`, retaining the [baseline](phase8d_baseline.md) and cumulative resource measurements as work proceeds. The baseline records user-provided evidence and sign-off; this does not imply completion of later browser-authentication acceptance checks. Suggested next request: -> M1 and 8D.5 are validated by explicit user sign-off. Work on Phase 8D.6 only: implement the browser terminal selector while preserving the serial connection, broker identity and writer lease when hidden. Preserve signed-off evidence and open numeric reserve gates; do not implement 8D.7 lifecycle parity or settings. +> Review and target-validate Phase 8D.6 using its implementation checklist. Preserve the serial connection, broker identity and writer lease while switching terminals. Record actual browser/target results and open numeric reserve gates; do not implement 8D.7 lifecycle parity or settings without a separate request. For later chunks: diff --git a/src/web_ui.c b/src/web_ui.c index 4c86d1d..0bd4a0e 100644 --- a/src/web_ui.c +++ b/src/web_ui.c @@ -25,6 +25,7 @@ static const char s_index_html[] = "--line:#29364a;--text:#e8eef8;--muted:#91a0b5;--accent:#55c2ff;" "--good:#52d68b;--warn:#ffc857;--bad:#ff6b7a;--radius:14px}\n" "*{box-sizing:border-box}\n" + "[hidden]{display:none!important}\n" "html,body{height:100%;margin:0;overflow:hidden}\n" "body{background:radial-gradient(circle at top left,#142033 0,var(--bg) 42rem);" "color:var(--text);font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont," @@ -65,6 +66,7 @@ static const char s_index_html[] = "transition:background .15s,border-color .15s,transform .15s}\n" ".button:hover:not(:disabled){background:#203652;border-color:#4b789f;transform:translateY(-1px)}\n" ".button.primary{background:#126390;border-color:#278abd}\n" + ".button[aria-pressed=true]{background:#126390;border-color:#278abd}\n" ".button.danger{background:#512631;border-color:#81404e}\n" ".button:disabled{cursor:not-allowed;opacity:.42}\n" ".input-state{margin:0;color:var(--warn);font-size:13px}\n" @@ -74,11 +76,13 @@ static const char s_index_html[] = ".terminal-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;" "padding:1px 5px 9px;color:var(--muted);font-size:12px}\n" ".terminal-title{color:var(--text);font-weight:750;letter-spacing:.02em}\n" - "#terminal{flex:1;min-width:0;min-height:0;border-radius:9px;overflow:hidden;" + ".terminal-host{flex:1;min-width:0;min-height:0;border-radius:9px;overflow:hidden;" "background:#080c12;padding:8px}\n" - "#terminal .xterm{width:100%;height:100%}\n" - "#terminal .xterm-viewport{border-radius:7px}\n" - "@media(max-width:850px){.dashboard{grid-template-columns:1fr}.controls{align-items:flex-start}" + ".terminal-host .xterm{width:100%;height:100%}\n" + ".terminal-host .xterm-viewport{border-radius:7px}\n" + ".terminal-toolbar{flex-wrap:wrap}.terminal-toolbar .button{min-height:32px;padding:4px 10px}\n" + "@media(max-width:850px){html,body{overflow:auto}.page{height:auto;min-height:100dvh;grid-template-rows:auto auto minmax(280px,1fr)}" + ".terminal-panel{min-height:280px}.dashboard{grid-template-columns:1fr}.controls{align-items:flex-start}" ".status-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}\n" "@media(max-width:480px){.page{padding:10px;gap:10px}.topbar{align-items:flex-start}" ".logo{width:36px;height:36px}.status-grid{padding:10px;gap:8px}" @@ -156,10 +160,16 @@ static const char s_index_html[] = "

Loading application… If loading fails, open login or reload this page.

\n" "\n" "\n" - "
\n" - "
Live serial stream" - "Binary, unmodified device output
\n" - "
\n" + "
\n" + "
Live serial stream" + "" + "
\n" + "\n" + "

Scrollback: 5000 lines per terminal; oldest lines expire.

\n" + "
\n" + "\n" "
\n" "\n" "\n" @@ -184,6 +194,12 @@ static const char s_app_js[] = "const signOut = element('sign-out');\n" "const sessionInfo = element('session-info');\n" "const terminalHost = element('terminal');\n" + "const adminHost = element('admin-terminal');\n" + "const adminToggle = element('admin-toggle');\n" + "const adminDetail = element('admin-detail');\n" + "let accountRole = 'user', selected = 'serial';\n" + "let adminTerminal = null, adminFit = null, adminSocket = null, adminAbort = null;\n" + "let adminGeneration = 0, adminTimer = null;\n" "const terminal = new Terminal({\n" " allowProposedApi: false, convertEol: false, cursorBlink: true, disableStdin: true,\n" " fontFamily: '\"SFMono-Regular\",Consolas,\"Liberation Mono\",monospace',\n" @@ -198,7 +214,93 @@ static const char s_app_js[] = "terminal.loadAddon(fitAddon);\n" "terminal.open(terminalHost);\n" "const encoder = new TextEncoder();\n" + "// Bound xterm's asynchronous write backlog separately from its scrollback.\n" + "const output = {serial: {pending: 0, dropped: 0}, admin: {pending: 0, dropped: 0}};\n" + "function writeOutput(name, target, data) {\n" + " const state = output[name];\n" + " if (data.byteLength > 65536 - state.pending) {\n" + " state.dropped = Math.min(Number.MAX_SAFE_INTEGER, state.dropped + data.byteLength);\n" + " element('output-detail').textContent = `Browser output dropped: serial ${output.serial.dropped} B, admin ${output.admin.dropped} B. Scrollback: 5000 lines each; oldest lines expire.`;\n" + " return;\n" + " }\n" + " state.pending += data.byteLength;\n" + " target.write(new Uint8Array(data), () => { state.pending -= data.byteLength; });\n" + "}\n" + "function closeAdmin(detail = 'Admin closed. Serial connection and lease are unchanged.') {\n" + " ++adminGeneration;\n" + " if (adminAbort) adminAbort.abort();\n" + " adminAbort = null;\n" + " window.clearTimeout(adminTimer); adminTimer = null;\n" + " if (adminSocket) {\n" + " const previous = adminSocket; adminSocket = null;\n" + " previous.onopen = previous.onmessage = previous.onerror = previous.onclose = null;\n" + " previous.close();\n" + " }\n" + " adminDetail.textContent = detail;\n" + " updateControls();\n" + "}\n" + "function selectTerminal(mode) {\n" + " if (unloading || navigating || loggingOut || !sessionVerified || (mode === 'admin' && accountRole !== 'admin')) return;\n" + " selected = mode;\n" + " if (mode === 'admin' && !adminTerminal) {\n" + " adminTerminal = new Terminal({...terminal.options, disableStdin: true, scrollback: 5000});\n" + " adminFit = new FitAddon.FitAddon(); adminTerminal.loadAddon(adminFit); adminTerminal.open(adminHost);\n" + " adminTerminal.onData((data) => {\n" + " if (selected !== 'admin' || !adminSocket || adminSocket.readyState !== WebSocket.OPEN || unloading || navigating || suspended) return;\n" + " const bytes = encoder.encode(data);\n" + " if (bytes.length > 4096 || adminSocket.bufferedAmount + bytes.length > 4096) {\n" + " closeAdmin('Admin input exceeded the browser buffer limit; shell closed to discard partial input. Open admin to retry.'); return;\n" + " }\n" + " for (let offset = 0; offset < bytes.length; offset += 512) adminSocket.send(bytes.subarray(offset, offset + 512));\n" + " });\n" + " }\n" + " terminalHost.hidden = mode !== 'serial'; adminHost.hidden = mode !== 'admin';\n" + " adminToggle.hidden = adminDetail.hidden = mode !== 'admin';\n" + " element('select-serial').setAttribute('aria-pressed', String(mode === 'serial'));\n" + " element('select-admin').setAttribute('aria-pressed', String(mode === 'admin'));\n" + " element('terminal-title').textContent = mode === 'serial' ? 'Live serial stream' : 'Administration shell';\n" + " lastFitWidth = lastFitHeight = 0; updateControls(); scheduleFit();\n" + " (mode === 'serial' ? terminal : adminTerminal).focus();\n" + "}\n" + "async function openAdmin() {\n" + " if (accountRole !== 'admin' || selected !== 'admin' || unloading || navigating || loggingOut || suspended || !csrf || adminSocket || adminAbort) return;\n" + " const generation = ++adminGeneration, work = workGeneration;\n" + " const controller = new AbortController(); adminAbort = controller;\n" + " const current = () => generation === adminGeneration;\n" + " adminDetail.textContent = 'Opening admin shell...'; updateControls();\n" + " try {\n" + " const {payload} = await api('/api/admin/ws-ticket', work, {method: 'POST', signal: controller.signal, current});\n" + " if (!payload || !/^[A-Za-z0-9_-]{32}$/.test(payload.ticket)) throw new Error('Invalid ticket');\n" + " if (!live(work) || !current()) return;\n" + " const url = new URL('/ws/admin', window.location.origin); url.protocol = 'wss:';\n" + " url.searchParams.set('ticket', payload.ticket); payload.ticket = '';\n" + " const next = new WebSocket(url.toString()); url.search = '';\n" + " adminSocket = next; adminAbort = null; next.binaryType = 'arraybuffer';\n" + " const active = () => live(work) && current() && adminSocket === next;\n" + " adminTimer = window.setTimeout(() => { if (active()) closeAdmin('Admin connection timed out. Open admin to retry.'); }, 15000);\n" + " next.onopen = () => {\n" + " if (!active()) return;\n" + " window.clearTimeout(adminTimer); adminTimer = null;\n" + " adminDetail.textContent = 'Admin connected. Input goes only to the selected terminal. Existing web-shell command restrictions apply.'; updateControls();\n" + " };\n" + " next.onmessage = (event) => {\n" + " if (!active()) return;\n" + " if (!(event.data instanceof ArrayBuffer)) { closeAdmin('Invalid admin output. Open admin to retry.'); return; }\n" + " writeOutput('admin', adminTerminal, event.data);\n" + " };\n" + " next.onerror = () => { if (active()) closeAdmin('Admin connection failed or capacity unavailable. Open admin to retry.'); };\n" + " next.onclose = () => { if (active()) closeAdmin('Admin shell ended. Serial is unchanged. Open admin to reconnect.'); };\n" + " } catch (error) {\n" + " if (live(work) && current()) closeAdmin(error.status ? error.message + ' Open admin to retry explicitly.' : 'Admin connection failed. Open admin to retry.');\n" + " }\n" + "}\n" "let socket = null;\n" + "function closeSerial() {\n" + " if (!socket) return;\n" + " const previous = socket; socket = null;\n" + " previous.onopen = previous.onmessage = previous.onerror = previous.onclose = null;\n" + " previous.close();\n" + "}\n" "let ticketAbort = null;\n" "let reconnectTimer = null;\n" "let reconnectDelay = 1000;\n" @@ -209,11 +311,13 @@ static const char s_app_js[] = "let writerId = 0;\n" "let unloading = false;\n" "let fitFrame = 0;\n" + "let fitRetries = 0;\n" "let lastFitWidth = 0;\n" "let lastFitHeight = 0;\n" "let statusInFlight = false;\n" "let statusTimer = null;\n" "let csrf = '';\n" + "let sessionIdentity = null, sessionVerified = false;\n" "let sessionDeadline = 0;\n" "let expiryTimer = null;\n" "let navigating = false;\n" @@ -226,6 +330,9 @@ static const char s_app_js[] = "const cancelWork = () => {\n" " ++workGeneration;\n" " ++connectionGeneration;\n" + " if (fitFrame) window.cancelAnimationFrame(fitFrame);\n" + " fitFrame = 0; fitRetries = 0;\n" + " closeAdmin();\n" " clearReconnectTimer();\n" " for (const controller of requests) controller.abort();\n" " requests.clear();\n" @@ -236,20 +343,22 @@ static const char s_app_js[] = " statusInFlight = false;\n" " window.clearTimeout(expiryTimer);\n" " expiryTimer = null;\n" - " if (socket) { const previous = socket; socket = null; previous.close(); }\n" + " closeSerial();\n" " clientId = null;\n" " clientIdField.textContent = '—';\n" " setRole('observer');\n" "};\n" - "const login = () => {\n" + "const login = (path = '/login') => {\n" " if (navigating) return;\n" " navigating = true;\n" " csrf = '';\n" " cancelWork();\n" " reconnectEnabled = false;\n" " if (fitFrame) window.cancelAnimationFrame(fitFrame);\n" + " if (resizeObserver !== null) resizeObserver.disconnect();\n" + " window.removeEventListener('resize', scheduleFit);\n" " updateControls();\n" - " if (!window.sakLoginNavigating) { window.sakLoginNavigating = true; window.location.replace('/login'); }\n" + " if (!window.sakLoginNavigating) { window.sakLoginNavigating = true; window.location.replace(path); }\n" "};\n" "window.sakSessionExpired = login;\n" "if (window.sakLoginNavigating) { navigating = true; reconnectEnabled = false; }\n" @@ -308,7 +417,21 @@ static const char s_app_js[] = " !Number.isInteger(payload.expires_in) || payload.expires_in < 0 || payload.expires_in > 3600) {\n" " throw new Error('Invalid session response. Reload to retry.');\n" " }\n" + " // CSRF is stable for a session; never attach a new cookie identity to old scrollback.\n" + " if (sessionIdentity && (sessionIdentity.username !== payload.username ||\n" + " sessionIdentity.role !== payload.role || sessionIdentity.csrf !== payload.csrf)) {\n" + " payload.csrf = ''; sessionIdentity = null; sessionVerified = false;\n" + " terminalHost.hidden = adminHost.hidden = true;\n" + " login('/'); return false;\n" + " }\n" + " sessionIdentity = {username: payload.username, role: payload.role, csrf: payload.csrf};\n" + " sessionVerified = true;\n" " csrf = payload.csrf; payload.csrf = '';\n" + " accountRole = payload.role;\n" + " element('terminal-selector').hidden = accountRole !== 'admin';\n" + " if (accountRole !== 'admin') { closeAdmin(); selectTerminal('serial'); }\n" + " terminalHost.hidden = selected !== 'serial'; adminHost.hidden = selected !== 'admin';\n" + " scheduleFit();\n" " const deadline = Date.now() + payload.expires_in * 1000;\n" " sessionDeadline = sessionDeadline ? Math.min(sessionDeadline, deadline) : deadline;\n" " sessionInfo.textContent = `${payload.username} · Session expires at ${new Date(sessionDeadline).toLocaleTimeString()} (one hour absolute; traffic does not extend it).`;\n" @@ -354,7 +477,11 @@ static const char s_app_js[] = "const socketOpen = () => socket !== null && socket.readyState === WebSocket.OPEN;\n" "const updateControls = () => {\n" " const writer = role === 'writer';\n" - " terminal.options.disableStdin = !writer;\n" + " terminal.options.disableStdin = !writer || selected !== 'serial' || !socketOpen();\n" + " const adminOpen = adminSocket !== null && adminSocket.readyState === WebSocket.OPEN;\n" + " if (adminTerminal) adminTerminal.options.disableStdin = selected !== 'admin' || !adminOpen || suspended || unloading || navigating;\n" + " adminToggle.textContent = adminSocket || adminAbort ? 'Close admin' : 'Open admin';\n" + " adminToggle.disabled = accountRole !== 'admin' || unloading || navigating || loggingOut || suspended || !csrf;\n" " requestControl.disabled = !socketOpen() || writer;\n" " releaseControl.disabled = !socketOpen() || !writer;\n" " const connectionActive = reconnectEnabled || socket !== null || ticketAbort !== null || reconnectTimer !== null;\n" @@ -366,6 +493,7 @@ static const char s_app_js[] = " inputState.textContent = writer\n" " ? 'Writer mode — terminal input is enabled.'\n" " : 'Observer mode — terminal input is disabled.';\n" + " if (selected === 'admin') inputState.textContent = `Serial ${writer ? 'writer lease retained' : 'observer'}; serial input disabled while hidden. Admin input ${adminOpen ? 'enabled' : 'disabled'}.`;\n" " setBadge(roleStatus, writer ? 'Writer' : 'Observer', writer ? 'good' : 'warn');\n" "};\n" "const setRole = (nextRole) => {\n" @@ -418,7 +546,7 @@ static const char s_app_js[] = " return;\n" " }\n" " if (event.data instanceof ArrayBuffer) {\n" - " terminal.write(new Uint8Array(event.data));\n" + " writeOutput('serial', terminal, event.data);\n" " }\n" "};\n" "async function requestTicket(signal, generation) {\n" @@ -435,11 +563,7 @@ static const char s_app_js[] = " const work = workGeneration;\n" " if (ticketAbort !== null) ticketAbort.abort();\n" " ticketAbort = new AbortController();\n" - " if (socket !== null) {\n" - " const previous = socket;\n" - " socket = null;\n" - " previous.close();\n" - " }\n" + " closeSerial();\n" " clientId = null;\n" " clientIdField.textContent = '—';\n" " setRole('observer');\n" @@ -458,26 +582,26 @@ static const char s_app_js[] = " url.search = '';\n" " nextSocket.binaryType = 'arraybuffer';\n" " socket = nextSocket;\n" - " nextSocket.addEventListener('open', () => {\n" + " nextSocket.onopen = () => {\n" " if (!live(work) || generation !== connectionGeneration || socket !== nextSocket) return;\n" " setConnection('Connected', 'good', 'Connected; waiting for broker role information.');\n" - " });\n" - " nextSocket.addEventListener('message', (event) => {\n" + " };\n" + " nextSocket.onmessage = (event) => {\n" " if (live(work) && generation === connectionGeneration && socket === nextSocket) handleSocketMessage(event);\n" - " });\n" - " nextSocket.addEventListener('error', () => {\n" + " };\n" + " nextSocket.onerror = () => {\n" " if (live(work) && generation === connectionGeneration && socket === nextSocket) {\n" " setConnection('Connection error', 'bad', 'The WebSocket connection failed.');\n" " }\n" - " });\n" - " nextSocket.addEventListener('close', () => {\n" + " };\n" + " nextSocket.onclose = () => {\n" " if (!live(work) || generation !== connectionGeneration || socket !== nextSocket) return;\n" - " socket = null;\n" + " closeSerial();\n" " clientId = null;\n" " clientIdField.textContent = '—';\n" " setRole('observer');\n" " scheduleReconnect();\n" - " });\n" + " };\n" " } catch (error) {\n" " if (generation !== connectionGeneration || unloading || error.name === 'AbortError') return;\n" " ticketAbort = null;\n" @@ -493,7 +617,7 @@ static const char s_app_js[] = " }\n" "}\n" "terminal.onData((data) => {\n" - " if (role !== 'writer' || !socketOpen()) return;\n" + " if (selected !== 'serial' || role !== 'writer' || !socketOpen() || unloading || navigating || suspended) return;\n" " const bytes = encoder.encode(data);\n" " for (let offset = 0; offset < bytes.length; offset += 1024) {\n" " socket.send(bytes.subarray(offset, Math.min(offset + 1024, bytes.length)));\n" @@ -519,33 +643,47 @@ static const char s_app_js[] = " ++connectionGeneration;\n" " clearReconnectTimer();\n" " if (ticketAbort !== null) { ticketAbort.abort(); ticketAbort = null; }\n" - " if (socket !== null) { const previous = socket; socket = null; previous.close(); }\n" + " closeSerial();\n" " clientId = null;\n" " clientIdField.textContent = '—';\n" " setRole('observer');\n" " setConnection('Disconnected', 'warn', 'Disconnected by user. Automatic reconnect is paused.');\n" "});\n" + "element('select-serial').addEventListener('click', () => selectTerminal('serial'));\n" + "element('select-admin').addEventListener('click', () => selectTerminal('admin'));\n" + "adminToggle.addEventListener('click', () => { if (adminSocket || adminAbort) closeAdmin(); else openAdmin(); });\n" "const fitTerminal = () => {\n" " fitFrame = 0;\n" - " const bounds = terminalHost.getBoundingClientRect();\n" + " if (unloading || navigating || loggingOut || !sessionVerified) return;\n" + " const target = selected === 'admin' ? adminTerminal : terminal;\n" + " const addon = selected === 'admin' ? adminFit : fitAddon;\n" + " const bounds = (selected === 'admin' ? adminHost : terminalHost).getBoundingClientRect();\n" " const width = Math.floor(bounds.width);\n" " const height = Math.floor(bounds.height);\n" - " if (width < 1 || height < 1 || (width === lastFitWidth && height === lastFitHeight)) return;\n" - " lastFitWidth = width;\n" - " lastFitHeight = height;\n" + " if (width > 0 && height > 0 && width === lastFitWidth && height === lastFitHeight) return;\n" " try {\n" - " const dimensions = fitAddon.proposeDimensions();\n" - " if (dimensions && dimensions.cols > 0 && dimensions.rows > 0 &&\n" - " (dimensions.cols !== terminal.cols || dimensions.rows !== terminal.rows)) {\n" - " terminal.resize(dimensions.cols, dimensions.rows);\n" + " const dimensions = width > 0 && height > 0 ? addon.proposeDimensions() : null;\n" + " if (dimensions && Number.isInteger(dimensions.cols) && dimensions.cols > 0 &&\n" + " Number.isInteger(dimensions.rows) && dimensions.rows > 0) {\n" + " if (dimensions.cols !== target.cols || dimensions.rows !== target.rows) target.resize(dimensions.cols, dimensions.rows);\n" + " lastFitWidth = width; lastFitHeight = height; return;\n" " }\n" " } catch (_) {}\n" + " if (fitRetries > 0) {\n" + " --fitRetries;\n" + " const generation = workGeneration;\n" + " fitFrame = window.requestAnimationFrame(() => { if (live(generation)) fitTerminal(); });\n" + " }\n" "};\n" "const scheduleFit = () => {\n" - " if (!unloading && !navigating && fitFrame === 0) fitFrame = window.requestAnimationFrame(fitTerminal);\n" + " if (unloading || navigating || loggingOut) return;\n" + " fitRetries = 3;\n" + " const generation = workGeneration;\n" + " if (fitFrame === 0) fitFrame = window.requestAnimationFrame(() => { if (live(generation)) fitTerminal(); });\n" "};\n" "const resizeObserver = 'ResizeObserver' in window ? new ResizeObserver(scheduleFit) : null;\n" "if (resizeObserver !== null) resizeObserver.observe(terminalHost);\n" + "if (resizeObserver !== null) resizeObserver.observe(adminHost);\n" "window.addEventListener('resize', scheduleFit);\n" "const textValue = (value, fallback) => typeof value === 'string' && value.length > 0 ? value : fallback;\n" "const updateStatus = (status) => {\n" @@ -606,9 +744,11 @@ static const char s_app_js[] = " if (unloading) return;\n" " cancelWork();\n" " unloading = true; csrf = '';\n" + " sessionVerified = false; terminalHost.hidden = adminHost.hidden = true;\n" " if (fitFrame !== 0) window.cancelAnimationFrame(fitFrame);\n" " fitFrame = 0;\n" " if (resizeObserver !== null) resizeObserver.disconnect();\n" + " window.removeEventListener('resize', scheduleFit);\n" " updateControls();\n" "};\n" "window.addEventListener('pagehide', shutdown);\n" @@ -616,6 +756,9 @@ static const char s_app_js[] = " if (!event.persisted || navigating) return;\n" " unloading = false; loggingOut = false;\n" " if (resizeObserver !== null) resizeObserver.observe(terminalHost);\n" + " if (resizeObserver !== null) resizeObserver.observe(adminHost);\n" + " window.addEventListener('resize', scheduleFit);\n" + " lastFitWidth = lastFitHeight = 0;\n" " scheduleFit(); updateControls();\n" " if (reconnectEnabled && !suspended) connect();\n" " else {\n" diff --git a/tests/web_ui_session/README.md b/tests/web_ui_session/README.md index 5311755..6968251 100644 --- a/tests/web_ui_session/README.md +++ b/tests/web_ui_session/README.md @@ -1,4 +1,4 @@ -# Existing serial app cookie-session cutover tests +# Browser Session and Terminal Selector Tests Run from the repository root: @@ -32,21 +32,32 @@ Coverage: - Authentication/ticket response cap 512 bytes, existing status cap 3,072 bytes, 15-second request deadline, single status request in flight, bounded retry delay, and unchanged 5,000-line terminal scrollback. +- Admin-only navigation, explicit protected-ticket open, 20 switches preserving + serial socket/client/writer IDs, separate output and selected-only input. +- Independent 64 KiB pending output limits, visible dropped-byte counters and + resumed hidden output draining; 512-byte admin input frames and overflow close. +- Admin close/reopen/remote exit isolation, 401/logout/expiry/pagehide cancellation, + late tickets/callbacks, handshake timeout, selected resize and three listener + cleanup/restore cycles. +- Session identity changes (username, role or session-stable CSRF) force a clean + document before view adoption, close old admin and prevent replacement-session + logout; same-session restore retains both scrollbacks behind validation gating. +- Undefined initial dimensions recover at unchanged bounds; failed fits never + populate the cache, readiness retries stop at three, and teardown fences stale + callbacks even after restore. Sixteen Node groups total. ## Integration and known gaps -This is only the existing application browser portion of Phase 8D.3. It requires -the simultaneous server cookie/Origin/CSRF cutover for every route. The renderer +This covers the Phase 8D.3 browser session behavior and 8D.6 selector. The renderer still relies on its caller to authenticate resources; protected asset failures must be 401, never a redirect to HTML served as JavaScript. No Basic fallback is -implemented here. No server, auth-store, transport, admin UI, or generated asset -changes are included. +implemented here. Existing 8D.5 server authorization/protocols are unchanged. These tests model DOM, timers, fetch cancellation and WebSocket events. They do not prove real-browser CSP enforcement, script-loading errors, TLS/HTTPD behavior, actual bfcache policy, cookie expiry, server revocation, or hardware serial byte -integrity. Full firmware build and mandatory M1 browser/target checks remain the -integrator's responsibility. The full build was deliberately not run in this -restricted-write subtask. No target resource reserve is claimed. Browser secret +integrity, actual xterm escape parsing, hidden prompts, or desktop/mobile layout. +The 8D.6 firmware build and pending target checklist are recorded separately in +`docs/phase8d6_implementation.md`. No target resource reserve is claimed. Browser secret references are dropped and never persisted/logged, but JavaScript cannot securely wipe engine-managed strings. diff --git a/tests/web_ui_session/browser.cjs b/tests/web_ui_session/browser.cjs index b94e2c6..c4863c3 100644 --- a/tests/web_ui_session/browser.cjs +++ b/tests/web_ui_session/browser.cjs @@ -9,36 +9,41 @@ const ticket = () => json({ticket: 't'.repeat(32)}); const failure = status => new Response('SECRET ERROR BODY', {status, headers: {'Retry-After': '7'}}); const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return {promise, resolve}; }; const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); }; -function browser({onlyLoader = false, withLoader = false} = {}) { +function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) { const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = []; - const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/logout': []}; + const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': []}; + const fits = []; let serial = 0; - const on = (key, fn) => { (events[key] ||= []).push(fn); }; + const on = (key, fn) => { if (!(events[key] ||= []).includes(fn)) events[key].push(fn); }; const emit = (key, event = {}) => { for (const fn of events[key] || []) fn(event); }; const timeout = (fn, ms, interval = false) => { timers.set(++serial, {fn, ms, interval}); return serial; }; class Socket { static OPEN = 1; - constructor(url) { this.url = url; this.readyState = 0; this.events = {}; this.sent = []; sockets.push(this); } + constructor(url) { this.url = url; this.readyState = 0; this.bufferedAmount = 0; this.events = {}; this.sent = []; sockets.push(this); } addEventListener(k, fn) { this.events[k] = fn; } - emit(k, event = {}) { if (k === 'open') this.readyState = 1; this.events[k]?.(event); } + emit(k, event = {}) { if (k === 'open') this.readyState = 1; this.events[k]?.(event); this['on' + k]?.(event); } close() { this.closed = true; this.readyState = 3; this.emit('close'); } send(value) { this.sent.push(value); } } class Terminal { constructor(options) { this.options = options; this.writes = []; terminals.push(this); } - loadAddon() {} open() {} resize() {} onData(fn) { this.input = fn; } - write(bytes) { this.writes.push([...bytes]); } + loadAddon() {} open() {} focus() {} resize(cols, rows) { this.cols = cols; this.rows = rows; } onData(fn) { this.input = fn; } + write(bytes, callback) { this.writes.push([...bytes]); if (this.holdWrites) (this.pending ||= []).push(callback); else callback?.(); } } - const window = {addEventListener: on, removeEventListener() {}, + const window = {addEventListener: on, removeEventListener(k, fn) { events[k] = (events[k] || []).filter(f => f !== fn); }, setTimeout: timeout, clearTimeout: id => timers.delete(id), setInterval: (fn, ms) => timeout(fn, ms, true), clearInterval: id => timers.delete(id), requestAnimationFrame: fn => timeout(fn, -1), cancelAnimationFrame: id => timers.delete(id), location: {origin: 'https://sak.local', replace: path => redirects.push(path)}}; const context = vm.createContext({window, document: {getElementById(id) { return nodes[id] ||= {textContent: '', dataset: {}, classList: {toggle() {}}, + setAttribute(k, v) { this[k] = v; }, getBoundingClientRect: () => ({width: 100, height: 100}), addEventListener(k, fn) { this[k] = fn; }}; - }}, Terminal, FitAddon: {FitAddon: class {proposeDimensions() { return null; }}}, + }}, Terminal, FitAddon: {FitAddon: class { + constructor() { this.measurements = []; this.calls = 0; fits.push(this); } + proposeDimensions() { ++this.calls; return this.measurements.length ? this.measurements.shift() : {cols: 80, rows: 24}; } + }}, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, AbortController, URL, Date, WebSocket: Socket, fetch: async (url, options) => { // Apply the Origin regression guard to every mutation, including logout. @@ -48,9 +53,9 @@ function browser({onlyLoader = false, withLoader = false} = {}) { calls.push({url, ...options}); const next = queues[url].shift(); if (next !== undefined) return typeof next === 'function' ? next(options) : next; - if (url === '/api/session') return session(); + if (url === '/api/session') return session({role}); if (url === '/api/status') return json({}); - if (url === '/api/ws-ticket') return ticket(); + if (url === '/api/ws-ticket' || url === '/api/admin/ws-ticket') return ticket(); throw new Error('network unavailable'); }}); if (withLoader || onlyLoader) vm.runInContext(loader, context); @@ -59,7 +64,7 @@ function browser({onlyLoader = false, withLoader = false} = {}) { const match = [...timers].find(([, t]) => t.ms === ms); assert.ok(match, `missing timer ${ms}`); const [id, t] = match; if (!t.interval) timers.delete(id); t.fn(); }; - return {nodes, calls, redirects, timers, sockets, terminals, queues, emit, start, fire, + return {nodes, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire, click: id => nodes[id].click(), window}; } async function connected() { const b = browser(); b.start(); await tick(); assert.equal(b.sockets.length, 1); return b; } @@ -191,5 +196,164 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na c.queues['/api/session'].push(failure(401)); c.emit('error', {target: {tagName: 'IMG'}}); await tick(); assert.deepEqual(c.redirects, ['/login']); assert.ok(c.sockets[0].closed); assert.equal(c.timers.size, 0); }); + async function adminBrowser() { + const b = browser({role: 'admin'}); b.queues['/api/session'].push(session({role: 'admin'})); + b.start(); await tick(); const ws = b.sockets[0]; ws.emit('open'); + ws.emit('message', {data: JSON.stringify({type: 'hello', clientId: 8, writerId: 8, role: 'writer'})}); + b.click('select-admin'); b.click('admin-toggle'); await tick(); + assert.equal(b.sockets.length, 2); b.sockets[1].emit('open'); return b; + } + await test('ordinary user is serial-only; admin selection preserves socket/lease and isolates input/output', async () => { + const u = await connected(); assert.equal(u.nodes['terminal-selector'].hidden, true); + u.click('select-admin'); u.click('admin-toggle'); await tick(); assert.equal(u.terminals.length, 1); assert.equal(u.sockets.length, 1); + const b = await adminBrowser(), [serial, admin] = b.sockets, [st, at] = b.terminals; + assert.match(admin.url, /\/ws\/admin\?ticket=/); + assert.equal(b.calls.find(c => c.url === '/api/admin/ws-ticket').headers['X-CSRF-Token'], token); + st.input('WRONG'); at.input('x'.repeat(1025)); assert.equal(serial.sent.length, 0); + assert.deepEqual(admin.sent.map(x => x.length), [512, 512, 1]); + serial.emit('message', {data: Uint8Array.of(0, 255).buffer}); + admin.emit('message', {data: Uint8Array.of(27, 91).buffer}); + assert.deepEqual(st.writes, [[0, 255]]); assert.deepEqual(at.writes, [[27, 91]]); + b.click('release-control'); assert.equal(serial.sent.at(-1), 'release-writer'); + for (let i = 0; i < 20; ++i) { b.click('select-serial'); at.input('WRONG'); b.click('select-admin'); } + assert.equal(b.sockets.length, 2); assert.equal(b.terminals.length, 2); assert.ok(!serial.closed); + assert.equal(b.nodes['client-id'].textContent, '8'); assert.equal(b.nodes['writer-id'].textContent, '8'); + assert.equal(b.nodes['release-control'].disabled, false); assert.equal(admin.sent.length, 3); + b.click('admin-toggle'); assert.ok(admin.closed); assert.equal(admin.onmessage, null); assert.ok(!serial.closed); + b.click('admin-toggle'); await tick(); const replacement = b.sockets[2]; replacement.emit('open'); replacement.emit('close'); + assert.ok(!serial.closed); assert.equal(b.nodes['admin-toggle'].textContent, 'Open admin'); + b.click('select-serial'); st.input('ok'); assert.deepEqual([...serial.sent.at(-1)], [111, 107]); + }); + await test('bounded hidden output continues draining, separate scrollback and input overflow closes only admin', async () => { + const b = await adminBrowser(), [serial, admin] = b.sockets, [st, at] = b.terminals; + assert.equal(st.options.scrollback, 5000); assert.equal(at.options.scrollback, 5000); + st.holdWrites = true; + for (let i = 0; i < 65; ++i) serial.emit('message', {data: new Uint8Array(1024).buffer}); + assert.equal(st.writes.length, 64); assert.match(b.nodes['output-detail'].textContent, /serial 1024 B, admin 0 B/); + st.pending.shift()(); serial.emit('message', {data: Uint8Array.of(42).buffer}); assert.equal(st.writes.at(-1)[0], 42); + at.input('x'.repeat(4097)); assert.ok(admin.closed); assert.ok(!serial.closed); assert.equal(admin.sent.length, 0); + }); + await test('admin failures and cancellation are isolated; 401/logout/expiry/pagehide close both', async () => { + for (const status of [403, 503, 401]) { + const b = await adminBrowser(), serial = b.sockets[0]; b.click('admin-toggle'); + b.queues['/api/admin/ws-ticket'].push(failure(status)); b.click('admin-toggle'); await tick(); + assert.equal(!!serial.closed, status === 401); + assert.deepEqual(b.redirects, status === 401 ? ['/login'] : []); + assert.equal(b.sockets.length, 2); + } + for (const action of ['pagehide', 'expiry', 'logout']) { + const b = await adminBrowser(); + if (action === 'pagehide') b.emit('pagehide'); + if (action === 'expiry') b.window.sakSessionExpired(); + if (action === 'logout') { b.queues['/api/logout'].push(new Response(null, {status: 204})); await b.click('sign-out'); } + await tick(); assert.ok(b.sockets.every(s => s.closed)); assert.equal(b.sockets[1].onmessage, null); + if (action === 'pagehide') { + b.queues['/api/session'].push(session({role: 'admin'})); b.emit('pageshow', {persisted: true}); await tick(); + assert.equal(b.sockets.length, 3); assert.match(b.sockets[2].url, /\/ws\/serial/); + assert.equal(b.nodes['admin-toggle'].textContent, 'Open admin'); + } + } + const b = await adminBrowser(); b.click('admin-toggle'); const d = deferred(); + b.queues['/api/admin/ws-ticket'].push(d.promise); b.click('admin-toggle'); await tick(); + b.click('admin-toggle'); d.resolve(failure(401)); await tick(); + assert.deepEqual(b.redirects, []); assert.ok(!b.sockets[0].closed); assert.equal(b.sockets.length, 2); + }); + await test('selected resize, listener cleanup, admin handshake deadline and stale callback fencing', async () => { + const b = await adminBrowser(), [serial, old] = b.sockets; + b.fire(-1); assert.equal(b.terminals[1].cols, 80); assert.equal(b.terminals[0].cols, undefined); + b.click('select-serial'); b.fire(-1); assert.equal(b.terminals[0].rows, 24); + const stale = old.onmessage; + b.click('select-admin'); b.click('admin-toggle'); b.click('admin-toggle'); await tick(); + stale({data: Uint8Array.of(99).buffer}); assert.equal(b.terminals[1].writes.length, 0); + b.fire(15000); assert.ok(b.sockets[2].closed); assert.ok(!serial.closed); + assert.match(b.nodes['admin-detail'].textContent, /timed out/); + for (let i = 0; i < 3; ++i) { + b.emit('pagehide'); assert.equal(b.events.resize.length, 0); + assert.ok(b.sockets.every(s => s.onmessage === null)); + b.queues['/api/session'].push(session({role: 'admin'})); + b.emit('pageshow', {persisted: true}); await tick(); assert.equal(b.events.resize.length, 1); + } + b.window.sakSessionExpired(); assert.equal(b.events.resize.length, 0); + }); + await test('changed session identity replaces document before adoption, including live admin and paused restore', async () => { + const identities = [ + {username: 'other-admin', role: 'admin', csrf: 'b'.repeat(64)}, + {username: 'other-user', role: 'user', csrf: 'b'.repeat(64)}, + {role: 'admin', csrf: 'b'.repeat(64)}, // Same account, different login session. + {username: 'other-admin', role: 'admin'}, // Principal fields are checked independently. + {role: 'user'} + ]; + for (const identity of identities) for (const route of ['restore', 'paused-restore', 'live', 'logout']) { + const b = await adminBrowser(), [serial, admin] = b.sockets; + serial.emit('message', {data: Uint8Array.of(65).buffer}); + admin.emit('message', {data: Uint8Array.of(66).buffer}); + const stale = admin.onmessage, info = b.nodes['session-info'].textContent; + const count = b.calls.length; + b.queues['/api/session'].push(session(identity)); + if (route.includes('restore')) { + if (route === 'paused-restore') b.click('connection-toggle'); + b.emit('pagehide'); + assert.equal(b.nodes.terminal.hidden, true); assert.equal(b.nodes['admin-terminal'].hidden, true); + b.emit('pageshow', {persisted: true}); b.click('select-admin'); + assert.equal(b.nodes['admin-terminal'].hidden, true); + } else if (route === 'live') { + b.click('connection-toggle'); assert.ok(!admin.closed); b.click('connection-toggle'); + } else await b.click('sign-out'); + await tick(); + assert.deepEqual(b.redirects, ['/']); assert.ok(serial.closed && admin.closed); + assert.equal(b.nodes['session-info'].textContent, info); // B was never adopted into A's document. + assert.equal(b.nodes.terminal.hidden, true); assert.equal(b.nodes['admin-terminal'].hidden, true); + assert.deepEqual(b.calls.slice(count).map(c => c.url), ['/api/session']); + assert.equal(b.timers.size, 0); assert.equal(b.events.resize.length, 0); + stale({data: Uint8Array.of(67).buffer}); b.terminals[1].input('WRONG'); + b.click('select-admin'); b.click('admin-toggle'); b.emit('pageshow', {persisted: true}); + assert.deepEqual(b.terminals[1].writes, [[66]]); assert.equal(admin.sent.length, 0); + assert.equal(b.nodes['admin-terminal'].hidden, true); assert.equal(b.sockets.length, 2); + } + }); + await test('same-session restore preserves both scrollbacks; pending validation never reveals them', async () => { + for (const paused of [false, true]) { + const b = await adminBrowser(); + b.sockets[0].emit('message', {data: Uint8Array.of(65).buffer}); + b.sockets[1].emit('message', {data: Uint8Array.of(66).buffer}); + if (paused) b.click('connection-toggle'); + b.emit('pagehide'); const d = deferred(); b.queues['/api/session'].push(d.promise); + b.emit('pageshow', {persisted: true}); await tick(); b.click('select-serial'); b.click('select-admin'); + assert.equal(b.nodes.terminal.hidden, true); assert.equal(b.nodes['admin-terminal'].hidden, true); + d.resolve(session({role: 'admin', expires_in: 3500})); await tick(); + assert.deepEqual(b.redirects, []); assert.equal(b.terminals.length, 2); + assert.deepEqual(b.terminals.map(t => t.writes), [[[65]], [[66]]]); + assert.equal(b.nodes['admin-terminal'].hidden, false); assert.equal(b.sockets.length, paused ? 2 : 3); + } + const b = await adminBrowser(), admin = b.sockets[1]; + admin.emit('message', {data: Uint8Array.of(66).buffer}); + b.click('connection-toggle'); b.click('connection-toggle'); await tick(); + assert.ok(!admin.closed); assert.deepEqual(b.redirects, []); assert.deepEqual(b.terminals[1].writes, [[66]]); + }); + await test('undefined first fit retries unchanged bounds, stops after three retries and fences stale work', async () => { + const b = await adminBrowser(), fit = b.fits[1]; + fit.measurements.push(undefined); b.fire(-1); + assert.equal(b.terminals[1].cols, undefined); b.fire(-1); + assert.equal(b.terminals[1].cols, 80); assert.equal(fit.calls, 2); + b.emit('resize'); b.fire(-1); assert.equal(fit.calls, 2); // Successful measurement is cached. + b.click('select-serial'); b.click('select-admin'); + fit.measurements.push(undefined, undefined, undefined, undefined); + for (let i = 0; i < 4; ++i) b.fire(-1); + assert.equal(fit.calls, 6); assert.ok(![...b.timers.values()].some(t => t.ms === -1)); + b.emit('resize'); b.fire(-1); assert.equal(fit.calls, 7); // Failed bounds were never cached. + for (const action of ['pagehide', 'expiry', 'logout']) { + const c = await adminBrowser(); c.fits[1].measurements.push(undefined); c.fire(-1); + const stale = [...c.timers.values()].find(t => t.ms === -1).fn; + if (action === 'pagehide') c.emit('pagehide'); + if (action === 'expiry') c.window.sakSessionExpired(); + if (action === 'logout') { c.queues['/api/logout'].push(failure(403)); await c.click('sign-out'); } + await tick(); stale(); assert.equal(c.fits[1].calls, 1); + assert.ok(![...c.timers.values()].some(t => t.ms === -1)); + if (action === 'pagehide') { + c.emit('pageshow', {persisted: true}); await tick(); stale(); + assert.equal(c.fits[1].calls, 1); c.fire(-1); assert.equal(c.fits[1].calls, 2); + } + } + }); console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`); })().catch(error => { console.error(error); process.exitCode = 1; });