Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- *(webui-v2)* serve the Reborn WebUI from root-level browser routes, with temporary `/v2` compatibility redirects that preserve deep links and login query parameters; `/api/webchat/v2/*` remains unchanged ([#6142](https://github.com/nearai/ironclaw/issues/6142)).
- *(reborn)* raise the default agent-loop runaway backstop from 256 to 1,024 iterations and the subagent ceiling from 16 to 256 ([#5959](https://github.com/nearai/ironclaw/pull/5959)).
- *(reborn-cli)* document the standalone `config init` atomic-write dependency on `tempfile` and call out the default runner cadence change to 5s heartbeats / 200ms polling (down from 10s / 2s).
- *(reborn)* expose runtime poll settings and document the standalone turn-runner cadence change for callers using `TurnRunnerSettings::default()`.
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion FEATURE_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -649,7 +649,7 @@ Trace Commons issuer/TenantCtx note: the server-side `zmanian/tracedao-server` s
| Model selection | ✅ | ✅ | - | TUI only |
| Config editing | ✅ | ❌ | P3 | Raw config pending-changes diff panel with redacted reveal |
| Debug/logs viewer | ✅ | ✅ | - | Real-time log streaming with level/target filters |
| WebChat interface | ✅ | ✅ | - | Web gateway chat with SSE/WebSocket |
| WebChat interface | ✅ | ✅ | - | Web gateway chat with SSE/WebSocket; Reborn serves canonical SPA routes at `/chat`, `/settings`, and `/extensions`, while legacy `/v2/*` browser URLs temporarily redirect to root equivalents and `/api/webchat/v2/*` stays unchanged |
| Canvas system (A2UI) | ✅ | ❌ | P3 | Agent-driven UI, improved asset resolution; macOS canvas hosts pushed A2UI without auto-reload |
| Control UI i18n | ✅ | ❌ | P3 | English, Chinese, Portuguese; expanded with Persian (fa), Dutch (nl), Vietnamese (vi), Italian (it), Arabic (ar), Thai (th), Traditional Chinese (zh-TW) |
| WebChat theme sync | ✅ | ❌ | P3 | Sync with system dark/light mode |
Expand Down
10 changes: 9 additions & 1 deletion crates/ironclaw_reborn_composition/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,14 @@ Inbound order (outer → inner → handler):
send-message, get-timeline, stream-events SSE, stream-events WS,
cancel-run, resolve-gate, setup-extension, list/rename automations).

After the complete descriptor set is assembled, composition derives every
literal root namespace and supplies it to `static_router_with_config`. Exact
host routes still win through Axum routing, while unknown paths in any
host-owned namespace return 404 instead of the SPA shell. A descriptor whose
first segment is dynamic, percent-encoded/noncanonical, missing, or already
owned by a static asset or explicit static route fails composition: no finite
fail-closed reservation can represent those overlaps safely.

### Product-auth routes

When `bundle.product_auth` is present, `webui_v2_app` also mounts the
Expand Down Expand Up @@ -247,7 +255,7 @@ Reborn-native auth router. v1 gateway code remains untouched —
### Session transport decision (#4116)

The OAuth callback returns a short-lived, one-time login ticket to
the SPA via the URL query (`/v2?login_ticket=<ticket>`), not the
the SPA via the URL query (`/?login_ticket=<ticket>`), not the
session bearer itself and not an `HttpOnly` cookie. The SPA
immediately POSTs that ticket to `/auth/session/exchange` and stores
the returned bearer in `sessionStorage`.
Expand Down
4 changes: 2 additions & 2 deletions crates/ironclaw_reborn_composition/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,9 @@ ironclaw_product_context = { path = "../ironclaw_product_context", version = "0.
nix = { version = "0.30", default-features = false, features = ["process"] }
# Unpacking uploaded WASM-tool bundles for the WebUI "Install Tool" import flow.
zip = { version = "8", default-features = false, features = ["deflate"] }
# WebUI v2 route surface — feature-gated; only used by `webui_serve`.
# WebUI v2 JSON route surface and root-mounted static SPA — feature-gated;
# only used by `webui_serve`.
ironclaw_webui_v2 = { path = "../ironclaw_webui_v2", optional = true }
# WebUI v2 static SPA bundle — feature-gated; mounted under `/v2`.
ironclaw_wasm_product_adapters = { path = "../ironclaw_wasm_product_adapters", optional = true }
libsql = { version = "0.9", optional = true, default-features = false, features = ["core", "replication", "remote", "tls"] }
libc = "0.2"
Expand Down
120 changes: 116 additions & 4 deletions crates/ironclaw_reborn_composition/src/llm_admin/nearai_login_serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ const NEARAI_CALLBACK_RATE_MAX: NonZeroU32 = match NonZeroU32::new(60) {
// SAFETY: 60 is a non-zero literal rate limit.
None => unreachable!(),
};
const NEARAI_LOGIN_SUCCESS_REDIRECT: &str = "/chat";
const NEARAI_LOGIN_ERROR_REDIRECT: &str = "/settings/inference?nearai_login=error";

#[derive(Clone)]
struct NearAiCallbackState {
Expand All @@ -61,16 +63,16 @@ async fn nearai_callback(
Query(query): Query<CallbackQuery>,
) -> Redirect {
if !state.states.consume(&login_state).await {
return Redirect::to("/v2/settings/inference?nearai_login=error");
return Redirect::to(NEARAI_LOGIN_ERROR_REDIRECT);
}
let Some(token) = query.token.filter(|token| !token.trim().is_empty()) else {
return Redirect::to("/v2/settings/inference?nearai_login=error");
return Redirect::to(NEARAI_LOGIN_ERROR_REDIRECT);
};
match apply_nearai_login(&state.session, &state.boot, state.reload.as_ref(), &token).await {
Ok(()) => Redirect::to("/v2/chat"),
Ok(()) => Redirect::to(NEARAI_LOGIN_SUCCESS_REDIRECT),
Err(error) => {
tracing::warn!(%error, "NEAR AI login callback failed");
Redirect::to("/v2/settings/inference?nearai_login=error")
Redirect::to(NEARAI_LOGIN_ERROR_REDIRECT)
}
}
}
Expand Down Expand Up @@ -125,8 +127,50 @@ fn nearai_callback_descriptor() -> IngressRouteDescriptor {

#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};

use async_trait::async_trait;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use ironclaw_llm::{SessionConfig, SessionManager};
use ironclaw_reborn_config::{RebornHome, RebornProfile};
use tower::ServiceExt;

use super::*;

#[derive(Default)]
struct RecordingReload {
calls: AtomicUsize,
}

#[async_trait]
impl LlmReloadTrigger for RecordingReload {
async fn reload(&self) -> Result<(), String> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}

fn callback_mount(
root: &std::path::Path,
states: Arc<NearAiLoginStateStore>,
reload: Arc<RecordingReload>,
) -> PublicRouteMount {
let home =
RebornHome::resolve_from_env_parts(Some(root.as_os_str().to_os_string()), None, None)
.expect("temporary Reborn home is valid");
let session = Arc::new(SessionManager::new(SessionConfig {
auth_base_url: "https://private.near.ai".to_string(),
session_path: root.join("nearai-session.json"),
}));
nearai_login_callback_mount(
session,
reload,
RebornBootConfig::new(home, RebornProfile::LocalDev),
states,
)
}

#[test]
fn nearai_callback_descriptor_records_state_guarded_effectful_workflow() {
let descriptor = nearai_callback_descriptor();
Expand All @@ -141,4 +185,72 @@ mod tests {
assert_eq!(policy.scope_source(), IngressScopeSource::HostResolved);
assert_eq!(policy.effect_path(), &AllowedEffectPath::ProductWorkflow);
}

#[tokio::test]
async fn nearai_callback_redirects_success_to_root_chat() {
let temp = tempfile::tempdir().expect("temporary directory");
let states = Arc::new(NearAiLoginStateStore::new());
let login_state = states.issue().await;
let reload = Arc::new(RecordingReload::default());
let mount = callback_mount(temp.path(), states, Arc::clone(&reload));

let response = mount
.router
.oneshot(
Request::builder()
.uri(format!(
"/api/webchat/v2/llm/nearai/{login_state}/auth/callback?token=session-token"
))
.body(Body::empty())
.expect("callback request"),
)
.await
.expect("callback response");

assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response
.headers()
.get(header::LOCATION)
.and_then(|value| value.to_str().ok()),
Some("/chat"),
);
assert_eq!(reload.calls.load(Ordering::SeqCst), 1);
}

#[tokio::test]
async fn nearai_callback_redirects_invalid_or_incomplete_login_to_root_settings() {
let temp = tempfile::tempdir().expect("temporary directory");
let states = Arc::new(NearAiLoginStateStore::new());
let valid_state = states.issue().await;
let reload = Arc::new(RecordingReload::default());
let mount = callback_mount(temp.path(), states, Arc::clone(&reload));

for uri in [
"/api/webchat/v2/llm/nearai/unknown/auth/callback?token=session-token".to_string(),
format!("/api/webchat/v2/llm/nearai/{valid_state}/auth/callback"),
] {
let response = mount
.router
.clone()
.oneshot(
Request::builder()
.uri(uri)
.body(Body::empty())
.expect("callback request"),
)
.await
.expect("callback response");

assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response
.headers()
.get(header::LOCATION)
.and_then(|value| value.to_str().ok()),
Some("/settings/inference?nearai_login=error"),
);
}
assert_eq!(reload.calls.load(Ordering::SeqCst), 0);
}
}
79 changes: 69 additions & 10 deletions crates/ironclaw_reborn_composition/src/webui/webui_serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ use ironclaw_auth::GoogleOAuthRouteConfig;
use ironclaw_host_api::ingress::IngressRouteDescriptor;
use ironclaw_host_api::{AgentId, ProjectId, TenantId, UserId};
use ironclaw_webui_v2::{
DEFAULT_SSE_MAX_CONCURRENT_PER_CALLER, WebUiV2Capabilities, WebUiV2RouteOptions, WebUiV2State,
is_webui_v2_operator_webui_config_route_id, webui_v2_router_with_options,
DEFAULT_SSE_MAX_CONCURRENT_PER_CALLER, StaticRouterConfig, StaticRouterConfigError,
WebUiV2Capabilities, WebUiV2RouteOptions, WebUiV2State,
is_webui_v2_operator_webui_config_route_id, static_router_with_config,
webui_v2_router_with_options,
};
use tower_http::catch_panic::CatchPanicLayer;
use tower_http::cors::{AllowHeaders, CorsLayer};
Expand Down Expand Up @@ -530,6 +532,65 @@ pub enum WebuiServeError {
InvalidCspHeader(String),
#[error("rate-limit composition failed: {0}")]
RateLimit(#[from] crate::webui::webui_rate_limit::RateLimitConfigError),
#[error(
"route descriptor `{route_id}` must begin with a literal root namespace: `{route_pattern}`"
)]
NonLiteralRootNamespace {
route_id: String,
route_pattern: String,
},
#[error(
"route descriptor `{route_id}` has noncanonical root namespace `{root_namespace}`: `{route_pattern}`"
)]
NonCanonicalRootNamespace {
route_id: String,
route_pattern: String,
root_namespace: String,
},
#[error(
"route descriptor `{route_id}` conflicts with static WebUI root namespace `{root_namespace}`: `{route_pattern}`"
)]
StaticRootNamespaceConflict {
route_id: String,
route_pattern: String,
root_namespace: String,
},
}

fn static_router_config_from_descriptors(
descriptors: &[IngressRouteDescriptor],
) -> Result<StaticRouterConfig, WebuiServeError> {
let mut config = StaticRouterConfig::default();
for descriptor in descriptors {
let route_pattern = descriptor.route_pattern().as_str();
let root_namespace = route_pattern
.strip_prefix('/')
.and_then(|path| path.split('/').next())
.filter(|segment| !segment.is_empty())
.ok_or_else(|| WebuiServeError::NonLiteralRootNamespace {
route_id: descriptor.route_id().as_str().to_string(),
route_pattern: route_pattern.to_string(),
})?;
config = match config.try_with_additional_reserved_root_namespaces([root_namespace]) {
Ok(config) => config,
Err(StaticRouterConfigError::NonCanonicalRootNamespace { namespace }) => {
return Err(WebuiServeError::NonCanonicalRootNamespace {
route_id: descriptor.route_id().as_str().to_string(),
route_pattern: route_pattern.to_string(),
root_namespace: namespace,
});
}
Err(StaticRouterConfigError::StaticRootNamespaceConflict { namespace }) => {
return Err(WebuiServeError::StaticRootNamespaceConflict {
route_id: descriptor.route_id().as_str().to_string(),
route_pattern: route_pattern.to_string(),
root_namespace: namespace,
});
}
};
}

Ok(config)
}

/// Build the fully-composed Reborn WebChat v2 axum app:
Expand Down Expand Up @@ -655,6 +716,7 @@ pub fn webui_v2_app_with_lifecycle(
for mount in &protected_mounts {
descriptors.extend(mount.descriptors.iter().cloned());
}
let static_router_config = static_router_config_from_descriptors(&descriptors)?;
let rate_limit_state = build_rate_limit_state(&descriptors)?;
let body_limit_state = build_body_limit_state(&descriptors);
let ws_origin_state = build_websocket_origin_state(
Expand Down Expand Up @@ -767,14 +829,11 @@ pub fn webui_v2_app_with_lifecycle(
// CORS, panic boundary, and the global body-limit
// (`.layer(...)` calls below) still apply, defense in depth.
//
// The static crate's `mount_at_prefix` factory owns the
// routing surface (root, trailing-slash, wildcard, and any
// future routes it adds) so the composition layer never
// enumerates individual handlers. `merge` (not `nest`) is
// used because the factory already returns fully prefixed
// routes — `nest` in axum 0.8 has quirky dispatch for the
// exact prefix with/without trailing slash.
.merge(ironclaw_webui_v2::mount_at_prefix("/v2"))
// The static crate owns the complete browser surface: root SPA
// routes, assets, the isolated wallet popup, and compatibility
// redirects from the former `/v2` mount. Composition merges that
// surface as a unit so it never re-implements route policy.
.merge(static_router_with_config(static_router_config))
// Outer global cap: applies to unmatched paths (e.g. 404 fallback)
// as defense in depth. v2 routes are tighter via the per-route
// body-limit middleware above.
Expand Down
Loading
Loading