Repository navigation
feat(reborn): WebChat v2 host gateway — serve loop + auth + routes - #3815
think-in-universe merged 23 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces the Reborn WebChat v2 native surface to the gateway, gated by the webui-v2-beta feature. It adds the necessary dependencies, updates the GatewayState to include v2 composition, and integrates a new v2 router into the protected route tree. A new middleware, inject_webui_v2_caller, is implemented to adapt the existing UserIdentity for v2 handlers. Extensive documentation and integration tests are included to verify security invariants such as CORS, static headers, and query-token authentication for SSE. Review feedback suggests refactoring the middleware's identity extraction to use the more idiomatic let-else construct for improved readability.
|
Please do not change channel behaviour in v1. There is a separate binary for reborn, changing channel behaviour in /src will not effect the reborn binary |
serrrfirat
left a comment
There was a problem hiding this comment.
Do not change /src or v1 binary
bb3077b to
9d6b321
Compare
serrrfirat
left a comment
There was a problem hiding this comment.
Reviewed against #3580 and the Path A porting guide. The shape is right: keeping WebUI v2 as a native host-owned Reborn surface, avoiding ProductAdapter/protocol-auth shims, and stopping this product/API crate at Router is the right boundary. The caller-level router tests for bearer auth, SSE ?token=, CORS, headers, and per-caller rate limiting are also the right kind of proof.
A few things I want addressed before this lands.
- Descriptor body limits are part of the host ingress contract, but this composition only enforces a global 14 MiB cap.
ironclaw_webui_v2::webui_v2_routes() declares the production policy surface: create-thread is 16 KiB, send-message is 1 MiB, cancel/resolve are 4 KiB, and timeline/SSE are NoBody. webui_v2_app reads those descriptors for rate limits, but then applies only RequestBodyLimitLayer::new(config.max_body_bytes) across the whole router. That lets authenticated clients send bodies well above the descriptor contract and still reach handler parsing.
Please make body-limit resolution descriptor-driven in the same spirit as rate limiting, including fail-closed behavior for unsupported body policy shapes. A good regression would drive the composed webui_v2_app with an oversized create-thread/send-message body and assert the facade is not called; also cover NoBody routes if the chosen middleware can observe that.
- This should not close #3580 as written.
#3580 tracks the whole native WebUI/Web Gateway port: preserving auth/query-token exceptions, CSRF/origin, CORS, body/rate/connection limits, static headers, mapping submissions, keeping replies/progress/projections native, and caller-level tests for submission and streaming/projection behavior. This PR lands the composition-library slice and is honest in the body that ironclaw-reborn serve wiring, browser reachability, concrete env/DB/OIDC authenticators, WebSocket, thread list, and onboarding are not in scope. Merging with Closes #3580 would close the tracker while several checklist items remain intentionally open.
Please change this to Advances #3580 (or link a narrower issue that this fully closes) and name the follow-up issue/branch for host-side serve/auth wiring.
Smaller items:
- Add the unsupported rate-limit scope regression noted inline. That fail-closed path is the guard that keeps future descriptor changes from silently weakening enforcement.
- Fill in the PR template's Security Impact, Blast Radius, Rollback Plan, and actual validation commands. This is a Track C-shaped gateway/security change even if it is feature-gated and not yet browser-mounted.
Net: the boundary and native-surface direction are sound, but descriptor body-limit enforcement and issue-scope honesty need to be fixed before merge.
…ted rate-limit scope Per-review on PR #3815: the WebChat v2 gateway only applied the global config.max_body_bytes (14 MiB) to every route, so create_thread (16 KiB), send_message (1 MiB), cancel_run / resolve_gate (4 KiB), and NoBody read/SSE routes all accepted payloads larger than their descriptor contract before the handler saw the request. Resolve BodyLimitPolicy from ironclaw_webui_v2::webui_v2_routes() at composition time and enforce it as a route_layer that runs before bearer auth, so an oversized request never spends a bearer-validation step. The outer RequestBodyLimitLayer stays as defense in depth for paths that don't match any v2 descriptor.
Add the Reborn-side host composition for the WebChat v2 native surface in `ironclaw_reborn_composition`, behind the new `webui-v2-beta` Cargo feature on this crate. Path A from `docs/reborn/how-to-port-channel-to-reborn.md`: host auth and gateway security live in Reborn-owned code; nothing is shared with the v1 gateway under `/src/channels/web/` and the v1 binary is untouched.
…ted rate-limit scope Per-review on PR #3815: the WebChat v2 gateway only applied the global config.max_body_bytes (14 MiB) to every route, so create_thread (16 KiB), send_message (1 MiB), cancel_run / resolve_gate (4 KiB), and NoBody read/SSE routes all accepted payloads larger than their descriptor contract before the handler saw the request. Resolve BodyLimitPolicy from ironclaw_webui_v2::webui_v2_routes() at composition time and enforce it as a route_layer that runs before bearer auth, so an oversized request never spends a bearer-validation step. The outer RequestBodyLimitLayer stays as defense in depth for paths that don't match any v2 descriptor.
995ec5d to
1192b35
Compare
Lands the host-side counterpart to PR #3815's composition slice so `ironclaw-reborn serve` actually binds a listener and runs the v2 gateway. New crate `ironclaw_reborn_webui_ingress` is the seam between product/API composition (which exposes a `Router` but must not bind sockets per `reborn_product_api_crates_do_not_bind_http_ingress`) and the standalone CLI.
…nsion Adds the upstream surface the WebChat v2 entrypoint inventory needs to round out #3580: thread list (real), extensions onboarding (skeleton returning NotImplemented). list_threads_for_scope lands on SessionThreadService with a default empty impl so non-enumerating backends still build.
Rounds out the v2 entrypoint inventory listed in #3580: thread list (GET /api/webchat/v2/threads, NoBody + read rate limit), WebSocket transport variant of stream_events (GET .../ws, SameOriginRequired, stream rate limit), and extensions onboarding (POST /api/webchat/v2/ extensions/{name}/setup, 16 KiB body, skeleton returning NotImplemented). Descriptor contract test extended to 9 routes.
…ion-3580 # Conflicts: # Cargo.toml
The previous string-prefix check accepted any http:// host starting with "127.", which let DNS names like 127.evil.com or 127.0.0.1.evil.com look like loopback and would have made the OIDC authenticator trust attacker- supplied signing keys. The guard now parses with url::Url, refuses any userinfo component, and accepts http only for the literal host "localhost" or an IP whose IpAddr::is_loopback() is true. Regression tests cover the DNS-prefix bypass, userinfo-disguised authorities, malformed/non-http schemes, plus IPv4 and bracketed-IPv6 loopback.
e6a6540 to
d93e92a
Compare
serrrfirat
left a comment
There was a problem hiding this comment.
Multi-agent review of PR #3815 at d93e92a. Reviewed by Security, Bugs, Performance/Concurrency, Tests, and Conventions lenses, with local aggregation.
Requesting changes because the shipped ironclaw-reborn serve path cannot deliver the advertised SSE/WS projections, and there are several security/runtime follow-ups around session secrecy, OIDC refresh throttling, and long-lived WS slots.
Findings posted inline:
- High: CLI serve builds WebUI services with no projection stream, so
/eventsand/wsnever provide native progress/projections. - Medium: session records duplicate the bearer token in a public serializable/debuggable struct.
- Medium: unknown JWT
kidvalues can force repeated JWKS refreshes before authenticated rate limits apply. - Medium: idle closed WebSockets are not observed until send/error/max lifetime, holding stream slots and polling backend.
- Medium:
serverejects configs with[identity].default_projectbefore applying the WebUI project scope it parses later. - Medium: session backend lookup errors collapse silently to auth miss with no operator evidence.
- Low:
[webui].listen_port = 0from config binds an undiscoverable ephemeral port while the banner still prints port 0.
Additional nonblocking coverage gaps from the Tests lens: add concrete RebornServices::list_threads success and missing-agent contract tests, plus a handler test proving ?limit=&cursor= reach the facade. I did not inline those because the runtime defects above are the merge blockers.
|
@copilot resolve the merge conflicts in this pull request |
Resolved. I merged |
b4ae5ae to
d93e92a
Compare
…on/OIDC `ironclaw-reborn serve` now refuses to start by default when no ProjectionStream is wired into the WebUI bundle (operator opt-in via IRONCLAW_REBORN_ALLOW_NO_PROJECTION_STREAM=1 for non-streaming routes only). The WS drain loop selects on socket.recv() so a closed peer releases its SseSlot immediately instead of pinning it for SSE_MAX_LIFETIME. SessionRecord no longer carries the bearer token in Debug/Serialize — the bearer is the durable store's lookup key and the record carries only a non-secret SessionId for audit. SessionAuthenticator logs SessionStore::Err at warn! so backend outages stay diagnosable while still failing closed to the client. OIDC adds a 60s forced-refresh backoff so JWTs with rotating unknown kids cannot DoS the upstream JWKS or this gateway pre-auth. The serve subcommand and its smoke tests are now gated behind a new CLI `webui-v2-beta` Cargo feature; the default binary build does not link the beta HTTP/auth gateway. Build-time runtime-input validation treats `[identity].default_project` as supported when serve is the caller (was rejected for all callers), and `[webui].listen_port = 0` from config is refused with an actionable message (banner reports the literal :0 the OS doesn't know yet — only a CLI --port 0 is allowed for that case).
The 43.x line is vulnerable to RUSTSEC-2026-0149: WASI path_open with TRUNCATE can bypass the host's FilePerms::WRITE restriction. 44.0.2 is the minimum patch the advisory lists as fixed. No source changes were needed — the public API surface this workspace consumes (component engine setup, WASI p2 linker, store helpers, fuel/epoch limits) is unchanged between 43.0.2 and 44.0.2. Builds + the WASM sandbox/adapter test suites pass; the three failing tests in wasm_dispatch_integration are pre-existing manifest-validation failures already failing on the parent branch, unrelated to this bump.
* feat(webui-v2): port static SPA to Reborn WebChat ingress (#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 #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 #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 #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>
…earai#3815) * feat(reborn-composition): compose Reborn WebChat v2 HTTP gateway Add the Reborn-side host composition for the WebChat v2 native surface in `ironclaw_reborn_composition`, behind the new `webui-v2-beta` Cargo feature on this crate. Path A from `docs/reborn/how-to-port-channel-to-reborn.md`: host auth and gateway security live in Reborn-owned code; nothing is shared with the v1 gateway under `/src/channels/web/` and the v1 binary is untouched. * fix(reborn-composition): drop serve_webui_v2 to satisfy product/API ingress boundary * fix(reborn-composition): enforce per-route body limit + lock unsupported rate-limit scope Per-review on PR nearai#3815: the WebChat v2 gateway only applied the global config.max_body_bytes (14 MiB) to every route, so create_thread (16 KiB), send_message (1 MiB), cancel_run / resolve_gate (4 KiB), and NoBody read/SSE routes all accepted payloads larger than their descriptor contract before the handler saw the request. Resolve BodyLimitPolicy from ironclaw_webui_v2::webui_v2_routes() at composition time and enforce it as a route_layer that runs before bearer auth, so an oversized request never spends a bearer-validation step. The outer RequestBodyLimitLayer stays as defense in depth for paths that don't match any v2 descriptor. * feat(reborn): host-owned WebChat v2 serve loop + env-bearer auth Lands the host-side counterpart to PR nearai#3815's composition slice so `ironclaw-reborn serve` actually binds a listener and runs the v2 gateway. New crate `ironclaw_reborn_webui_ingress` is the seam between product/API composition (which exposes a `Router` but must not bind sockets per `reborn_product_api_crates_do_not_bind_http_ingress`) and the standalone CLI. * feat(reborn): extend RebornServicesApi with list_threads + setup_extension Adds the upstream surface the WebChat v2 entrypoint inventory needs to round out nearai#3580: thread list (real), extensions onboarding (skeleton returning NotImplemented). list_threads_for_scope lands on SessionThreadService with a default empty impl so non-enumerating backends still build. * feat(webui-v2): list-threads + WS + extensions-setup routes Rounds out the v2 entrypoint inventory listed in nearai#3580: thread list (GET /api/webchat/v2/threads, NoBody + read rate limit), WebSocket transport variant of stream_events (GET .../ws, SameOriginRequired, stream rate limit), and extensions onboarding (POST /api/webchat/v2/ extensions/{name}/setup, 16 KiB body, skeleton returning NotImplemented). Descriptor contract test extended to 9 routes. * feat(reborn): session + OIDC WebuiAuthenticator impls Adds two concrete authenticators to the ingress crate: a session-backed SessionAuthenticator wired around a pluggable SessionStore trait (with an InMemorySessionStore for local dev / tests), and an OIDC ID-token verifier with JWKS caching, asymmetric-algorithm allowlist, and a host-supplied claim-to-UserId mapper. Both fail closed on lookup error and use constant-time comparison on secret material. * feat(reborn-composition): WS same-origin middleware + entrypoint inventory close-out Descriptor-driven Origin enforcement for WebChat v2 routes that declare a non-NotApplicable WebSocketOriginPolicy. WebSocket upgrades skip the CORS pre-flight the existing CorsLayer relies on, so origin validation runs inline; the stream_events_ws descriptor lands today as SameOriginRequired. Updates the entrypoint inventory in CLAUDE.md to flip list-threads, WebSocket, and extensions-onboarding to "Mapped" now that the upstream surface + handlers + middleware are wired. * fix(reborn): review-feedback hardening pass Closes a batch of risks called out in the self-review of nearai#3580's follow-up branch: list_threads_for_scope's default impl now fails closed instead of fabricating an empty thread list; InMemorySessionStore sweeps expired sessions on every create/lookup; WebSocket same-origin checks consume a host-configured canonical_host before trusting the client-supplied Host header; OIDC's JWKS refresh path is single-flight to stop concurrent-burst stampede; origin_is_localhost handles bracketed IPv6 + IPv4 /8 loopback; security-event log lines moved to info!; non-loopback bind warns loudly; dead session-token helpers removed. * test(reborn-composition): real WS upgrade caller tests * fix(reborn): second review pass — sharded rate limit + auth API hygiene Addresses the second multi-agent review: replaces the single global rate-limit Mutex with 16-way sharding (hash caller_key → shard, each shard caps at 512 entries for the same 8192-entry total budget), seals WebuiServeConfig fields as pub(crate) so the public surface is builder-only, gates InMemorySessionStore behind dev-in-memory-session so production builds can't accidentally wire process-local sessions, adds Referrer-Policy: no-referrer + an operational warning in extract_bearer_token about ?token= showing up in URL logs, and adds two unit tests asserting extract_bearer_token rejects ?token= on every non-SSE shape. * fix(reborn): wire default agent_id/project_id into authenticated caller Without this every authenticated v2 request 400s downstream because the facade builds ThreadScope from caller.agent_id. Also: CLI --host/--port become Option<> for explicit precedence; [webui].canonical_host wired through with_canonical_host; OIDC rejects non-https:// JWKS URLs at construction; on kid-miss OIDC forces one single-flight JWKS refresh before rejecting. * fix(reborn): bound WS sends + JWKS stale-while-revalidate + OIDC/WS e2e tests Two real bugs: WS handler's socket.send was unbounded so a backpressuring client could pin the task past SSE_MAX_LIFETIME and hold its per-caller slot — now every send is wrapped in a remaining-lifetime timeout. JWKS refresh failures used to convoy every concurrent auth attempt behind the next 5s upstream timeout — fetch failures now stamp a 30s backoff, during which expired-cache reads return stale keys instead of queueing behind the lock. Plus the test coverage gaps the reviewer flagged: ingress's tests/oidc_e2e.rs spins up a real loopback JWKS server and drives valid-token / wrong-iss / wrong-aud / expired-token / kid-rotation / single-flight / failure-convoy paths against a freshly generated RSA keypair; webui_v2's stream_events_ws_emits_projection_frames_and_redacted_error opens a real WebSocket and asserts projection envelopes + after_cursor advancement + the redacted error-frame shape. * type extension_name path segment + cover SSE/WS pool and list_threads error path ExtensionName from ironclaw_common now flows through the handler/facade boundary so internal request/response state never carries the raw String. Adds three regression tests: SSE and WS transports share one per-caller SseCapacity pool, the list_threads facade returns retryable service_unavailable when the SessionThreadService backend has no list_threads_for_scope override, and a malformed extension_name path segment is rejected at the boundary. .env.example documents IRONCLAW_REBORN_WEBUI_TOKEN / IRONCLAW_REBORN_WEBUI_USER_ID, and the WebUI v2 CLAUDE.md route table now lists the full nine-route surface including list_threads, stream_events_ws, and setup_extension. * parse JWKS URL with url::Url so loopback-prefix DNS bypass is rejected The previous string-prefix check accepted any http:// host starting with "127.", which let DNS names like 127.evil.com or 127.0.0.1.evil.com look like loopback and would have made the OIDC authenticator trust attacker- supplied signing keys. The guard now parses with url::Url, refuses any userinfo component, and accepts http only for the literal host "localhost" or an IP whose IpAddr::is_loopback() is true. Regression tests cover the DNS-prefix bypass, userinfo-disguised authorities, malformed/non-http schemes, plus IPv4 and bracketed-IPv6 loopback. * fail serve without projection stream + recv-aware WS + hardened session/OIDC `ironclaw-reborn serve` now refuses to start by default when no ProjectionStream is wired into the WebUI bundle (operator opt-in via IRONCLAW_REBORN_ALLOW_NO_PROJECTION_STREAM=1 for non-streaming routes only). The WS drain loop selects on socket.recv() so a closed peer releases its SseSlot immediately instead of pinning it for SSE_MAX_LIFETIME. SessionRecord no longer carries the bearer token in Debug/Serialize — the bearer is the durable store's lookup key and the record carries only a non-secret SessionId for audit. SessionAuthenticator logs SessionStore::Err at warn! so backend outages stay diagnosable while still failing closed to the client. OIDC adds a 60s forced-refresh backoff so JWTs with rotating unknown kids cannot DoS the upstream JWKS or this gateway pre-auth. The serve subcommand and its smoke tests are now gated behind a new CLI `webui-v2-beta` Cargo feature; the default binary build does not link the beta HTTP/auth gateway. Build-time runtime-input validation treats `[identity].default_project` as supported when serve is the caller (was rejected for all callers), and `[webui].listen_port = 0` from config is refused with an actionable message (banner reports the literal :0 the OS doesn't know yet — only a CLI --port 0 is allowed for that case). * bump wasmtime + wasmtime-wasi 43.0.2 -> 44.0.2 (RUSTSEC-2026-0149) The 43.x line is vulnerable to RUSTSEC-2026-0149: WASI path_open with TRUNCATE can bypass the host's FilePerms::WRITE restriction. 44.0.2 is the minimum patch the advisory lists as fixed. No source changes were needed — the public API surface this workspace consumes (component engine setup, WASI p2 linker, store helpers, fuel/epoch limits) is unchanged between 43.0.2 and 44.0.2. Builds + the WASM sandbox/adapter test suites pass; the three failing tests in wasm_dispatch_integration are pre-existing manifest-validation failures already failing on the parent branch, unrelated to this bump. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
* 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>
Summary
native Path A surface (
docs/reborn/how-to-port-channel-to-reborn.md).webui_v2_app(bundle, config) -> Routerwithdescriptor-driven body limits, per-
(route, caller)sliding-windowrate limits, bearer +
?token=(SSE only) auth, CORS, static securityheaders (nosniff / DENY / CSP), and descriptor-driven WebSocket
same-origin enforcement with operator-supplied
canonical_host.ironclaw_reborn_webui_ingress): binds theTCP listener and drives
axum::servewith graceful shutdown — theproduct/API composition crate stays off the listener-binding boundary
enforced by
reborn_product_api_crates_do_not_bind_http_ingress.WebuiAuthenticatorimplementations: env-bearer (withsubtle::ConstantTimeEq), session-backed (pluggableSessionStoretrait + in-memory impl with expiry sweep), and OIDC ID-token verifier
(jsonwebtoken + JWKS cache with single-flight refresh + asymmetric
algorithm allowlist).
ironclaw-reborn servenow actually serves: reads[webui]configsection, mints the v2 Router, and runs the listener with Ctrl-C
graceful shutdown. Warns loudly when bound to a non-loopback address.
(
GET /api/webchat/v2/threads), WebSocket transport variant ofstream-events (
GET .../ws, same-origin enforced inline because WSupgrades skip CORS pre-flight), and extensions onboarding
(
POST /api/webchat/v2/extensions/{name}/setup, skeleton returningNotImplementeduntil a v2-aware extension lifecycle lands).Change Type
webui-v2-beta, off by default)Linked Issue
Closes #3580.
The Path A native-host-surface checklist on #3580 is satisfied:
inventory ✅, auth (bearer/DB/OIDC) ✅, query-token + CSRF/origin
(WS same-origin) ✅, CORS ✅, body/rate/connection limits ✅, static
security headers ✅, browser submissions → ProductWorkflow ✅, native
replies/progress/projections (SSE + WS) ✅, caller-level tests for
submission and streaming/projection ✅, parity docs ✅.
Validation
cargo fmt --all -- --checkcargo clippy --workspace --all-targets --features webui-v2-beta -- -D warningscargo build --bin ironclaw(v1 binary still builds, untouched)cargo test -p ironclaw_reborn_webui_ingress— 13 unit + 3 integration(real listener bind / serve / graceful shutdown / bind error / EnvBearer
accept-reject-prefix-empty / Session create-lookup-expired-unknown / OIDC
audience-exp-nbf-mapper-rejects).
cargo test -p ironclaw_reborn_composition --features webui-v2-beta—21 caller-level integration tests including: bearer happy-path,
missing/invalid bearer, SSE
?token=, timeline rejects?token=,static headers, CORS allow + reject, descriptor body-limit 413,
in-budget mutation reaches facade, NoBody body rejection,
rate-limit 429 + per-caller independence, list-threads, setup-extension
skeleton, WS upgrade happy-path (101) via tokio-tungstenite, and
WS canonical_host override of forged Host.
cargo test -p ironclaw_webui_v2 --features webui-v2-beta—14 handler-contract + 1 descriptor-contract tests; route table
locked at 9 routes.
cargo test -p ironclaw_architecture reborn— 12 boundary testspass, including
reborn_product_api_crates_do_not_bind_http_ingress(new ingress crate is the only one allowed to bind a listener) and
reborn_cli_binary_crate_stays_separate_from_v1_root.cargo test --features integration— not applicable (no DB schemachange, no integration tier behavior).
real-listener WS upgrade test and
tokio-tungsteniteround-trip.Security Impact
This is a Track C change. Security-relevant properties pinned by the
gateway:
?token=shim ONLY on theSSE event route (browser's
EventSourcecan't set headers).Authenticator
None→ 401 before facade. Three concrete authenticatorsshipped:
EnvBearerAuthenticator— constant-time comparison viasubtle::ConstantTimeEq; rejects empty configured token atconstruction.
SessionAuthenticator— pluggableSessionStoretrait. In-memoryimpl included; sweeps expired sessions on every create/lookup so a
long-running process cannot leak memory. Production deployments
wire their own durable store.
OidcAuthenticator— JWKS with TTL cache +tokio::sync::Mutexsingle-flight refresh; asymmetric algorithms only (RS256/384/512,
ES256/384) — symmetric HS* explicitly rejected so a stolen shared
secret cannot forge tokens; defense-in-depth iss/aud/exp/nbf checks
after
jsonwebtoken's own validation.WebUiAuthenticatedCallerbuilt fromconfig.tenant_id(host-trusted installation config) plus theauthenticator's verified
UserId. Browser body cannot influenceeither field.
config.allowed_origins; empty list →fail closed, never echoes attacker-supplied Origin.
the same-origin check runs inline on every WS request whose
descriptor declares
WebSocketOriginPolicy::SameOriginRequired.Optional
canonical_hostconfig defends againstreverse-proxy-passthrough-Host attacks by comparing
Originagainst the operator-trusted value instead of trusting the
client-supplied
Hostheader.send_message 1 MiB, cancel_run / resolve_gate / setup_extension
4 KiB / 16 KiB, get_timeline / stream_events / WS NoBody). Outer
14 MiB fallback for unmatched paths.
(route, caller). Mutation 60/60, read 120/60, stream 12/60. Compositionfails closed for unsupported
RateLimitScopevalues(
webui_rate_limit::tests::unsupported_scope_is_rejected_at_composition).SseCapacity(3 streams per(tenant, user),5-min stream lifetime) applies to both SSE and the new WS route.
nosniff,DENY, default CSPdefault-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'.reborn_product_api_crates_do_not_bind_http_ingressis unchanged. New
ironclaw_reborn_webui_ingresscrate is the onlypermitted place for
axum::serve/TcpListener::bind; product/APIcrates (composition, webui_v2, product_workflow, etc.) still cannot
bind sockets.
SessionThreadService::list_threads_for_scopedefault impl now returns
Err(Backend(...))instead of fabricating anempty thread list — backends that don't implement enumeration surface
503 to the client instead of pretending zero threads exist.
ironclaw-reborn serveemits a stderrWARNING +
tracing::warn!when binding to a non-loopback interface,to catch operator footguns.
Reborn Trust-Boundary Checklist
WebuiServeConfig,WebuiAuthenticator,SessionStore,OidcAuthenticatorare allhost-owned.
WebUiAuthenticatedCallercomes fromironclaw_product_workflowand is built by composition's bearermiddleware from trusted config tenant + verified user.
into prompts.
WebuiServeError,WebuiServeConfigError,RateLimitConfigError,SessionStoreError,OidcAuthenticatorError,RebornWebuiServeError. Match sites local to this PR; downstreamcallers wrap via
?. Command:rg "WebuiServe|RateLimitConfig|SessionStoreError|OidcAuthenticatorError|RebornWebuiServeError" --type rust.serde(default)audit:WebUiSetupExtensionRequest.extension_nameis
#[serde(default)]so the path-bound name (host-trusted route param)cannot be overridden by a body field. Verified the handler always
overwrites with the path-bound value before forwarding to the facade.
expired records on every touch; body buffer per-route ≤ 1 MiB; WS
buffer reuses SSE drain bounds.
RebornServicesErroralready classes(NotFound / Conflict / InvalidRequest / Internal / RateLimited /
ServiceUnavailable). New
Backendfailures fromlist_threads_for_scopedefault route through
service_unavailable(true)(503 retryable).ironclaw_reborn_webui_ingressis host-owned ingress, named accordingly; composition stays a
product/API library.
Database Impact
None. No schema, migration, or DB-touch in this PR.
SessionStoreis atrait with an in-memory impl; durable backends (Postgres / libSQL) are
follow-up work.
Blast Radius
ironclaw_reborn_webui_ingress; modifications toironclaw_reborn_composition,ironclaw_reborn_cli,ironclaw_reborn_config,ironclaw_product_workflow,ironclaw_threads,ironclaw_webui_v2,ironclaw_architecture(testallowlist).
/src/,channels-src/,migrations/,root
tests/*.rs— zero file changes. v1 binary (ironclaw)builds clean. v1 doesn't import any new trait / type from this PR
(verified via grep).
SessionThreadService::list_threads_for_scopehas a default impl, soexisting impls don't need to change.
RebornServicesApi::list_threads/setup_extensionare required methods, but the trait is inironclaw_product_workflowwhich v1 cannot depend on (architecturerule).
WebUiSetupExtensionRequest.extension_namechanged required →
#[serde(default)]. Accepts more inputs; the pathbinding always overwrites.
[webui]config section with malformed entriesfails closed at parse time via
secrets_guard::reject_inline_secret.Missing
IRONCLAW_REBORN_WEBUI_TOKEN/IRONCLAW_REBORN_WEBUI_USER_IDenv vars fail
servestartup with a clear error, never start aserve loop with auth disabled.
Rollback Plan
The whole surface is feature-gated by
webui-v2-beta(off by default).Rollback is:
(no v1 file touched).
webui-v2-betain itsCargo.toml,remove that feature line; the non-beta build path of
ironclaw_reborn_compositionis identical to pre-PR.~/.ironclaw/reborn/config.tomlkeeps parsing fine without[webui].The standalone
ironclaw-reborn servesubcommand is the only placethis code runs today (no v1 binary serves v2 traffic), so revert is
safe at any time.
Review Follow-Through
ironclaw_reborn_composition::runtime::tests::local_dev_runtime_wires_input_skill_context_source_to_model_callsfails on
RebornRuntimeInput.services must include a resolved runtime policy. Verified this exact failure exists on the parent mergecommit before any of this PR's changes; left as-is.
SessionStoreships with an in-memory impl only — durablePostgres / libSQL backends are explicit follow-up.
RebornServicesApi::setup_extensionreturnsRebornSetupExtensionStatus::NotImplementedbecause the v1extension onboarding controller is intentionally not ported (per
"do not touch v1" constraint). The route exists so the v2 entrypoint
inventory is complete and the host facade has a stable surface for
future onboarding work.
SessionThreadService::list_threads_for_scopedefault impl returnsErr(Backend). Existing impls (InMemorySessionThreadService,FilesystemSessionThreadService) don't override yet, so the/api/webchat/v2/threadsroute returns 503 until a backend wiresreal enumeration. Fail-closed by design.
Review track: C (security/runtime gateway, feature-gated, host
auth + composition).