Skip to content

feat(reborn): WebChat v2 host gateway — serve loop + auth + routes - #3815

Merged
think-in-universe merged 23 commits into
reborn-integrationfrom
feat/webui-v2-gateway-composition-3580
May 22, 2026
Merged

think-in-universe merged 23 commits into
reborn-integrationfrom
feat/webui-v2-gateway-composition-3580

Conversation

@italic-jinxin

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

Copy link
Copy Markdown
Contributor

Summary

  • Lands the full Reborn-side host port of the v1 WebUI / Web Gateway as a
    native Path A surface (docs/reborn/how-to-port-channel-to-reborn.md).
  • Composition layer: webui_v2_app(bundle, config) -> Router with
    descriptor-driven body limits, per-(route, caller) sliding-window
    rate limits, bearer + ?token= (SSE only) auth, CORS, static security
    headers (nosniff / DENY / CSP), and descriptor-driven WebSocket
    same-origin enforcement with operator-supplied canonical_host.
  • Host-owned serve crate (ironclaw_reborn_webui_ingress): binds the
    TCP listener and drives axum::serve with graceful shutdown — the
    product/API composition crate stays off the listener-binding boundary
    enforced by reborn_product_api_crates_do_not_bind_http_ingress.
  • Three concrete WebuiAuthenticator implementations: env-bearer (with
    subtle::ConstantTimeEq), session-backed (pluggable SessionStore
    trait + in-memory impl with expiry sweep), and OIDC ID-token verifier
    (jsonwebtoken + JWKS cache with single-flight refresh + asymmetric
    algorithm allowlist).
  • ironclaw-reborn serve now actually serves: reads [webui] config
    section, mints the v2 Router, and runs the listener with Ctrl-C
    graceful shutdown. Warns loudly when bound to a non-loopback address.
  • Three new v2 routes round out the entrypoint inventory: thread list
    (GET /api/webchat/v2/threads), WebSocket transport variant of
    stream-events (GET .../ws, same-origin enforced inline because WS
    upgrades skip CORS pre-flight), and extensions onboarding
    (POST /api/webchat/v2/extensions/{name}/setup, skeleton returning
    NotImplemented until a v2-aware extension lifecycle lands).

Change Type

  • New feature (feature-gated webui-v2-beta, off by default)
  • Security (gateway middleware composition + host auth)

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 -- --check
  • cargo clippy --workspace --all-targets --features webui-v2-beta -- -D warnings
  • cargo build --bin ironclaw (v1 binary still builds, untouched)
  • Relevant tests pass:
    • 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 tests
      pass, 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 schema
    change, no integration tier behavior).
  • Manual: no browser session yet; full pipe-through is exercised by the
    real-listener WS upgrade test and tokio-tungstenite round-trip.

Security Impact

This is a Track C change. Security-relevant properties pinned by the
gateway:

  • Auth. Bearer-only on mutation/read; ?token= shim ONLY on the
    SSE event route (browser's EventSource can't set headers).
    Authenticator None → 401 before facade. Three concrete authenticators
    shipped:
    • EnvBearerAuthenticator — constant-time comparison via
      subtle::ConstantTimeEq; rejects empty configured token at
      construction.
    • SessionAuthenticator — pluggable SessionStore trait. In-memory
      impl 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::Mutex
      single-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.
  • Caller identity. WebUiAuthenticatedCaller built from
    config.tenant_id (host-trusted installation config) plus the
    authenticator's verified UserId. Browser body cannot influence
    either field.
  • CORS. Allow-origin = config.allowed_origins; empty list →
    fail closed, never echoes attacker-supplied Origin.
  • WebSocket same-origin. WS upgrades bypass CORS pre-flight, so
    the same-origin check runs inline on every WS request whose
    descriptor declares WebSocketOriginPolicy::SameOriginRequired.
    Optional canonical_host config defends against
    reverse-proxy-passthrough-Host attacks by comparing Origin
    against the operator-trusted value instead of trusting the
    client-supplied Host header.
  • Body limits. Descriptor-driven per route (create_thread 16 KiB,
    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.
  • Rate limits. Descriptor-driven sliding window per (route, caller). Mutation 60/60, read 120/60, stream 12/60. Composition
    fails closed for unsupported RateLimitScope values
    (webui_rate_limit::tests::unsupported_scope_is_rejected_at_composition).
  • Connection limits. SseCapacity (3 streams per (tenant, user),
    5-min stream lifetime) applies to both SSE and the new WS route.
  • Static headers. nosniff, DENY, default CSP
    default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'.
  • Listener boundary. reborn_product_api_crates_do_not_bind_http_ingress
    is unchanged. New ironclaw_reborn_webui_ingress crate is the only
    permitted place for axum::serve / TcpListener::bind; product/API
    crates (composition, webui_v2, product_workflow, etc.) still cannot
    bind sockets.
  • Fail-closed defaults. SessionThreadService::list_threads_for_scope
    default impl now returns Err(Backend(...)) instead of fabricating an
    empty thread list — backends that don't implement enumeration surface
    503 to the client instead of pretending zero threads exist.
  • Non-loopback bind warning. ironclaw-reborn serve emits a stderr
    WARNING + tracing::warn! when binding to a non-loopback interface,
    to catch operator footguns.

Reborn Trust-Boundary Checklist

  • Public policy/evidence/trust-bearing types — WebuiServeConfig,
    WebuiAuthenticator, SessionStore, OidcAuthenticator are all
    host-owned. WebUiAuthenticatedCaller comes from
    ironclaw_product_workflow and is built by composition's bearer
    middleware from trusted config tenant + verified user.
  • Untrusted content into prompts — N/A, gateway layer doesn't reach
    into prompts.
  • Hashes declare purpose — N/A, no new hashing.
  • New error variants audited: WebuiServeError, WebuiServeConfigError,
    RateLimitConfigError, SessionStoreError, OidcAuthenticatorError,
    RebornWebuiServeError. Match sites local to this PR; downstream
    callers wrap via ?. Command: rg "WebuiServe|RateLimitConfig|SessionStoreError|OidcAuthenticatorError|RebornWebuiServeError" --type rust.
  • Security serde(default) audit: WebUiSetupExtensionRequest.extension_name
    is #[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.
  • Bounded buffers — rate-limit LRU 8192 entries; session map sweeps
    expired records on every touch; body buffer per-route ≤ 1 MiB; WS
    buffer reuses SSE drain bounds.
  • Stable error classes — RebornServicesError already classes
    (NotFound / Conflict / InvalidRequest / Internal / RateLimited /
    ServiceUnavailable). New Backend failures from list_threads_for_scope
    default route through service_unavailable(true) (503 retryable).
  • Sandbox / native / host naming accurate — ironclaw_reborn_webui_ingress
    is host-owned ingress, named accordingly; composition stays a
    product/API library.

Database Impact

None. No schema, migration, or DB-touch in this PR. SessionStore is a
trait with an in-memory impl; durable backends (Postgres / libSQL) are
follow-up work.

Blast Radius

  • Touched crates (all Reborn-side): new
    ironclaw_reborn_webui_ingress; modifications to
    ironclaw_reborn_composition, ironclaw_reborn_cli,
    ironclaw_reborn_config, ironclaw_product_workflow,
    ironclaw_threads, ironclaw_webui_v2, ironclaw_architecture (test
    allowlist).
  • v1 surface untouched. /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).
  • Trait additions are backward-compatible.
    SessionThreadService::list_threads_for_scope has a default impl, so
    existing impls don't need to change. RebornServicesApi::list_threads /
    setup_extension are required methods, but the trait is in
    ironclaw_product_workflow which v1 cannot depend on (architecture
    rule).
  • Wire-format relaxations only. WebUiSetupExtensionRequest.extension_name
    changed required → #[serde(default)]. Accepts more inputs; the path
    binding always overwrites.
  • Could surprise. A [webui] config section with malformed entries
    fails closed at parse time via secrets_guard::reject_inline_secret.
    Missing IRONCLAW_REBORN_WEBUI_TOKEN / IRONCLAW_REBORN_WEBUI_USER_ID
    env vars fail serve startup with a clear error, never start a
    serve loop with auth disabled.

Rollback Plan

The whole surface is feature-gated by webui-v2-beta (off by default).
Rollback is:

  1. Revert the merge commit. v1 binary behavior is unchanged regardless
    (no v1 file touched).
  2. If a downstream host turned on webui-v2-beta in its Cargo.toml,
    remove that feature line; the non-beta build path of
    ironclaw_reborn_composition is identical to pre-PR.
  3. No DB migration to reverse. No on-disk schema introduced. Operator
    ~/.ironclaw/reborn/config.toml keeps parsing fine without [webui].

The standalone ironclaw-reborn serve subcommand is the only place
this code runs today (no v1 binary serves v2 traffic), so revert is
safe at any time.

Review Follow-Through

  • Pre-existing test failure unrelated to this PR:
    ironclaw_reborn_composition::runtime::tests::local_dev_runtime_wires_input_skill_context_source_to_model_calls
    fails on RebornRuntimeInput.services must include a resolved runtime policy. Verified this exact failure exists on the parent merge
    commit before any of this PR's changes; left as-is.
  • Skeleton implementations acknowledged in code:
    • SessionStore ships with an in-memory impl only — durable
      Postgres / libSQL backends are explicit follow-up.
    • RebornServicesApi::setup_extension returns
      RebornSetupExtensionStatus::NotImplemented because the v1
      extension 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_scope default impl returns
      Err(Backend). Existing impls (InMemorySessionThreadService,
      FilesystemSessionThreadService) don't override yet, so the
      /api/webchat/v2/threads route returns 503 until a backend wires
      real enumeration. Fail-closed by design.

Review track: C (security/runtime gateway, feature-gated, host
auth + composition).

@italic-jinxin italic-jinxin self-assigned this May 20, 2026
@github-actions github-actions Bot added scope: channel/web Web gateway channel scope: docs Documentation scope: dependencies Dependency updates size: L 200-499 changed lines risk: medium Business logic, config, or moderate-risk modules contributor: experienced 6-19 merged PRs labels May 20, 2026

@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 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.

Comment thread src/channels/web/platform/webui_v2.rs Outdated
@italic-jinxin italic-jinxin linked an issue May 20, 2026 that may be closed by this pull request
6 tasks
@italic-jinxin
italic-jinxin requested a review from serrrfirat May 20, 2026 09:24
@serrrfirat

Copy link
Copy Markdown
Collaborator

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 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.

Do not change /src or v1 binary

@italic-jinxin
italic-jinxin force-pushed the feat/webui-v2-gateway-composition-3580 branch from bb3077b to 9d6b321 Compare May 20, 2026 10:58
@github-actions github-actions Bot added size: XL 500+ changed lines risk: low Changes to docs, tests, or low-risk modules and removed size: L 200-499 changed lines risk: medium Business logic, config, or moderate-risk modules labels May 20, 2026
serrrfirat

This comment was marked as resolved.

@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.

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.

  1. 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.

  1. 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.

italic-jinxin added a commit that referenced this pull request May 20, 2026
…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.
@serrrfirat
serrrfirat force-pushed the feat/webui-v2-gateway-composition-3580 branch from 995ec5d to 1192b35 Compare May 20, 2026 20:26
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.
serrrfirat

This comment was marked as resolved.

@serrrfirat serrrfirat mentioned this pull request May 21, 2026
14 of 37 tasks
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.
@italic-jinxin
italic-jinxin force-pushed the feat/webui-v2-gateway-composition-3580 branch from e6a6540 to d93e92a Compare May 22, 2026 04:23
@italic-jinxin
italic-jinxin requested a review from serrrfirat May 22, 2026 04:46

@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.

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 /events and /ws never provide native progress/projections.
  • Medium: session records duplicate the bearer token in a public serializable/debuggable struct.
  • Medium: unknown JWT kid values 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: serve rejects configs with [identity].default_project before 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 = 0 from 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.

Comment thread crates/ironclaw_reborn_cli/src/commands/serve.rs
Comment thread crates/ironclaw_reborn_webui_ingress/src/session.rs Outdated
Comment thread crates/ironclaw_reborn_webui_ingress/src/oidc.rs
Comment thread crates/ironclaw_webui_v2/src/handlers.rs Outdated
Comment thread crates/ironclaw_reborn_cli/src/commands/serve.rs Outdated
Comment thread crates/ironclaw_reborn_webui_ingress/src/session.rs Outdated
Comment thread crates/ironclaw_reborn_cli/src/commands/serve.rs Outdated
Comment thread crates/ironclaw_reborn_cli/Cargo.toml Outdated
@think-in-universe

Copy link
Copy Markdown
Collaborator

@copilot resolve the merge conflicts in this pull request

Copilot AI commented May 22, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Resolved. I merged origin/reborn-integration into this branch and fixed the merge conflicts; merge commit: b4ae5ae0.

@italic-jinxin
italic-jinxin force-pushed the feat/webui-v2-gateway-composition-3580 branch from b4ae5ae to d93e92a Compare May 22, 2026 09:13
serrrfirat

This comment was marked as resolved.

…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).
@italic-jinxin
italic-jinxin requested a review from serrrfirat May 22, 2026 10:32
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.
@think-in-universe
think-in-universe merged commit 1962b84 into reborn-integration May 22, 2026
15 checks passed
@think-in-universe
think-in-universe deleted the feat/webui-v2-gateway-composition-3580 branch May 22, 2026 10:50
@think-in-universe think-in-universe linked an issue May 25, 2026 that may be closed by this pull request
6 tasks
serrrfirat added a commit that referenced this pull request May 27, 2026
* 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>
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…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>
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: channel/web Web gateway channel 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.

Port WebUI / Web Gateway to native Reborn surface

4 participants