diff --git a/crates/ironclaw_reborn_cli/Cargo.toml b/crates/ironclaw_reborn_cli/Cargo.toml index 473c21d91c5..80025d1e266 100644 --- a/crates/ironclaw_reborn_cli/Cargo.toml +++ b/crates/ironclaw_reborn_cli/Cargo.toml @@ -19,7 +19,7 @@ layer = "app" dist = false [features] -default = ["root-llm-provider"] +default = ["root-llm-provider", "webui-v2-beta"] # Wire a real LLM provider into the assembled runtime. Mirrors the same # `root-llm-provider` feature on `ironclaw_reborn_composition` / # `ironclaw_runner`, so building without it produces a CLI binary that diff --git a/crates/ironclaw_reborn_cli/src/commands/config/init.rs b/crates/ironclaw_reborn_cli/src/commands/config/init.rs index 2561dae6245..3efe2dae5c7 100644 --- a/crates/ironclaw_reborn_cli/src/commands/config/init.rs +++ b/crates/ironclaw_reborn_cli/src/commands/config/init.rs @@ -204,13 +204,15 @@ regex_activation_enabled = true # # session-pool cap after reserving capacity for restarts/operator sessions. # pool_max_size = 2 -[llm.default] -# LLM slot selection. `provider_id` references an entry in +# [llm.default] +# LLM slot selection. Left commented so first run leads to model setup +# (REPL prompt / Web UI welcome screen). Uncomment and edit to pin a +# provider here instead. `provider_id` references an entry in # providers.json (built-in or user-overlay). `model` / `base_url` / # `api_key_env` override the catalog defaults for this deployment. -provider_id = "openai" -model = "gpt-4o-mini" -api_key_env = "OPENAI_API_KEY" +# provider_id = "openai" +# model = "gpt-4o-mini" +# api_key_env = "OPENAI_API_KEY" # [llm.mission] # # Reserved for the future planned-driver "mission" slot. diff --git a/crates/ironclaw_reborn_cli/src/commands/onboard.rs b/crates/ironclaw_reborn_cli/src/commands/onboard.rs index c75ea2b6eb6..85c37fcdd5e 100644 --- a/crates/ironclaw_reborn_cli/src/commands/onboard.rs +++ b/crates/ironclaw_reborn_cli/src/commands/onboard.rs @@ -1,3 +1,4 @@ +use std::io::{self, IsTerminal, Write}; use std::path::{Path, PathBuf}; use clap::Args; @@ -26,6 +27,27 @@ pub(crate) struct OnboardCommand { /// step explicit without touching v1 setup/import state. #[arg(long = "import-history")] import_history: bool, + + /// Scaffold only — never prompt to launch REPL/Web UI, even on a TTY. + /// Use in scripts and CI. + #[arg(long = "no-launch")] + no_launch: bool, +} + +/// Surface an operator can launch straight after onboarding. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LaunchSurface { + Repl, + WebUi, +} + +/// Map a prompt answer to a launch surface. `None` = skip / launch nothing. +fn parse_launch_choice(input: &str) -> Option { + match input.trim().to_ascii_lowercase().as_str() { + "1" | "repl" | "r" => Some(LaunchSurface::Repl), + "2" | "webui" | "web" | "w" => Some(LaunchSurface::WebUi), + _ => None, + } } impl OnboardCommand { @@ -60,19 +82,82 @@ impl OnboardCommand { println!("- onboarding completion marker available"); println!(); println!("remaining:"); - println!("- configure LLM credentials through env vars referenced by config.toml"); - println!( - "- run `ironclaw-reborn models set-provider --model ` as needed" - ); + println!("- choose a model (in the REPL prompt or the Web UI setup screen below)"); if self.import_history { println!("- history import requested but not wired yet"); } else { println!("- history import not requested"); } + + // Offer to launch straight into a usable surface, where model setup + // happens (Web UI setup screen / REPL prompt). Skipped for --no-launch + // and non-interactive stdin so scripts/CI keep the scaffold-only path. + if !self.no_launch && io::stdin().is_terminal() { + if let Some(surface) = prompt_launch_surface()? { + return launch_surface(surface); + } + } else { + println!(); + println!("next: run `ironclaw-reborn repl` or `ironclaw-reborn serve` to finish setup"); + } Ok(()) } } +/// Whether this binary was compiled with the Web UI (`serve`) subcommand. +const WEBUI_AVAILABLE: bool = cfg!(feature = "webui-v2-beta"); + +fn prompt_launch_surface() -> anyhow::Result> { + if WEBUI_AVAILABLE { + print!("\nStart now? [1] REPL [2] Web UI [Enter] skip: "); + } else { + print!( + "\nStart now? [1] REPL [Enter] skip\n\ + (Web UI needs a build with `--features webui-v2-beta`): " + ); + } + io::stdout().flush()?; + let mut line = String::new(); + if io::stdin().read_line(&mut line)? == 0 { + return Ok(None); // EOF (piped input) + } + match parse_launch_choice(&line) { + Some(LaunchSurface::WebUi) if !WEBUI_AVAILABLE => { + println!( + "Web UI is not available in this build. Rebuild with \ + `cargo build --features webui-v2-beta`, or choose REPL." + ); + Ok(None) + } + other => Ok(other), + } +} + +/// Re-exec this same binary into the chosen surface so onboarding hands off to a +/// live session (REPL prompt or Web UI, where the model is configured). +fn launch_surface(surface: LaunchSurface) -> anyhow::Result<()> { + let exe = std::env::current_exe()?; + let subcommand = match surface { + LaunchSurface::Repl => "repl", + LaunchSurface::WebUi => "serve", + }; + println!("launching `{subcommand}`…"); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // exec replaces this process; only returns on failure. + Err(std::process::Command::new(&exe) + .arg(subcommand) + .exec() + .into()) + } + #[cfg(not(unix))] + { + let status = std::process::Command::new(&exe).arg(subcommand).status()?; + std::process::exit(status.code().unwrap_or(1)); + } +} + pub(crate) fn onboarding_marker_path(home: &RebornHome) -> PathBuf { home.path().join(ONBOARDING_MARKER_FILE) } @@ -139,3 +224,21 @@ fn pending_steps(import_history: bool) -> Vec<&'static str> { } steps } + +#[cfg(test)] +mod launch_choice_tests { + use super::*; + + #[test] + fn parses_repl_webui_and_skip_answers() { + for a in ["1", "repl", "REPL", " r ", "\tRepl\n"] { + assert_eq!(parse_launch_choice(a), Some(LaunchSurface::Repl), "{a:?}"); + } + for a in ["2", "webui", "web", "W"] { + assert_eq!(parse_launch_choice(a), Some(LaunchSurface::WebUi), "{a:?}"); + } + for a in ["", "\n", "3", "quit", "xyz"] { + assert_eq!(parse_launch_choice(a), None, "{a:?}"); + } + } +} diff --git a/crates/ironclaw_reborn_cli/src/commands/repl.rs b/crates/ironclaw_reborn_cli/src/commands/repl.rs index 8140abb14ab..c151248708c 100644 --- a/crates/ironclaw_reborn_cli/src/commands/repl.rs +++ b/crates/ironclaw_reborn_cli/src/commands/repl.rs @@ -1,3 +1,5 @@ +use std::io::{self, IsTerminal, Write}; + use clap::Args; use crate::context::RebornCliContext; @@ -9,11 +11,21 @@ pub(crate) struct ReplCommand { /// Confirm trusted-laptop host filesystem access for local-dev-yolo. #[arg(long = "confirm-host-access")] confirm_host_access: bool, + + /// Skip the first-run model-setup prompt shown when no LLM is configured. + #[arg(long = "no-setup")] + no_setup: bool, } impl ReplCommand { pub(crate) fn execute(self, context: RebornCliContext) -> anyhow::Result<()> { crate::runtime::init_tracing(); + // First-run model setup: only when interactive and not opted out. Runs + // before the runtime starts so the chosen provider/key are live for the + // session. Skipped for pipes/CI so scripted `repl` keeps working. + if !self.no_setup && io::stdin().is_terminal() { + maybe_run_model_setup(&context)?; + } crate::runtime::execute( context, None, @@ -23,3 +35,167 @@ impl ReplCommand { ) } } + +/// Parse a provider-menu answer. `None` = skip; otherwise a 1-based index in +/// range. +fn parse_provider_choice(input: &str, count: usize) -> Option { + let trimmed = input.trim(); + if trimmed.is_empty() { + return None; + } + trimmed + .parse::() + .ok() + .filter(|n| *n >= 1 && *n <= count) +} + +#[cfg(not(feature = "root-llm-provider"))] +fn maybe_run_model_setup(_context: &RebornCliContext) -> anyhow::Result<()> { + Ok(()) +} + +#[cfg(feature = "root-llm-provider")] +fn maybe_run_model_setup(context: &RebornCliContext) -> anyhow::Result<()> { + use ironclaw_reborn_composition::RebornProviderAdmin; + + // An env-driven selection (`LLM_BACKEND=...`) is already active — don't nag. + if std::env::var("LLM_BACKEND") + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + { + return Ok(()); + } + + let admin = RebornProviderAdmin::new(context.boot_config().clone()); + if let Some(selection) = admin.status()?.default { + // A provider is already selected. Prompt for its API key only when the + // provider actually *requires* one and the env var is missing — + // otherwise we'd nag for providers that need no key (Ollama) or use a + // different credential (nearai / OAuth providers authenticate via a + // session token or login, not `*_API_KEY`). + if let Some(env_var) = selection.api_key_env.as_deref() + && std::env::var_os(env_var).is_none() + && provider_requires_api_key(&admin, selection.provider_id.as_deref()) + { + prompt_and_set_api_key(env_var, selection.provider_id.as_deref()); + } + return Ok(()); + } + + let providers = admin.list(None, false)?.providers; + if providers.is_empty() { + return Ok(()); + } + + println!("\nNo AI model configured yet. Choose a provider to get started"); + println!( + "(press Enter to skip and set one later with `ironclaw-reborn models set-provider`):\n" + ); + for (idx, provider) in providers.iter().enumerate() { + println!( + " {}) {:<20} {}", + idx + 1, + provider.id, + provider.description + ); + } + // Re-prompt on an invalid entry; only an empty line skips and EOF exits. + let index = loop { + print!("\nProvider [1-{} / Enter to skip]: ", providers.len()); + io::stdout().flush()?; + let mut answer = String::new(); + if io::stdin().read_line(&mut answer)? == 0 { + return Ok(()); // EOF + } + if answer.trim().is_empty() { + return Ok(()); // skip + } + if let Some(index) = parse_provider_choice(&answer, providers.len()) { + break index; + } + println!("please enter a number between 1 and {}.", providers.len()); + }; + let provider = &providers[index - 1]; + + print!("Model [Enter for default `{}`]: ", provider.default_model); + io::stdout().flush()?; + let mut model = String::new(); + io::stdin().read_line(&mut model)?; + let model = model.trim(); + let model_arg = (!model.is_empty()).then_some(model); + + let outcome = admin.set_provider(&provider.id, model_arg)?; + println!( + "configured: provider `{}`, model `{}`", + outcome.provider_id, outcome.model + ); + + if outcome.missing_api_key + && let Some(env_var) = outcome.api_key_env.as_deref() + { + prompt_and_set_api_key(env_var, Some(&outcome.provider_id)); + } + println!(); + Ok(()) +} + +/// Whether the given provider genuinely requires an API key (vs. Ollama, or a +/// session-token / OAuth provider like nearai). Looks up the provider's +/// metadata; defaults to `false` so an unknown/unreadable provider never nags. +#[cfg(feature = "root-llm-provider")] +fn provider_requires_api_key( + admin: &ironclaw_reborn_composition::RebornProviderAdmin, + provider_id: Option<&str>, +) -> bool { + let Some(id) = provider_id else { + return false; + }; + admin + .list(Some(id), true) + .ok() + .and_then(|list| list.providers.into_iter().next()) + .and_then(|provider| provider.metadata) + .map(|metadata| metadata.api_key_required) + .unwrap_or(false) +} + +/// Prompt for an API key and set it in the process env for this session. +#[cfg(feature = "root-llm-provider")] +fn prompt_and_set_api_key(env_var: &str, provider: Option<&str>) { + let label = provider.unwrap_or("the selected provider"); + print!( + "\n{label} needs an API key. Enter value for `{env_var}` (used this session; Enter to skip): " + ); + if io::stdout().flush().is_err() { + return; + } + let mut key = String::new(); + if io::stdin().read_line(&mut key).is_err() { + return; + } + let key = key.trim(); + if key.is_empty() { + println!("no key entered — set `{env_var}` in your environment before chatting."); + return; + } + // SAFETY: single-threaded CLI startup, before the runtime spawns any threads + // that read process environment variables. + unsafe { std::env::set_var(env_var, key) }; + println!("key set for this session. To persist it, add `export {env_var}=...` to your shell."); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_choice_parses_index_and_skip() { + assert_eq!(parse_provider_choice("1", 3), Some(1)); + assert_eq!(parse_provider_choice(" 3 \n", 3), Some(3)); + assert_eq!(parse_provider_choice("", 3), None); // Enter = skip + assert_eq!(parse_provider_choice("\n", 3), None); + assert_eq!(parse_provider_choice("0", 3), None); // out of range + assert_eq!(parse_provider_choice("4", 3), None); // out of range + assert_eq!(parse_provider_choice("openai", 3), None); // non-numeric + } +} diff --git a/crates/ironclaw_reborn_cli/src/commands/serve.rs b/crates/ironclaw_reborn_cli/src/commands/serve.rs index fd398c548a1..ff367499443 100644 --- a/crates/ironclaw_reborn_cli/src/commands/serve.rs +++ b/crates/ironclaw_reborn_cli/src/commands/serve.rs @@ -25,7 +25,7 @@ use ironclaw_reborn_composition::{ build_webui_services_with_slack_host_beta_mounts, }; use ironclaw_reborn_config::{ - IdentitySection, RebornConfigFile, seed_default_config_file_if_missing, + IdentitySection, RebornConfigFile, RebornProfile, seed_default_config_file_if_missing, }; use ironclaw_reborn_webui_ingress::{ DeferredWebuiRouterHandle, EnvBearerAuthenticator, RebornWebuiServeError, @@ -43,6 +43,113 @@ const DEFAULT_SERVE_HOST: &str = "127.0.0.1"; const DEFAULT_SERVE_PORT: u16 = 3000; const DEFAULT_ENV_TOKEN_VAR: &str = "IRONCLAW_REBORN_WEBUI_TOKEN"; const DEFAULT_ENV_USER_ID_VAR: &str = "IRONCLAW_REBORN_WEBUI_USER_ID"; +/// Default operator UserId used when a local-dev profile auto-provisions the +/// WebUI credentials (no `IRONCLAW_REBORN_WEBUI_USER_ID` supplied). +const DEFAULT_DEV_WEBUI_USER_ID: &str = "local-operator"; +/// Filename under the Reborn home holding the auto-generated local-dev WebUI +/// bearer token, so restarts reuse the same token (existing browser sessions +/// and admin-minted API tokens stay valid across restarts). +const DEV_WEBUI_TOKEN_FILE: &str = "webui-dev-token"; +/// Minimum byte length for the session-signing bearer token, mirroring the +/// floor enforced in `ironclaw_reborn_composition` (>= 32 bytes). A cached dev +/// token shorter than this is treated as absent and regenerated. +const DEV_WEBUI_TOKEN_MIN_BYTES: usize = 32; + +/// Resolve the WebUI bearer token and operator user id. +/// +/// Operator-supplied env values always win. When both are absent and the boot +/// profile is a trusted local-dev laptop, a stable local token is generated and +/// the operator user id defaults to `dev_user_id_default` — the config's +/// `[identity].default_owner` when set (so the WebUI user matches the runtime +/// owner and threads stay visible to the turn runner), otherwise +/// [`DEFAULT_DEV_WEBUI_USER_ID`]. So `ironclaw-reborn serve` boots with no manual +/// env setup. Hosted and production profiles fail closed with the original +/// guidance. +/// Deployment context for [`resolve_webui_credentials`] — the env-var names, +/// config path (for error guidance), home path (for the dev-token cache), and +/// the local-dev default user id. +struct WebuiCredentialConfig<'a> { + home_path: &'a std::path::Path, + config_path: &'a std::path::Path, + env_token_var: &'a str, + env_user_id_var: &'a str, + dev_user_id_default: &'a str, +} + +fn resolve_webui_credentials( + profile: RebornProfile, + token_env: Option, + user_id_env: Option, + config: &WebuiCredentialConfig<'_>, +) -> anyhow::Result<(String, String)> { + let token = match token_env { + Some(value) => value, + None if profile.allows_dev_credential_autoprovision() => { + load_or_create_dev_webui_token(config.home_path)? + } + None => { + return Err(anyhow!( + "{} must be set to the WebChat v2 bearer token. \ + Override the variable name via `[webui].env_token_var` in {}.", + config.env_token_var, + config.config_path.display(), + )); + } + }; + let user_id_raw = match user_id_env { + Some(value) => value, + None if profile.allows_dev_credential_autoprovision() => { + config.dev_user_id_default.to_string() + } + None => { + return Err(anyhow!( + "{} must be set to the UserId an env-bearer-authenticated caller \ + maps to. Override the variable name via `[webui].env_user_id_var` in {}.", + config.env_user_id_var, + config.config_path.display(), + )); + } + }; + Ok((token, user_id_raw)) +} + +/// Load the persisted local-dev WebUI token, or generate and persist a fresh +/// one. The token is 64 hex chars (32 bytes of entropy), clearing the +/// session-signing entropy floor. +fn load_or_create_dev_webui_token(home_path: &std::path::Path) -> anyhow::Result { + let path = home_path.join(DEV_WEBUI_TOKEN_FILE); + // silent-ok: dev-token cache — an absent or unreadable/short file just means + // "generate a fresh local token"; there is no authoritative source to lose. + if let Ok(existing) = std::fs::read_to_string(&path) { + let trimmed = existing.trim(); + if trimmed.len() >= DEV_WEBUI_TOKEN_MIN_BYTES { + return Ok(trimmed.to_string()); + } + } + let token = format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ); + write_dev_webui_token(&path, &token) + .with_context(|| format!("failed to persist dev WebUI token to {}", path.display()))?; + Ok(token) +} + +/// Write the dev token with owner-only permissions where the platform supports +/// it (it doubles as the session-signing key). +fn write_dev_webui_token(path: &std::path::Path, token: &str) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, token)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} /// Lifetime of the one-time API bearer minted when an admin creates a user. A /// year: this is a long-lived programmatic credential, not a browser session. const ADMIN_API_TOKEN_LIFETIME_DAYS: i64 = 365; @@ -93,6 +200,28 @@ pub(crate) struct ServeCommand { /// Confirm trusted-laptop host filesystem access for local-dev-yolo. #[arg(long = "confirm-host-access")] confirm_host_access: bool, + + /// Do not auto-open the browser on local-dev startup. + #[arg(long = "no-browser")] + no_browser: bool, +} + +/// Best-effort open a URL in the default browser (macOS `open`, Linux +/// `xdg-open`, Windows `start`). Spawned after a short delay so the listener is +/// bound first; failures are ignored — the URL is already printed. +fn spawn_browser_open(url: String) { + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(1200)); + let opener: &[&str] = if cfg!(target_os = "macos") { + &["open"] + } else if cfg!(target_os = "windows") { + &["cmd", "/C", "start", ""] + } else { + &["xdg-open"] + }; + let (cmd, pre) = opener.split_first().expect("opener is non-empty"); + let _ = std::process::Command::new(cmd).args(pre).arg(&url).status(); + }); } impl ServeCommand { @@ -141,20 +270,50 @@ impl ServeCommand { .and_then(|section| section.env_user_id_var.as_deref()) .unwrap_or(DEFAULT_ENV_USER_ID_VAR); - let token_value = env::var(env_token_var).map_err(|_| { - anyhow!( - "{env_token_var} must be set to the WebChat v2 bearer token. \ - Override the variable name via `[webui].env_token_var` in {}.", - boot_config.home().config_file_path().display(), - ) - })?; - let user_id_raw = env::var(env_user_id_var).map_err(|_| { - anyhow!( - "{env_user_id_var} must be set to the UserId an env-bearer-authenticated caller maps to. \ - Override the variable name via `[webui].env_user_id_var` in {}.", - boot_config.home().config_file_path().display(), - ) - })?; + // Resolve the boot profile so local-dev laptops can boot the WebUI with + // zero env-var setup, while hosted/production profiles still fail closed + // and require an operator-supplied token + user id. + let profile = crate::runtime::effective_profile(boot_config, config_file.as_ref())?; + let token_env = env::var(env_token_var).ok(); + let user_id_env = env::var(env_user_id_var).ok(); + let token_autoprovisioned = + token_env.is_none() && profile.allows_dev_credential_autoprovision(); + // Default the auto-provisioned WebUI user to the config's runtime owner + // so it matches `[identity].default_owner` (avoids the owner/WebUI-user + // mismatch that hides threads from the turn runner). + let dev_user_id_default = config_file + .as_ref() + .and_then(|file| file.identity.as_ref()) + .and_then(|identity| identity.default_owner.as_deref()) + .unwrap_or(DEFAULT_DEV_WEBUI_USER_ID); + let config_file_path = boot_config.home().config_file_path(); + let (token_value, user_id_raw) = resolve_webui_credentials( + profile, + token_env, + user_id_env, + &WebuiCredentialConfig { + home_path: boot_config.home().path(), + config_path: &config_file_path, + env_token_var, + env_user_id_var, + dev_user_id_default, + }, + )?; + // Kept for the sign-in URL printed after the listen address resolves — + // `token_value` itself is moved into the authenticator below. + let dev_token_for_url = token_autoprovisioned.then(|| token_value.clone()); + if token_autoprovisioned { + eprintln!( + "ironclaw serve: no {env_token_var} set — using an auto-generated local dev \ + bearer token persisted under the Reborn home ({}). Set {env_token_var} to \ + override. This is enabled only for local-dev profiles.", + boot_config + .home() + .path() + .join(DEV_WEBUI_TOKEN_FILE) + .display(), + ); + } let user_id = UserId::new(&user_id_raw) .map_err(|err| anyhow!("{env_user_id_var} value `{user_id_raw}` is invalid: {err}"))?; @@ -289,6 +448,18 @@ impl ServeCommand { .transpose()?; let listen_addr = SocketAddr::new(host, port); + if let Some(dev_token) = dev_token_for_url.as_deref() { + // The SPA auto-signs-in from `?token=`, so a local-dev operator can + // click straight through without pasting the generated dev token. + let sign_in_url = format!("http://{listen_addr}/v2/?token={dev_token}"); + eprintln!( + "ironclaw serve: open this URL to sign in automatically (dev token embedded):\n \ + {sign_in_url}" + ); + if !self.no_browser { + spawn_browser_open(sign_in_url); + } + } reject_non_loopback_privileged_local_runtime(host, &runtime_input)?; let callback_origin = webui_notion_dcr_callback_origin(listen_addr, canonical_host.as_deref())?; @@ -342,7 +513,7 @@ impl ServeCommand { // access store used to seed default-user and SSO-user trigger access; // canonical identity itself lives on the runtime's scoped filesystem, // not in this file. - let profile = crate::runtime::effective_profile(boot_config, config_file.as_ref())?; + // `profile` was resolved above (WebUI credential auto-provision gate). let user_store_path = crate::runtime::local_runtime_storage_root(boot_config, profile) .join("reborn-local-dev.db"); // CORS allow-origin list. Empty = fail-closed on every @@ -1760,4 +1931,139 @@ slack_user_id = "U123" clear_webui_env(); } + + // The resolver is the seam that gates whether `serve` boots (auto-provision) + // or fails closed. It takes the env values as parameters, so these tests + // drive the caller logic without mutating process env. + + #[test] + fn local_dev_autoprovisions_stable_token_and_default_user_when_env_absent() { + let home = tempfile::tempdir().expect("tempdir"); + let config = home.path().join("config.toml"); + + let (token, user_id) = resolve_webui_credentials( + RebornProfile::LocalDev, + None, + None, + &WebuiCredentialConfig { + home_path: home.path(), + config_path: &config, + env_token_var: DEFAULT_ENV_TOKEN_VAR, + env_user_id_var: DEFAULT_ENV_USER_ID_VAR, + dev_user_id_default: DEFAULT_DEV_WEBUI_USER_ID, + }, + ) + .expect("local-dev must auto-provision credentials"); + + assert!( + token.len() >= DEV_WEBUI_TOKEN_MIN_BYTES, + "generated token must clear the session-signing floor, got {} bytes", + token.len() + ); + assert!( + token + .as_bytes() + .iter() + .collect::>() + .len() + >= 8, + "generated token must have >= 8 distinct byte values" + ); + assert_eq!(user_id, DEFAULT_DEV_WEBUI_USER_ID); + + // Second call reuses the persisted token so restarts keep sessions valid. + let (token_again, _) = resolve_webui_credentials( + RebornProfile::LocalDev, + None, + None, + &WebuiCredentialConfig { + home_path: home.path(), + config_path: &config, + env_token_var: DEFAULT_ENV_TOKEN_VAR, + env_user_id_var: DEFAULT_ENV_USER_ID_VAR, + dev_user_id_default: DEFAULT_DEV_WEBUI_USER_ID, + }, + ) + .expect("second resolve"); + assert_eq!(token, token_again, "dev token must persist across calls"); + } + + #[test] + fn production_fails_closed_when_token_env_absent() { + let home = tempfile::tempdir().expect("tempdir"); + let config = home.path().join("config.toml"); + + let error = resolve_webui_credentials( + RebornProfile::Production, + None, + Some("operator".to_string()), + &WebuiCredentialConfig { + home_path: home.path(), + config_path: &config, + env_token_var: DEFAULT_ENV_TOKEN_VAR, + env_user_id_var: DEFAULT_ENV_USER_ID_VAR, + dev_user_id_default: DEFAULT_DEV_WEBUI_USER_ID, + }, + ) + .expect_err("production must not auto-provision a token"); + assert!( + error.to_string().contains(DEFAULT_ENV_TOKEN_VAR), + "error must name the required token env var, got: {error}" + ); + assert!( + !home.path().join(DEV_WEBUI_TOKEN_FILE).exists(), + "production must not write a dev token file" + ); + } + + #[test] + fn production_fails_closed_when_user_id_env_absent() { + let home = tempfile::tempdir().expect("tempdir"); + let config = home.path().join("config.toml"); + + let error = resolve_webui_credentials( + RebornProfile::Production, + Some("a".repeat(32)), + None, + &WebuiCredentialConfig { + home_path: home.path(), + config_path: &config, + env_token_var: DEFAULT_ENV_TOKEN_VAR, + env_user_id_var: DEFAULT_ENV_USER_ID_VAR, + dev_user_id_default: DEFAULT_DEV_WEBUI_USER_ID, + }, + ) + .expect_err("production must require an explicit user id"); + assert!( + error.to_string().contains(DEFAULT_ENV_USER_ID_VAR), + "error must name the required user-id env var, got: {error}" + ); + } + + #[test] + fn operator_supplied_env_values_win_regardless_of_profile() { + let home = tempfile::tempdir().expect("tempdir"); + let config = home.path().join("config.toml"); + + let (token, user_id) = resolve_webui_credentials( + RebornProfile::LocalDev, + Some("operator-supplied-token-value-0123456789".to_string()), + Some("real-operator".to_string()), + &WebuiCredentialConfig { + home_path: home.path(), + config_path: &config, + env_token_var: DEFAULT_ENV_TOKEN_VAR, + env_user_id_var: DEFAULT_ENV_USER_ID_VAR, + dev_user_id_default: DEFAULT_DEV_WEBUI_USER_ID, + }, + ) + .expect("explicit env values resolve"); + + assert_eq!(token, "operator-supplied-token-value-0123456789"); + assert_eq!(user_id, "real-operator"); + assert!( + !home.path().join(DEV_WEBUI_TOKEN_FILE).exists(), + "no dev token file when the operator supplied a token" + ); + } } diff --git a/crates/ironclaw_reborn_config/src/profile.rs b/crates/ironclaw_reborn_config/src/profile.rs index 857130e6918..927c16c9e8b 100644 --- a/crates/ironclaw_reborn_config/src/profile.rs +++ b/crates/ironclaw_reborn_config/src/profile.rs @@ -96,6 +96,17 @@ impl RebornProfile { | Self::HostedSingleTenantVolume ) } + + /// Whether the WebUI `serve` listener may auto-provision a local bearer + /// token + operator user id when the operator has not supplied them via + /// environment variables. + /// + /// True only for the trusted-laptop developer profiles. Hosted and + /// production profiles must fail closed: their session-signing secret and + /// operator identity are operator-owned and never generated for them. + pub fn allows_dev_credential_autoprovision(self) -> bool { + matches!(self, Self::LocalDev | Self::LocalDevYolo) + } } impl FromStr for RebornProfile { @@ -122,3 +133,30 @@ impl std::fmt::Display for RebornProfile { formatter.write_str(self.as_str()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_local_dev_profiles_allow_credential_autoprovision() { + for profile in RebornProfile::all() { + let allowed = profile.allows_dev_credential_autoprovision(); + match profile { + RebornProfile::LocalDev | RebornProfile::LocalDevYolo => { + assert!( + allowed, + "{profile} must allow dev credential auto-provision" + ) + } + RebornProfile::HostedSingleTenant + | RebornProfile::HostedSingleTenantVolume + | RebornProfile::Production + | RebornProfile::MigrationDryRun => assert!( + !allowed, + "{profile} must fail closed and require operator-supplied credentials" + ), + } + } + } +} diff --git a/crates/ironclaw_webui_v2/build.rs b/crates/ironclaw_webui_v2/build.rs index 43357ac6115..c8124727877 100644 --- a/crates/ironclaw_webui_v2/build.rs +++ b/crates/ironclaw_webui_v2/build.rs @@ -124,7 +124,20 @@ fn required_frontend_dist_files(dist_dir: &Path) -> [PathBuf; 3] { } fn run_command(command: &str, args: &[&str], cwd: &Path) -> Result<(), Box> { - let status = Command::new(command).args(args).current_dir(cwd).status()?; + let status = match Command::new(command).args(args).current_dir(cwd).status() { + Ok(status) => status, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Err(std::io::Error::other(format!( + "`{command}` was not found on PATH — the `webui-v2-beta` feature builds the \ + WebChat v2 frontend and needs Node 22 (which bundles corepack). \ + Install Node 22+ from https://nodejs.org (then `corepack enable`), or \ + `npm i -g pnpm@11.7.0`. To build without Node, ship a prebuilt SPA bundle. \ + Underlying error: {err}" + )) + .into()); + } + Err(err) => return Err(err.into()), + }; if status.success() { return Ok(()); } diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/onboarding/onboarding-page.tsx b/crates/ironclaw_webui_v2/frontend/src/pages/onboarding/onboarding-page.tsx index 329deb3f7aa..35baf4dd106 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/onboarding/onboarding-page.tsx +++ b/crates/ironclaw_webui_v2/frontend/src/pages/onboarding/onboarding-page.tsx @@ -241,7 +241,9 @@ function OperatorOnboardingPage() {

{t("onboarding.title")}

-

{t("onboarding.subtitle")}

+

+ {t("onboarding.subtitle")} +