Skip to content

feat: webui v2 static port - #4061

Merged
serrrfirat merged 15 commits into
reborn-integrationfrom
feat/webui-v2-static-port-3886
May 27, 2026
Merged

serrrfirat merged 15 commits into
reborn-integrationfrom
feat/webui-v2-static-port-3886

Conversation

@italic-jinxin

@italic-jinxin italic-jinxin commented May 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Port the fork's static V2 SPA into a new ironclaw_webui_v2_static crate (build-time asset embed, per-request CSP nonce, static_router(...) factory) and mount it under /v2 from the Reborn WebUI host surface.
  • Rewrite the browser API client to drive only /api/webchat/v2/* — zero /api/engine/, /api/chat/, /auth/, /api/profile calls remain in browser code.
  • Implement list_threads_for_scope on InMemorySessionThreadService and FilesystemSessionThreadService so GET /api/webchat/v2/threads no longer surfaces service_unavailable.
  • Reborn binary auto-loads .env via dotenvy::dotenv() on startup, mirroring v1 dev UX.
  • Hide v2-unsupported nav entries (workspace / projects / jobs / routines / missions / settings / admin) — routes stay registered for direct-URL access but sidebar filters them out.

Change Type

  • Bug fix — post-merge chat-flow unblocks (filesystem list_threads_for_scope, timeline refetch fallback, race guards, CSP fail-closed)
  • New feature — static SPA bundle and /v2 mount
  • Refactor
  • Documentation
  • CI/Infrastructure
  • Security — per-request CSP nonce, SSE ?token= shim scope preserved, CSP header build fail-closed
  • Dependencies

Linked Issue

Closes #3886.

Validation

  • cargo fmt --all -- --check
  • cargo clippy --all --benches --tests --examples --all-features -- -D warnings
  • cargo build (with --features webui-v2-beta on ironclaw_reborn_cli)
  • Relevant tests pass: session_thread_contract::list_threads_for_scope_is_scope_filtered_and_paginated, webui_v2_descriptors_contract, webui_v2_serve integration suite
  • cargo test --features integration — N/A: no database-backed change (libSQL local-dev backend is filesystem-style storage, not Postgres integration)
  • Manual testing: ?token= login, thread create / list, send message via Moonshot Kimi (openai_compatible provider), SSE delivers accepted → running → completed, timeline refetch surfaces assistant reply, cancel run, attempt setup_extension (returns not_implemented cleanly), v1-unsupported nav items not shown
  • python3 scripts/check_no_panics.py clean
  • node --check on every changed JS file
  • coding-agent review-pr — not run

Security Impact

  • Bearer auth + SSE ?token= exception scope unchanged: composition middleware still allowlists ?token= only for GET /api/webchat/v2/threads/{id}/events; mutations and timeline reads stay bearer-only.
  • CSP: SPA shell ships a per-request nonce; CDN allowlist matches the SPA's import map (esm.sh / jsdelivr / cdnjs). Header build now fails closed — if HeaderValue::from_str ever rejects the CSP string the request returns 500 instead of serving the shell without a CSP header (banned silent fail-open per error-handling.md).
  • No new credential surfaces: js/app/auth.js reduced to ?token= URL ingestion + sessionStorage; no OAuth UI, no /auth/* calls.
  • DELETE thread affordance removed: v2 has no delete endpoint, so the sidebar trash button is gated on onDelete and no longer rendered — eliminates the silent no-op the prior stub created.
  • WS same-origin and per-route body/rate limits are enforced by composition middleware (descriptor-driven); no change in this PR.

Reborn Trust-Boundary Checklist

  • Public policy/evidence/trust-bearing types: no new ones constructed. The WebUiAuthenticatedCaller extension comes from existing composition middleware; static-asset router doesn't mint trust types.
  • Untrusted content enters prompts only through an envelope/escaping primitive: this PR does not introduce a new prompt-injecting code path. The frontend renders timeline content; backend prompt construction is unchanged.
  • Hashes declare purpose: N/A — no new hashing in this PR.
  • New/changed status, exit, policy, runtime, or error variants: none added. RebornServicesError and SessionThreadError variants are reused.
  • Security/durability serde(default) fields fail closed: N/A — no new wire fields added.
  • Queues/maps/buffers/counters have bounds: list_threads_for_scope clamps caller-supplied limit to [1, 200] on both backends; SPA asset table is build-time const.
  • Driver/operator-visible errors have stable class semantics: list_threads_for_scope surfaces SessionThreadError::Backend only when the backend itself fails; trait default's service_unavailable mapping at the gateway is unchanged.
  • Sandbox/native/host names accurately describe trust boundary: SPA is browser-untrusted; backend authority is WebUiAuthenticatedCaller synthesized server-side from a verified bearer.

Database Impact

FilesystemSessionThreadService::list_threads_for_scope reads existing thread records under /tenants/{tenant}/users/{user}/threads/agents/{agent}/.../threads/<thread_id>/thread.json and paginates by sorted thread_id. No schema migration, no new index, no new storage shape. The libSQL local-dev backend stores the same records via LibSqlRootFilesystem mounted at /tenants — same path layout, same record bodies. Both PostgreSQL and libSQL paths inherit the new method through the trait.

Blast Radius

  • New crate ironclaw_webui_v2_static — touched only by composition's webui_serve module behind webui-v2-beta feature; binaries built without that feature are not affected.
  • Composition mount — adds /v2 route under the existing v2 middleware stack; does not modify existing routes.
  • ironclaw_threads — adds method impls only; trait default unchanged so other consumers continue to surface service_unavailable if they're on a backend without the method.
  • ironclaw_webui_v2 — SSE per-route rate limit bumped from 12/60s to 60/60s (5×); could increase request volume per caller during reconnect storms.
  • ironclaw_reborn_cli — adds dotenvy::dotenv() at startup. Production hosts that don't have a .env are unaffected (silent on missing file).
  • v1 gateway, channels/web, src/main.rs — untouched. git diff over those paths against the PR base is empty.

Rollback Plan

This PR is feature-gated behind webui-v2-beta. To roll back:

  1. Soft: rebuild ironclaw-reborn without --features webui-v2-beta (or drop the feature from ironclaw_reborn_cli's default features once enabled). /v2 mount is excluded; nine v2 endpoints stop being reachable. v1 gateway path is unaffected.
  2. Hard: git revert <merge commit>. Reverts the new crate, the mount, the filesystem list_threads_for_scope impl, and the in-memory test. Trait default reverts to service_unavailable — callers that were depending on enumeration go back to the M0 baseline.
  3. Frontend-only: the SPA bundle is embedded at build time; rebuilding the binary swaps it. No persistent state to reset.

No DB migration to reverse.

Review Follow-Through

Three follow-up items surfaced during integration; each belongs on its own ticket, not this PR:

  1. Reborn projection bridge: emit ProductProjectionItem::Text { id, body }. Today no production code path mints Text items, so assistant replies are not pushed via SSE — this PR's frontend refetches /timeline on terminal run_status: completed as a fallback. When Reborn projection lands Text emit, the existing item.text branch in useChatEvents will activate and the refetch becomes redundant.
  2. setup_extension v2 lifecycle. Route is wired but backend returns NotImplemented. UI surfaces the not_implemented taxonomy cleanly; no UI changes needed when the backend lands.
  3. Identity unification: runtime default_owner ↔ WebUiAuthenticatedCaller.user_id. Two independent config sources for the same logical identity; mismatch fails silently as unknown thread at the prompt port. Should fail closed at startup or derive both from a single source.

Review track: B (new feature — issue-approved)

Ports ArtemSirobaba's feat/static-v2 chat SPA into a new
ironclaw_webui_v2_static crate mounted under /v2 by
ironclaw_reborn_composition. The API client and chat hooks now drive
only the nine /api/webchat/v2/* endpoints from PR #3815; every page
and hook that called /api/engine, /api/chat, /auth/*, or /api/profile
is removed or rewritten. The SPA shell substitutes a CSP nonce per
request and emits a matching CSP header so its inline scripts and CDN
imports execute. v1 gateway UI is left untouched.
…m budget

The SPA was missing replies because the Rust handler tags each SSE frame
with a typed `event:` name, so `EventSource.onmessage` never fires; the
chat handler now subscribes to every `WebChatV2Event` name explicitly.
The live runtime also only emits `projection_snapshot` / `projection_update`
today, so the handler walks the projection items (`run_status`, `text`,
`gate`) to update isProcessing, append the assistant reply, and surface
recovery_required as an error bubble. SSE rate-limit budget is bumped
from 12/60s to 60/60s (parity with mutation budget) so a browser doing
exponential-backoff reconnects no longer locks itself out before the
per-caller concurrency cap recycles.
# Conflicts:
#	crates/ironclaw_reborn_composition/Cargo.toml
GET /threads was 503ing because Filesystem-backed SessionThreadService
fell through to the trait's default unimplemented list_threads_for_scope
when the libsql feature is on. Reborn's projection bridge doesn't mint
Text items yet, so SSE delivers only run_status and the UI never showed
the assistant reply — frontend now refetches /timeline on terminal
success as a fallback until projection-side Text emit lands.
Routes for v2-unsupported pages (workspace, projects, jobs, routines,
missions, settings, admin) now carry hidden: true so sidebar-nav skips
them, satisfying AC #4 of #3886 — entries stay registered for direct
URL / breadcrumb resolution. Reborn binary also calls dotenvy::dotenv()
on startup so .env works like it does for the v1 binary.
@italic-jinxin italic-jinxin self-assigned this May 25, 2026
@github-actions github-actions Bot added size: XL 500+ changed lines scope: dependencies Dependency updates risk: medium Business logic, config, or moderate-risk modules contributor: experienced 6-19 merged PRs and removed size: XL 500+ changed lines labels May 25, 2026
@italic-jinxin
italic-jinxin marked this pull request as draft May 25, 2026 19:45
@italic-jinxin
italic-jinxin marked this pull request as ready for review May 25, 2026 19:46
@italic-jinxin italic-jinxin changed the title Feat/webui v2 static port 3886 feat: webui v2 static port May 25, 2026
@italic-jinxin
italic-jinxin requested a review from serrrfirat May 25, 2026 19:48

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a new crate, ironclaw_webui_v2_static, which embeds and serves the static assets for the WebChat v2 SPA under the /v2 route. It also implements thread listing and pagination in ironclaw_threads, increases the stream rate limit in ironclaw_webui_v2, and adds comprehensive integration tests. The review feedback highlights a critical security issue where the CSP header lacks 'unsafe-eval' (required by widgets using new Function), a fail-open vulnerability if the CSP header generation fails, a silent failure in thread deletion, and a potential race condition with overlapping history loading requests.

Comment thread crates/ironclaw_webui_v2_static/src/router.rs
Comment thread crates/ironclaw_webui_v2_static/src/router.rs Outdated
Comment thread crates/ironclaw_webui_v2_static/static/js/layout/gateway-layout.js Outdated
@github-actions github-actions Bot added the size: XL 500+ changed lines label May 26, 2026
…dance, history reentrancy guard

CSP header build now returns 500 instead of silently serving the SPA
shell without it when HeaderValue::from_str ever fails. Sidebar trash
button is gated on `onDelete` and the gateway layout no longer wires
the stub `deleteThread` no-op — v2 has no DELETE thread endpoint, so
the affordance shouldn't appear at all. `useHistory.loadHistory` adds
a synchronous loadingRef guard so overlapping calls (scroll-to-load
racing the onRunCompleted refetch) no longer fire duplicate timeline
requests.
CI `check_no_panics.py` flagged seven `.unwrap()` / `.expect()` calls
in build.rs. Build-script panics fail the build cleanly by design, so
suppress with inline `// safety: build script — <reason>` per the
script's same-line annotation convention.
…_port test stub

Test-stub line tripped clippy::unwrap-or-default after the merge from
reborn-integration. TurnRunId's Default impl just calls new(), so the
two are byte-equivalent.
…rvice slot

reborn-integration's build_default_planned_runtime returns
RebornRuntimeLoopComposition<_, dyn SessionThreadService, _>, but the
harness's from_composition signature still required the concrete
FilesystemSessionThreadService<LocalFilesystem>. The five tests that
build a real composition (reborn_trace_core_builtin_tools_parity,
reborn_trace_wasm_github_fixture_parity, and the three identity/scope
isolation parity tests) failed E0308 on the merged tree.
@italic-jinxin
italic-jinxin force-pushed the feat/webui-v2-static-port-3886 branch from 3709b79 to 0786039 Compare May 26, 2026 07:00
Resolved a trivial path-qualification conflict in
tests/support/reborn/harness.rs — both sides type-erase the same
SessionThreadService slot, so kept the unqualified form that matches
the existing import.
serrrfirat

This comment was marked as resolved.

Code review on #3886 surfaced a real perf hotspot — list_threads_for_scope
page reads were sequential, now fanned out with join_all — alongside
missing tests around the path-traversal and SPA-fallback rules and a
handful of defense-in-depth items: admin defaults closed, CSP connect-src
narrowed to self, SSE/WS rate window halved to shrink the URL-token
replay surface, single corrupted thread.json tolerated, and the static
crate mounted through a single mount_at_prefix factory so future routes
propagate to host composition automatically.
@italic-jinxin
italic-jinxin requested a review from serrrfirat May 26, 2026 12:46
serrrfirat

This comment was marked as resolved.

@github-actions github-actions Bot added the scope: docs Documentation label May 26, 2026
@italic-jinxin
italic-jinxin force-pushed the feat/webui-v2-static-port-3886 branch from e19a285 to 2e10e67 Compare May 26, 2026 13:58
Second code-review pass turned up a real auth bug — a crafted
`?token=` link could replace a working bearer in sessionStorage with
garbage — plus gaps in test coverage: the filesystem session-thread
service had no contract for list_threads_for_scope despite its
layout diverging from the in-memory impl, and the resolve-gate route
test never verified that axum percent-decodes the `gate_ref` segment
before the handler sees it. The directory-scan cost in the same
service is documented in-line; a proper fix needs an upstream
filesystem-port change.
@italic-jinxin
italic-jinxin force-pushed the feat/webui-v2-static-port-3886 branch from 2e10e67 to c99568b Compare May 26, 2026 14:23
@italic-jinxin
italic-jinxin requested a review from serrrfirat May 26, 2026 15:11
…bui-v2-static-port-3886

# Conflicts:
#	Cargo.toml
#	crates/ironclaw_threads/Cargo.toml
#	crates/ironclaw_threads/src/filesystem_service.rs
#	crates/ironclaw_threads/src/in_memory.rs
#	crates/ironclaw_threads/tests/session_thread_contract.rs

@serrrfirat serrrfirat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Conflict resolution looks good after merging current reborn-integration. Local verification passed: cargo test -p ironclaw_threads; cargo check -p ironclaw_webui_v2_static; cargo test -p ironclaw_reborn_composition --features webui-v2-beta --test webui_v2_serve.

@serrrfirat
serrrfirat merged commit fa1eead into reborn-integration May 27, 2026
21 checks passed
@serrrfirat
serrrfirat deleted the feat/webui-v2-static-port-3886 branch May 27, 2026 00:30
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
* feat(webui-v2): port static SPA to Reborn WebChat ingress (nearai#3886)

Ports ArtemSirobaba's feat/static-v2 chat SPA into a new
ironclaw_webui_v2_static crate mounted under /v2 by
ironclaw_reborn_composition. The API client and chat hooks now drive
only the nine /api/webchat/v2/* endpoints from PR nearai#3815; every page
and hook that called /api/engine, /api/chat, /auth/*, or /api/profile
is removed or rewritten. The SPA shell substitutes a CSP nonce per
request and emits a matching CSP header so its inline scripts and CDN
imports execute. v1 gateway UI is left untouched.

* fix(webui-v2): wire typed SSE events + projection items + raise stream budget

The SPA was missing replies because the Rust handler tags each SSE frame
with a typed `event:` name, so `EventSource.onmessage` never fires; the
chat handler now subscribes to every `WebChatV2Event` name explicitly.
The live runtime also only emits `projection_snapshot` / `projection_update`
today, so the handler walks the projection items (`run_status`, `text`,
`gate`) to update isProcessing, append the assistant reply, and surface
recovery_required as an error bubble. SSE rate-limit budget is bumped
from 12/60s to 60/60s (parity with mutation budget) so a browser doing
exponential-backoff reconnects no longer locks itself out before the
per-caller concurrency cap recycles.

* fix(webui-v2): unblock chat flow after reborn-integration merge

GET /threads was 503ing because Filesystem-backed SessionThreadService
fell through to the trait's default unimplemented list_threads_for_scope
when the libsql feature is on. Reborn's projection bridge doesn't mint
Text items yet, so SSE delivers only run_status and the UI never showed
the assistant reply — frontend now refetches /timeline on terminal
success as a fallback until projection-side Text emit lands.

* fix(webui-v2): hide unsupported pages + auto-load .env in Reborn binary

Routes for v2-unsupported pages (workspace, projects, jobs, routines,
missions, settings, admin) now carry hidden: true so sidebar-nav skips
them, satisfying AC #4 of nearai#3886 — entries stay registered for direct
URL / breadcrumb resolution. Reborn binary also calls dotenvy::dotenv()
on startup so .env works like it does for the v1 binary.

* fix(webui-v2): address PR review — CSP fail-closed, drop delete affordance, history reentrancy guard

CSP header build now returns 500 instead of silently serving the SPA
shell without it when HeaderValue::from_str ever fails. Sidebar trash
button is gated on `onDelete` and the gateway layout no longer wires
the stub `deleteThread` no-op — v2 has no DELETE thread endpoint, so
the affordance shouldn't appear at all. `useHistory.loadHistory` adds
a synchronous loadingRef guard so overlapping calls (scroll-to-load
racing the onRunCompleted refetch) no longer fire duplicate timeline
requests.

* fix(webui-v2-static): annotate build.rs panics for the no-panic check

CI `check_no_panics.py` flagged seven `.unwrap()` / `.expect()` calls
in build.rs. Build-script panics fail the build cleanly by design, so
suppress with inline `// safety: build script — <reason>` per the
script's same-line annotation convention.

* fix(loop-support): satisfy clippy unwrap_or_default in subagent_spawn_port test stub

Test-stub line tripped clippy::unwrap-or-default after the merge from
reborn-integration. TurnRunId's Default impl just calls new(), so the
two are byte-equivalent.

* fix(tests): align harness composition type with type-erased thread service slot

reborn-integration's build_default_planned_runtime returns
RebornRuntimeLoopComposition<_, dyn SessionThreadService, _>, but the
harness's from_composition signature still required the concrete
FilesystemSessionThreadService<LocalFilesystem>. The five tests that
build a real composition (reborn_trace_core_builtin_tools_parity,
reborn_trace_wasm_github_fixture_parity, and the three identity/scope
isolation parity tests) failed E0308 on the merged tree.

* fix(webui-v2-static): address review findings on the static SPA port

Code review on nearai#3886 surfaced a real perf hotspot — list_threads_for_scope
page reads were sequential, now fanned out with join_all — alongside
missing tests around the path-traversal and SPA-fallback rules and a
handful of defense-in-depth items: admin defaults closed, CSP connect-src
narrowed to self, SSE/WS rate window halved to shrink the URL-token
replay surface, single corrupted thread.json tolerated, and the static
crate mounted through a single mount_at_prefix factory so future routes
propagate to host composition automatically.

* fix(webui-v2-static): address second-pass review findings

Second code-review pass turned up a real auth bug — a crafted
`?token=` link could replace a working bearer in sessionStorage with
garbage — plus gaps in test coverage: the filesystem session-thread
service had no contract for list_threads_for_scope despite its
layout diverging from the in-memory impl, and the resolve-gate route
test never verified that axum percent-decodes the `gate_ref` segment
before the handler sees it. The directory-scan cost in the same
service is documented in-line; a proper fix needs an upstream
filesystem-port change.

---------

Co-authored-by: serrrfirat <f@nuff.tech>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: experienced 6-19 merged PRs risk: medium Business logic, config, or moderate-risk modules scope: dependencies Dependency updates scope: docs Documentation size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants