diff --git a/Cargo.lock b/Cargo.lock index 5ce17ef2b4f..b15f6d89d86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4880,12 +4880,15 @@ dependencies = [ "chrono", "clap", "clap_complete", + "crossterm 0.29.0", "dotenvy", "hex", "ironclaw_common", + "ironclaw_host_api", "ironclaw_reborn_composition", "ironclaw_reborn_config", "ironclaw_reborn_traces", + "ironclaw_secrets", "ironclaw_webui", "libc", "rand 0.10.1", @@ -4895,6 +4898,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "tempfile", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", diff --git a/crates/ironclaw_llm/src/config.rs b/crates/ironclaw_llm/src/config.rs index faf1b48061a..9b6be0297a8 100644 --- a/crates/ironclaw_llm/src/config.rs +++ b/crates/ironclaw_llm/src/config.rs @@ -458,6 +458,31 @@ impl LlmConfig { .unwrap_or_else(|| self.nearai.model.clone()), } } + + /// Resolve the base URL of the backend `serve` actually boots with, when + /// the backend has one. + /// + /// Mirrors `active_model_name`'s per-backend dispatch. Exists so callers + /// outside this crate (the boot-time resolved-LLM debug trace, tests) + /// can observe the base URL without reaching into backend-specific + /// fields directly. `bedrock` and `gemini_oauth` authenticate via the AWS + /// credential chain / a fixed Google OAuth endpoint rather than an + /// operator-configurable base URL, so they return `None`. + pub fn active_base_url(&self) -> Option { + match self.backend.as_str() { + "nearai" | "near_ai" | "near" => Some(self.nearai.base_url.clone()), + "bedrock" | "aws_bedrock" | "aws" | "gemini_oauth" | "gemini-oauth" => None, + "openai_codex" | "openai-codex" | "codex" => self + .openai_codex + .as_ref() + .map(|cfg| cfg.api_base_url.clone()), + _ => self + .provider + .as_ref() + .map(|cfg| cfg.base_url.clone()) + .or_else(|| Some(self.nearai.base_url.clone())), + } + } } /// NEAR AI configuration. @@ -699,4 +724,107 @@ mod tests { assert_eq!(cfg.session_path, PathBuf::from("/tmp/sess.json")); assert_eq!(cfg.token_refresh_margin_secs, 60); } + + /// Minimal `LlmConfig` with every optional backend-specific config left + /// `None` — the caller sets `backend` and populates whichever field the + /// case under test dispatches on. + fn base_llm_config(backend: &str) -> LlmConfig { + LlmConfig { + backend: backend.to_string(), + session: SessionConfig::default(), + nearai: NearAiConfig { + model: "test-model".to_string(), + cheap_model: None, + base_url: "https://cloud-api.near.ai".to_string(), + api_key: None, + fallback_model: None, + max_retries: 0, + circuit_breaker_threshold: None, + circuit_breaker_recovery_secs: 30, + response_cache_enabled: false, + response_cache_ttl_secs: 3600, + response_cache_max_entries: 1000, + failover_cooldown_secs: 300, + failover_cooldown_threshold: 3, + smart_routing_cascade: true, + }, + provider: None, + bedrock: None, + gemini_oauth: None, + openai_codex: None, + request_timeout_secs: DEFAULT_REQUEST_TIMEOUT_SECS, + cheap_model: None, + smart_routing_cascade: true, + max_retries: 0, + circuit_breaker_threshold: None, + circuit_breaker_recovery_secs: 30, + response_cache_enabled: false, + response_cache_ttl_secs: 3600, + response_cache_max_entries: 1000, + } + } + + /// `active_base_url` dispatches per-backend, mirroring `active_model_name`: + /// nearai aliases resolve to the nearai base URL, bedrock/gemini_oauth + /// have none (fixed credential chain / OAuth endpoint), openai_codex + /// reads its own config (or `None` when unset), a registry-backed + /// provider reads its config, and an unknown backend with no provider + /// config falls back to the nearai base URL. + #[test] + fn active_base_url_dispatches_backend_aliases_and_fallbacks() { + for alias in ["nearai", "near_ai", "near"] { + let cfg = base_llm_config(alias); + assert_eq!( + cfg.active_base_url().as_deref(), + Some("https://cloud-api.near.ai") + ); + } + + for backend in [ + "bedrock", + "aws_bedrock", + "aws", + "gemini_oauth", + "gemini-oauth", + ] { + let cfg = base_llm_config(backend); + assert_eq!(cfg.active_base_url(), None); + } + + let mut cfg = base_llm_config("openai_codex"); + cfg.openai_codex = Some(OpenAiCodexConfig::build( + None, + None, + Some("https://codex.example".to_string()), + None, + None, + None, + )); + assert_eq!( + cfg.active_base_url().as_deref(), + Some("https://codex.example") + ); + + let cfg_no_codex_config = base_llm_config("codex"); + assert_eq!(cfg_no_codex_config.active_base_url(), None); + + let mut cfg = base_llm_config("openai"); + cfg.provider = Some(RegistryProviderConfig::generic( + ProviderProtocol::OpenAiCompletions, + "openai", + None, + "https://api.openai.com/v1", + "gpt-test", + )); + assert_eq!( + cfg.active_base_url().as_deref(), + Some("https://api.openai.com/v1") + ); + + let cfg_unknown_no_provider = base_llm_config("some_unknown_backend"); + assert_eq!( + cfg_unknown_no_provider.active_base_url().as_deref(), + Some("https://cloud-api.near.ai") + ); + } } diff --git a/crates/ironclaw_llm/src/resolution.rs b/crates/ironclaw_llm/src/resolution.rs index 19f8f00a3c6..e83785efabe 100644 --- a/crates/ironclaw_llm/src/resolution.rs +++ b/crates/ironclaw_llm/src/resolution.rs @@ -56,6 +56,18 @@ impl ResolvedProviderConfig { Self::Dedicated(config) => &config.model, } } + + /// The resolved API key, if the provider carries one — the same value + /// (from env) that would otherwise only ever reach a live provider + /// client, exposed so a caller (onboard's env-detect step) can persist + /// it into the encrypted secret store rather than leaving it only in + /// the process's shell env. + pub fn api_key(&self) -> Option<&SecretString> { + match self { + Self::Registry(config) => config.api_key.as_ref(), + Self::Dedicated(config) => config.api_key.as_ref(), + } + } } /// Provider selection overrides supplied by a composition root. @@ -420,7 +432,7 @@ fn apply_registry_provider_env(config: &mut RegistryProviderConfig) -> Result<() fn nearai_config_from_env(chain: &ChainSettings) -> Result { let api_key = nonempty_env("NEARAI_API_KEY").map(SecretString::from); - let base_url = default_nearai_base_url(api_key.is_some(), nonempty_env("NEARAI_BASE_URL")); + let base_url = default_nearai_base_url(nonempty_env("NEARAI_BASE_URL")); Ok(build_nearai_config( NearAiRuntimeFields { model: nonempty_env("NEARAI_MODEL").unwrap_or_else(|| crate::DEFAULT_MODEL.to_string()), @@ -443,7 +455,7 @@ fn nearai_config_from_dedicated( } else { Some(resolved.base_url.clone()) }; - let base_url = default_nearai_base_url(api_key.is_some(), configured_base_url); + let base_url = default_nearai_base_url(configured_base_url); Ok(build_nearai_config( NearAiRuntimeFields { @@ -487,19 +499,27 @@ fn build_nearai_config(fields: NearAiRuntimeFields, chain: &ChainSettings) -> Ne } pub const NEARAI_CLOUD_DEFAULT_BASE_URL: &str = "https://cloud-api.near.ai"; +/// No longer used by [`default_nearai_base_url`] (nearai always defaults to +/// cloud regardless of key presence — see that function's doc comment). +/// Kept only because `session.rs`'s OAuth/session-token auth URL default and +/// v1 `src/` still reference it independently of provider-config base-URL +/// resolution. pub const NEARAI_PRIVATE_DEFAULT_BASE_URL: &str = "https://private.near.ai"; -pub fn default_nearai_base_url( - api_key_present: bool, - configured_base_url: Option, -) -> String { - if let Some(base_url) = configured_base_url { - base_url - } else if api_key_present { - NEARAI_CLOUD_DEFAULT_BASE_URL.to_string() - } else { - NEARAI_PRIVATE_DEFAULT_BASE_URL.to_string() - } +/// Resolve the nearai provider's base URL: an explicit override always wins, +/// otherwise the cloud endpoint. +/// +/// Used to key on API-key presence (cloud when a key was present, private +/// otherwise), to match the session-token-only private backend's implicit +/// no-key affordance. That coupling made resolution order load-bearing: +/// whichever step attached the key had to run *before* this was called, and +/// an operator-stored key (attached after resolution, from the secret store) +/// always missed the window and landed on the keyless private default. There +/// is now exactly one nearai default — cloud — so resolution order no longer +/// matters and every caller (probe, resolution, snapshot display) agrees +/// unconditionally. +pub fn default_nearai_base_url(configured_base_url: Option) -> String { + configured_base_url.unwrap_or_else(|| NEARAI_CLOUD_DEFAULT_BASE_URL.to_string()) } fn is_registry_protocol(protocol: ProviderProtocol) -> bool { diff --git a/crates/ironclaw_reborn_cli/Cargo.toml b/crates/ironclaw_reborn_cli/Cargo.toml index da7f1f28ceb..1e3d103124e 100644 --- a/crates/ironclaw_reborn_cli/Cargo.toml +++ b/crates/ironclaw_reborn_cli/Cargo.toml @@ -106,6 +106,10 @@ chrono = { version = "0.4", features = ["serde"] } hex = "0.4.3" clap = { version = "4", features = ["derive", "env"] } clap_complete = "4.5.0" +# Masked (echo-suppressed) API-key prompt in `onboard`. Same crate v1's setup +# wizard uses for secret-input masking — reused here rather than adding a +# second terminal-masking dependency. +crossterm = "0.29" dotenvy = "0.15" ironclaw_reborn_composition = { path = "../ironclaw_reborn_composition", version = "0.1.0" } ironclaw_reborn_config = { path = "../ironclaw_reborn_config", version = "0.1.0" } @@ -118,6 +122,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.11" tempfile = "3" +thiserror = "2" tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "io-util", "io-std", "sync", "time"] } tokio-util = { version = "0.7", features = ["rt"] } tracing = "0.1" @@ -145,6 +150,22 @@ ironclaw_reborn_composition = { path = "../ironclaw_reborn_composition", version # second, non-serializing lock domain (the #6015 flake). Dev-only: the lock is # reached solely from `#[cfg(test)]` code in `runtime::test_env`. ironclaw_common = { path = "../ironclaw_common", version = "0.4.2" } +# Unconditional (not the feature-gated `dep:async-trait` optional dependency +# above): `commands::onboard::mod`'s write-first-then-config-ordering test +# implements a fake `ironclaw_secrets::SecretStore` whose `put` always fails, +# needing `#[async_trait::async_trait]` in every feature combination `cargo +# test` runs, not just under `webui-v2-beta`. +async-trait = "0.1" +# Same fake `SecretStore` impl takes `ResourceScope` / `SecretHandle` / +# `Timestamp` in its trait method signatures. +ironclaw_host_api = { path = "../ironclaw_host_api", version = "0.1.0" } +# Same fake `SecretStore` impl (`FailingSecretStore` in `commands::onboard::mod`'s +# tests) implements `ironclaw_secrets::SecretStore` directly; production +# onboarding code only ever touches secrets through +# `ironclaw_reborn_composition::LlmKeyStore`'s facade (`put_plaintext`), so +# this stays a dev-only dependency — see +# `crates/ironclaw_architecture/tests/reborn_dependency_boundaries.rs::reborn_cli_binary_crate_stays_separate_from_v1_root`. +ironclaw_secrets = { path = "../ironclaw_secrets", version = "0.1.0" } [[bin]] name = "ironclaw-reborn" diff --git a/crates/ironclaw_reborn_cli/src/commands/config/init.rs b/crates/ironclaw_reborn_cli/src/commands/config/init.rs index 2561dae6245..afbf72f160d 100644 --- a/crates/ironclaw_reborn_cli/src/commands/config/init.rs +++ b/crates/ironclaw_reborn_cli/src/commands/config/init.rs @@ -30,6 +30,13 @@ impl ConfigInitCommand { println!("{}", outcome.config.display_line()); println!("{}", outcome.providers.display_line()); println!(); + println!( + "hint: config.toml ships with `[llm.default]` commented out, so `run`/`serve` fall \ + back to LLM environment variables until you configure a slot — run `ironclaw-reborn \ + onboard` interactively, `ironclaw-reborn models set-provider `, or edit \ + config.toml and uncomment `[llm.default]` with a `provider_id` (and usually a \ + `model`) to pin an explicit provider." + ); println!("edit them, then run `ironclaw-reborn run`."); Ok(()) } @@ -59,6 +66,27 @@ impl ConfigFileWrite { } } +/// Canonical zero-friction LLM default: the provider named in the +/// `config.toml` stub's commented-out `[llm.default]` example, and the +/// numbered `onboard` menu's preferred entry. +/// - `config.toml` is the single source of truth for `[llm.default]`: +/// written ONLY by an explicit act (`onboard` seeding, `config set` / +/// `models set-provider`, or WebUI settings) — never seeded implicitly by +/// `config init`/`onboard`'s stub write. See `onboard::llm_credentials` +/// for seeding paths and `resolve_reborn_runtime_llm` for the env fallback +/// a commented-out `[llm.default]` falls through to. +/// - `nearai` is preferred for a fresh install because it's the intended +/// session-token-auth provider (a NEAR account, no third-party API key), +/// but that flow is not wired in reborn yet — no `SessionRenewer` attaches +/// at `serve` boot — so `effective_api_key_required` overrides it to +/// `true` and onboarding still asks for a `cloud-api.near.ai` API key like +/// every other provider. See `effective_api_key_required`'s doc. +const DEFAULT_LLM_PROVIDER_ID: &str = "nearai"; +/// Mirrors `providers.json`'s `nearai` entry's `default_model`. +const DEFAULT_LLM_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash"; +/// Mirrors `providers.json`'s `nearai` entry's `api_key_env`. +const DEFAULT_LLM_API_KEY_ENV: &str = "NEARAI_API_KEY"; + pub(crate) fn write_default_config_files( home: &RebornHome, force: bool, @@ -204,13 +232,25 @@ 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 -# 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" +# [llm.default] +# # LLM slot selection. `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. +# # No slot is seeded by default: leaving this section commented means the +# # runtime falls back to LLM environment variables (`LLM_BACKEND`, or a +# # provider whose own env vars are set — see `ironclaw-reborn onboard` and +# # `.env.example`). Run `ironclaw-reborn onboard` interactively, `ironclaw- +# # reborn config set` / `models set-provider`, or the WebUI settings page to +# # write an explicit slot here. +# # +# # CAUTION: uncommenting only the `[llm.default]` header with no fields +# # below it does NOT fall through to the environment — an empty slot is +# # still "present" and resolution fails closed with a missing-provider-id +# # error. Always set `provider_id` (and usually `model`) together with the +# # header, or leave the whole section commented. +# provider_id = "{default_llm_provider_id}" +# model = "{default_llm_model}" +# api_key_env = "{default_llm_api_key_env}" # [llm.mission] # # Reserved for the future planned-driver "mission" slot. @@ -227,6 +267,9 @@ api_key_env = "OPENAI_API_KEY" # # from WebUI channel setup after the server starts. "#, api_version = REBORN_CONFIG_API_VERSION, + default_llm_provider_id = DEFAULT_LLM_PROVIDER_ID, + default_llm_model = DEFAULT_LLM_MODEL, + default_llm_api_key_env = DEFAULT_LLM_API_KEY_ENV, ) } @@ -256,3 +299,85 @@ const PROVIDERS_STUB: &str = r#"[ } ] "#; + +#[cfg(all(test, feature = "root-llm-provider"))] +mod tests { + use super::*; + use crate::context::RebornCliContext; + + /// `DEFAULT_LLM_*` are hand-maintained mirrors of `providers.json`'s + /// `nearai` entry (see each const's doc) rather than derived from it — + /// `ironclaw_reborn_cli` is excluded from depending on `ironclaw_llm` + /// directly (per `reborn_dependency_boundaries`), so there's no shared + /// type to read the catalog through here. Parses the real + /// `providers.json` as raw JSON instead, so a future catalog edit that + /// forgets to update these consts fails this test rather than silently + /// drifting. + #[test] + fn default_llm_consts_match_the_real_providers_json_nearai_entry() { + const PROVIDERS_JSON: &str = include_str!("../../../../../providers.json"); + let providers: serde_json::Value = + serde_json::from_str(PROVIDERS_JSON).expect("providers.json must parse as JSON"); + let nearai = providers + .as_array() + .expect("providers.json is a JSON array") + .iter() + .find(|entry| { + entry.get("id").and_then(|id| id.as_str()) == Some(DEFAULT_LLM_PROVIDER_ID) + }) + .unwrap_or_else(|| panic!("providers.json has no `{DEFAULT_LLM_PROVIDER_ID}` entry")); + assert_eq!( + nearai.get("default_model").and_then(|v| v.as_str()), + Some(DEFAULT_LLM_MODEL), + "DEFAULT_LLM_MODEL has drifted from providers.json's `{DEFAULT_LLM_PROVIDER_ID}` \ + entry's default_model" + ); + assert_eq!( + nearai.get("api_key_env").and_then(|v| v.as_str()), + Some(DEFAULT_LLM_API_KEY_ENV), + "DEFAULT_LLM_API_KEY_ENV has drifted from providers.json's `{DEFAULT_LLM_PROVIDER_ID}` \ + entry's api_key_env" + ); + } + + /// The config stub written by `onboard`/`config init` must carry NO + /// `[llm.default]` selection at all — `default_llm_slot()` must return + /// `None` (not `Some` with empty fields — a bare header still fails + /// closed with `MissingProviderId`, see `DEFAULT_LLM_PROVIDER_ID`'s doc) + /// — so `resolve_reborn_runtime_llm` reaches the env fallback. + #[test] + fn deseeded_stub_has_no_default_llm_slot_and_reaches_env_fallback() { + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + let outcome = write_default_config_files(home, false, ExistingConfigPolicy::FailIfPresent) + .expect("write stub config files"); + assert_eq!(outcome.config.action, FileWriteAction::Wrote); + + let config_text = + fs::read_to_string(home.config_file_path()).expect("read stub config.toml"); + let config_file = ironclaw_reborn_config::RebornConfigFile::parse_text( + &config_text, + &home.config_file_path(), + ) + .expect("stub config.toml must parse"); + assert!( + config_file.default_llm_slot().is_none(), + "de-seeded stub must carry no `[llm.default]` slot at all: {config_text}" + ); + + // A pre-existing LLM env var in the ambient test environment would make + // an exact outcome assertion environment-dependent, so this only pins + // the *shape*: env fallback reached, not a stub-seeded slot short-circuit. + let resolved = ironclaw_reborn_composition::resolve_reborn_runtime_llm( + context.boot_config(), + Some(&config_file), + ); + assert!( + !matches!( + &resolved, + Err(ironclaw_reborn_composition::RebornLlmCatalogError::MissingProviderId) + ), + "a de-seeded stub must reach env fallback, not MissingProviderId; got: {resolved:?}" + ); + } +} diff --git a/crates/ironclaw_reborn_cli/src/commands/onboard.rs b/crates/ironclaw_reborn_cli/src/commands/onboard.rs deleted file mode 100644 index 76492b73c40..00000000000 --- a/crates/ironclaw_reborn_cli/src/commands/onboard.rs +++ /dev/null @@ -1,173 +0,0 @@ -use std::path::{Path, PathBuf}; - -use clap::Args; -use ironclaw_reborn_config::RebornHome; - -use crate::commands::config::init::{ExistingConfigPolicy, write_default_config_files}; -use crate::context::RebornCliContext; -use crate::file_write::{FileWriteAction, write_atomic}; - -const ONBOARDING_MARKER_FILE: &str = ".onboard-completed.json"; - -/// Initialize the standalone Reborn home and first-run setup marker. -#[derive(Debug, Args)] -pub(crate) struct OnboardCommand { - /// Overwrite generated config.toml, providers.json, and the completion marker. - #[arg(long = "force")] - force: bool, - - /// Show what would be initialized without writing files. - #[arg(long = "dry-run")] - dry_run: bool, - - /// Reserve the history-import step in the onboarding summary. - /// - /// History import is not wired in this slice; the flag makes the missing - /// step explicit without touching v1 setup/import state. - #[arg(long = "import-history")] - import_history: bool, -} - -impl OnboardCommand { - pub(crate) fn execute(self, context: RebornCliContext) -> anyhow::Result<()> { - let home = context.boot_config().home(); - let marker_path = onboarding_marker_path(home); - - if self.dry_run { - print_dry_run(home, &marker_path, self.force, self.import_history)?; - return Ok(()); - } - - let outcome = write_default_config_files(home, self.force, ExistingConfigPolicy::Preserve)?; - // Independent of `--force`: a valid existing token is never - // regenerated (see `ensure_webui_token_file`'s doc for why), so a - // repeated `onboard --force` cannot invalidate sessions or an - // operator-copied env var keyed to the current token value. - let webui_token_action = crate::webui_token::ensure_webui_token_file(home.path())?; - let marker_action = - write_onboarding_marker(home, &marker_path, self.force, self.import_history)?; - - println!("IronClaw Reborn onboarding"); - println!("reborn_home: {}", home.path().display()); - println!("home_source: {}", home.source_label()); - println!("{}", outcome.config.display_line()); - println!("{}", outcome.providers.display_line()); - println!( - "webui_token: {} ({})", - crate::webui_token::webui_token_file_path(home.path()).display(), - webui_token_action - ); - println!( - "onboarding_marker: {} ({})", - marker_path.display(), - marker_action - ); - println!("v1_state: not-used"); - println!(); - println!("completed:"); - println!("- reborn home initialized"); - println!("- config.toml and providers.json available"); - println!("- webui bearer token provisioned (used by `serve` when the env var is unset)"); - 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" - ); - if self.import_history { - println!("- history import requested but not wired yet"); - } else { - println!("- history import not requested"); - } - Ok(()) - } -} - -pub(crate) fn onboarding_marker_path(home: &RebornHome) -> PathBuf { - home.path().join(ONBOARDING_MARKER_FILE) -} - -fn print_dry_run( - home: &RebornHome, - marker_path: &Path, - force: bool, - import_history: bool, -) -> anyhow::Result<()> { - println!("IronClaw Reborn onboarding dry run"); - println!("reborn_home: {}", home.path().display()); - println!("home_source: {}", home.source_label()); - println!("would_ensure: {}", home.path().display()); - println!( - "would_write_or_preserve: {}", - home.config_file_path().display() - ); - println!( - "would_write_or_preserve: {}", - home.providers_file_path().display() - ); - // Propagates rather than defaulting to "would_write" on an I/O error: - // an unreadable-but-present token file must be reported as an error, - // not silently promised a (destructive) overwrite that wouldn't - // actually happen the same way on a real run. - let webui_token_action = if crate::webui_token::webui_token_file_is_valid(home.path())? { - "would_preserve" - } else { - "would_write" - }; - println!( - "{webui_token_action}: {}", - crate::webui_token::webui_token_file_path(home.path()).display() - ); - let marker_action = if marker_path.exists() && !force { - "would_preserve" - } else { - "would_write" - }; - println!("{marker_action}: {}", marker_path.display()); - println!("import_history_requested: {import_history}"); - println!("v1_state: not-used"); - Ok(()) -} - -fn write_onboarding_marker( - home: &RebornHome, - marker_path: &Path, - force: bool, - import_history: bool, -) -> anyhow::Result { - if marker_path.exists() && !force { - return Ok(FileWriteAction::Preserved); - } - let body = serde_json::to_string_pretty(&serde_json::json!({ - "schema_version": "ironclaw.reborn.onboarding/v1", - "completed_at": chrono::Utc::now().to_rfc3339(), - "reborn_home": home.path(), - "home_source": home.source_label(), - "config_file": home.config_file_path(), - "providers_file": home.providers_file_path(), - "webui_token_file": crate::webui_token::webui_token_file_path(home.path()), - "steps_completed": [ - "reborn_home", - "config_files", - "webui_token", - "completion_marker" - ], - "steps_pending": pending_steps(import_history), - "v1_state": "not-used" - }))?; - write_atomic( - marker_path, - &format!("{body}\n"), - force, - ONBOARDING_MARKER_FILE, - ) -} - -fn pending_steps(import_history: bool) -> Vec<&'static str> { - let mut steps = vec!["llm_credentials", "model_selection", "channel_setup"]; - if import_history { - steps.push("history_import"); - } - steps -} diff --git a/crates/ironclaw_reborn_cli/src/commands/onboard/llm_credentials.rs b/crates/ironclaw_reborn_cli/src/commands/onboard/llm_credentials.rs new file mode 100644 index 00000000000..34270ac6d71 --- /dev/null +++ b/crates/ironclaw_reborn_cli/src/commands/onboard/llm_credentials.rs @@ -0,0 +1,2068 @@ +//! Onboarding's LLM-credential provisioning step: prompt for a provider and +//! API key, then persist both — the secret store write lands before the +//! `config.toml` selection (see [`provision_llm_credentials`]'s doc). + +use std::path::Path; + +use ironclaw_reborn_config::RebornHome; + +use super::prompts::{LlmCredentialPromptError, PromptSource}; + +/// Outcome of onboard's LLM provider/API-key prompt step. Every variant is a +/// successful `execute()` (exit 0) — mirrors [`super::master_key::MasterKeyProvisionOutcome`]'s +/// shape: the `Skipped*` variants are expected and normal, not a failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum LlmCredentialProvisionOutcome { + Configured { + provider_id: String, + model: String, + }, + /// `[llm.default]` was already pointed at a provider AND the encrypted + /// secret store already has a key for it (see + /// [`already_configured_outcome`]) — this run skipped prompting + /// entirely rather than re-asking for credentials that are already + /// durably stored. + AlreadyConfigured { + provider_id: String, + model: String, + }, + /// Complete LLM config detected in env (`RebornProviderAdmin::detect_env_llm`) + /// and `[llm.default]` seeded via `set_provider`. The detected API key is + /// also persisted to the encrypted secret store (same path the menu flow + /// uses) so a background service — which only carries + /// `IRONCLAW_REBORN_HOME`, not the operator's shell env — can still + /// resolve it. Reached via interactive "use it?" confirm or silently on + /// a headless run. + /// - Idempotency: once seeded, drift between slot and live env is accepted + /// (not re-synced) on later runs; `--force` re-seeds from env again. + ConfiguredFromEnv { + provider_id: String, + model: String, + }, + /// Headless (non-interactive) session; no LLM environment variables are + /// set at all. Nothing was seeded. + SkippedNonInteractive, + /// Headless (non-interactive) session; some LLM environment + /// configuration was present but incomplete or invalid (e.g. a + /// provider's model env var set without its required API key env var). + /// Nothing was seeded — a partial/broken environment must never be + /// silently adopted. + SkippedNonInteractivePartialEnv { + reason: String, + }, +} + +impl LlmCredentialProvisionOutcome { + pub(crate) fn display_line(&self) -> String { + match self { + Self::Configured { provider_id, model } => { + format!("configured provider `{provider_id}` (model `{model}`)") + } + Self::AlreadyConfigured { provider_id, model } => { + format!( + "already configured (provider `{provider_id}`, model `{model}`); use \ + --force to reconfigure" + ) + } + Self::ConfiguredFromEnv { provider_id, model } => { + format!("configured provider `{provider_id}` (model `{model}`) from environment") + } + Self::SkippedNonInteractive => "skipped (non-interactive session)".to_string(), + Self::SkippedNonInteractivePartialEnv { reason } => { + format!( + "skipped (non-interactive session; partial environment LLM config: {reason})" + ) + } + } + } +} + +/// Where [`provision_llm_credentials`] gets its (already-open) encrypted +/// secret store from. Injected — mirrors [`PromptSource`] — so a test can +/// supply a store whose `put` fails, proving the store-before-config write +/// ordering without touching the real local-dev libsql-backed store. +/// - Gated with the same `libsql`+`root-llm-provider` cfg as +/// `ironclaw_reborn_composition::LlmKeyStore` (only exists behind those +/// features); see the `#[cfg(not(...))]` stub below for feature-off. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +pub(crate) trait LlmKeyStoreOpener { + fn open(&self, home_path: &Path) -> anyhow::Result; +} + +/// Production [`LlmKeyStoreOpener`]: opens the real local-dev encrypted +/// secret store `serve` later reads from (see +/// `ironclaw_reborn_composition::open_local_dev_secret_store`'s doc for why +/// this is the same physical storage `serve` opens). +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +pub(crate) struct EncryptedLlmKeyStoreOpener; + +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +impl LlmKeyStoreOpener for EncryptedLlmKeyStoreOpener { + fn open(&self, home_path: &Path) -> anyhow::Result { + let home_path = home_path.to_path_buf(); + crate::runtime::block_on_cli(async move { + let store = ironclaw_reborn_composition::open_local_dev_secret_store(&home_path) + .await + .map_err(anyhow::Error::from)?; + Ok::<_, anyhow::Error>(ironclaw_reborn_composition::LlmKeyStore::new(store)) + }) + } +} + +/// Feature-off stub: no `LlmKeyStore` type without both `libsql` and +/// `root-llm-provider`. Exists so `execute()`'s unconditional +/// `&EncryptedLlmKeyStoreOpener` call site compiles everywhere — the +/// feature-off `provision_llm_credentials` below never calls `open`. +#[cfg(not(all(feature = "libsql", feature = "root-llm-provider")))] +pub(crate) trait LlmKeyStoreOpener { + fn open(&self, home_path: &Path) -> anyhow::Result<()>; +} + +#[cfg(not(all(feature = "libsql", feature = "root-llm-provider")))] +pub(crate) struct EncryptedLlmKeyStoreOpener; + +#[cfg(not(all(feature = "libsql", feature = "root-llm-provider")))] +impl LlmKeyStoreOpener for EncryptedLlmKeyStoreOpener { + fn open(&self, _home_path: &Path) -> anyhow::Result<()> { + Ok(()) + } +} + +/// Where `provision_via_menu`'s pre-write key/model verification probe comes +/// from — injected so a test can script outcomes (rejected key, unreachable +/// endpoint, ok-with-a-model-list, …) without a live LLM endpoint. +/// - Unlike [`LlmKeyStoreOpener`] (opens a durable resource), this performs +/// the side-effecting network call itself, so its method takes the +/// already-built [`ironclaw_reborn_composition::RebornProviderAdmin`] +/// rather than raw construction ingredients. +/// - Gated the same as [`LlmKeyStoreOpener`]: no `RebornProviderAdmin`/ +/// `ProviderProbeOutcome` without both `libsql` and `root-llm-provider`. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +pub(crate) trait LlmProbe { + fn probe( + &self, + admin: &ironclaw_reborn_composition::RebornProviderAdmin, + provider_id: &str, + api_key: Option<&str>, + model: Option<&str>, + ) -> anyhow::Result; +} + +/// Production [`LlmProbe`]: calls `RebornProviderAdmin::probe_candidate`, +/// which builds a transient provider from the candidate settings and lists +/// its models — the same machinery the webui2 settings "Test connection" +/// button uses, reused here rather than opening a second transport. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +pub(crate) struct LiveLlmProbe; + +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +impl LlmProbe for LiveLlmProbe { + fn probe( + &self, + admin: &ironclaw_reborn_composition::RebornProviderAdmin, + provider_id: &str, + api_key: Option<&str>, + model: Option<&str>, + ) -> anyhow::Result { + let admin = admin.clone(); + let provider_id = provider_id.to_string(); + let api_key = api_key.map(|key| secrecy::SecretString::from(key.to_string())); + let model = model.map(str::to_string); + crate::runtime::block_on_cli(async move { + admin + .probe_candidate(&provider_id, api_key, model.as_deref()) + .await + .map_err(anyhow::Error::from) + }) + } +} + +/// Feature-off stub, same reasoning as [`LlmKeyStoreOpener`]'s stub: keeps +/// `execute()`'s unconditional `&LiveLlmProbe` call site compiling; the +/// feature-off `provision_llm_credentials` below never calls `probe`. +#[cfg(not(all(feature = "libsql", feature = "root-llm-provider")))] +pub(crate) trait LlmProbe { + fn probe(&self) -> anyhow::Result<()>; +} + +#[cfg(not(all(feature = "libsql", feature = "root-llm-provider")))] +pub(crate) struct LiveLlmProbe; + +#[cfg(not(all(feature = "libsql", feature = "root-llm-provider")))] +impl LlmProbe for LiveLlmProbe { + fn probe(&self) -> anyhow::Result<()> { + Ok(()) + } +} + +/// Provision onboard's `[llm.default]` slot. +/// +/// Env-detect step (before the numbered menu), via `RebornProviderAdmin::detect_env_llm` +/// (same resolution `resolve_reborn_runtime_llm`'s fallback and the `run`/`serve` +/// stub-gateway warning use): +/// - **Interactive, detected**: asks "Found `` configured in environment +/// — use it?" ([`PromptSource::confirm`]). Yes seeds `[llm.default]` from +/// `set_provider`, storing NO key (env var stays the source at runtime — +/// `set_provider` leaves `api_key_env` at catalog default). No falls through +/// to the menu. +/// - **Interactive, partial/invalid env (`Err`)**: prints a note, falls through. +/// - **Interactive, nothing detected (`Ok(None)`)**: falls through unchanged. +/// - **Headless, detected**: seeds `[llm.default]` silently, reported in +/// onboard's printed output. +/// - **Headless, partial/invalid or nothing detected**: seeds nothing, returns +/// a `Skipped*` outcome whose `display_line` teaches the operator what's next. +/// +/// Menu step (via [`super::prompts::PromptSource::provider_menu`]): prompts for +/// provider, API key (if required), model override, then persists. +/// - Both prompts run BEFORE any write (pure reads, nothing durable), so the +/// only fallible steps left are the two durable writes. +/// - Write order: secret store (`LlmKeyStore`, key `llm_provider__api_key` +/// — same handle webui2 settings writes and `apply_startup_stored_llm_key` +/// reads at boot) FIRST, then `[llm.default]` in `config.toml` SECOND +/// (`RebornProviderAdmin::set_provider`, same machinery `models set-provider` +/// uses). Invariant: `config.toml` can never point at a provider whose key +/// failed to persist — a `put` failure aborts before `set_provider` runs, +/// and no prompt runs after the store write starts (no orphan key on a +/// later prompt failure). +/// - `api_key_required: false` menu entries skip the key prompt/store write +/// entirely; every menu-eligible provider today (including `nearai`, via a +/// menu-level override) requires a key, so this is currently unreachable +/// but stays for future entries. +/// +/// Idempotent no-op on a rerun where `[llm.default]` is already configured AND +/// (no key required OR store already has one), unless `--force` — see +/// [`already_configured_outcome`]. Covers env-seeded slots too: drift between +/// slot and a since-changed environment is accepted, not re-detected; +/// `--force` re-seeds from environment again. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +pub(crate) fn provision_llm_credentials( + _home: &RebornHome, + boot: &ironclaw_reborn_config::RebornBootConfig, + prompts: &mut dyn PromptSource, + store_opener: &dyn LlmKeyStoreOpener, + probe: &dyn LlmProbe, + force: bool, +) -> Result { + let admin = ironclaw_reborn_composition::RebornProviderAdmin::new(boot.clone()); + // Secret-store root MUST match what `serve` opens at boot + // (`local_runtime_storage_root`, i.e. `/`), NOT the + // bare home — a key written to the bare-root db is invisible to the + // runtime (live bug: onboarded key never reached chat turns). + // NOTE: the directory itself is only created lazily, right before a store + // is actually opened (see `open_llm_key_store`) — a headless/no-op + // onboard run must not touch the filesystem. + let store_root = crate::runtime::local_runtime_storage_root(boot, boot.profile()); + + if !force && let Some(outcome) = already_configured_outcome(&admin, &store_root, store_opener)? + { + return Ok(outcome); + } + + if !prompts.is_interactive() { + return provision_headless_from_env(&store_root, store_opener, &admin); + } + + match admin.detect_env_llm() { + Ok(Some(detected)) => { + let question = format!( + "Found `{}` configured in environment — use it?", + detected.provider_id + ); + if prompts.confirm(&question)? { + persist_env_detected_key(&store_root, store_opener, &admin, &detected.provider_id)?; + let write_outcome = admin + .set_provider(&detected.provider_id, Some(detected.model.as_str())) + .map_err(|error| LlmCredentialPromptError::Other(error.into()))?; + return Ok(LlmCredentialProvisionOutcome::ConfiguredFromEnv { + provider_id: write_outcome.provider_id, + model: write_outcome.model, + }); + } + } + Ok(None) => {} + Err(error) => { + println!("ignoring partial environment LLM config: {error}"); + } + } + + provision_via_menu(&store_root, &admin, prompts, store_opener, probe) +} + +/// Headless counterpart of the env-detect step in [`provision_llm_credentials`]'s +/// doc: no prompt possible, so a detected config is seeded silently; anything +/// else seeds nothing and returns a `Skipped*` outcome. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +fn provision_headless_from_env( + store_root: &Path, + store_opener: &dyn LlmKeyStoreOpener, + admin: &ironclaw_reborn_composition::RebornProviderAdmin, +) -> Result { + match admin.detect_env_llm() { + Ok(Some(detected)) => { + persist_env_detected_key(store_root, store_opener, admin, &detected.provider_id)?; + let write_outcome = admin + .set_provider(&detected.provider_id, Some(detected.model.as_str())) + .map_err(|error| LlmCredentialPromptError::Other(error.into()))?; + Ok(LlmCredentialProvisionOutcome::ConfiguredFromEnv { + provider_id: write_outcome.provider_id, + model: write_outcome.model, + }) + } + Ok(None) => Ok(LlmCredentialProvisionOutcome::SkippedNonInteractive), + Err(error) => Ok( + LlmCredentialProvisionOutcome::SkippedNonInteractivePartialEnv { + reason: error.to_string(), + }, + ), + } +} + +/// Persist the env-detected key for `provider_id` into the encrypted secret +/// store — used by both the interactive confirm-yes and headless env-seed +/// branches above. The installed service inherits only +/// `IRONCLAW_REBORN_HOME`, not the shell env that ran onboard, so a key left +/// only in the env var is invisible to it at boot; the store is the only +/// channel that reaches the daemon. A no-op when the env no longer resolves +/// a key for `provider_id` (keyless provider, or the env changed between +/// `detect_env_llm` and this call) — never writes an empty/wrong value. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +fn persist_env_detected_key( + store_root: &Path, + store_opener: &dyn LlmKeyStoreOpener, + admin: &ironclaw_reborn_composition::RebornProviderAdmin, + provider_id: &str, +) -> Result<(), LlmCredentialPromptError> { + let Some(key) = admin + .resolve_env_api_key(provider_id) + .map_err(|error| LlmCredentialPromptError::Other(error.into()))? + else { + return Ok(()); + }; + let store = + open_llm_key_store(store_root, store_opener).map_err(LlmCredentialPromptError::Other)?; + let provider_id = provider_id.to_string(); + crate::runtime::block_on_cli(async move { + store + .put(&provider_id, key) + .await + .map_err(anyhow::Error::from) + }) + .map_err(LlmCredentialPromptError::Other) +} + +/// Create `store_root` (if missing) then open the encrypted key store there. +/// Deferred to just before a store is actually needed — see +/// [`provision_llm_credentials`]'s doc — so a headless/no-op onboard run that +/// never touches the store leaves the filesystem untouched. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +fn open_llm_key_store( + store_root: &Path, + store_opener: &dyn LlmKeyStoreOpener, +) -> anyhow::Result { + std::fs::create_dir_all(store_root).map_err(|error| { + anyhow::anyhow!("create secret-store root {}: {error}", store_root.display()) + })?; + store_opener.open(store_root) +} + +/// Drives the full numbered provider menu, factored out so the "declined +/// confirm" and "nothing detected" branches share one implementation. See +/// [`provision_llm_credentials`]'s doc for the store-then-config write ordering. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +fn provision_via_menu( + store_root: &Path, + admin: &ironclaw_reborn_composition::RebornProviderAdmin, + prompts: &mut dyn PromptSource, + store_opener: &dyn LlmKeyStoreOpener, + probe: &dyn LlmProbe, +) -> Result { + let entries = admin + .menu_entries() + .map_err(|error| LlmCredentialPromptError::Other(error.into()))?; + let selection = prompts.provider_menu(&entries)?; + // Re-resolve to keep this call site's id agreeing with `set_provider`'s + // own resolution (menu offers canonical ids already, but stay consistent). + let canonical_provider_id = admin + .resolve_provider_id(&selection) + .map_err(|error| LlmCredentialPromptError::Other(error.into()))?; + let entry = entries + .iter() + .find(|entry| entry.id == canonical_provider_id) + .ok_or_else(|| { + LlmCredentialPromptError::Other(anyhow::anyhow!( + "selected provider `{canonical_provider_id}` is not on the onboarding menu" + )) + })?; + + // Both prompts run BEFORE any write (pure reads), so only the two durable + // writes below remain fallible — no prompt can fail with a secret already + // committed. See `provision_llm_credentials`'s doc for write ordering. + let initial_key = if entry.api_key_required { + let key = prompts.api_key(&canonical_provider_id)?; + // Defense in depth: guards every `PromptSource` impl against a blank + // key reaching the secret store (`StdinPromptSource` already re-prompts). + if key.trim().is_empty() { + return Err(LlmCredentialPromptError::Other(anyhow::anyhow!( + "LLM API key must not be blank" + ))); + } + Some(key) + } else { + None + }; + + let default_model = admin + .list(Some(&canonical_provider_id), false) + .map_err(|error| LlmCredentialPromptError::Other(error.into()))? + .providers + .into_iter() + .next() + .map(|info| info.default_model) + .unwrap_or_default(); + let model = prompts.model(&canonical_provider_id, &default_model)?; + let effective_model = model.as_deref().unwrap_or(default_model.as_str()); + + // Live key/model verification — key-required providers only. Paths that + // never reach this function (headless seeding, env-detect confirm-yes) + // are never probed — env-sourced/keyless credentials are already trusted. + // `nearai` is `api_key_required: true` here (menu-level override), so it + // takes this same branch like any other key-required provider. + let key = match initial_key { + Some(key) => Some(probe_and_confirm_key( + prompts, + probe, + admin, + &canonical_provider_id, + effective_model, + key, + )?), + None => None, + }; + + if let Some(key) = key { + let store = open_llm_key_store(store_root, store_opener) + .map_err(LlmCredentialPromptError::Other)?; + let provider_id_for_store = canonical_provider_id.clone(); + crate::runtime::block_on_cli(async move { + store + .put_plaintext(&provider_id_for_store, key) + .await + .map_err(anyhow::Error::from) + }) + .map_err(LlmCredentialPromptError::Other)?; + } + + let write_outcome = admin + .set_provider(&canonical_provider_id, model.as_deref()) + .map_err(|error| LlmCredentialPromptError::Other(error.into()))?; + + Ok(LlmCredentialProvisionOutcome::Configured { + provider_id: write_outcome.provider_id, + model: write_outcome.model, + }) +} + +/// Probe `candidate_key` against `provider_id`/`effective_model` and, on +/// failure, either reprompt for a new key or accept the operator's "store +/// anyway" answer — the loop [`provision_via_menu`] runs after the key and +/// model prompts, before either durable write. +/// +/// - `probe`'s outcome (`ProviderProbeOutcome`) carries a single `ok: bool` +/// with no auth-vs-transport signal, so every failure (rejected key or +/// unreachable endpoint) takes the same branch: show the provider's +/// message, ask "store anyway?" ([`PromptSource::confirm`]). Yes accepts +/// the key as-is; no reprompts, up to `MAX_PROBE_ATTEMPTS` total entries. +/// - Successful probe with a non-empty model list missing `effective_model` +/// prints a warning but still returns the key (provider lists are often +/// incomplete); an empty model list warns about nothing. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +fn probe_and_confirm_key( + prompts: &mut dyn PromptSource, + probe: &dyn LlmProbe, + admin: &ironclaw_reborn_composition::RebornProviderAdmin, + provider_id: &str, + effective_model: &str, + mut candidate_key: String, +) -> Result { + const MAX_PROBE_ATTEMPTS: u8 = 3; + let mut attempt = 1u8; + loop { + let outcome = probe + .probe( + admin, + provider_id, + Some(candidate_key.as_str()), + Some(effective_model), + ) + .map_err(LlmCredentialPromptError::Other)?; + + if outcome.ok { + if !outcome.models.is_empty() && !outcome.models.iter().any(|m| m == effective_model) { + println!( + "warning: `{effective_model}` was not in {provider_id}'s reported model \ + list ({} models) — continuing anyway, provider model lists are often \ + incomplete", + outcome.models.len() + ); + } + return Ok(candidate_key); + } + + println!("{}", outcome.message); + let question = format!("Could not reach {provider_id} to verify — store anyway?"); + if prompts.confirm(&question)? { + return Ok(candidate_key); + } + + if attempt >= MAX_PROBE_ATTEMPTS { + return Err(LlmCredentialPromptError::Other(anyhow::anyhow!( + "no working API key for `{provider_id}` after {MAX_PROBE_ATTEMPTS} attempts; \ + configure it later with `ironclaw-reborn models set-provider {provider_id}`" + ))); + } + attempt += 1; + candidate_key = prompts.api_key(provider_id)?; + if candidate_key.trim().is_empty() { + return Err(LlmCredentialPromptError::Other(anyhow::anyhow!( + "LLM API key must not be blank" + ))); + } + } +} + +/// `Some` when `[llm.default]` already names a provider AND is durably +/// credentialed (no API key required per [`provider_api_key_required`]'s +/// menu-level definition, or the secret store already has a key) — the +/// idempotent-rerun case [`provision_llm_credentials`] must skip prompting +/// for. +/// - A bare stub-seeded `[llm.default]` with no stored key for a key-requiring +/// provider does NOT count (never actually credentialed) — a later +/// interactive rerun must still prompt. +/// - `nearai` is `api_key_required: true` at the menu level, so a `nearai` +/// slot with no stored/env key does NOT count either — see +/// `provision_llm_credentials_nearai_slot_without_a_stored_key_is_not_already_configured`. +/// - Store-open failure → "can't tell", falls through to prompting (not a +/// hard error) — deliberate, unlike the two failures below. +/// - Registry lookup failure and `config.toml` LOAD failure (unparseable +/// TOML) both propagate instead of being swallowed to "can't tell": a +/// corrupt/unreadable config is a real failure onboard must surface, not +/// silently reinterpret as "never configured" (which would re-run the +/// prompt, or re-write credentials on `--force`, every time against a +/// config the operator needs to fix by hand). Matches +/// [`provider_api_key_required`]'s registry-lookup-failure precedent. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +fn already_configured_outcome( + admin: &ironclaw_reborn_composition::RebornProviderAdmin, + store_root: &Path, + store_opener: &dyn LlmKeyStoreOpener, +) -> Result, LlmCredentialPromptError> { + let status = admin + .status() + .map_err(|error| LlmCredentialPromptError::Other(error.into()))?; + let Some(selection) = status.default else { + return Ok(None); + }; + let Some(provider_id) = selection.provider_id else { + return Ok(None); + }; + + let Some(api_key_required) = provider_api_key_required(admin, &provider_id)? else { + return Ok(None); + }; + if !api_key_required { + return Ok(Some(LlmCredentialProvisionOutcome::AlreadyConfigured { + provider_id, + model: selection.model.unwrap_or_default(), + })); + } + + let store = match open_llm_key_store(store_root, store_opener) { + Ok(store) => store, + Err(error) => { + tracing::debug!( + %error, + "secret store open failed while checking already-configured LLM; falling \ + through to prompt" + ); + return Ok(None); + } + }; + let provider_id_for_check = provider_id.clone(); + let has_key = crate::runtime::block_on_cli(async move { + store + .exists(&provider_id_for_check) + .await + .map_err(anyhow::Error::from) + }) + .map_err(LlmCredentialPromptError::Other)?; + if !has_key { + return Ok(None); + } + Ok(Some(LlmCredentialProvisionOutcome::AlreadyConfigured { + provider_id, + model: selection.model.unwrap_or_default(), + })) +} + +/// Whether `provider_id` requires an API key, per the MENU-LEVEL definition +/// (`RebornProviderAdmin::effective_api_key_required` — not the raw +/// `providers.json` field; a `session_token`-kind provider like `nearai` is +/// overridden to `true` since reborn has no session-token auth wired). Not +/// menu-restricted otherwise — `[llm.default]` may name a provider excluded +/// from the onboard menu, e.g. one set via `models set-provider`. +/// +/// - `Err`: registry lookup itself failed (corrupt/unreadable `providers.json`) +/// — a real failure, must not be swallowed into a silent re-prompt (would +/// make `already_configured_outcome` treat a broken registry as "never +/// configured" and re-run the prompt, or re-write credentials on `--force`, +/// every time). +/// - `Ok(None)`: genuinely "can't tell" — `provider_id` isn't in the registry. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +fn provider_api_key_required( + admin: &ironclaw_reborn_composition::RebornProviderAdmin, + provider_id: &str, +) -> Result, LlmCredentialPromptError> { + admin + .effective_api_key_required(provider_id) + .map_err(|error| LlmCredentialPromptError::Other(error.into())) +} + +/// No `libsql`/`root-llm-provider`, nothing to write to — same reasoning as +/// `provision_master_key`'s not-any-storage-feature fallback. +#[cfg(not(all(feature = "libsql", feature = "root-llm-provider")))] +pub(crate) fn provision_llm_credentials( + _home: &RebornHome, + _boot: &ironclaw_reborn_config::RebornBootConfig, + _prompts: &mut dyn PromptSource, + _store_opener: &dyn LlmKeyStoreOpener, + _probe: &dyn LlmProbe, + _force: bool, +) -> Result { + Ok(LlmCredentialProvisionOutcome::SkippedNonInteractive) +} + +#[cfg(all(test, feature = "libsql", feature = "root-llm-provider"))] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::context::RebornCliContext; + + /// Selects `provider` on the menu (matched by id), answers `key` for the + /// API-key prompt (only reached when the selected entry requires one), + /// and answers `model` for the model prompt (`None` means an empty + /// answer — use the catalog default). + struct FakePromptSource { + provider: &'static str, + key: &'static str, + model: Option<&'static str>, + } + + impl PromptSource for FakePromptSource { + fn is_interactive(&self) -> bool { + true + } + + fn provider_menu( + &mut self, + entries: &[ironclaw_reborn_composition::ProviderMenuEntry], + ) -> Result { + entries + .iter() + .find(|entry| entry.id == self.provider) + .map(|entry| entry.id.clone()) + .ok_or_else(|| { + LlmCredentialPromptError::Other(anyhow::anyhow!( + "fake-selected provider `{}` is not on the menu", + self.provider + )) + }) + } + + fn api_key(&mut self, _provider: &str) -> Result { + Ok(self.key.to_string()) + } + + fn model( + &mut self, + _provider_id: &str, + _default_model: &str, + ) -> Result, LlmCredentialPromptError> { + Ok(self.model.map(str::to_string)) + } + + fn confirm(&mut self, _question: &str) -> Result { + panic!( + "confirm() must not be called: these tests run with a clean process \ + environment (no LLM env vars set), so detect_env_llm() must return Ok(None) \ + and fall straight through to the numbered menu" + ) + } + } + + struct NonInteractivePromptSource; + + impl PromptSource for NonInteractivePromptSource { + fn is_interactive(&self) -> bool { + false + } + + fn provider_menu( + &mut self, + _entries: &[ironclaw_reborn_composition::ProviderMenuEntry], + ) -> Result { + unreachable!("provider_menu() must not be called once is_interactive() is false") + } + + fn api_key(&mut self, _provider: &str) -> Result { + unreachable!("api_key must not be prompted once provider_menu() has already failed") + } + + fn model( + &mut self, + _provider_id: &str, + _default_model: &str, + ) -> Result, LlmCredentialPromptError> { + unreachable!("model must not be prompted once provider_menu() has already failed") + } + + fn confirm(&mut self, _question: &str) -> Result { + unreachable!( + "confirm() must not be called once is_interactive() is false: the headless \ + env-detect path never prompts" + ) + } + } + + /// A [`PromptSource`] whose prompt methods panic if called — proves an + /// idempotent rerun skips prompting entirely, not merely tolerates a + /// repeated answer. + struct PanickingPromptSource; + + impl PromptSource for PanickingPromptSource { + fn is_interactive(&self) -> bool { + true + } + + fn provider_menu( + &mut self, + _entries: &[ironclaw_reborn_composition::ProviderMenuEntry], + ) -> Result { + panic!("provider_menu() must not be called on an idempotent, already-configured rerun") + } + + fn api_key(&mut self, _provider: &str) -> Result { + panic!("api_key() must not be called on an idempotent, already-configured rerun") + } + + fn model( + &mut self, + _provider_id: &str, + _default_model: &str, + ) -> Result, LlmCredentialPromptError> { + panic!("model() must not be called on an idempotent, already-configured rerun") + } + + fn confirm(&mut self, _question: &str) -> Result { + panic!("confirm() must not be called on an idempotent, already-configured rerun") + } + } + + /// An [`LlmProbe`] that always reports success with no model list — used + /// by tests unrelated to the probe-driven reprompt loop, so their + /// `Configured`/store-write assertions stay unaffected by probing. Also + /// pins "empty model list is not warned about". + struct StubOkProbe; + + impl LlmProbe for StubOkProbe { + fn probe( + &self, + _admin: &ironclaw_reborn_composition::RebornProviderAdmin, + _provider_id: &str, + _api_key: Option<&str>, + _model: Option<&str>, + ) -> anyhow::Result { + Ok(ironclaw_reborn_composition::ProviderProbeOutcome { + ok: true, + models: Vec::new(), + message: String::new(), + }) + } + } + + /// An [`LlmProbe`] that panics if called — proves the probe never runs + /// on excluded paths: an idempotent already-configured rerun, a headless + /// run, an env-detect confirm-yes branch, and a blank-key rejection + /// (fails before the probe step). Every menu-eligible provider requires + /// a key today (including `nearai`), so there's no "no-key provider" case. + struct PanickingProbe; + + impl LlmProbe for PanickingProbe { + fn probe( + &self, + _admin: &ironclaw_reborn_composition::RebornProviderAdmin, + _provider_id: &str, + _api_key: Option<&str>, + _model: Option<&str>, + ) -> anyhow::Result { + panic!( + "probe() must not be called: idempotent reruns, headless runs, env-seeded \ + selections, and a rejected blank key must never reach the live key/model \ + verification probe" + ) + } + } + + /// A [`LlmProbe`] scripted with a fixed sequence of outcomes, consumed + /// in order — proves the probe-driven reprompt loop in + /// `probe_and_confirm_key` without a live LLM endpoint. Panics if + /// called more times than scripted (proving the loop asks exactly as + /// many times as expected, no more). + /// Args a single [`ScriptedProbe::probe`] call was made with — recorded + /// so a test can assert the selected provider/key/model actually reached + /// the probe, not just that a probe happened. + #[derive(Debug, Clone, PartialEq, Eq)] + struct RecordedProbeCall { + provider_id: String, + api_key: Option, + model: Option, + } + + struct ScriptedProbe { + outcomes: std::cell::RefCell< + std::collections::VecDeque, + >, + calls: std::cell::RefCell>, + } + + impl ScriptedProbe { + fn new(outcomes: Vec) -> Self { + Self { + outcomes: std::cell::RefCell::new(outcomes.into()), + calls: std::cell::RefCell::new(Vec::new()), + } + } + + fn calls(&self) -> Vec { + self.calls.borrow().clone() + } + } + + impl LlmProbe for ScriptedProbe { + fn probe( + &self, + _admin: &ironclaw_reborn_composition::RebornProviderAdmin, + provider_id: &str, + api_key: Option<&str>, + model: Option<&str>, + ) -> anyhow::Result { + self.calls.borrow_mut().push(RecordedProbeCall { + provider_id: provider_id.to_string(), + api_key: api_key.map(str::to_string), + model: model.map(str::to_string), + }); + self.outcomes + .borrow_mut() + .pop_front() + .ok_or_else(|| anyhow::anyhow!("ScriptedProbe: no more scripted outcomes")) + } + } + + /// A [`PromptSource`] for probe-driven reprompt tests: scripts a fixed + /// sequence of `api_key()`/`confirm()` answers, consumed in order; panics + /// if either sequence is exhausted, proving the loop asks exactly as many + /// times as expected. + struct ScriptedKeyPromptSource { + provider: &'static str, + keys: std::collections::VecDeque<&'static str>, + confirms: std::collections::VecDeque, + model: Option<&'static str>, + } + + impl PromptSource for ScriptedKeyPromptSource { + fn is_interactive(&self) -> bool { + true + } + + fn provider_menu( + &mut self, + entries: &[ironclaw_reborn_composition::ProviderMenuEntry], + ) -> Result { + entries + .iter() + .find(|entry| entry.id == self.provider) + .map(|entry| entry.id.clone()) + .ok_or_else(|| { + LlmCredentialPromptError::Other(anyhow::anyhow!( + "fake-selected provider `{}` is not on the menu", + self.provider + )) + }) + } + + fn api_key(&mut self, _provider: &str) -> Result { + Ok(self + .keys + .pop_front() + .expect("ScriptedKeyPromptSource: api_key() called more times than scripted") + .to_string()) + } + + fn model( + &mut self, + _provider_id: &str, + _default_model: &str, + ) -> Result, LlmCredentialPromptError> { + Ok(self.model.map(str::to_string)) + } + + fn confirm(&mut self, _question: &str) -> Result { + Ok(self + .confirms + .pop_front() + .expect("ScriptedKeyPromptSource: confirm() called more times than scripted")) + } + } + + /// A [`LlmKeyStoreOpener`] whose store's `put` always fails — used to + /// prove `provision_llm_credentials` writes the secret store BEFORE + /// `config.toml`: a `put` failure must leave `config.toml` untouched. + struct FailingLlmKeyStoreOpener; + + impl LlmKeyStoreOpener for FailingLlmKeyStoreOpener { + fn open( + &self, + _home_path: &Path, + ) -> anyhow::Result { + Ok(ironclaw_reborn_composition::LlmKeyStore::new(Arc::new( + FailingSecretStore, + ))) + } + } + + struct FailingSecretStore; + + #[async_trait::async_trait] + impl ironclaw_secrets::SecretStore for FailingSecretStore { + async fn put( + &self, + _scope: ironclaw_host_api::ResourceScope, + _handle: ironclaw_host_api::SecretHandle, + _material: ironclaw_secrets::SecretMaterial, + _expires_at: Option, + ) -> Result { + Err(ironclaw_secrets::SecretStoreError::StoreUnavailable { + reason: "simulated failure for write-ordering RED test".to_string(), + }) + } + + async fn metadata( + &self, + _scope: &ironclaw_host_api::ResourceScope, + _handle: &ironclaw_host_api::SecretHandle, + ) -> Result, ironclaw_secrets::SecretStoreError> + { + unreachable!("not exercised by provision_llm_credentials") + } + + async fn metadata_for_scope( + &self, + _scope: &ironclaw_host_api::ResourceScope, + ) -> Result, ironclaw_secrets::SecretStoreError> + { + unreachable!("not exercised by provision_llm_credentials") + } + + async fn delete( + &self, + _scope: &ironclaw_host_api::ResourceScope, + _handle: &ironclaw_host_api::SecretHandle, + ) -> Result { + unreachable!("not exercised by provision_llm_credentials") + } + + async fn lease_once( + &self, + _scope: &ironclaw_host_api::ResourceScope, + _handle: &ironclaw_host_api::SecretHandle, + ) -> Result { + unreachable!("not exercised by provision_llm_credentials") + } + + async fn consume( + &self, + _scope: &ironclaw_host_api::ResourceScope, + _lease_id: ironclaw_secrets::SecretLeaseId, + ) -> Result { + unreachable!("not exercised by provision_llm_credentials") + } + + async fn revoke( + &self, + _scope: &ironclaw_host_api::ResourceScope, + _lease_id: ironclaw_secrets::SecretLeaseId, + ) -> Result { + unreachable!("not exercised by provision_llm_credentials") + } + + async fn leases_for_scope( + &self, + _scope: &ironclaw_host_api::ResourceScope, + ) -> Result, ironclaw_secrets::SecretStoreError> + { + unreachable!("not exercised by provision_llm_credentials") + } + } + + /// Seed a cached master-key dotfile so the real local-dev store opener's + /// resolver never reaches the OS keychain step in a test — see + /// `ironclaw_reborn_composition::factory`'s + /// `open_local_dev_secret_store_opens_a_working_store_over_the_bare_root` + /// for the same seeding pattern. + fn seed_cached_master_key(home: &RebornHome) { + std::fs::write( + home.path() + .join(ironclaw_reborn_composition::LOCAL_DEV_SECRETS_MASTER_KEY_PATH), + ironclaw_secrets::keychain::generate_master_key_hex(), + ) + .expect("seed cached master key"); + } + + /// A fake interactive `PromptSource` selecting `openai` (key-requiring) + /// and answering `"sk-test-value"` must land the provider selection in + /// `config.toml` and the key in the encrypted secret store, readable back + /// through a *fresh* open of the same root — proving the opener and + /// `LlmKeyStore::put`/`read` agree on physical storage. + /// + /// Also proves the idempotent-rerun guard for a key-requiring provider: a + /// second call with `PanickingPromptSource` must return `AlreadyConfigured` + /// without ever calling `provider_menu()`/`api_key()`. + #[test] + fn provision_llm_credentials_writes_config_and_secret_store_through_fake_prompts() { + let _env_guard = crate::runtime::test_env::lock_runtime_env(); + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + seed_cached_master_key(home); + + let mut prompts = FakePromptSource { + provider: "openai", + key: "sk-test-value", + model: None, + }; + let outcome = provision_llm_credentials( + home, + context.boot_config(), + &mut prompts, + &EncryptedLlmKeyStoreOpener, + &StubOkProbe, + false, + ) + .expect("provision must succeed with a fake interactive source"); + assert_eq!( + outcome, + LlmCredentialProvisionOutcome::Configured { + provider_id: "openai".to_string(), + model: "gpt-5-mini".to_string(), + } + ); + + // Verify through the RUNTIME storage root (`/local-dev`) — the same + // db `serve` opens at boot; pins the onboard-write/serve-read convergence. + let home_path = home.path().join("local-dev"); + let stored = crate::runtime::block_on_cli(async move { + let store = ironclaw_reborn_composition::open_local_dev_secret_store(&home_path) + .await + .map_err(anyhow::Error::from)?; + ironclaw_reborn_composition::LlmKeyStore::new(store) + .read("openai") + .await + .map_err(anyhow::Error::from) + }) + .expect("read back through a fresh open of the same root"); + let material = stored.expect("a value must have been written"); + assert_eq!( + secrecy::ExposeSecret::expose_secret(&material), + "sk-test-value" + ); + + let config_text = + std::fs::read_to_string(home.config_file_path()).expect("read config.toml"); + assert!( + config_text.contains("provider_id = \"openai\""), + "config.toml: {config_text}" + ); + + // A rerun with an already-configured provider + stored key must skip + // prompting entirely. + let mut second_prompts = PanickingPromptSource; + let second_outcome = provision_llm_credentials( + home, + context.boot_config(), + &mut second_prompts, + &EncryptedLlmKeyStoreOpener, + &PanickingProbe, + false, + ) + .expect("an idempotent rerun must succeed without prompting"); + assert_eq!( + second_outcome, + LlmCredentialProvisionOutcome::AlreadyConfigured { + provider_id: "openai".to_string(), + model: "gpt-5-mini".to_string(), + } + ); + } + + /// `nearai` is `api_key_required: true` at the menu level (see + /// `RebornProviderAdmin::menu_entries`'s doc: reborn has no + /// session-token auth wired, so a `session_token`-kind provider is + /// required-key here even though the raw catalog entry marks it + /// optional). This test pins that it now takes the EXACT SAME path as + /// `openai` — required-key prompt, live probe, store-then-config write, + /// idempotent rerun via a stored key — with no nearai-specific + /// behavior anywhere in `provision_via_menu`. + #[test] + fn provision_llm_credentials_nearai_requires_and_stores_an_api_key_like_any_other_provider() { + let _env_guard = crate::runtime::test_env::lock_runtime_env(); + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + seed_cached_master_key(home); + + let mut prompts = FakePromptSource { + provider: "nearai", + key: "session-test-value", + model: None, + }; + let outcome = provision_llm_credentials( + home, + context.boot_config(), + &mut prompts, + &EncryptedLlmKeyStoreOpener, + &StubOkProbe, + false, + ) + .expect("provision must succeed with a fake interactive source"); + assert_eq!( + outcome, + LlmCredentialProvisionOutcome::Configured { + provider_id: "nearai".to_string(), + model: "deepseek-ai/DeepSeek-V4-Flash".to_string(), + } + ); + + // Verify through the RUNTIME storage root (`/local-dev`) — the same + // db `serve` opens at boot; pins the onboard-write/serve-read convergence. + let home_path = home.path().join("local-dev"); + let stored = crate::runtime::block_on_cli(async move { + let store = ironclaw_reborn_composition::open_local_dev_secret_store(&home_path) + .await + .map_err(anyhow::Error::from)?; + ironclaw_reborn_composition::LlmKeyStore::new(store) + .read("nearai") + .await + .map_err(anyhow::Error::from) + }) + .expect("read back through a fresh open of the same root"); + assert_eq!( + secrecy::ExposeSecret::expose_secret(&stored.expect("a value must have been stored")), + "session-test-value" + ); + + // Idempotent rerun, exactly like the openai test above: a stored + // key makes a second run skip prompting entirely. + let mut second_prompts = PanickingPromptSource; + let second_outcome = provision_llm_credentials( + home, + context.boot_config(), + &mut second_prompts, + &EncryptedLlmKeyStoreOpener, + &PanickingProbe, + false, + ) + .expect("an idempotent rerun must succeed without prompting"); + assert_eq!( + second_outcome, + LlmCredentialProvisionOutcome::AlreadyConfigured { + provider_id: "nearai".to_string(), + model: "deepseek-ai/DeepSeek-V4-Flash".to_string(), + } + ); + } + + /// A `[llm.default]` slot already pointing at `nearai` (e.g. seeded + /// directly via `set_provider`, mirroring what `models set-provider nearai` + /// leaves behind) with NO stored key must NOT be treated as already + /// configured — that state is broken (no session-token auth wired, so a + /// keyless nearai slot dead-ends at the first chat turn). A rerun must + /// fall through to the full prompt flow and land a real key. + #[test] + fn provision_llm_credentials_nearai_slot_without_a_stored_key_is_not_already_configured() { + let _env_guard = crate::runtime::test_env::lock_runtime_env(); + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + seed_cached_master_key(home); + + let admin = + ironclaw_reborn_composition::RebornProviderAdmin::new(context.boot_config().clone()); + admin + .set_provider("nearai", None) + .expect("seed a bare nearai slot directly, bypassing onboard's key prompt/store"); + + let mut prompts = FakePromptSource { + provider: "nearai", + key: "session-test-value", + model: None, + }; + let outcome = provision_llm_credentials( + home, + context.boot_config(), + &mut prompts, + &EncryptedLlmKeyStoreOpener, + &StubOkProbe, + false, + ) + .expect("a keyless nearai slot must re-prompt, not error"); + assert_eq!( + outcome, + LlmCredentialProvisionOutcome::Configured { + provider_id: "nearai".to_string(), + model: "deepseek-ai/DeepSeek-V4-Flash".to_string(), + }, + "a nearai slot with no stored key must never be treated as AlreadyConfigured — \ + `Configured` here proves the full prompt flow ran instead of skipping it" + ); + } + + /// A malformed `config.toml` (unparseable TOML) must surface as a real + /// error from `already_configured_outcome`'s `admin.status()` call, not + /// be swallowed to "can't tell" and silently fall through to prompting — + /// `PanickingPromptSource` proves no prompt is ever reached. + #[test] + fn provision_llm_credentials_fails_loudly_on_a_malformed_config_toml() { + let _env_guard = crate::runtime::test_env::lock_runtime_env(); + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + std::fs::write(home.config_file_path(), "not valid toml [[[").expect("write bad config"); + + let mut prompts = PanickingPromptSource; + let error = provision_llm_credentials( + home, + context.boot_config(), + &mut prompts, + &EncryptedLlmKeyStoreOpener, + &PanickingProbe, + false, + ) + .expect_err("a malformed config.toml must surface as an error, not a silent fall-through"); + assert!(matches!(error, LlmCredentialPromptError::Other(_))); + } + + /// A non-interactive session with no LLM environment variables set must + /// succeed with `Ok(SkippedNonInteractive)` and write nothing: + /// `provider_menu()`/`api_key()`/`model()`/`confirm()` are all + /// `unreachable!()` on `NonInteractivePromptSource` (interactivity check + /// short-circuits before any prompt), and `config.toml` must not exist + /// afterward. + #[test] + fn provision_llm_credentials_is_a_noop_when_non_interactive_with_no_env_detected() { + let _env_guard = crate::runtime::test_env::lock_runtime_env(); + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + + let mut prompts = NonInteractivePromptSource; + let outcome = provision_llm_credentials( + home, + context.boot_config(), + &mut prompts, + &EncryptedLlmKeyStoreOpener, + &PanickingProbe, + false, + ) + .expect("a non-interactive source with nothing detected in env must succeed as a no-op"); + assert_eq!( + outcome, + LlmCredentialProvisionOutcome::SkippedNonInteractive + ); + assert!( + !home.config_file_path().exists(), + "a non-interactive no-op must not write config.toml" + ); + let store_root = crate::runtime::local_runtime_storage_root( + context.boot_config(), + context.boot_config().profile(), + ); + assert!( + !store_root.exists(), + "a non-interactive no-op must not create the secret-store root either" + ); + } + + /// A store whose `put` always fails must leave `config.toml` completely + /// untouched — proves the secret is written BEFORE the provider selection. + /// Uses `openai`; any key-requiring menu entry would exercise this + /// equally, but a no-key provider couldn't. + #[test] + fn provision_llm_credentials_leaves_config_untouched_when_the_store_put_fails() { + let _env_guard = crate::runtime::test_env::lock_runtime_env(); + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + + let mut prompts = FakePromptSource { + provider: "openai", + key: "sk-test-value", + model: None, + }; + let error = provision_llm_credentials( + home, + context.boot_config(), + &mut prompts, + &FailingLlmKeyStoreOpener, + &StubOkProbe, + false, + ) + .expect_err("a failing store put must surface as an error"); + assert!(matches!(error, LlmCredentialPromptError::Other(_))); + assert!( + !home.config_file_path().exists(), + "a failed key-store write must leave config.toml untouched — store first, config \ + second" + ); + } + + /// A `PromptSource` whose `api_key()` returns a whitespace-only answer + /// (e.g. a fake without the blank-rejection retry loop + /// `StdinPromptSource::api_key` has) must never reach the secret store — + /// `provision_llm_credentials`'s own blank guard backstops every + /// `PromptSource`, not just the terminal-backed one. + #[test] + fn provision_llm_credentials_rejects_a_blank_api_key_without_touching_anything() { + let _env_guard = crate::runtime::test_env::lock_runtime_env(); + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + seed_cached_master_key(home); + + let mut prompts = FakePromptSource { + provider: "openai", + key: " ", + model: None, + }; + let error = provision_llm_credentials( + home, + context.boot_config(), + &mut prompts, + &EncryptedLlmKeyStoreOpener, + &PanickingProbe, + false, + ) + .expect_err("a blank API key must be rejected"); + assert!(matches!(error, LlmCredentialPromptError::Other(_))); + assert!( + !home.config_file_path().exists(), + "a rejected blank API key must leave config.toml untouched" + ); + } + + /// (v) An excluded provider id typed at the menu (not a menu entry — + /// `ollama`/`bedrock`/etc are excluded by `menu_entries()` by design) + /// must be rejected as invalid, never resolved via the full registry. + /// Covers `bedrock` (onboarding-scope exclusion) and `openai_compatible` + /// (base-URL-trap exclusion — see `RebornProviderAdmin::menu_entries`'s + /// doc): both are real, resolvable registry providers, so this pins + /// that menu exclusion — not registry absence — is what blocks them. + #[test] + fn provision_llm_credentials_rejects_a_menu_excluded_provider_id() { + for excluded_provider in ["bedrock", "openai_compatible"] { + let _env_guard = crate::runtime::test_env::lock_runtime_env(); + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + + let mut prompts = FakePromptSource { + provider: excluded_provider, + key: "unused", + model: None, + }; + let error = provision_llm_credentials( + home, + context.boot_config(), + &mut prompts, + &EncryptedLlmKeyStoreOpener, + &PanickingProbe, + false, + ) + .expect_err(&format!( + "menu-excluded provider `{excluded_provider}` must be rejected" + )); + assert!(matches!(error, LlmCredentialPromptError::Other(_))); + assert!( + !home.config_file_path().exists(), + "a rejected menu selection ({excluded_provider}) must leave config.toml untouched" + ); + } + } + + /// (iv) An empty model answer must land the catalog default in + /// `[llm.default].model`, not a blank string. + #[test] + fn provision_llm_credentials_empty_model_answer_uses_catalog_default() { + let _env_guard = crate::runtime::test_env::lock_runtime_env(); + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + seed_cached_master_key(home); + + let mut prompts = FakePromptSource { + provider: "openai", + key: "sk-test-value", + model: None, + }; + let outcome = provision_llm_credentials( + home, + context.boot_config(), + &mut prompts, + &EncryptedLlmKeyStoreOpener, + &StubOkProbe, + false, + ) + .expect("provision must succeed"); + assert_eq!( + outcome, + LlmCredentialProvisionOutcome::Configured { + provider_id: "openai".to_string(), + model: "gpt-5-mini".to_string(), + }, + "an empty model answer must resolve to openai's catalog default model" + ); + } + + /// `PromptSource` that answers a fixed `confirm()` result and, if the + /// flow falls through to the menu instead, selects `provider`/`model` + /// like [`FakePromptSource`] — used to drive both branches of the + /// interactive env-detect step from one fake. + struct ConfirmingPromptSource { + confirm_answer: bool, + provider: &'static str, + /// Answered by `api_key()` on the confirm-NO fall-through-to-menu + /// branch, which now always needs one — every menu entry (including + /// `nearai`, via its menu-level override) requires a key. Unused on + /// the confirm-YES branch, which seeds straight from the env-detected + /// provider and never reaches `api_key()` at all. + key: &'static str, + model: Option<&'static str>, + } + + impl PromptSource for ConfirmingPromptSource { + fn is_interactive(&self) -> bool { + true + } + + fn provider_menu( + &mut self, + entries: &[ironclaw_reborn_composition::ProviderMenuEntry], + ) -> Result { + entries + .iter() + .find(|entry| entry.id == self.provider) + .map(|entry| entry.id.clone()) + .ok_or_else(|| { + LlmCredentialPromptError::Other(anyhow::anyhow!( + "fake-selected provider `{}` is not on the menu", + self.provider + )) + }) + } + + fn api_key(&mut self, _provider: &str) -> Result { + Ok(self.key.to_string()) + } + + fn model( + &mut self, + _provider_id: &str, + _default_model: &str, + ) -> Result, LlmCredentialPromptError> { + Ok(self.model.map(str::to_string)) + } + + fn confirm(&mut self, question: &str) -> Result { + assert!( + question.contains("openai"), + "confirm question must name the detected provider: {question}" + ); + Ok(self.confirm_answer) + } + } + + /// `PromptSource` whose `confirm()`/`provider_menu()` both panic if + /// called — used to prove the headless env-detect path never prompts. + struct HeadlessPromptSource; + + impl PromptSource for HeadlessPromptSource { + fn is_interactive(&self) -> bool { + false + } + + fn provider_menu( + &mut self, + _entries: &[ironclaw_reborn_composition::ProviderMenuEntry], + ) -> Result { + unreachable!("provider_menu() must not be called once is_interactive() is false") + } + + fn api_key(&mut self, _provider: &str) -> Result { + unreachable!("api_key() must not be called once is_interactive() is false") + } + + fn model( + &mut self, + _provider_id: &str, + _default_model: &str, + ) -> Result, LlmCredentialPromptError> { + unreachable!("model() must not be called once is_interactive() is false") + } + + fn confirm(&mut self, _question: &str) -> Result { + unreachable!("confirm() must not be called once is_interactive() is false") + } + } + + /// With a complete `openai` config in the environment (`OPENAI_API_KEY` + /// set), an interactive session must ask to confirm using it, and "yes" + /// must seed `[llm.default]` from the DETECTED provider/model via + /// `set_provider` AND persist the detected key into the encrypted secret + /// store. The installed service only inherits `IRONCLAW_REBORN_HOME`, + /// not the operator's shell env, so a key left only in `OPENAI_API_KEY` + /// is invisible to it at boot — the store is the only channel that + /// reaches the daemon. See `provision_llm_credentials`'s doc. + #[test] + fn provision_llm_credentials_seeds_from_env_on_interactive_confirm_yes() { + let _env_guard = crate::runtime::test_env::lock_runtime_env(); + // SAFETY: serialized by the shared crate process-env lock; cleaned up + // before the guard drops. + unsafe { + std::env::set_var("OPENAI_API_KEY", "sk-env-detected-value"); + } + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + seed_cached_master_key(home); + + let mut prompts = ConfirmingPromptSource { + confirm_answer: true, + provider: "openai", + key: "unused", + model: None, + }; + let outcome = provision_llm_credentials( + home, + context.boot_config(), + &mut prompts, + &EncryptedLlmKeyStoreOpener, + &PanickingProbe, + false, + ); + unsafe { + std::env::remove_var("OPENAI_API_KEY"); + } + let outcome = outcome.expect("provision must succeed on confirm-yes"); + assert_eq!( + outcome, + LlmCredentialProvisionOutcome::ConfiguredFromEnv { + provider_id: "openai".to_string(), + model: "gpt-5-mini".to_string(), + } + ); + + let config_text = + std::fs::read_to_string(home.config_file_path()).expect("read config.toml"); + assert!( + config_text.contains("provider_id = \"openai\""), + "config.toml: {config_text}" + ); + + // Verify through the RUNTIME storage root (`/local-dev`) — the same + // db `serve` opens at boot; pins the onboard-write/serve-read convergence. + let home_path = home.path().join("local-dev"); + let stored = crate::runtime::block_on_cli(async move { + let store = ironclaw_reborn_composition::open_local_dev_secret_store(&home_path) + .await + .map_err(anyhow::Error::from)?; + ironclaw_reborn_composition::LlmKeyStore::new(store) + .read("openai") + .await + .map_err(anyhow::Error::from) + }) + .expect("read back through a fresh open of the same root"); + let material = stored.expect( + "an env-detected+confirmed seed must persist the key into the secret store — a \ + service manager does not inherit the shell env, so the store is the only channel \ + that reaches the daemon", + ); + assert_eq!( + secrecy::ExposeSecret::expose_secret(&material), + "sk-env-detected-value" + ); + } + + /// A "no" answer to the confirm prompt must fall through to the full + /// numbered menu — the decline path is not a dead end. + #[test] + fn provision_llm_credentials_falls_through_to_menu_on_interactive_confirm_no() { + let _env_guard = crate::runtime::test_env::lock_runtime_env(); + // SAFETY: serialized by the shared crate process-env lock; cleaned up + // before the guard drops. + unsafe { + std::env::set_var("OPENAI_API_KEY", "sk-env-detected-value"); + } + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + seed_cached_master_key(home); + + let mut prompts = ConfirmingPromptSource { + confirm_answer: false, + provider: "nearai", + key: "session-test-value", + model: None, + }; + let outcome = provision_llm_credentials( + home, + context.boot_config(), + &mut prompts, + &EncryptedLlmKeyStoreOpener, + &StubOkProbe, + false, + ); + unsafe { + std::env::remove_var("OPENAI_API_KEY"); + } + let outcome = outcome.expect("provision must succeed after declining the env prompt"); + assert_eq!( + outcome, + LlmCredentialProvisionOutcome::Configured { + provider_id: "nearai".to_string(), + model: "deepseek-ai/DeepSeek-V4-Flash".to_string(), + }, + "declining the env-detected provider must fall through to the full menu, landing \ + the menu's own selection instead of the env-detected one" + ); + } + + /// A non-interactive session with a complete `openai` config in the + /// environment must seed `[llm.default]` from it SILENTLY (no prompt + /// possible) AND persist the detected key into the encrypted secret + /// store — the installed service inherits only `IRONCLAW_REBORN_HOME`, + /// not the seeding shell's env, so the store is the only channel that + /// reaches the daemon. + #[test] + fn provision_llm_credentials_seeds_from_env_when_headless() { + let _env_guard = crate::runtime::test_env::lock_runtime_env(); + // SAFETY: serialized by the shared crate process-env lock; cleaned up + // before the guard drops. + unsafe { + std::env::set_var("OPENAI_API_KEY", "sk-env-detected-value"); + } + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + seed_cached_master_key(home); + + let mut prompts = HeadlessPromptSource; + let outcome = provision_llm_credentials( + home, + context.boot_config(), + &mut prompts, + &EncryptedLlmKeyStoreOpener, + &PanickingProbe, + false, + ); + unsafe { + std::env::remove_var("OPENAI_API_KEY"); + } + let outcome = outcome.expect("headless provision with a detected env config must succeed"); + assert_eq!( + outcome, + LlmCredentialProvisionOutcome::ConfiguredFromEnv { + provider_id: "openai".to_string(), + model: "gpt-5-mini".to_string(), + } + ); + let config_text = + std::fs::read_to_string(home.config_file_path()).expect("read config.toml"); + assert!( + config_text.contains("provider_id = \"openai\""), + "config.toml: {config_text}" + ); + + // Verify through the RUNTIME storage root (`/local-dev`) — the + // same db `serve` opens at boot; pins the onboard-write/serve-read + // convergence for the headless env-seed path too. + let home_path = home.path().join("local-dev"); + let stored = crate::runtime::block_on_cli(async move { + let store = ironclaw_reborn_composition::open_local_dev_secret_store(&home_path) + .await + .map_err(anyhow::Error::from)?; + ironclaw_reborn_composition::LlmKeyStore::new(store) + .read("openai") + .await + .map_err(anyhow::Error::from) + }) + .expect("read back through a fresh open of the same root"); + let material = stored.expect( + "a headless env-seed must persist the key into the secret store — a service \ + manager does not inherit the shell env, so the store is the only channel that \ + reaches the daemon", + ); + assert_eq!( + secrecy::ExposeSecret::expose_secret(&material), + "sk-env-detected-value" + ); + } + + /// A non-interactive session with an INCOMPLETE env config (`OPENAI_MODEL` + /// set without `OPENAI_API_KEY`) must seed nothing and report a + /// `SkippedNonInteractivePartialEnv` outcome naming the reason — never + /// silently adopt a broken environment or fall back to a hardcoded default. + #[test] + fn provision_llm_credentials_seeds_nothing_when_headless_env_is_partial() { + let _env_guard = crate::runtime::test_env::lock_runtime_env(); + // SAFETY: serialized by the shared crate process-env lock; cleaned up + // before the guard drops. + unsafe { + std::env::set_var("OPENAI_MODEL", "gpt-test-model"); + } + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + + let mut prompts = HeadlessPromptSource; + let outcome = provision_llm_credentials( + home, + context.boot_config(), + &mut prompts, + &EncryptedLlmKeyStoreOpener, + &PanickingProbe, + false, + ); + unsafe { + std::env::remove_var("OPENAI_MODEL"); + } + let outcome = outcome.expect("a partial env must not fail onboard overall"); + match outcome { + LlmCredentialProvisionOutcome::SkippedNonInteractivePartialEnv { reason } => { + assert!( + reason.to_lowercase().contains("openai") || !reason.is_empty(), + "reason should describe the incomplete provider: {reason}" + ); + } + other => panic!("expected SkippedNonInteractivePartialEnv, got {other:?}"), + } + assert!( + !home.config_file_path().exists(), + "a partial env must leave config.toml untouched" + ); + } + + /// A fresh reborn home, interactive session, clean environment (no LLM + /// env vars) must still invoke the full numbered `provider_menu()` — an + /// interactive session with `Ok(None)` (nothing detected) must not be + /// treated as `SkippedNonInteractive`-shaped and skip the first-run menu. + /// `FakePromptSource` panics on `confirm()`, so this also fails loudly if + /// `confirm()` is spuriously invoked with nothing detected. + #[test] + fn fresh_home_interactive_with_clean_env_still_invokes_the_provider_menu() { + let _env_guard = crate::runtime::test_env::lock_runtime_env(); + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + seed_cached_master_key(home); + assert!( + !home.config_file_path().exists(), + "must start from a genuinely fresh home with no pre-existing config.toml" + ); + + let mut prompts = FakePromptSource { + provider: "nearai", + key: "session-test-value", + model: None, + }; + let outcome = provision_llm_credentials( + home, + context.boot_config(), + &mut prompts, + &EncryptedLlmKeyStoreOpener, + &StubOkProbe, + false, + ) + .expect("provision must succeed by falling through to the menu"); + assert_eq!( + outcome, + LlmCredentialProvisionOutcome::Configured { + provider_id: "nearai".to_string(), + model: "deepseek-ai/DeepSeek-V4-Flash".to_string(), + }, + "the numbered menu's own selection must land in config.toml, proving \ + provider_menu() was actually invoked (FakePromptSource's provider_menu is the only \ + path that can produce this outcome)" + ); + } + + fn probe_outcome( + ok: bool, + models: Vec<&str>, + message: &str, + ) -> ironclaw_reborn_composition::ProviderProbeOutcome { + ironclaw_reborn_composition::ProviderProbeOutcome { + ok, + models: models.into_iter().map(str::to_string).collect(), + message: message.to_string(), + } + } + + /// A probe failure (rejected key or unreachable endpoint — + /// `ProviderProbeOutcome` carries no signal to tell them apart, see + /// `probe_and_confirm_key`'s doc) followed by a "store anyway?" decline + /// must reprompt for a NEW key, and a second successful probe must store + /// THAT key — the reprompt loop replaces the candidate, not retries it. + #[test] + fn provision_llm_credentials_probe_failure_then_reprompt_then_accepted() { + let _env_guard = crate::runtime::test_env::lock_runtime_env(); + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + seed_cached_master_key(home); + + let mut prompts = ScriptedKeyPromptSource { + provider: "openai", + keys: std::collections::VecDeque::from(["sk-bad", "sk-good"]), + confirms: std::collections::VecDeque::from([false]), + model: None, + }; + let probe = ScriptedProbe::new(vec![ + probe_outcome(false, vec![], "invalid api key"), + probe_outcome(true, vec![], ""), + ]); + let outcome = provision_llm_credentials( + home, + context.boot_config(), + &mut prompts, + &EncryptedLlmKeyStoreOpener, + &probe, + false, + ) + .expect("provision must succeed after a reprompted key passes the probe"); + assert_eq!( + outcome, + LlmCredentialProvisionOutcome::Configured { + provider_id: "openai".to_string(), + model: "gpt-5-mini".to_string(), + } + ); + + // Verify through the RUNTIME storage root (`/local-dev`) — the same + // db `serve` opens at boot; pins the onboard-write/serve-read convergence. + let home_path = home.path().join("local-dev"); + let stored = crate::runtime::block_on_cli(async move { + let store = ironclaw_reborn_composition::open_local_dev_secret_store(&home_path) + .await + .map_err(anyhow::Error::from)?; + ironclaw_reborn_composition::LlmKeyStore::new(store) + .read("openai") + .await + .map_err(anyhow::Error::from) + }) + .expect("read back through a fresh open of the same root"); + assert_eq!( + secrecy::ExposeSecret::expose_secret(&stored.expect("a value must have been stored")), + "sk-good", + "the SECOND (reprompted) key must be the one stored, not the first rejected one" + ); + assert_eq!( + probe.calls(), + vec![ + RecordedProbeCall { + provider_id: "openai".to_string(), + api_key: Some("sk-bad".to_string()), + model: Some("gpt-5-mini".to_string()), + }, + RecordedProbeCall { + provider_id: "openai".to_string(), + api_key: Some("sk-good".to_string()), + model: Some("gpt-5-mini".to_string()), + }, + ], + "each probe call must carry the SELECTED provider/model and the candidate key \ + actually being tried, not stale values" + ); + } + + /// Three consecutive probe failures, each declined via "store anyway? no", + /// must exhaust `MAX_PROBE_ATTEMPTS` and error out, leaving `config.toml` + /// untouched — not loop forever or give up after a different count. + #[test] + fn provision_llm_credentials_probe_failure_three_times_errors_without_writing() { + let _env_guard = crate::runtime::test_env::lock_runtime_env(); + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + seed_cached_master_key(home); + + let mut prompts = ScriptedKeyPromptSource { + provider: "openai", + keys: std::collections::VecDeque::from(["sk-1", "sk-2", "sk-3"]), + confirms: std::collections::VecDeque::from([false, false, false]), + model: None, + }; + let probe = ScriptedProbe::new(vec![ + probe_outcome(false, vec![], "invalid api key"), + probe_outcome(false, vec![], "invalid api key"), + probe_outcome(false, vec![], "invalid api key"), + ]); + let error = provision_llm_credentials( + home, + context.boot_config(), + &mut prompts, + &EncryptedLlmKeyStoreOpener, + &probe, + false, + ) + .expect_err("three failed probe attempts, all declined, must error"); + assert!(matches!(error, LlmCredentialPromptError::Other(_))); + assert!( + !home.config_file_path().exists(), + "an exhausted probe-reprompt loop must leave config.toml untouched" + ); + assert_eq!( + probe + .calls() + .into_iter() + .map(|call| call.api_key) + .collect::>(), + vec![ + Some("sk-1".to_string()), + Some("sk-2".to_string()), + Some("sk-3".to_string()) + ], + "each of the three attempts must probe its own freshly-entered key" + ); + } + + /// A single probe failure followed by an accepted "store anyway?" must + /// store the key as entered without further reprompt — offline/ + /// unreachable-endpoint onboarding stays possible. + #[test] + fn provision_llm_credentials_probe_failure_confirm_yes_stores_anyway() { + let _env_guard = crate::runtime::test_env::lock_runtime_env(); + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + seed_cached_master_key(home); + + let mut prompts = ScriptedKeyPromptSource { + provider: "openai", + keys: std::collections::VecDeque::from(["sk-offline"]), + confirms: std::collections::VecDeque::from([true]), + model: None, + }; + let probe = ScriptedProbe::new(vec![probe_outcome( + false, + vec![], + "could not reach openai with these settings", + )]); + let outcome = provision_llm_credentials( + home, + context.boot_config(), + &mut prompts, + &EncryptedLlmKeyStoreOpener, + &probe, + false, + ) + .expect("a confirmed store-anyway must succeed"); + assert_eq!( + outcome, + LlmCredentialProvisionOutcome::Configured { + provider_id: "openai".to_string(), + model: "gpt-5-mini".to_string(), + } + ); + + // Verify through the RUNTIME storage root (`/local-dev`) — the same + // db `serve` opens at boot; pins the onboard-write/serve-read convergence. + let home_path = home.path().join("local-dev"); + let stored = crate::runtime::block_on_cli(async move { + let store = ironclaw_reborn_composition::open_local_dev_secret_store(&home_path) + .await + .map_err(anyhow::Error::from)?; + ironclaw_reborn_composition::LlmKeyStore::new(store) + .read("openai") + .await + .map_err(anyhow::Error::from) + }) + .expect("read back through a fresh open of the same root"); + assert_eq!( + secrecy::ExposeSecret::expose_secret(&stored.expect("a value must have been stored")), + "sk-offline" + ); + assert_eq!( + probe.calls(), + vec![RecordedProbeCall { + provider_id: "openai".to_string(), + api_key: Some("sk-offline".to_string()), + model: Some("gpt-5-mini".to_string()), + }], + "the single probe attempt must carry the entered key and selected model" + ); + } + + /// A successful probe whose model list doesn't contain the chosen model + /// must still write the key/config — an incomplete provider model list + /// is a warning, never an error. + #[test] + fn provision_llm_credentials_probe_ok_model_not_in_list_still_writes() { + let _env_guard = crate::runtime::test_env::lock_runtime_env(); + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + seed_cached_master_key(home); + + let mut prompts = FakePromptSource { + provider: "openai", + key: "sk-test-value", + model: Some("not-a-real-model"), + }; + let probe = ScriptedProbe::new(vec![probe_outcome(true, vec!["gpt-5-mini", "gpt-5"], "")]); + let outcome = provision_llm_credentials( + home, + context.boot_config(), + &mut prompts, + &EncryptedLlmKeyStoreOpener, + &probe, + false, + ) + .expect("an unlisted model must warn, not fail"); + assert_eq!( + outcome, + LlmCredentialProvisionOutcome::Configured { + provider_id: "openai".to_string(), + model: "not-a-real-model".to_string(), + } + ); + assert_eq!( + probe.calls(), + vec![RecordedProbeCall { + provider_id: "openai".to_string(), + api_key: Some("sk-test-value".to_string()), + model: Some("not-a-real-model".to_string()), + }], + "the probe must be called with the operator's chosen (unlisted) model, not the \ + catalog default" + ); + } +} diff --git a/crates/ironclaw_reborn_cli/src/commands/onboard/master_key.rs b/crates/ironclaw_reborn_cli/src/commands/onboard/master_key.rs new file mode 100644 index 00000000000..576bfa2ba35 --- /dev/null +++ b/crates/ironclaw_reborn_cli/src/commands/onboard/master_key.rs @@ -0,0 +1,92 @@ +//! Onboarding's OS-keychain local-dev secrets master-key provisioning step. + +use ironclaw_reborn_config::RebornBootConfig; + +/// Outcome of onboarding's OS-keychain master-key provisioning attempt. +/// +/// - Status enum, not an error type: every variant is a successful `execute()`. +/// - `Suppressed` is expected/normal (headless CI via `IRONCLAW_DISABLE_OS_KEYCHAIN`, +/// or the OS denies the prompt) — the resolver +/// (`ironclaw_reborn_composition::factory::resolve_local_dev_secret_master_key_with_env`) +/// still falls back to dotfile auto-generation, so this must never fail onboarding. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MasterKeyProvisionOutcome { + /// A cached `.reborn-local-dev-secrets-master-key` dotfile already + /// exists under this Reborn home; nothing to provision. + DotfileAlreadyPresent, + /// The OS keychain already has a master key from a prior onboarding run. + KeychainAlreadyPresent, + /// A fresh key was generated and stored in the OS keychain. + Provisioned, + /// Keychain unavailable (test/CI suppression or OS denial). Resolver + /// falls through to `SECRETS_MASTER_KEY` env var, then dotfile + /// auto-generation on first boot. + Suppressed, +} + +impl MasterKeyProvisionOutcome { + pub(crate) fn display_line(self) -> &'static str { + match self { + Self::DotfileAlreadyPresent => "cached dotfile already present", + Self::KeychainAlreadyPresent => "already provisioned in OS keychain", + Self::Provisioned => "provisioned in OS keychain", + Self::Suppressed => "OS keychain unavailable; falling back to env/dotfile", + } + } +} + +/// Provisions a local-dev master key in the OS keychain if absent (no cached +/// dotfile, no keychain key); no-op if either already exists. Never fails +/// `execute()` — an unavailable/denied keychain reports +/// [`MasterKeyProvisionOutcome::Suppressed`], matching the resolver's own +/// env/dotfile fallback (`crates/ironclaw_reborn_composition/src/factory.rs`). +/// +/// Accepted risk (TOCTOU): the `dotfile_path.exists()` check below and the +/// keychain's own internal `has_master_key()` check +/// (`provision_local_dev_keychain_master_key`) are two separate +/// check-then-act steps with no lock between them, so two concurrent +/// `onboard` runs against the same home could both observe "absent" and +/// both provision. This is accepted for LocalDev: onboarding is a +/// single-operator, run-once-by-hand flow (never invoked concurrently by +/// `serve`, which only reads keys, never writes the keychain), so the +/// realistic worst case is a wrongly-regenerated key from running `onboard` +/// twice at once by hand — recoverable by re-entering one API key. +#[cfg(any(feature = "libsql", feature = "postgres"))] +pub(crate) fn provision_master_key( + boot: &RebornBootConfig, +) -> anyhow::Result { + // Must match the root `resolve_local_dev_secret_master_key_with_env` + // actually reads/writes (`/local-dev/…`, not the bare home) — see + // `crate::runtime::local_runtime_storage_root`. Checking the bare home + // here always misses the cached dotfile, so onboarding would + // re-attempt keychain provisioning on every rerun (PR #6174 item D). + let dotfile_path = crate::runtime::local_runtime_storage_root(boot, boot.profile()) + .join(ironclaw_reborn_composition::LOCAL_DEV_SECRETS_MASTER_KEY_PATH); + if dotfile_path.exists() { + return Ok(MasterKeyProvisionOutcome::DotfileAlreadyPresent); + } + + crate::runtime::block_on_cli(async move { + let outcome = ironclaw_reborn_composition::provision_local_dev_keychain_master_key().await; + Ok::<_, anyhow::Error>(match outcome { + ironclaw_reborn_composition::KeychainMasterKeyOutcome::AlreadyPresent => { + MasterKeyProvisionOutcome::KeychainAlreadyPresent + } + ironclaw_reborn_composition::KeychainMasterKeyOutcome::Provisioned => { + MasterKeyProvisionOutcome::Provisioned + } + ironclaw_reborn_composition::KeychainMasterKeyOutcome::Suppressed => { + MasterKeyProvisionOutcome::Suppressed + } + }) + }) +} + +/// No storage backend, no secret store: the resolver lives behind the same +/// `libsql`/`postgres` feature gate in `ironclaw_reborn_composition`. +#[cfg(not(any(feature = "libsql", feature = "postgres")))] +pub(crate) fn provision_master_key( + _boot: &RebornBootConfig, +) -> anyhow::Result { + Ok(MasterKeyProvisionOutcome::Suppressed) +} diff --git a/crates/ironclaw_reborn_cli/src/commands/onboard/mod.rs b/crates/ironclaw_reborn_cli/src/commands/onboard/mod.rs new file mode 100644 index 00000000000..d82a3ce5281 --- /dev/null +++ b/crates/ironclaw_reborn_cli/src/commands/onboard/mod.rs @@ -0,0 +1,426 @@ +use std::path::{Path, PathBuf}; + +use clap::Args; +use ironclaw_reborn_config::RebornHome; + +use crate::commands::config::init::{ExistingConfigPolicy, write_default_config_files}; +use crate::context::RebornCliContext; +use crate::file_write::{FileWriteAction, write_atomic}; + +mod llm_credentials; +mod master_key; +mod prompts; + +use llm_credentials::{ + EncryptedLlmKeyStoreOpener, LiveLlmProbe, LlmCredentialProvisionOutcome, + provision_llm_credentials, +}; +use master_key::{MasterKeyProvisionOutcome, provision_master_key}; +#[cfg(feature = "webui-v2-beta")] +use prompts::PromptSource; +use prompts::{LlmCredentialPromptError, StdinPromptSource}; + +const ONBOARDING_MARKER_FILE: &str = ".onboard-completed.json"; + +/// Initialize the standalone Reborn home and first-run setup marker. +#[derive(Debug, Args)] +pub(crate) struct OnboardCommand { + /// Overwrite generated config.toml, providers.json, and the completion marker. + #[arg(long = "force")] + force: bool, + + /// Show what would be initialized without writing files. + #[arg(long = "dry-run")] + dry_run: bool, + + /// Reserve the history-import step in the onboarding summary. + /// + /// History import is not wired in this slice; the flag makes the missing + /// step explicit without touching v1 setup/import state. + #[arg(long = "import-history")] + import_history: bool, + + /// Skip installing/starting the OS service (launchd/systemd) at the end + /// of onboarding. Always effectively on in a non-interactive session + /// (headless CI, a piped/scripted invocation) regardless of this flag — + /// see `execute()`'s service step. + #[cfg(feature = "webui-v2-beta")] + #[arg(long = "no-service")] + no_service: bool, +} + +impl OnboardCommand { + pub(crate) fn execute(self, context: RebornCliContext) -> anyhow::Result<()> { + let home = context.boot_config().home(); + let marker_path = onboarding_marker_path(home); + + if self.dry_run { + print_dry_run(home, &marker_path, self.force, self.import_history)?; + return Ok(()); + } + + let outcome = write_default_config_files(home, self.force, ExistingConfigPolicy::Preserve)?; + // Independent of `--force`: a valid existing token is never regenerated + // (see `ensure_webui_token_file`), so repeated `onboard --force` can't + // invalidate sessions or an operator-copied env var. + let webui_token_action = crate::webui_token::ensure_webui_token_file(home.path())?; + let master_key_outcome = provision_master_key(context.boot_config())?; + let mut prompts = StdinPromptSource; + let llm_outcome = match provision_llm_credentials( + home, + context.boot_config(), + &mut prompts, + &EncryptedLlmKeyStoreOpener, + &LiveLlmProbe, + self.force, + ) { + Ok(outcome) => outcome, + // Non-interactive session (headless CI, piped/scripted) is expected — + // mirrors `MasterKeyProvisionOutcome::Suppressed`; `models set-provider` + // remains the non-interactive path to configure a provider. + Err(LlmCredentialPromptError::NonInteractive) => { + LlmCredentialProvisionOutcome::SkippedNonInteractive + } + Err(LlmCredentialPromptError::Other(error)) => return Err(error), + }; + // Computed after `llm_outcome` so `steps_pending` reflects what actually + // happened this run, not an unconditional `llm_credentials` pending. + let llm_configured = matches!( + llm_outcome, + LlmCredentialProvisionOutcome::Configured { .. } + | LlmCredentialProvisionOutcome::AlreadyConfigured { .. } + | LlmCredentialProvisionOutcome::ConfiguredFromEnv { .. } + ); + let marker_action = write_onboarding_marker( + home, + &marker_path, + self.force, + self.import_history, + llm_configured, + )?; + + println!("IronClaw Reborn onboarding"); + println!("reborn_home: {}", home.path().display()); + println!("home_source: {}", home.source_label()); + println!("{}", outcome.config.display_line()); + println!("{}", outcome.providers.display_line()); + println!( + "webui_token: {} ({})", + crate::webui_token::webui_token_file_path(home.path()).display(), + webui_token_action + ); + println!( + "onboarding_marker: {} ({})", + marker_path.display(), + marker_action + ); + println!("master_key: {}", master_key_outcome.display_line()); + if let MasterKeyProvisionOutcome::Suppressed = master_key_outcome { + println!( + "master_key_note: OS keychain unavailable; set SECRETS_MASTER_KEY yourself or \ + let the first `serve`/`onboard` run auto-generate and cache \ + .reborn-local-dev-secrets-master-key in the Reborn home" + ); + } + println!("llm_credentials: {}", llm_outcome.display_line()); + println!("v1_state: not-used"); + println!(); + println!("completed:"); + println!("- reborn home initialized"); + println!("- config.toml and providers.json available"); + println!("- webui bearer token provisioned (used by `serve` when the env var is unset)"); + println!("- onboarding completion marker available"); + if let LlmCredentialProvisionOutcome::Configured { provider_id, .. } + | LlmCredentialProvisionOutcome::AlreadyConfigured { provider_id, .. } = &llm_outcome + { + println!("- LLM provider `{provider_id}` credentials stored"); + } + if let LlmCredentialProvisionOutcome::ConfiguredFromEnv { provider_id, .. } = &llm_outcome { + println!( + "- LLM provider `{provider_id}` configured from environment (key also saved to \ + the encrypted secret store so the background service can use it)" + ); + } + println!(); + println!("remaining:"); + if llm_configured { + println!("- none for LLM credentials (configured above)"); + } else { + println!( + "- configure LLM credentials: rerun `ironclaw-reborn onboard` from an \ + interactive terminal, run \ + `ironclaw-reborn models set-provider --model ` directly, or \ + export a provider's LLM environment variables (e.g. `LLM_BACKEND` or \ + `OPENAI_API_KEY`; see `.env.example`) before the next `onboard`/`serve`" + ); + } + if self.import_history { + println!("- history import requested but not wired yet"); + } else { + println!("- history import not requested"); + } + + #[cfg(feature = "webui-v2-beta")] + self.finish_with_service_and_login_link(&context, home, prompts.is_interactive())?; + + Ok(()) + } + + /// Onboarding's last two steps, gated behind `webui-v2-beta` (both depend + /// on `serve`/`service`): + /// - install-and-start the OS service (skippable, see [`Self::should_install_service`]) + /// - print the CLI-token login link, reusing the `webui-token` value + /// `ensure_webui_token_file` already provisioned above + /// + /// `interactive` is passed down from the same [`PromptSource::is_interactive`] + /// reading the LLM-credential prompt step made, so `should_install_service` + /// doesn't need its own `IsTerminal` check. + #[cfg(feature = "webui-v2-beta")] + fn finish_with_service_and_login_link( + &self, + context: &RebornCliContext, + home: &RebornHome, + interactive: bool, + ) -> anyhow::Result<()> { + let service_outcome = if self.should_install_service(interactive) { + match crate::commands::service::install_and_start(context) { + Ok(()) => ServiceStartOutcome::InstalledAndStarted, + Err(error) => ServiceStartOutcome::Failed(error.to_string()), + } + } else if self.no_service { + ServiceStartOutcome::SkippedFlag + } else { + ServiceStartOutcome::SkippedNonInteractive + }; + println!("service: {}", service_outcome.display_line()); + if let ServiceStartOutcome::Failed(reason) = &service_outcome { + println!( + "service_note: install/start failed ({reason}); run `ironclaw-reborn service \ + install` and `ironclaw-reborn service start` manually" + ); + } + + // `.ok().flatten()`, not `?`: only needed to read `[webui].env_token_var` + // for the login-link-vs-note decision below. `serve` remains the + // authority that fails closed on real config errors at boot; mirrors + // `status`'s own resolver swallowing the same load failure. + // silent-ok: a config.toml that fails to parse (or predates this + // repo's schema) must not abort an otherwise-successful onboarding + // run; falling back to the default env var name is a fine + // degradation for this purely informational courtesy. + let config_file = ironclaw_reborn_config::RebornConfigFile::load(&home.config_file_path()) + .ok() + .flatten(); + match crate::webui_token::resolve_login_link_announcement(home, config_file.as_ref())? { + crate::webui_token::LoginLinkAnnouncement::Link(login_link) => { + println!("login_link: {login_link}"); + } + crate::webui_token::LoginLinkAnnouncement::EnvTokenActive { env_var_name } => { + println!( + "login_note: {env_var_name} is set; `serve` authenticates with that env \ + token directly (no login link — the CLI-token login route only mounts for \ + a file-sourced token)" + ); + } + crate::webui_token::LoginLinkAnnouncement::Unavailable => {} + } + println!("hint: add Gmail or Slack any time: ironclaw-reborn config set --help"); + Ok(()) + } + + /// `true` when onboarding should install/start the OS service: + /// `--no-service` unset AND session is interactive. Non-interactive + /// (headless CI, piped/scripted) must never attempt a launchd/systemd + /// install regardless of the flag — mirrors the LLM-credential prompt's + /// non-interactive short-circuit. + /// + /// `interactive` comes from the same [`PromptSource::is_interactive`] + /// reading used to gate the LLM-credential prompts (`prompts::StdinPromptSource` + /// is the sole `IsTerminal` check in this command) rather than re-deriving it here. + #[cfg(feature = "webui-v2-beta")] + fn should_install_service(&self, interactive: bool) -> bool { + !self.no_service && interactive + } +} + +/// Outcome of onboard's OS-service install/start finale. Even `Failed` is a +/// successful `execute()` (exit 0) — reported via `service_note`, but a +/// service-manager hiccup must not fail an otherwise-successful onboarding run. +#[cfg(feature = "webui-v2-beta")] +#[derive(Debug, Clone, PartialEq, Eq)] +enum ServiceStartOutcome { + InstalledAndStarted, + SkippedFlag, + SkippedNonInteractive, + Failed(String), +} + +#[cfg(feature = "webui-v2-beta")] +impl ServiceStartOutcome { + fn display_line(&self) -> String { + match self { + Self::InstalledAndStarted => "installed and started".to_string(), + Self::SkippedFlag => "skipped (--no-service)".to_string(), + Self::SkippedNonInteractive => "skipped (non-interactive session)".to_string(), + Self::Failed(reason) => format!("failed: {reason}"), + } + } +} + +pub(crate) fn onboarding_marker_path(home: &RebornHome) -> PathBuf { + home.path().join(ONBOARDING_MARKER_FILE) +} + +fn print_dry_run( + home: &RebornHome, + marker_path: &Path, + force: bool, + import_history: bool, +) -> anyhow::Result<()> { + println!("IronClaw Reborn onboarding dry run"); + println!("reborn_home: {}", home.path().display()); + println!("home_source: {}", home.source_label()); + println!("would_ensure: {}", home.path().display()); + println!( + "would_write_or_preserve: {}", + home.config_file_path().display() + ); + println!( + "would_write_or_preserve: {}", + home.providers_file_path().display() + ); + // Propagates rather than defaulting to "would_write" on I/O error: an + // unreadable-but-present token file must error, not be silently promised + // an overwrite that wouldn't happen the same way on a real run. + let webui_token_action = if crate::webui_token::webui_token_file_is_valid(home.path())? { + "would_preserve" + } else { + "would_write" + }; + println!( + "{webui_token_action}: {}", + crate::webui_token::webui_token_file_path(home.path()).display() + ); + let marker_action = if marker_path.exists() && !force { + "would_preserve" + } else { + "would_write" + }; + println!("{marker_action}: {}", marker_path.display()); + println!("import_history_requested: {import_history}"); + println!("v1_state: not-used"); + Ok(()) +} + +fn write_onboarding_marker( + home: &RebornHome, + marker_path: &Path, + force: bool, + import_history: bool, + llm_configured: bool, +) -> anyhow::Result { + if marker_path.exists() && !force { + return Ok(FileWriteAction::Preserved); + } + let body = serde_json::to_string_pretty(&serde_json::json!({ + "schema_version": "ironclaw.reborn.onboarding/v1", + "completed_at": chrono::Utc::now().to_rfc3339(), + "reborn_home": home.path(), + "home_source": home.source_label(), + "config_file": home.config_file_path(), + "providers_file": home.providers_file_path(), + "webui_token_file": crate::webui_token::webui_token_file_path(home.path()), + "steps_completed": [ + "reborn_home", + "config_files", + "webui_token", + "completion_marker" + ], + "steps_pending": pending_steps(import_history, llm_configured), + "v1_state": "not-used" + }))?; + write_atomic( + marker_path, + &format!("{body}\n"), + force, + ONBOARDING_MARKER_FILE, + ) +} + +/// `llm_credentials` is only reported pending when this run did NOT +/// configure it — an interactive `provision_llm_credentials` success means +/// there is nothing left to do for that step, so the marker must not +/// unconditionally claim it is still outstanding. +fn pending_steps(import_history: bool, llm_configured: bool) -> Vec<&'static str> { + let mut steps = Vec::new(); + if !llm_configured { + steps.push("llm_credentials"); + } + steps.push("model_selection"); + steps.push("channel_setup"); + if import_history { + steps.push("history_import"); + } + steps +} + +#[cfg(all(test, feature = "libsql", feature = "root-llm-provider"))] +mod tests { + use super::*; + + /// The marker's `steps_pending` must only list `llm_credentials` when + /// this run did NOT actually configure it. + #[test] + fn pending_steps_omits_llm_credentials_once_configured() { + assert_eq!( + pending_steps(false, true), + vec!["model_selection", "channel_setup"], + "llm_credentials must not be reported pending once this run configured it" + ); + assert_eq!( + pending_steps(false, false), + vec!["llm_credentials", "model_selection", "channel_setup"], + "llm_credentials must still be reported pending when this run did not configure it" + ); + assert_eq!( + pending_steps(true, true), + vec!["model_selection", "channel_setup", "history_import"], + "import_history must still append history_import regardless of llm_configured" + ); + } + + /// Same guarantee as `pending_steps_omits_llm_credentials_once_configured`, + /// but driven through `write_onboarding_marker` (the production caller) — + /// parses the actual `.onboard-completed.json` `steps_pending` field + /// rather than calling `pending_steps` directly. + #[test] + fn write_onboarding_marker_steps_pending_reflects_llm_configured() { + let (_tmp, context) = crate::context::RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + + let marker_path = home.path().join("configured.json"); + write_onboarding_marker(home, &marker_path, false, false, true) + .expect("write marker (llm configured)"); + let marker: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&marker_path).expect("read marker")) + .expect("marker must be valid JSON"); + assert_eq!( + marker["steps_pending"], + serde_json::json!(["model_selection", "channel_setup"]), + "steps_pending must omit llm_credentials once configured: {marker}" + ); + + let marker_path = home.path().join("unconfigured.json"); + write_onboarding_marker(home, &marker_path, false, false, false) + .expect("write marker (llm not configured)"); + let marker: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&marker_path).expect("read marker")) + .expect("marker must be valid JSON"); + assert_eq!( + marker["steps_pending"], + serde_json::json!(["llm_credentials", "model_selection", "channel_setup"]), + "steps_pending must include llm_credentials when not configured: {marker}" + ); + } +} diff --git a/crates/ironclaw_reborn_cli/src/commands/onboard/prompts.rs b/crates/ironclaw_reborn_cli/src/commands/onboard/prompts.rs new file mode 100644 index 00000000000..8fdd998defa --- /dev/null +++ b/crates/ironclaw_reborn_cli/src/commands/onboard/prompts.rs @@ -0,0 +1,763 @@ +//! Onboarding's LLM-credential prompt seam: where onboard's prompts +//! (provider menu, API key, model) come from, and the production +//! terminal-backed implementation. +//! +//! Injected (`PromptSource`) so `provision_llm_credentials` is testable with +//! a fixed answer sequence, and so [`StdinPromptSource`] is the *only* place +//! that decides "is this session interactive" — matches the +//! injected-lookup convention `resolve_google_oauth_config` already +//! established, and the "only `main.rs` may exit" rule: this trait's methods +//! return [`LlmCredentialPromptError::NonInteractive`] rather than calling +//! `process::exit`. + +use std::io::{IsTerminal, Write as _}; + +/// Where onboarding's LLM-credential prompts (provider menu, API key, +/// model) come from, plus whether this session can prompt at all +/// ([`Self::is_interactive`]) — the single seam `provision_llm_credentials`'s +/// idempotent-rerun guard and `OnboardCommand::should_install_service` both +/// route through, so terminal detection lives in exactly one place. +pub(crate) trait PromptSource { + /// `true` when this session can prompt at all (a real terminal is + /// attached). Checked once up front so a non-interactive session skips + /// both the LLM-credential prompts and the OS-service install without + /// either one independently re-deriving "is this interactive". + fn is_interactive(&self) -> bool; + + /// Prompt for the LLM provider via a numbered menu built from `entries` + /// (`RebornProviderAdmin::menu_entries`'s output — `nearai` is entry 0 + /// in `providers.json`, so it is always menu item 1). Accepts a menu + /// number, an exact provider id, or an alias (case-insensitive); + /// invalid input re-prompts up to 3 attempts, then errors. Returns the + /// selected entry's canonical provider id. + /// + /// Gated with the same `libsql`+`root-llm-provider` cfg as + /// `ironclaw_reborn_composition::ProviderMenuEntry` itself, matching + /// `provision_llm_credentials`'s own cfg split (see that function's + /// feature-off stub, which never calls this method). + #[cfg(all(feature = "libsql", feature = "root-llm-provider"))] + fn provider_menu( + &mut self, + entries: &[ironclaw_reborn_composition::ProviderMenuEntry], + ) -> Result; + + /// Prompt for `provider`'s API key with input masked (not echoed). + fn api_key(&mut self, provider: &str) -> Result; + + /// Ask a yes/no `question`, defaulting to yes on a blank answer (`[Y/n]` + /// framing). Used by onboard's env-detect-and-confirm step: "Found + /// `` configured in environment — use it?" + fn confirm(&mut self, question: &str) -> Result; + + /// Prompt for a model override for `provider_id`. `default_model` is + /// shown as the bracketed default; an empty/whitespace-only answer + /// means "use the catalog default" (`Ok(None)`), any other answer is + /// returned trimmed (`Ok(Some(..))`). + /// + /// Gated the same as [`Self::provider_menu`] — this trait's two + /// composition-DTO-touching methods share one cfg reason. + #[cfg(all(feature = "libsql", feature = "root-llm-provider"))] + fn model( + &mut self, + provider_id: &str, + default_model: &str, + ) -> Result, LlmCredentialPromptError>; +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum LlmCredentialPromptError { + /// stdin is not a terminal (headless CI, a piped/scripted invocation). + /// Callers should treat this as "skip, don't fail" — see + /// `OnboardCommand::execute`'s handling next to + /// `MasterKeyProvisionOutcome::Suppressed`, the same non-fatal shape for + /// an unavailable interactive input. + #[error( + "onboarding LLM credential prompts require an interactive terminal; run \ + `ironclaw-reborn models set-provider ` and set the provider's API key env \ + var instead, or rerun `onboard` from an interactive shell" + )] + NonInteractive, + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +/// Production [`PromptSource`]: reads the menu selection and model as plain +/// lines, the API key with terminal echo suppressed. The *only* place in +/// this module that checks [`IsTerminal`] or touches the real terminal — +/// everything else goes through the trait, matching the "only `main.rs` may +/// exit" convention (this impl never calls `process::exit`; it returns +/// [`LlmCredentialPromptError::NonInteractive`] and lets the caller decide). +pub(crate) struct StdinPromptSource; + +impl PromptSource for StdinPromptSource { + fn is_interactive(&self) -> bool { + // Both streams matter: a redirected/piped stdout must not receive + // the masked `*` characters `api_key`'s raw-mode read writes as the + // operator types, even when stdin itself is a real terminal (e.g. + // `ironclaw-reborn onboard > log.txt` in an interactive shell). + std::io::stdin().is_terminal() && std::io::stdout().is_terminal() + } + + #[cfg(all(feature = "libsql", feature = "root-llm-provider"))] + fn provider_menu( + &mut self, + entries: &[ironclaw_reborn_composition::ProviderMenuEntry], + ) -> Result { + if !std::io::stdin().is_terminal() { + return Err(LlmCredentialPromptError::NonInteractive); + } + let mut typed_seed: Option = None; + if terminal_supports_arrow_menu() { + match run_arrow_menu(entries) { + Ok(ArrowMenuOutcome::Selected(provider_id)) => return Ok(provider_id), + Ok(ArrowMenuOutcome::Cancelled) => { + return Err(LlmCredentialPromptError::Other(anyhow::anyhow!( + "onboarding cancelled at provider selection" + ))); + } + Ok(ArrowMenuOutcome::FallBackTyped(seed)) => { + // Simplest robust hand-off (see this module's doc): drop + // straight to the plain numbered-list + line-read prompt + // below, seeded with the keystroke that triggered the + // hand-off so it isn't silently dropped (e.g. typing + // "openai" must not land "penai"). + typed_seed = seed; + } + Err(error) => { + tracing::debug!( + %error, + "arrow-key provider menu unavailable mid-flight; falling back to the \ + numbered list" + ); + } + } + } + provider_menu_typed(entries, typed_seed) + } + + fn api_key(&mut self, provider: &str) -> Result { + if !std::io::stdin().is_terminal() { + return Err(LlmCredentialPromptError::NonInteractive); + } + // Re-prompt on a blank/whitespace-only answer rather than persisting + // an empty key — a mis-timed Enter or accidental paste-then-clear + // must never end up stored as `llm_provider__api_key`, silently + // leaving the provider "configured" with a key that will fail every + // request. + const MAX_ATTEMPTS: u8 = 3; + for attempt in 1..=MAX_ATTEMPTS { + print!("{provider} API key (input hidden): "); + std::io::stdout() + .flush() + .map_err(|error| LlmCredentialPromptError::Other(error.into()))?; + let key = read_masked_line() + .map_err(|error| LlmCredentialPromptError::Other(error.into()))?; + println!(); + if !key.trim().is_empty() { + return Ok(key); + } + if attempt < MAX_ATTEMPTS { + println!("API key must not be blank; please try again."); + } + } + Err(LlmCredentialPromptError::Other(anyhow::anyhow!( + "no non-blank API key entered after {MAX_ATTEMPTS} attempts" + ))) + } + + #[cfg(all(feature = "libsql", feature = "root-llm-provider"))] + fn model( + &mut self, + provider_id: &str, + default_model: &str, + ) -> Result, LlmCredentialPromptError> { + if !std::io::stdin().is_terminal() { + return Err(LlmCredentialPromptError::NonInteractive); + } + print!("{provider_id} model [{default_model}]: "); + std::io::stdout() + .flush() + .map_err(|error| LlmCredentialPromptError::Other(error.into()))?; + let mut input = String::new(); + std::io::stdin() + .read_line(&mut input) + .map_err(|error| LlmCredentialPromptError::Other(error.into()))?; + let trimmed = input.trim(); + Ok(if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + }) + } + + fn confirm(&mut self, question: &str) -> Result { + if !std::io::stdin().is_terminal() { + return Err(LlmCredentialPromptError::NonInteractive); + } + print!("{question} [Y/n]: "); + std::io::stdout() + .flush() + .map_err(|error| LlmCredentialPromptError::Other(error.into()))?; + let mut input = String::new(); + std::io::stdin() + .read_line(&mut input) + .map_err(|error| LlmCredentialPromptError::Other(error.into()))?; + let trimmed = input.trim(); + Ok(trimmed.is_empty() + || trimmed.eq_ignore_ascii_case("y") + || trimmed.eq_ignore_ascii_case("yes")) + } +} + +/// Resolve one line of menu input against `entries`: a 1-based menu number, +/// an exact provider id, or an alias — all case-insensitive for the id/alias +/// forms. Returns the selected entry's canonical provider id, or `None` when +/// nothing matches. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +fn resolve_menu_selection( + entries: &[ironclaw_reborn_composition::ProviderMenuEntry], + input: &str, +) -> Option { + if let Ok(number) = input.parse::() { + if number >= 1 && number <= entries.len() { + return Some(entries[number - 1].id.clone()); + } + return None; + } + entries + .iter() + .find(|entry| { + entry.id.eq_ignore_ascii_case(input) + || entry + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(input)) + }) + .map(|entry| entry.id.clone()) +} + +/// The bracketed note shown after a menu entry's description: nothing for a +/// required-key provider, `" (no API key needed)"` otherwise. Shared by both +/// menu renderers ([`provider_menu_typed`] and [`render_menu`]) so the two +/// can never drift on this text. +/// - `nearai`'s `api_key_required` is `true` here (menu-level override) even +/// though the raw catalog marks it optional — no session-token auth wired, +/// so it's required-key like every other entry and gets no special note. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +fn menu_entry_key_note(entry: &ironclaw_reborn_composition::ProviderMenuEntry) -> &'static str { + if entry.api_key_required { + "" + } else { + " (no API key needed)" + } +} + +/// Plain numbered-list + line-read provider prompt, extracted so it can serve +/// two roles: (1) the fallback `StdinPromptSource::provider_menu` uses +/// whenever the interactive arrow-key menu can't run (raw mode failed, or +/// `TERM=dumb`), and (2) the hand-off target when an operator starts typing +/// during arrow mode instead of Up/Down (see [`ArrowMenuOutcome::FallBackTyped`]). +/// `seed`, when present, is the keystroke that triggered a hand-off from the +/// arrow-key menu (see [`ArrowMenuOutcome::FallBackTyped`]) — raw mode +/// already consumed that character as a `crossterm` event, so it never +/// reaches `read_line`'s buffer on its own; [`seed_typed_input`] prepends it +/// back onto the first attempt's line so the operator's keystroke isn't +/// dropped (e.g. typing "openai" must not land "penai"). Only the first +/// attempt is seeded — later re-prompts (on invalid input) read a plain line. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +fn provider_menu_typed( + entries: &[ironclaw_reborn_composition::ProviderMenuEntry], + seed: Option, +) -> Result { + println!("Select an LLM provider:"); + for (index, entry) in entries.iter().enumerate() { + let key_note = menu_entry_key_note(entry); + println!( + " {}. {} — {}{key_note}", + index + 1, + entry.display_name, + entry.description + ); + } + const MAX_ATTEMPTS: u8 = 3; + let mut seed = seed; + for attempt in 1..=MAX_ATTEMPTS { + print!("Provider [1-{}]: ", entries.len()); + let this_seed = seed.take(); + if let Some(c) = this_seed { + print!("{c}"); + } + std::io::stdout() + .flush() + .map_err(|error| LlmCredentialPromptError::Other(error.into()))?; + let mut rest = String::new(); + std::io::stdin() + .read_line(&mut rest) + .map_err(|error| LlmCredentialPromptError::Other(error.into()))?; + let input = seed_typed_input(this_seed, &rest); + let trimmed = input.trim(); + if let Some(provider_id) = resolve_menu_selection(entries, trimmed) { + return Ok(provider_id); + } + if attempt < MAX_ATTEMPTS { + println!( + "Unrecognized provider `{trimmed}`; enter a number, provider id, or alias from \ + the list above." + ); + } + } + Err(LlmCredentialPromptError::Other(anyhow::anyhow!( + "no valid provider selected after {MAX_ATTEMPTS} attempts" + ))) +} + +/// Prepend `seed` (the triggering keystroke, if any) onto `rest` (the line +/// `read_line` captured after it) — pure so the "does the seed survive" +/// behavior is unit-testable without a real terminal. See +/// [`provider_menu_typed`]'s doc for why the seed exists. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +fn seed_typed_input(seed: Option, rest: &str) -> String { + match seed { + Some(c) => { + let mut input = String::with_capacity(rest.len() + c.len_utf8()); + input.push(c); + input.push_str(rest); + input + } + None => rest.to_string(), + } +} + +/// `true` when the terminal looks capable of the interactive arrow-key menu: +/// stdin and stdout must both be real terminals (arrow menu writes cursor +/// escapes to stdout), and `TERM` must not be `dumb`. Cheap pre-check only — +/// [`run_arrow_menu`] can still fail if `enable_raw_mode()` errors later, +/// which [`PromptSource::provider_menu`] treats the same: fall back to +/// [`provider_menu_typed`]. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +fn terminal_supports_arrow_menu() -> bool { + if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { + return false; + } + !std::env::var("TERM").is_ok_and(|term| term.eq_ignore_ascii_case("dumb")) +} + +/// RAII guard for `crossterm::terminal::enable_raw_mode()`: disables raw mode +/// in [`Drop`] so every exit path out of [`run_arrow_menu`] (normal return, +/// early `?`, or panic) leaves the terminal in cooked mode. +/// - [`read_masked_line`] instead pairs enable/disable manually since it has +/// one exit point; `run_arrow_menu` has several, so a guard avoids +/// repeating the pairing at each one. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +struct RawModeGuard; + +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +impl RawModeGuard { + fn enable() -> std::io::Result { + crossterm::terminal::enable_raw_mode()?; + Ok(Self) + } +} + +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +impl Drop for RawModeGuard { + fn drop(&mut self) { + let _ = crossterm::terminal::disable_raw_mode(); + } +} + +/// Classified key input for the interactive provider menu — the terminal +/// loop ([`run_arrow_menu`]) maps a raw `crossterm::event::KeyEvent` down to +/// one of these before handing it to the pure reducer, [`apply_menu_key`]. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MenuKey { + Up, + Down, + Enter, + /// Esc, or Ctrl-C. + Cancel, + /// Any other key press (digit, letter, punctuation, …) — the signal to + /// hand off to the typed-line fallback. Carries the pressed character + /// when the key was a `Char`, so it can be seeded back into the typed + /// prompt instead of silently dropped. + Other(Option), +} + +/// One outcome of applying a single [`MenuKey`] to the currently highlighted +/// index — pure and terminal-free so it's unit-tested directly (see this +/// module's `tests`) rather than only indirectly through [`run_arrow_menu`]'s +/// real terminal loop. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MenuStep { + /// Up/Down: the new highlighted index (wraps at both ends — Up from + /// index 0 wraps to the last entry, Down from the last entry wraps to + /// 0), so an operator can reach any entry either direction without + /// hitting a dead stop. + Move(usize), + /// Enter: the highlighted index was chosen. + Select(usize), + Cancel, + /// Carries the triggering keystroke, if any — see [`MenuKey::Other`]. + FallBackTyped(Option), +} + +/// Pure key-event → selection-state reducer for the interactive provider +/// menu. `highlighted` and `len` (`entries.len()`, always `>= 1` — onboard +/// never calls `provider_menu` with an empty menu) come from the caller; +/// this function has no terminal or process-global state of its own. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +fn apply_menu_key(highlighted: usize, len: usize, key: MenuKey) -> MenuStep { + debug_assert!(len > 0, "apply_menu_key requires a non-empty menu"); + match key { + MenuKey::Up => MenuStep::Move((highlighted + len - 1) % len), + MenuKey::Down => MenuStep::Move((highlighted + 1) % len), + MenuKey::Enter => MenuStep::Select(highlighted), + MenuKey::Cancel => MenuStep::Cancel, + MenuKey::Other(seed) => MenuStep::FallBackTyped(seed), + } +} + +/// Outcome of [`run_arrow_menu`]'s interactive loop. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +enum ArrowMenuOutcome { + Selected(String), + Cancelled, + /// Carries the triggering keystroke, if any — see [`MenuKey::Other`]. + FallBackTyped(Option), +} + +/// Drive the interactive Up/Down/Enter provider menu in raw mode. Returns +/// `Ok` for every key-driven outcome (selection, cancellation, or a typed +/// hand-off); an `Err` means raw mode or a terminal I/O call itself failed +/// mid-flight, which the caller ([`StdinPromptSource::provider_menu`]) +/// treats the same as [`terminal_supports_arrow_menu`] returning `false` +/// up front: fall back to [`provider_menu_typed`]. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +fn run_arrow_menu( + entries: &[ironclaw_reborn_composition::ProviderMenuEntry], +) -> std::io::Result { + use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + use crossterm::{cursor, execute, style::Print}; + + let _raw_mode = RawModeGuard::enable()?; + drain_pending_events(); + + let mut stdout = std::io::stdout(); + let mut highlighted = 0usize; + render_menu(&mut stdout, entries, highlighted, false)?; + + loop { + let Event::Key(KeyEvent { + code, + modifiers, + kind: KeyEventKind::Press, + .. + }) = event::read()? + else { + continue; + }; + let key = match code { + KeyCode::Up => MenuKey::Up, + KeyCode::Down => MenuKey::Down, + KeyCode::Enter => MenuKey::Enter, + KeyCode::Esc => MenuKey::Cancel, + KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => MenuKey::Cancel, + KeyCode::Char(c) => MenuKey::Other(Some(c)), + _ => MenuKey::Other(None), + }; + match apply_menu_key(highlighted, entries.len(), key) { + MenuStep::Move(next) => { + highlighted = next; + render_menu(&mut stdout, entries, highlighted, true)?; + } + MenuStep::Select(index) => { + execute!(stdout, cursor::MoveToNextLine(1))?; + return Ok(ArrowMenuOutcome::Selected(entries[index].id.clone())); + } + MenuStep::Cancel => { + execute!(stdout, cursor::MoveToNextLine(1))?; + return Ok(ArrowMenuOutcome::Cancelled); + } + MenuStep::FallBackTyped(seed) => { + execute!( + stdout, + cursor::MoveToNextLine(1), + Print("switching to typed entry\r\n") + )?; + return Ok(ArrowMenuOutcome::FallBackTyped(seed)); + } + } + } +} + +/// Render (or, when `redraw` is `true`, re-render in place) the numbered +/// provider list with `highlighted` marked by a leading `>`. +/// - Header line prints once on the first draw, never redrawn; each `redraw` +/// moves the cursor up exactly `entries.len()` lines (entries only, header +/// stays put) and clears downward before reprinting — updates in place +/// rather than scrolling. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +fn render_menu( + stdout: &mut std::io::Stdout, + entries: &[ironclaw_reborn_composition::ProviderMenuEntry], + highlighted: usize, + redraw: bool, +) -> std::io::Result<()> { + use crossterm::{cursor, execute, style::Print, terminal}; + + if redraw { + execute!( + stdout, + cursor::MoveUp(entries.len() as u16), + terminal::Clear(terminal::ClearType::FromCursorDown) + )?; + } else { + execute!( + stdout, + Print( + "Select an LLM provider (Up/Down + Enter; Esc to cancel; or type a number, id, \ + or alias):\r\n" + ) + )?; + } + for (index, entry) in entries.iter().enumerate() { + let marker = if index == highlighted { ">" } else { " " }; + let key_note = menu_entry_key_note(entry); + execute!( + stdout, + Print(format!( + "{marker} {}. {} — {}{key_note}\r\n", + index + 1, + entry.display_name, + entry.description + )) + )?; + } + stdout.flush() +} + +/// Read one line with terminal echo suppressed, showing `*` per character. +/// +/// Ported from v1's `src/setup/prompts.rs` (`secret_input`/`read_secret_line`), +/// per this repo's "porting = copy, never depend" convention. Its leading +/// `drain_pending_events()` discards keystrokes buffered before raw mode was +/// entered (e.g. a stray Enter from the preceding plain-line prompt) so they +/// can't be replayed into the masked read. +fn read_masked_line() -> std::io::Result { + use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + use crossterm::{execute, style::Print, terminal}; + + let mut input = String::new(); + terminal::enable_raw_mode()?; + let result = (|| -> std::io::Result<()> { + drain_pending_events(); + loop { + if let Event::Key(KeyEvent { + code, + modifiers, + kind: KeyEventKind::Press, + .. + }) = event::read()? + { + match code { + KeyCode::Enter => break, + KeyCode::Backspace if !input.is_empty() => { + input.pop(); + execute!(std::io::stdout(), Print("\x08 \x08"))?; + std::io::stdout().flush()?; + } + KeyCode::Backspace => {} + KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { + return Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "Ctrl-C", + )); + } + KeyCode::Char(c) => { + input.push(c); + execute!(std::io::stdout(), Print('*'))?; + std::io::stdout().flush()?; + } + _ => {} + } + } + } + Ok(()) + })(); + terminal::disable_raw_mode()?; + result?; + Ok(input) +} + +/// Discard any terminal input events buffered before raw mode was entered, +/// so a stray keystroke (typically a leftover Enter from a preceding +/// plain-line prompt) can never be replayed into the masked read that +/// follows. Ported from v1's `src/setup/prompts.rs::drain_pending_events`. +fn drain_pending_events() { + use crossterm::event; + while event::poll(std::time::Duration::ZERO).unwrap_or(false) { + let _ = event::read(); + } +} + +#[cfg(all(test, feature = "libsql", feature = "root-llm-provider"))] +mod tests { + use ironclaw_reborn_composition::ProviderMenuEntry; + + use super::*; + + fn entries() -> Vec { + vec![ + ProviderMenuEntry { + id: "nearai".to_string(), + display_name: "NEAR AI".to_string(), + // Menu-level override (see `RebornProviderAdmin::menu_entries`'s + // doc): reborn has no session-token auth wired, so nearai is + // required-key on the onboard menu even though the raw + // catalog entry marks it optional. + api_key_required: true, + description: "multi-model access via NEAR account".to_string(), + aliases: vec!["near_ai".to_string(), "near".to_string()], + }, + ProviderMenuEntry { + id: "openai".to_string(), + display_name: "OpenAI".to_string(), + api_key_required: true, + description: "OpenAI GPT models (direct API)".to_string(), + aliases: vec!["open_ai".to_string()], + }, + ] + } + + /// (i) Selection by 1-based menu number. + #[test] + fn resolve_menu_selection_by_number() { + let entries = entries(); + assert_eq!( + resolve_menu_selection(&entries, "1"), + Some("nearai".to_string()) + ); + assert_eq!( + resolve_menu_selection(&entries, "2"), + Some("openai".to_string()) + ); + } + + /// (i) Selection by exact provider id, case-insensitively. + #[test] + fn resolve_menu_selection_by_id() { + let entries = entries(); + assert_eq!( + resolve_menu_selection(&entries, "openai"), + Some("openai".to_string()) + ); + assert_eq!( + resolve_menu_selection(&entries, "OpenAI"), + Some("openai".to_string()) + ); + } + + /// (i) Selection by alias, case-insensitively. + #[test] + fn resolve_menu_selection_by_alias() { + let entries = entries(); + assert_eq!( + resolve_menu_selection(&entries, "open_ai"), + Some("openai".to_string()) + ); + assert_eq!( + resolve_menu_selection(&entries, "NEAR"), + Some("nearai".to_string()) + ); + } + + /// (ii)/(v) Garbage input, an out-of-range number, and a menu-excluded + /// provider id (not present in `entries`, e.g. `bedrock`) all fail to + /// resolve — the caller (`provision_llm_credentials`) is responsible + /// for the retry-then-error behavior; this pins the pure matching logic + /// underneath it. + #[test] + fn resolve_menu_selection_rejects_unknown_input() { + let entries = entries(); + assert_eq!(resolve_menu_selection(&entries, "0"), None); + assert_eq!(resolve_menu_selection(&entries, "99"), None); + assert_eq!(resolve_menu_selection(&entries, "garbage"), None); + assert_eq!(resolve_menu_selection(&entries, "bedrock"), None); + } + + /// Down from the last entry wraps to the first, and Up from the first + /// wraps to the last — an operator can reach any entry from any + /// starting point going either direction, never hitting a dead stop at + /// either end of the list. + #[test] + fn apply_menu_key_up_down_wrap_at_both_ends() { + assert_eq!(apply_menu_key(0, 3, MenuKey::Down), MenuStep::Move(1)); + assert_eq!(apply_menu_key(1, 3, MenuKey::Down), MenuStep::Move(2)); + assert_eq!(apply_menu_key(2, 3, MenuKey::Down), MenuStep::Move(0)); + assert_eq!(apply_menu_key(0, 3, MenuKey::Up), MenuStep::Move(2)); + assert_eq!(apply_menu_key(2, 3, MenuKey::Up), MenuStep::Move(1)); + } + + /// A single-entry menu's Up/Down both stay put (wrap onto themselves) — + /// the `0 + 1 - 1 = 0` / `0 + 1 = 1 % 1 = 0` arithmetic must not panic + /// or index out of range at the degenerate `len == 1` case. + #[test] + fn apply_menu_key_single_entry_menu_stays_put() { + assert_eq!(apply_menu_key(0, 1, MenuKey::Up), MenuStep::Move(0)); + assert_eq!(apply_menu_key(0, 1, MenuKey::Down), MenuStep::Move(0)); + } + + /// Enter selects whichever index is currently highlighted. + #[test] + fn apply_menu_key_enter_selects_highlighted_index() { + assert_eq!(apply_menu_key(0, 3, MenuKey::Enter), MenuStep::Select(0)); + assert_eq!(apply_menu_key(2, 3, MenuKey::Enter), MenuStep::Select(2)); + } + + /// Cancel (Esc or Ctrl-C, already classified into `MenuKey::Cancel` by + /// the terminal loop) always cancels regardless of the highlighted + /// index. + #[test] + fn apply_menu_key_cancel_is_position_independent() { + assert_eq!(apply_menu_key(0, 3, MenuKey::Cancel), MenuStep::Cancel); + assert_eq!(apply_menu_key(2, 3, MenuKey::Cancel), MenuStep::Cancel); + } + + /// Any other key press hands off to the typed-line fallback rather than + /// being silently ignored or erroring, carrying the pressed character + /// through so [`provider_menu_typed`] can seed it back in rather than + /// dropping it (see [`seed_typed_input`]). + #[test] + fn apply_menu_key_other_falls_back_to_typed_entry_carrying_the_keystroke() { + assert_eq!( + apply_menu_key(1, 3, MenuKey::Other(Some('o'))), + MenuStep::FallBackTyped(Some('o')) + ); + assert_eq!( + apply_menu_key(1, 3, MenuKey::Other(None)), + MenuStep::FallBackTyped(None) + ); + } + + /// Pins the actual bug this seeding exists for: an operator typing + /// "openai" while the arrow menu is active must resolve to the full + /// provider id, not "penai" (the triggering keystroke dropped). + #[test] + fn seed_typed_input_prepends_the_triggering_keystroke_so_typed_ids_resolve_in_full() { + let combined = seed_typed_input(Some('o'), "penai\n"); + assert_eq!(combined.trim(), "openai"); + assert_eq!( + resolve_menu_selection(&entries(), combined.trim()), + Some("openai".to_string()) + ); + } + + /// No seed (the plain non-arrow-menu fallback path) must pass the read + /// line through unchanged. + #[test] + fn seed_typed_input_with_no_seed_passes_the_line_through_unchanged() { + assert_eq!(seed_typed_input(None, "openai\n"), "openai\n"); + } +} diff --git a/crates/ironclaw_reborn_cli/src/commands/serve.rs b/crates/ironclaw_reborn_cli/src/commands/serve.rs index 0972bf036f9..3d73ad5b1b2 100644 --- a/crates/ironclaw_reborn_cli/src/commands/serve.rs +++ b/crates/ironclaw_reborn_cli/src/commands/serve.rs @@ -45,9 +45,11 @@ use crate::runtime::{ resolve_google_oauth_config_from_env, }; -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"; +// pub(crate): reused by onboard's finale login-link print (same default host:port). +pub(crate) const DEFAULT_SERVE_HOST: &str = "127.0.0.1"; +pub(crate) const DEFAULT_SERVE_PORT: u16 = 3000; +// pub(crate): reused by onboard/status for `env_token_is_active` (webui_token.rs). +pub(crate) const DEFAULT_ENV_TOKEN_VAR: &str = "IRONCLAW_REBORN_WEBUI_TOKEN"; const DEFAULT_ENV_USER_ID_VAR: &str = "IRONCLAW_REBORN_WEBUI_USER_ID"; /// 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. @@ -63,7 +65,10 @@ const ADMIN_API_TOKEN_LIFETIME_DAYS: i64 = 365; /// credential instead of failing loudly. Only `NotPresent` means "treat /// as unset"; `NotUnicode` is a real configuration error and must /// propagate with context naming the variable. -fn present_unicode_env_var(name: &str) -> anyhow::Result> { +/// +/// pub(crate): shared with `webui_token::env_token_is_active` so both +/// checks (token source vs. login-link gating) never drift. +pub(crate) fn present_unicode_env_var(name: &str) -> anyhow::Result> { match env::var(name) { Ok(value) => Ok(Some(value)), Err(env::VarError::NotPresent) => Ok(None), @@ -85,11 +90,16 @@ struct SignedSessionTokenMinter { #[async_trait::async_trait] impl ironclaw_reborn_composition::AdminApiTokenMinter for SignedSessionTokenMinter { async fn mint(&self, tenant: &TenantId, user_id: &UserId) -> Result { + // `false`: this session is for the admin-created `user_id`, not the + // operator. Stamping `true` would let any admin-created user (even + // Member-role) bypass `require_operator_webui_config` — a distinct + // per-user RBAC axis from the single-box operator capability. self.session_store .create_session( tenant.clone(), user_id.clone(), chrono::Duration::days(ADMIN_API_TOKEN_LIFETIME_DAYS), + false, ) .await .map_err(|error| error.to_string()) @@ -178,18 +188,14 @@ impl ServeCommand { // `session_signing_secret` below) so a weak or missing token fails // closed here rather than starting the server and having it reject // the value opaquely. - let token_value = crate::webui_token::resolve_webui_token( + let resolved_token = crate::webui_token::resolve_webui_token( env_token_var, present_unicode_env_var(env_token_var)?.as_deref(), boot_config.home().path(), )?; - 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(), - ) - })?; + let webui_token_source = resolved_token.source; + let token_value = resolved_token.value; + let user_id_raw = resolve_webui_user_id_raw(env_user_id_var, config_file.as_ref())?; let user_id = UserId::new(&user_id_raw) .map_err(|err| anyhow!("{env_user_id_var} value `{user_id_raw}` is invalid: {err}"))?; @@ -247,6 +253,9 @@ impl ServeCommand { // mints tokens that validate under the login surface's own store. let admin_session_store = ironclaw_webui::signed_session_store(&session_signing_secret, &tenant_id); + // Cloned for the CLI-token-login mount, built later once `sso_enabled` + // is known — same operator secret + tenant, so it validates identically. + let cli_login_session_store = admin_session_store.clone(); runtime_input = runtime_input.with_admin_api_token_minter(Arc::new(SignedSessionTokenMinter { session_store: admin_session_store, @@ -609,6 +618,13 @@ impl ServeCommand { None }; + // Cloned before the moves below: the CLI-token-login mount (built + // after `build_webui_auth_surface`) needs its own tenant id and + // bearer authenticator, but the originals are moved into the + // auth-surface call immediately below. + let cli_login_tenant_id = tenant_id.clone(); + let cli_login_authenticator = Arc::clone(&env_authenticator); + // Assemble the WebChat v2 auth surface (authenticator + optional // public login mount). The auth/identity module owns the // signed-session wiring; `serve` supplies host config, the @@ -635,6 +651,30 @@ impl ServeCommand { ) .await?; + // CLI-token-login mount (`GET /login?token=`, printed by `onboard` + // at setup end) — only when SSO is off AND the token came from + // the FILE, not env: + // - `build_cli_token_login` mounts its own `POST + // /auth/session/exchange` unconditionally; mounting it while + // SSO is on would double-register that path and panic at + // router-merge time (no shared-ticket-store knob exists). + // - env-sourced tokens (e.g. Railway-style `IRONCLAW_REBORN_WEBUI_TOKEN`) + // must not appear in this route's query string, which flows + // through edge/proxy access logs. + let cli_login_mount = if sso_enabled + || webui_token_source != crate::webui_token::WebuiTokenSource::File + { + None + } else { + Some(ironclaw_webui::build_cli_token_login( + ironclaw_webui::CliTokenLoginConfig::new( + cli_login_tenant_id, + cli_login_authenticator, + cli_login_session_store, + ), + )) + }; + print_serve_banner( listen_addr, env_token_var, @@ -710,6 +750,9 @@ impl ServeCommand { if let Some(mount) = public_mount { serve_config = serve_config.with_public_route_mount(mount); } + if let Some(cli_login_mount) = cli_login_mount { + serve_config = serve_config.with_public_route_mount(cli_login_mount); + } let webui_app = webui_v2_app_with_lifecycle(bundle, serve_config) .context("failed to compose v2 Router")?; let (router, public_route_drains) = webui_app.into_parts(); @@ -1073,6 +1116,26 @@ fn resolve_webui_default_agent( .unwrap_or_else(|| runtime_identity.agent_id.clone()) } +/// Resolution: `env_user_id_var` (non-empty) → config `[identity].default_owner` +/// → `"reborn-cli"` (via `crate::runtime::default_owner_id`). +/// +/// A service-installed serve with only HOME/PROFILE in its unit env (no +/// per-operator var) must still boot bound to a stable identity rather than +/// hard-failing — see `resolve_webui_runtime_owner` below, same fallback. +/// +/// Uses `present_unicode_env_var` so a non-UTF-8 value for `env_user_id_var` +/// propagates as a startup error instead of being silently treated as +/// unset (the same `NotPresent`-vs-`NotUnicode` distinction documented on +/// `present_unicode_env_var`). +fn resolve_webui_user_id_raw( + env_user_id_var: &str, + config_file: Option<&RebornConfigFile>, +) -> anyhow::Result { + Ok(present_unicode_env_var(env_user_id_var)? + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| crate::runtime::default_owner_id(config_file).to_string())) +} + /// Resolve the owner the Reborn runtime must run under for the WebChat v2 /// serve path. /// @@ -1365,6 +1428,117 @@ mod tests { ); } + const WEBUI_USER_ID_TEST_ENV: &str = "IRONCLAW_REBORN_SERVE_TEST_USER_ID_RAW"; + + #[test] + fn webui_user_id_raw_prefers_a_set_nonempty_env_var() { + let _guard = crate::runtime::test_env::lock_runtime_env(); + // SAFETY: serialized by the shared crate process-env lock; cleaned up + // before the guard drops. + unsafe { std::env::set_var(WEBUI_USER_ID_TEST_ENV, "env-user") }; + + let config_file = RebornConfigFile { + identity: Some(IdentitySection::default().set_default_owner("config-user")), + ..Default::default() + }; + + assert_eq!( + resolve_webui_user_id_raw(WEBUI_USER_ID_TEST_ENV, Some(&config_file)) + .expect("valid unicode env value is not an error"), + "env-user" + ); + + // SAFETY: see above. + unsafe { std::env::remove_var(WEBUI_USER_ID_TEST_ENV) }; + } + + #[test] + fn webui_user_id_raw_falls_back_to_config_default_owner_when_env_absent() { + let _guard = crate::runtime::test_env::lock_runtime_env(); + // SAFETY: serialized by the shared crate process-env lock. + unsafe { std::env::remove_var(WEBUI_USER_ID_TEST_ENV) }; + + let config_file = RebornConfigFile { + identity: Some(IdentitySection::default().set_default_owner("config-user")), + ..Default::default() + }; + + assert_eq!( + resolve_webui_user_id_raw(WEBUI_USER_ID_TEST_ENV, Some(&config_file)) + .expect("absent env value is not an error"), + "config-user" + ); + } + + #[test] + fn webui_user_id_raw_treats_empty_env_var_as_absent() { + let _guard = crate::runtime::test_env::lock_runtime_env(); + // SAFETY: serialized by the shared crate process-env lock; cleaned up + // before the guard drops. + unsafe { std::env::set_var(WEBUI_USER_ID_TEST_ENV, "") }; + + let config_file = RebornConfigFile { + identity: Some(IdentitySection::default().set_default_owner("config-user")), + ..Default::default() + }; + + assert_eq!( + resolve_webui_user_id_raw(WEBUI_USER_ID_TEST_ENV, Some(&config_file)) + .expect("empty env value is not an error"), + "config-user" + ); + + // SAFETY: see above. + unsafe { std::env::remove_var(WEBUI_USER_ID_TEST_ENV) }; + } + + #[test] + fn webui_user_id_raw_defaults_to_reborn_cli_when_no_config_or_env() { + let _guard = crate::runtime::test_env::lock_runtime_env(); + // SAFETY: serialized by the shared crate process-env lock. + unsafe { std::env::remove_var(WEBUI_USER_ID_TEST_ENV) }; + + assert_eq!( + resolve_webui_user_id_raw(WEBUI_USER_ID_TEST_ENV, None) + .expect("no config or env is not an error"), + "reborn-cli" + ); + } + + #[cfg(unix)] + #[test] + fn webui_user_id_raw_propagates_not_unicode_instead_of_treating_it_as_unset() { + // Mirrors `present_unicode_env_var_propagates_not_unicode_instead_of_treating_it_as_unset`: + // before this fix, `resolve_webui_user_id_raw` read the env var with + // `env::var(..).ok()`, which collapsed `VarError::NotUnicode` (a real + // misconfiguration — the user-id env var got mangled into invalid + // UTF-8) into `None`, silently falling through to the config/default + // owner instead of failing loudly at startup. + use std::os::unix::ffi::OsStringExt as _; + + let _guard = crate::runtime::test_env::lock_runtime_env(); + let invalid_utf8 = std::ffi::OsString::from_vec(vec![0xFF, 0xFE, 0xFD]); + // SAFETY: serialized by `lock_runtime_env`; restored below. + unsafe { std::env::set_var(WEBUI_USER_ID_TEST_ENV, &invalid_utf8) }; + + let result = resolve_webui_user_id_raw(WEBUI_USER_ID_TEST_ENV, None); + + // SAFETY: serialized by `lock_runtime_env`. + unsafe { std::env::remove_var(WEBUI_USER_ID_TEST_ENV) }; + + let error = + result.expect_err("non-UTF-8 env value must be a real error, not a silent fallback"); + let message = error.to_string(); + assert!( + message.contains(WEBUI_USER_ID_TEST_ENV), + "error must name the variable: {message}" + ); + assert!( + message.contains("not valid UTF-8"), + "error must explain why: {message}" + ); + } + #[test] fn webui_runtime_owner_defaults_to_authenticated_user() { // With no `[identity].default_owner`, the runtime owner must be the diff --git a/crates/ironclaw_reborn_cli/src/commands/service/launchd.rs b/crates/ironclaw_reborn_cli/src/commands/service/launchd.rs index 7334e3d53a8..f4e16a3f221 100644 --- a/crates/ironclaw_reborn_cli/src/commands/service/launchd.rs +++ b/crates/ironclaw_reborn_cli/src/commands/service/launchd.rs @@ -24,7 +24,12 @@ fn xml_escape(raw: &str) -> String { // ── Plist generation ──────────────────────────────────────────── -fn plist_content(invocation: &ServeInvocation, stdout_log: &Path, stderr_log: &Path) -> String { +fn plist_content( + invocation: &ServeInvocation, + working_directory: &Path, + stdout_log: &Path, + stderr_log: &Path, +) -> String { let program_arguments: String = std::iter::once(invocation.exe.display().to_string()) .chain(invocation.args.iter().cloned()) .map(|value| format!(" {}", xml_escape(&value))) @@ -59,6 +64,8 @@ fn plist_content(invocation: &ServeInvocation, stdout_log: &Path, stderr_log: &P KeepAlive + WorkingDirectory + {working_directory} EnvironmentVariables {environment_variables} @@ -71,6 +78,7 @@ fn plist_content(invocation: &ServeInvocation, stdout_log: &Path, stderr_log: &P "#, label = SERVICE_LABEL, + working_directory = xml_escape(&working_directory.display().to_string()), stdout = xml_escape(&stdout_log.display().to_string()), stderr = xml_escape(&stderr_log.display().to_string()), ) @@ -195,7 +203,13 @@ pub(super) fn install_with_runner( // target the same label/path by design (see the module doc). Either // way the write below atomically replaces it. let replaced_existing = file.exists(); - let plist = plist_content(invocation, &stdout_log, &stderr_log); + // WorkingDirectory anchors cwd at `/workspace`, not + // launchd's default `/` and not the Reborn home itself — the home is + // an ancestor of every default skill root, so it still trips + // composition's `paths_overlap` check (see `service_working_directory`). + let reborn_home = context.boot_config().home().path(); + let working_directory = super::ensure_service_working_directory(reborn_home)?; + let plist = plist_content(invocation, &working_directory, &stdout_log, &stderr_log); super::write_atomic(&file, plist.as_bytes())?; if was_loaded { // A loaded job keeps running off its in-memory definition until @@ -338,9 +352,16 @@ pub(super) fn restart_with_runner(runner: &mut dyn ServiceCommandRunner) -> Resu ) } -pub(super) fn status_with_runner(runner: &mut dyn ServiceCommandRunner) -> Result<()> { - let plist = plist_path()?; - let file_exists = plist.exists(); +/// Installed/running state shared by [`status_with_runner`] and +/// [`current_state_with_runner`] so the two don't drift on how "installed" +/// and "running" are derived from `launchctl list`. +struct LaunchdStatusInfo { + installed: bool, + running: bool, +} + +fn resolve_status_info(runner: &mut dyn ServiceCommandRunner) -> Result { + let file_exists = plist_path()?.exists(); // Query launchctl unconditionally — a plist that was removed // out-of-band while the job is still loaded is an orphan we must // still report as installed, not silently claim "not installed". @@ -348,11 +369,33 @@ pub(super) fn status_with_runner(runner: &mut dyn ServiceCommandRunner) -> Resul runner.run_capture_checked("launchctl list", Command::new("launchctl").arg("list"))?; let running = service_running(&list); let installed = resolve_installed(file_exists, service_loaded(&list)); - println!("Service: {}", super::status_label(installed, running)); + Ok(LaunchdStatusInfo { installed, running }) +} + +pub(super) fn status_with_runner(runner: &mut dyn ServiceCommandRunner) -> Result<()> { + let plist = plist_path()?; + let info = resolve_status_info(runner)?; + println!( + "Service: {}", + super::status_label(info.installed, info.running) + ); println!("Unit: {}", plist.display()); Ok(()) } +/// Runner-injectable service-state query behind +/// [`super::ServicePlatform::current_state_with_runner`] — see that +/// method's doc. +pub(super) fn current_state_with_runner( + runner: &mut dyn ServiceCommandRunner, +) -> Result { + let info = resolve_status_info(runner)?; + Ok(super::ServiceState::from_installed_running( + info.installed, + info.running, + )) +} + pub(super) fn uninstall_with_runner(runner: &mut dyn ServiceCommandRunner) -> Result<()> { let file = plist_path()?; let list = @@ -672,6 +715,7 @@ mod tests { fn plist_content_includes_label() { let plist = plist_content( &sample_invocation(), + Path::new("/home/op/.ironclaw/reborn"), Path::new("/home/op/.ironclaw/reborn/logs/serve.stdout.log"), Path::new("/home/op/.ironclaw/reborn/logs/serve.stderr.log"), ); @@ -683,6 +727,7 @@ mod tests { fn plist_content_includes_program_arguments() { let plist = plist_content( &sample_invocation(), + Path::new("/home/op/.ironclaw/reborn"), Path::new("/tmp/o.log"), Path::new("/tmp/e.log"), ); @@ -694,6 +739,7 @@ mod tests { fn plist_content_includes_environment_variables() { let plist = plist_content( &sample_invocation(), + Path::new("/home/op/.ironclaw/reborn"), Path::new("/tmp/o.log"), Path::new("/tmp/e.log"), ); @@ -705,6 +751,7 @@ mod tests { fn plist_content_marks_run_at_load_and_keep_alive_true() { let plist = plist_content( &sample_invocation(), + Path::new("/home/op/.ironclaw/reborn"), Path::new("/tmp/o.log"), Path::new("/tmp/e.log"), ); @@ -718,6 +765,7 @@ mod tests { fn plist_content_includes_stdout_and_stderr_log_paths() { let plist = plist_content( &sample_invocation(), + Path::new("/home/op/.ironclaw/reborn"), Path::new("/home/op/.ironclaw/reborn/logs/serve.stdout.log"), Path::new("/home/op/.ironclaw/reborn/logs/serve.stderr.log"), ); @@ -725,6 +773,28 @@ mod tests { assert!(plist.contains("/home/op/.ironclaw/reborn/logs/serve.stderr.log")); } + /// Pins the crash-loop fix: without WorkingDirectory, launchd runs with + /// cwd=`/`, which overlaps a default skill root and composition refuses + /// to boot. `plist_content` just writes the caller-supplied path + /// faithfully — see `install_with_runner` / + /// `ensure_service_working_directory` for the actual path choice. + #[test] + fn plist_content_includes_working_directory_line() { + let plist = plist_content( + &sample_invocation(), + Path::new("/home/op/.ironclaw/reborn/workspace"), + Path::new("/home/op/.ironclaw/reborn/logs/serve.stdout.log"), + Path::new("/home/op/.ironclaw/reborn/logs/serve.stderr.log"), + ); + assert!(plist.contains("WorkingDirectory")); + assert!(plist.contains("/home/op/.ironclaw/reborn/workspace")); + // WorkingDirectory precedes EnvironmentVariables, matching the doc + // comment's placement. + let working_dir_index = plist.find("WorkingDirectory").unwrap(); + let env_vars_index = plist.find("EnvironmentVariables").unwrap(); + assert!(working_dir_index < env_vars_index); + } + #[test] fn plist_content_escapes_xml_reserved_chars_in_env_value() { let invocation = ServeInvocation { @@ -737,6 +807,7 @@ mod tests { }; let plist = plist_content( &invocation, + Path::new("/home/op/.ironclaw/reborn"), Path::new("/tmp/o.log"), Path::new("/tmp/e.log"), ); @@ -1364,4 +1435,84 @@ mod tests { } } } + + // ── current_state ─────────────────────────────────────────── + + #[test] + fn current_state_reports_not_installed_when_absent_everywhere() { + let _lock = crate::runtime::test_env::lock_runtime_env(); + let tmp = tempfile::tempdir().expect("tempdir"); + let prior = std::env::var_os("HOME"); + // SAFETY: serialized by `lock_runtime_env`; restored below. + unsafe { std::env::set_var("HOME", tmp.path()) }; + let mut runner = RecordingRunner::default(); + let state = current_state_with_runner(&mut runner); + // SAFETY: serialized by `lock_runtime_env`. + unsafe { + match prior { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + } + assert_eq!( + state.expect("current_state must succeed"), + super::super::ServiceState::NotInstalled + ); + } + + #[test] + fn current_state_reports_stopped_when_loaded_but_not_running() { + let _lock = crate::runtime::test_env::lock_runtime_env(); + let tmp = tempfile::tempdir().expect("tempdir"); + let prior = std::env::var_os("HOME"); + // SAFETY: serialized by `lock_runtime_env`; restored below. + unsafe { std::env::set_var("HOME", tmp.path()) }; + let file = plist_path().expect("plist path"); + std::fs::create_dir_all(file.parent().expect("plist parent")).expect("create parent"); + std::fs::write(&file, "plist").expect("write plist"); + let mut runner = RecordingRunner { + launchctl_list: format!("-\t0\t{SERVICE_LABEL}\n"), + ..RecordingRunner::default() + }; + let state = current_state_with_runner(&mut runner); + // SAFETY: serialized by `lock_runtime_env`. + unsafe { + match prior { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + } + assert_eq!( + state.expect("current_state must succeed"), + super::super::ServiceState::Stopped + ); + } + + #[test] + fn current_state_reports_running_when_pid_present() { + let _lock = crate::runtime::test_env::lock_runtime_env(); + let tmp = tempfile::tempdir().expect("tempdir"); + let prior = std::env::var_os("HOME"); + // SAFETY: serialized by `lock_runtime_env`; restored below. + unsafe { std::env::set_var("HOME", tmp.path()) }; + let file = plist_path().expect("plist path"); + std::fs::create_dir_all(file.parent().expect("plist parent")).expect("create parent"); + std::fs::write(&file, "plist").expect("write plist"); + let mut runner = RecordingRunner { + launchctl_list: format!("123\t0\t{SERVICE_LABEL}\n"), + ..RecordingRunner::default() + }; + let state = current_state_with_runner(&mut runner); + // SAFETY: serialized by `lock_runtime_env`. + unsafe { + match prior { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + } + assert_eq!( + state.expect("current_state must succeed"), + super::super::ServiceState::Running + ); + } } diff --git a/crates/ironclaw_reborn_cli/src/commands/service/mod.rs b/crates/ironclaw_reborn_cli/src/commands/service/mod.rs index 62a455e83b8..5c16b9d6537 100644 --- a/crates/ironclaw_reborn_cli/src/commands/service/mod.rs +++ b/crates/ironclaw_reborn_cli/src/commands/service/mod.rs @@ -115,14 +115,17 @@ impl ServiceCommand { /// The two supported service-management targets. Detected once per /// invocation via [`ServicePlatform::detect`]; every verb dispatches /// off the resolved variant instead of re-checking `cfg!(target_os)`. +/// +/// pub(super): `commands::status` queries [`Self::current_state`] to check +/// whether the installed service is actually running, not just present. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ServicePlatform { +pub(super) enum ServicePlatform { MacOs, Linux, } impl ServicePlatform { - fn detect() -> Result { + pub(super) fn detect() -> Result { if cfg!(target_os = "macos") { Ok(Self::MacOs) } else if cfg!(target_os = "linux") { @@ -161,7 +164,9 @@ impl ServicePlatform { Self::MacOs => { launchd::install_with_runner(context, &invocation, runner).map(|_replaced| ())? } - Self::Linux => systemd::install_with_runner(&invocation, runner).map(|_replaced| ())?, + Self::Linux => { + systemd::install_with_runner(context, &invocation, runner).map(|_replaced| ())? + } } Ok(warnings) } @@ -228,6 +233,32 @@ impl ServicePlatform { Self::Linux => systemd::uninstall_with_runner(runner), } } + + /// Production service-state query: real `launchctl`/`systemctl` via + /// [`OsServiceCommandRunner`]. Used by `commands::status` so `status` + /// reports the service as actually running, not just installed. + pub(super) fn current_state(&self) -> Result { + self.current_state_with_runner(&mut OsServiceCommandRunner) + } + + /// Runner-injectable variant of [`Self::current_state`] for tests. + pub(super) fn current_state_with_runner( + &self, + runner: &mut dyn ServiceCommandRunner, + ) -> Result { + match self { + Self::MacOs => launchd::current_state_with_runner(runner), + Self::Linux => systemd::current_state_with_runner(runner), + } + } +} + +/// Install then start the OS service in one call — used by `onboard`'s +/// finale so a fresh install ends with `serve` actually running. +pub(crate) fn install_and_start(context: &RebornCliContext) -> Result<()> { + let platform = ServicePlatform::detect()?; + platform.install(context)?; + platform.start() } // ── Path helpers ──────────────────────────────────────────────── @@ -246,6 +277,37 @@ fn home_dir() -> Result { Ok(path) } +/// The `serve` process's launched cwd, shared by both platforms' installers. +/// +/// - Not the Reborn home itself: composition's default skill/extension +/// roots live under `/...`, so the home is an *ancestor* of +/// them and trips `paths_overlap` (prefix match, not just equality). +/// - `/workspace` is a leaf dir, neither ancestor nor +/// descendant of any skill root, so it never overlaps. +fn service_working_directory(reborn_home: &Path) -> PathBuf { + reborn_home.join("workspace") +} + +/// Creates [`service_working_directory`] (0700, `create_dir_all` — a no-op +/// if it already exists) and returns its path. Called by both platforms' +/// `install_with_runner` before writing the unit/plist, so the directory +/// exists by the time `serve` is ever launched with it as cwd. +/// +/// 0700, not 0755: this is a single-user local service directory, not a +/// shared or world-readable path, so other local accounts must not be able +/// to list or read it. +fn ensure_service_working_directory(reborn_home: &Path) -> Result { + let dir = service_working_directory(reborn_home); + std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)) + .with_context(|| format!("set permissions on {}", dir.display()))?; + } + Ok(dir) +} + // ── Preflight ─────────────────────────────────────────────────── /// Non-fatal readiness warnings for `service install`: warn, don't @@ -340,15 +402,37 @@ fn restart_generic( // ── Status vocabulary ─────────────────────────────────────────── +/// Normalized service lifecycle state, shared by both platforms and by +/// `commands::status` (see [`ServicePlatform::current_state`]) so the +/// running/stopped/not-installed vocabulary can't drift between the +/// `service status` text output and the `status` command's `service` +/// field. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ServiceState { + NotInstalled, + Stopped, + Running, +} + +impl ServiceState { + fn from_installed_running(installed: bool, running: bool) -> Self { + if !installed { + Self::NotInstalled + } else if running { + Self::Running + } else { + Self::Stopped + } + } +} + /// Normalized `service status` line, shared by both platforms so the /// running/stopped/not-installed vocabulary can't drift between them. fn status_label(installed: bool, running: bool) -> &'static str { - if !installed { - "not installed" - } else if running { - "running" - } else { - "stopped" + match ServiceState::from_installed_running(installed, running) { + ServiceState::NotInstalled => "not installed", + ServiceState::Stopped => "stopped", + ServiceState::Running => "running", } } @@ -463,7 +547,11 @@ fn run_capture_checked(label: &str, command: &mut Command) -> Result { /// Production uses [`OsServiceCommandRunner`]. Tests provide a recorder so /// failure propagation and command ordering are verified without reaching the /// host's real launchd/systemd instance. -trait ServiceCommandRunner { +/// +/// `pub(super)`: `commands::status`'s tests implement this trait with their +/// own mock runner to drive [`ServicePlatform::current_state_with_runner`] +/// hermetically for all three [`ServiceState`] outcomes. +pub(super) trait ServiceCommandRunner { fn run_checked(&mut self, label: &str, command: &mut Command) -> Result<()>; fn run_capture_checked(&mut self, label: &str, command: &mut Command) -> Result; } @@ -785,10 +873,14 @@ mod tests { let _lock = crate::runtime::test_env::lock_runtime_env(); let tmp = tempfile::tempdir().expect("tempdir"); let _home = TempHomeGuard::set(tmp.path()); + let context = RebornCliContext::from_boot_config( + ironclaw_reborn_config::RebornBootConfig::resolve_from_env() + .expect("boot config must resolve under temp HOME"), + ); let invocation = serve_invocation().expect("serve invocation"); let mut runner = SuccessfulServiceCommandRunner::default(); - let fresh_replaced = - systemd::install_with_runner(&invocation, &mut runner).expect("install must succeed"); + let fresh_replaced = systemd::install_with_runner(&context, &invocation, &mut runner) + .expect("install must succeed"); let unit_path = tmp .path() .join(".config/systemd/user/ironclaw-reborn.service"); @@ -796,6 +888,31 @@ mod tests { let contents = std::fs::read_to_string(&unit_path).expect("read unit file"); assert!(contents.contains("ExecStart=")); assert!(contents.contains("IRONCLAW_REBORN_HOME=")); + let reborn_home = context.boot_config().home().path().to_path_buf(); + let expected_working_directory = reborn_home.join("workspace"); + assert!( + contents.contains(&format!( + "WorkingDirectory=\"{}\"", + expected_working_directory.display() + )), + "unit file must anchor cwd at /workspace, not the Reborn home itself \ + (the Reborn home is an ancestor of every default skill root, so cwd=reborn_home \ + still trips composition's overlap check — the crash-loop persisted after the first \ + attempt at this fix): {contents}" + ); + assert!( + expected_working_directory.is_dir(), + "install must create /workspace so `serve` has somewhere to cd into" + ); + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&expected_working_directory) + .expect("stat working directory") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o700, "working directory must be 0700, got {mode:o}"); + } assert!( !fresh_replaced, "a fresh install must not report a replaced unit" @@ -807,8 +924,8 @@ mod tests { // facade (`RebornLocalServiceLifecycle`), must now report that it // replaced an existing unit (this reinstall stands in for the // facade's unit being atomically replaced by the CLI's). - let reinstall_replaced = - systemd::install_with_runner(&invocation, &mut runner).expect("reinstall must succeed"); + let reinstall_replaced = systemd::install_with_runner(&context, &invocation, &mut runner) + .expect("reinstall must succeed"); assert!(unit_path.exists()); assert!( reinstall_replaced, @@ -843,6 +960,31 @@ mod tests { let contents = std::fs::read_to_string(&plist_path).expect("read plist file"); assert!(contents.contains(SERVICE_LABEL)); assert!(contents.contains("IRONCLAW_REBORN_HOME")); + let reborn_home = context.boot_config().home().path().to_path_buf(); + let expected_working_directory = reborn_home.join("workspace"); + assert!( + contents.contains(&format!( + "{}", + expected_working_directory.display() + )) && contents.contains("WorkingDirectory"), + "plist must anchor cwd at /workspace, not the Reborn home itself \ + (the Reborn home is an ancestor of every default skill root, so cwd=reborn_home \ + still trips composition's overlap check — the crash-loop persisted after the first \ + attempt at this fix): {contents}" + ); + assert!( + expected_working_directory.is_dir(), + "install must create /workspace so `serve` has somewhere to cd into" + ); + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&expected_working_directory) + .expect("stat working directory") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o700, "working directory must be 0700, got {mode:o}"); + } // Idempotent reinstall. ServicePlatform::MacOs diff --git a/crates/ironclaw_reborn_cli/src/commands/service/systemd.rs b/crates/ironclaw_reborn_cli/src/commands/service/systemd.rs index c50aeebe5e1..aef9a6374e8 100644 --- a/crates/ironclaw_reborn_cli/src/commands/service/systemd.rs +++ b/crates/ironclaw_reborn_cli/src/commands/service/systemd.rs @@ -6,6 +6,7 @@ use std::process::Command; use anyhow::{Context, Result, bail}; +use crate::context::RebornCliContext; use crate::serve_invocation::ServeInvocation; use super::{SYSTEMD_UNIT, ServiceCommandRunner, home_dir}; @@ -37,7 +38,7 @@ fn unit_quote(value: &str, escape_dollar: bool) -> Result { // ── Unit generation ───────────────────────────────────────────── -fn unit_content(invocation: &ServeInvocation) -> Result { +fn unit_content(invocation: &ServeInvocation, working_directory: &Path) -> Result { let environment_lines = invocation .env .iter() @@ -53,6 +54,12 @@ fn unit_content(invocation: &ServeInvocation) -> Result { .collect::>>()? .join(" "); + // WorkingDirectory anchors cwd at `/workspace`, not + // systemd's default and not the Reborn home itself — the home is an + // ancestor of every default skill root, so it still trips + // composition's `paths_overlap` check (see `service_working_directory`). + let working_directory = unit_quote(&working_directory.display().to_string(), false)?; + Ok(format!( "[Unit]\n\ Description=IronClaw Reborn daemon\n\ @@ -60,6 +67,7 @@ fn unit_content(invocation: &ServeInvocation) -> Result { \n\ [Service]\n\ Type=simple\n\ + WorkingDirectory={working_directory}\n\ {environment_lines}\ ExecStart={exec_start_args}\n\ Restart=always\n\ @@ -181,6 +189,7 @@ fn config_home() -> Result { /// [`super::ServicePlatform::install`], which discards the bool once the /// advisory line has been printed. pub(super) fn install_with_runner( + context: &RebornCliContext, invocation: &ServeInvocation, runner: &mut dyn ServiceCommandRunner, ) -> Result { @@ -199,7 +208,9 @@ pub(super) fn install_with_runner( // target the same unit name/path by design (see the module doc). The // write below atomically replaces it. let replaced_existing = previous.is_some(); - let unit = unit_content(invocation)?; + let reborn_home = context.boot_config().home().path(); + let working_directory = super::ensure_service_working_directory(reborn_home)?; + let unit = unit_content(invocation, &working_directory)?; let previous_state = query_unit_state(runner)?; super::write_atomic(&file, unit.as_bytes())?; if let Err(error) = runner.run_checked( @@ -375,9 +386,19 @@ fn resolve_installed(file_exists: bool, unit_state: SystemdUnitState) -> bool { file_exists || unit_state.loaded || unit_state.enabled } -pub(super) fn status_with_runner(runner: &mut dyn ServiceCommandRunner) -> Result<()> { - let file = unit_path()?; - let file_exists = file.exists(); +/// Installed/running state (plus the raw `ActiveState` detail) shared by +/// [`status_with_runner`] and [`current_state_with_runner`] so the two +/// don't drift on how "installed" and "running" are derived from +/// `systemctl show`. +struct SystemdStatusInfo { + file_exists: bool, + installed: bool, + running: bool, + active_state: String, +} + +fn resolve_status_info(runner: &mut dyn ServiceCommandRunner) -> Result { + let file_exists = unit_path()?.exists(); // Query the manager unconditionally — a unit file removed out-of-band // while systemd still has it loaded/enabled is an orphan we must // still report as installed, not silently claim "not installed". @@ -397,16 +418,30 @@ pub(super) fn status_with_runner(runner: &mut dyn ServiceCommandRunner) -> Resul )?; let running = active_state.trim() == "active"; let installed = resolve_installed(file_exists, unit_state); + Ok(SystemdStatusInfo { + file_exists, + installed, + running, + active_state, + }) +} + +pub(super) fn status_with_runner(runner: &mut dyn ServiceCommandRunner) -> Result<()> { + let file = unit_path()?; + let info = resolve_status_info(runner)?; // Detail line stays keyed off file presence: for a genuine orphan // (no unit file) the `Service: running/stopped` line already covers // it, and there's no installed-config context to attach the raw // ActiveState to. - let detail = if file_exists { - systemd_status_detail(&active_state) + let detail = if info.file_exists { + systemd_status_detail(&info.active_state) } else { None }; - println!("Service: {}", super::status_label(installed, running)); + println!( + "Service: {}", + super::status_label(info.installed, info.running) + ); if let Some(detail) = detail { println!("{detail}"); } @@ -414,6 +449,19 @@ pub(super) fn status_with_runner(runner: &mut dyn ServiceCommandRunner) -> Resul Ok(()) } +/// Runner-injectable service-state query behind +/// [`super::ServicePlatform::current_state_with_runner`] — see that +/// method's doc. +pub(super) fn current_state_with_runner( + runner: &mut dyn ServiceCommandRunner, +) -> Result { + let info = resolve_status_info(runner)?; + Ok(super::ServiceState::from_installed_running( + info.installed, + info.running, + )) +} + /// Shared uninstall rollback: restore the previous unit file (or remove /// it if there was none), reload the manager, and — if the unit was /// previously enabled — re-enable it. Used by both the `remove_file` @@ -659,6 +707,21 @@ mod tests { } } + fn sample_reborn_home() -> PathBuf { + PathBuf::from("/home/op/.ironclaw/reborn") + } + + /// Resolves a `RebornCliContext` from the currently-set `$HOME` (set by + /// [`TempHomeGuard::set`]) — used by `install_with_runner` call sites + /// below, which now need `context` to derive the unit's + /// WorkingDirectory. + fn sample_context() -> RebornCliContext { + RebornCliContext::from_boot_config( + ironclaw_reborn_config::RebornBootConfig::resolve_from_env() + .expect("boot config must resolve under temp HOME"), + ) + } + #[test] fn unit_quote_escapes_backslash_and_double_quote() { assert_eq!( @@ -682,31 +745,45 @@ mod tests { #[test] fn unit_content_includes_service_type() { - let unit = unit_content(&sample_invocation()).expect("valid unit"); + let unit = unit_content(&sample_invocation(), &sample_reborn_home()).expect("valid unit"); assert!(unit.contains("Type=simple")); } #[test] fn unit_content_includes_exec_start_tokens() { - let unit = unit_content(&sample_invocation()).expect("valid unit"); + let unit = unit_content(&sample_invocation(), &sample_reborn_home()).expect("valid unit"); assert!(unit.contains(r#""/usr/local/bin/ironclaw-reborn""#)); assert!(unit.contains(r#""serve""#)); } #[test] fn unit_content_includes_environment_line() { - let unit = unit_content(&sample_invocation()).expect("valid unit"); + let unit = unit_content(&sample_invocation(), &sample_reborn_home()).expect("valid unit"); assert!(unit.contains(r#"Environment="IRONCLAW_REBORN_HOME=/home/op/.ironclaw/reborn""#)); } #[test] fn unit_content_includes_restart_policy_and_install_target() { - let unit = unit_content(&sample_invocation()).expect("valid unit"); + let unit = unit_content(&sample_invocation(), &sample_reborn_home()).expect("valid unit"); assert!(unit.contains("Restart=always")); assert!(unit.contains("RestartSec=3")); assert!(unit.contains("WantedBy=default.target")); } + /// Pins the crash-loop fix: without WorkingDirectory, systemd's default + /// cwd overlaps a default skill root and composition refuses to boot. + /// `unit_content` just writes the caller-supplied path faithfully — see + /// `install_with_runner` / `ensure_service_working_directory` for the + /// actual path choice. + #[test] + fn unit_content_includes_working_directory_line() { + let unit = unit_content(&sample_invocation(), &sample_reborn_home()).expect("valid unit"); + assert!(unit.contains(r#"WorkingDirectory="/home/op/.ironclaw/reborn""#)); + let working_dir_index = unit.find("WorkingDirectory=").unwrap(); + let exec_start_index = unit.find("ExecStart=").unwrap(); + assert!(working_dir_index < exec_start_index); + } + #[test] fn unit_content_escapes_quotes_in_env_value() { let invocation = ServeInvocation { @@ -717,7 +794,7 @@ mod tests { r#"has"quote"#.to_string(), )], }; - let unit = unit_content(&invocation).expect("valid unit"); + let unit = unit_content(&invocation, &sample_reborn_home()).expect("valid unit"); assert!(unit.contains(r#"IRONCLAW_REBORN_PROFILE=has\"quote"#)); } @@ -731,7 +808,7 @@ mod tests { "safe\nExecStart=/bin/evil%h".to_string(), )], }; - let unit = unit_content(&invocation).expect("escaped unit"); + let unit = unit_content(&invocation, &sample_reborn_home()).expect("escaped unit"); assert!( unit.contains(r#"Environment="IRONCLAW_REBORN_PROFILE=safe\nExecStart=/bin/evil%%h""#) @@ -843,7 +920,7 @@ mod tests { fail_args: Some(vec!["--user", "daemon-reload"]), ..RecordingRunner::default() }; - let result = install_with_runner(&sample_invocation(), &mut runner); + let result = install_with_runner(&sample_context(), &sample_invocation(), &mut runner); assert!(result.is_err()); assert_eq!( @@ -865,7 +942,7 @@ mod tests { fail_nth_args: Some((vec!["--user", "enable", SYSTEMD_UNIT], 1)), ..RecordingRunner::default() }; - let result = install_with_runner(&sample_invocation(), &mut runner); + let result = install_with_runner(&sample_context(), &sample_invocation(), &mut runner); assert!(result.is_err()); assert_eq!( @@ -963,7 +1040,7 @@ mod tests { fail_nth_args: Some((vec!["--user", "enable", SYSTEMD_UNIT], 1)), ..RecordingRunner::default() }; - let result = install_with_runner(&sample_invocation(), &mut runner); + let result = install_with_runner(&sample_context(), &sample_invocation(), &mut runner); assert!(result.is_err()); assert_eq!( @@ -1002,7 +1079,7 @@ mod tests { fail_nth_args: Some((vec!["--user", "enable", SYSTEMD_UNIT], 1)), ..RecordingRunner::default() }; - let result = install_with_runner(&sample_invocation(), &mut runner); + let result = install_with_runner(&sample_context(), &sample_invocation(), &mut runner); assert!(result.is_err()); assert_eq!( @@ -1077,7 +1154,7 @@ mod tests { fail_nth_args: Some((vec!["--user", "daemon-reload"], 2)), ..RecordingRunner::default() }; - let error = install_with_runner(&sample_invocation(), &mut runner) + let error = install_with_runner(&sample_context(), &sample_invocation(), &mut runner) .expect_err("enable and compensating reload failure must surface"); // Top-level `Display` (`to_string()`) now only shows the rollback @@ -1110,7 +1187,7 @@ mod tests { fail_nth_args: Some((vec!["--user", "daemon-reload"], 2)), ..RecordingRunner::default() }; - let error = install_with_runner(&sample_invocation(), &mut runner) + let error = install_with_runner(&sample_context(), &sample_invocation(), &mut runner) .expect_err("enable and compensating reload failure must surface"); let source = error @@ -1500,4 +1577,50 @@ mod tests { ); } } + + // ── current_state ─────────────────────────────────────────── + + #[test] + fn current_state_reports_not_installed_when_absent_everywhere() { + let _lock = crate::runtime::test_env::lock_runtime_env(); + let tmp = tempfile::tempdir().expect("tempdir"); + let _home = TempHomeGuard::set(tmp.path()); + let mut runner = RecordingRunner::default(); + let state = current_state_with_runner(&mut runner).expect("current_state must succeed"); + assert_eq!(state, super::super::ServiceState::NotInstalled); + } + + #[test] + fn current_state_reports_stopped_when_installed_but_inactive() { + let _lock = crate::runtime::test_env::lock_runtime_env(); + let tmp = tempfile::tempdir().expect("tempdir"); + let _home = TempHomeGuard::set(tmp.path()); + let file = unit_path().expect("unit path"); + std::fs::create_dir_all(file.parent().expect("unit parent")).expect("create parent"); + std::fs::write(file, "unit").expect("write unit"); + let mut runner = RecordingRunner { + unit_state_output: Some("LoadState=loaded\nUnitFileState=enabled\n".to_string()), + active_state_output: Some("inactive\n".to_string()), + ..RecordingRunner::default() + }; + let state = current_state_with_runner(&mut runner).expect("current_state must succeed"); + assert_eq!(state, super::super::ServiceState::Stopped); + } + + #[test] + fn current_state_reports_running_when_active() { + let _lock = crate::runtime::test_env::lock_runtime_env(); + let tmp = tempfile::tempdir().expect("tempdir"); + let _home = TempHomeGuard::set(tmp.path()); + let file = unit_path().expect("unit path"); + std::fs::create_dir_all(file.parent().expect("unit parent")).expect("create parent"); + std::fs::write(file, "unit").expect("write unit"); + let mut runner = RecordingRunner { + unit_state_output: Some("LoadState=loaded\nUnitFileState=enabled\n".to_string()), + active_state_output: Some("active\n".to_string()), + ..RecordingRunner::default() + }; + let state = current_state_with_runner(&mut runner).expect("current_state must succeed"); + assert_eq!(state, super::super::ServiceState::Running); + } } diff --git a/crates/ironclaw_reborn_cli/src/commands/status.rs b/crates/ironclaw_reborn_cli/src/commands/status.rs index 631cf8720d0..7e2a7b32837 100644 --- a/crates/ironclaw_reborn_cli/src/commands/status.rs +++ b/crates/ironclaw_reborn_cli/src/commands/status.rs @@ -4,7 +4,7 @@ use ironclaw_reborn_composition::{ }; use crate::context::RebornCliContext; -use crate::dto::{ComponentStatus, DriversSnapshot, FilePresence, StatusDto}; +use crate::dto::{ComponentStatus, DriversSnapshot, FilePresence, ServiceStateDto, StatusDto}; use crate::render::{self, OutputMode, Renderable, terminal_safe_text}; use std::io::Write; @@ -28,9 +28,22 @@ impl StatusCommand { } fn build_status_dto(context: &RebornCliContext) -> anyhow::Result { + build_status_dto_with_service_state(context, resolve_service_state()) +} + +/// Same as [`build_status_dto`] but with the live service-state query +/// injectable (mirrors `commands::service`'s `*_with_runner` seam), so +/// tests don't depend on the test host's actual launchd/systemd state. +fn build_status_dto_with_service_state( + context: &RebornCliContext, + service: ServiceStateDto, +) -> anyhow::Result { let home = context.boot_config().home(); let profile = context.boot_config().profile(); let config_path = home.config_file_path(); + // Cloned before `config_path` moves into `FilePresence` below — + // `resolve_login_link_and_note` needs it to check `[webui].env_token_var`. + let config_path_for_webui_lookup = config_path.clone(); let providers_path = home.providers_file_path(); let snapshot = reborn_runtime_readiness_snapshot(); @@ -38,6 +51,9 @@ fn build_status_dto(context: &RebornCliContext) -> anyhow::Result { .into_iter() .map(|s| s.to_string()) .collect(); + let (login_link, login_note) = + resolve_login_link_and_note(home, &config_path_for_webui_lookup)?; + let (login_link, login_note) = apply_service_suppression(service, login_link, login_note); Ok(StatusDto { version: env!("CARGO_PKG_VERSION").to_string(), @@ -59,9 +75,102 @@ fn build_status_dto(context: &RebornCliContext) -> anyhow::Result { subagent_planned: convert_component_status(&snapshot.subagent_planned_driver), planned_default_profile: convert_component_status(&snapshot.planned_default_profile), }, + login_link, + login_note, + service, }) } +const SERVICE_NOT_RUNNING_LOGIN_NOTE: &str = "service is not running — start with \ + `ironclaw-reborn service restart`; link available once running"; + +/// Overrides `(login_link, login_note)` when the live service state is +/// *known* not-running (`Stopped`/`NotInstalled`) — no login link can be +/// live then, regardless of credential source. `Running`/`Unknown` +/// (detection failed or feature off) pass the original pair through. +fn apply_service_suppression( + service: ServiceStateDto, + login_link: Option, + login_note: Option, +) -> (Option, Option) { + match service { + ServiceStateDto::Stopped | ServiceStateDto::NotInstalled => { + (None, Some(SERVICE_NOT_RUNNING_LOGIN_NOTE.to_string())) + } + ServiceStateDto::Running | ServiceStateDto::Unknown => (login_link, login_note), + } +} + +/// Live OS-service state for the `service` DTO field — see +/// `StatusDto::service`'s doc. Detection failure (unsupported platform, a +/// broken `launchctl`/`systemctl` query) folds to `Unknown` rather than +/// failing `status`: this is diagnostic best-effort, not a hard +/// requirement. +#[cfg(feature = "webui-v2-beta")] +fn resolve_service_state() -> ServiceStateDto { + let state = crate::commands::service::ServicePlatform::detect() + .and_then(|platform| platform.current_state()); + match state { + Ok(crate::commands::service::ServiceState::Running) => ServiceStateDto::Running, + Ok(crate::commands::service::ServiceState::Stopped) => ServiceStateDto::Stopped, + Ok(crate::commands::service::ServiceState::NotInstalled) => ServiceStateDto::NotInstalled, + Err(error) => { + tracing::debug!(error = %error, "service state detection failed"); + ServiceStateDto::Unknown + } + } +} + +/// `commands::service` (and the OS-service concept it manages) is gated +/// behind `webui-v2-beta` — a build without it has no service to query. +#[cfg(not(feature = "webui-v2-beta"))] +fn resolve_service_state() -> ServiceStateDto { + ServiceStateDto::Unknown +} + +/// `status` reprints the CLI-token login link `onboard` originally printed +/// (a closed browser loses its `sessionStorage` session, so this is how to +/// get a fresh link without rerunning `onboard`). Reuses the shared +/// `webui_token::resolve_login_link_announcement` resolver rather than +/// re-deriving the host:port/token construction. +/// +/// - Returns `(login_link, login_note)`, mutually exclusive: file-sourced +/// token → `(Some(link), None)`; active env var → `(None, Some(note))` +/// (the file-token link's route isn't mounted for an env-sourced token, +/// see `commands::serve::execute`'s `cli_login_mount`); neither → `(None, None)`. +/// - Propagates an error when the token env var is set but not valid +/// UTF-8 — see `webui_token::env_token_is_active` — rather than silently +/// treating it as inactive, which would let `status` disagree with `serve` +/// about which credential source is live. +#[cfg(feature = "webui-v2-beta")] +fn resolve_login_link_and_note( + home: &ironclaw_reborn_config::RebornHome, + config_path: &std::path::Path, +) -> anyhow::Result<(Option, Option)> { + let config_file = ironclaw_reborn_config::RebornConfigFile::load(config_path)?; + Ok( + match crate::webui_token::resolve_login_link_announcement(home, config_file.as_ref())? { + crate::webui_token::LoginLinkAnnouncement::Link(link) => (Some(link), None), + crate::webui_token::LoginLinkAnnouncement::EnvTokenActive { env_var_name } => ( + None, + Some(format!( + "{env_var_name} is set; serve authenticates with that env token directly (no \ + login link — the CLI-token login route only mounts for a file-sourced token)" + )), + ), + crate::webui_token::LoginLinkAnnouncement::Unavailable => (None, None), + }, + ) +} + +#[cfg(not(feature = "webui-v2-beta"))] +fn resolve_login_link_and_note( + _home: &ironclaw_reborn_config::RebornHome, + _config_path: &std::path::Path, +) -> anyhow::Result<(Option, Option)> { + Ok((None, None)) +} + pub(super) fn convert_component_status(status: &RebornRuntimeComponentStatus) -> ComponentStatus { match status { RebornRuntimeComponentStatus::Initialized => ComponentStatus::Initialized, @@ -106,6 +215,13 @@ impl Renderable for StatusDto { ), )?; kv(w, "model_slots", &self.model_slots.join(", "))?; + kv(w, "service", service_state_text(self.service))?; + if let Some(login_link) = &self.login_link { + kv(w, "login_link", login_link)?; + } + if let Some(login_note) = &self.login_note { + kv(w, "login_note", login_note)?; + } writeln!(w)?; writeln!(w, "drivers:")?; driver_line(w, " text_only", &self.drivers.text_only)?; @@ -120,6 +236,19 @@ impl Renderable for StatusDto { } } +/// Human-readable text for the `service:` status line — matches +/// `commands::service::status_label`'s vocabulary +/// (running/stopped/not installed) plus `unknown` for a build/host where +/// live detection isn't possible. +fn service_state_text(state: ServiceStateDto) -> &'static str { + match state { + ServiceStateDto::Running => "running", + ServiceStateDto::Stopped => "stopped", + ServiceStateDto::NotInstalled => "not installed", + ServiceStateDto::Unknown => "unknown", + } +} + fn driver_line(w: &mut impl Write, label: &str, status: &ComponentStatus) -> std::io::Result<()> { match status { ComponentStatus::Initialized => writeln!(w, "{label}: initialized"), @@ -145,6 +274,73 @@ mod tests { let dto = build_status_dto(&context).expect("must build"); assert_eq!(dto.version, env!("CARGO_PKG_VERSION")); assert!(!dto.model_slots.is_empty()); + assert!( + dto.login_link.is_none(), + "no webui-token file exists yet, so there is nothing to link into: {:?}", + dto.login_link + ); + } + + /// `status` must reprint the same CLI-token login link `onboard` + /// printed. Drives `build_status_dto_with_service_state(.., Running)` + /// rather than `build_status_dto` directly to stay hermetic (no + /// dependency on the test host's actual OS service install). + #[cfg(feature = "webui-v2-beta")] + #[test] + fn status_dto_includes_login_link_once_a_valid_webui_token_file_exists() { + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + std::fs::write( + home.path().join("webui-token"), + "reborn-status-test-token-0123456789abcdef", + ) + .expect("seed webui-token file"); + + let dto = build_status_dto_with_service_state(&context, ServiceStateDto::Running) + .expect("must build"); + let login_link = dto + .login_link + .expect("a valid webui-token file must produce a login link"); + assert!( + login_link.contains("/login?token=reborn-status-test-token-0123456789abcdef"), + "login_link must carry the token file's contents: {login_link}" + ); + assert!( + login_link.starts_with("http://127.0.0.1:3000/"), + "login_link must use serve's default host:port: {login_link}" + ); + } + + /// `status --json` must never leak the bearer token embedded in + /// `login_link`'s `/login?token=` query string; the text output + /// legitimately prints it, only JSON is redacted. Pinned to `Running` + /// so `login_link` isn't suppressed, defeating the test's premise. + #[cfg(feature = "webui-v2-beta")] + #[test] + fn status_dto_json_excludes_the_login_link_token() { + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + let token = "reborn-status-json-test-token-0123456789abcdef"; + std::fs::write(home.path().join("webui-token"), token).expect("seed webui-token file"); + + let dto = build_status_dto_with_service_state(&context, ServiceStateDto::Running) + .expect("must build"); + assert!( + dto.login_link.is_some(), + "sanity: the DTO must actually carry a login_link to make this test meaningful" + ); + + let json = serde_json::to_string(&dto).expect("StatusDto must serialize"); + assert!( + !json.contains(token), + "status --json must not leak the webui bearer token: {json}" + ); + assert!( + !json.contains("login_link"), + "status --json must not emit a login_link field at all: {json}" + ); } #[test] @@ -158,4 +354,122 @@ mod tests { ComponentStatus::Initialized => panic!("expected Failed variant"), } } + + // ── service state: `status` tells the truth (bug fix) ────────── + + /// `Stopped`/`NotInstalled` must always override login_link/login_note; + /// `Running`/`Unknown` must pass them through unchanged. + #[test] + fn apply_service_suppression_overrides_only_when_known_and_not_running() { + let link = Some("http://127.0.0.1:3000/login?token=t".to_string()); + let note = Some("env token active".to_string()); + + for state in [ServiceStateDto::Stopped, ServiceStateDto::NotInstalled] { + let (suppressed_link, suppressed_note) = + apply_service_suppression(state, link.clone(), note.clone()); + assert_eq!( + suppressed_link, None, + "a known not-running service must suppress login_link ({state:?})" + ); + assert_eq!( + suppressed_note.as_deref(), + Some(SERVICE_NOT_RUNNING_LOGIN_NOTE), + "a known not-running service must carry the restart guidance ({state:?})" + ); + } + + for state in [ServiceStateDto::Running, ServiceStateDto::Unknown] { + let (passthrough_link, passthrough_note) = + apply_service_suppression(state, link.clone(), note.clone()); + assert_eq!( + passthrough_link, link, + "Running/Unknown must not touch login_link ({state:?})" + ); + assert_eq!( + passthrough_note, note, + "Running/Unknown must not touch login_note ({state:?})" + ); + } + } + + /// `status --json` must serialize `service` as snake_case, matching this + /// file's other status/doctor enums (`CheckCategory`, `CheckOutcome`, + /// `ComponentStatus`) rather than the odd kebab-case one out. + #[test] + fn status_dto_json_serializes_service_state_as_snake_case() { + let (_tmp, context) = RebornCliContext::test_context(); + for (state, expected) in [ + (ServiceStateDto::Running, "\"service\":\"running\""), + (ServiceStateDto::Stopped, "\"service\":\"stopped\""), + ( + ServiceStateDto::NotInstalled, + "\"service\":\"not_installed\"", + ), + (ServiceStateDto::Unknown, "\"service\":\"unknown\""), + ] { + let dto = build_status_dto_with_service_state(&context, state).expect("must build"); + let json = serde_json::to_string(&dto).expect("StatusDto must serialize"); + assert!(json.contains(expected), "json: {json}"); + } + } + + /// The text renderer must print a `service:` line for every state, + /// same vocabulary as `service status`, plus `unknown` when detection + /// wasn't possible. + #[test] + fn status_text_renders_service_line_for_every_state() { + let (_tmp, context) = RebornCliContext::test_context(); + for (state, expected_value) in [ + (ServiceStateDto::Running, "running"), + (ServiceStateDto::Stopped, "stopped"), + (ServiceStateDto::NotInstalled, "not installed"), + (ServiceStateDto::Unknown, "unknown"), + ] { + let dto = build_status_dto_with_service_state(&context, state).expect("must build"); + let mut buf = Vec::new(); + dto.render_text_to(&mut buf).expect("render must succeed"); + let text = String::from_utf8(buf).expect("render output must be UTF-8"); + // contains()-based rather than an exact-column-spacing match: the + // `kv` column width is a formatting detail this test shouldn't + // pin, only that the `service:` line carries the right value. + assert!( + text.lines() + .any(|line| line.trim_start().starts_with("service:") + && line.contains(expected_value)), + "expected a `service:` line containing `{expected_value}`, got:\n{text}" + ); + } + } + + /// Once the service state is known not-running, text output must show + /// restart guidance instead of a (necessarily stale) login link. + #[cfg(feature = "webui-v2-beta")] + #[test] + fn status_text_suppresses_login_link_and_shows_restart_guidance_when_service_stopped() { + let (_tmp, context) = RebornCliContext::test_context(); + let home = context.boot_config().home(); + std::fs::create_dir_all(home.path()).expect("create reborn home"); + std::fs::write( + home.path().join("webui-token"), + "reborn-status-suppression-test-0123456789abcdef", + ) + .expect("seed webui-token file"); + + let dto = build_status_dto_with_service_state(&context, ServiceStateDto::Stopped) + .expect("must build"); + assert!( + dto.login_link.is_none(), + "a stopped service must suppress the login link even though a valid token file exists" + ); + assert_eq!( + dto.login_note.as_deref(), + Some(SERVICE_NOT_RUNNING_LOGIN_NOTE) + ); + + let mut buf = Vec::new(); + dto.render_text_to(&mut buf).expect("render must succeed"); + let text = String::from_utf8(buf).expect("render output must be UTF-8"); + assert!(!text.contains("login_link:")); + assert!(text.contains(SERVICE_NOT_RUNNING_LOGIN_NOTE)); + } } diff --git a/crates/ironclaw_reborn_cli/src/dto.rs b/crates/ironclaw_reborn_cli/src/dto.rs index db6238db971..009a21b554a 100644 --- a/crates/ironclaw_reborn_cli/src/dto.rs +++ b/crates/ironclaw_reborn_cli/src/dto.rs @@ -14,6 +14,39 @@ pub(crate) struct StatusDto { pub providers_file: FilePresence, pub model_slots: Vec, pub drivers: DriversSnapshot, + /// CLI-token `/login?token=` bootstrap link, present only when a valid + /// `webui-token` file exists under `reborn_home`. `None` without the + /// `webui-v2-beta` feature. + /// + /// `skip_serializing`: carries a live bearer token in the query string; + /// `status --json` is diagnostic data pasted into issues/logs and must + /// never leak it. The text renderer reads this field directly, not + /// through serde, so the terminal `login_link:` line is unaffected. + #[serde(skip_serializing)] + pub login_link: Option, + /// `Some` when `serve` will authenticate off an active env var rather + /// than the token file — mutually exclusive with `login_link`. Carries + /// no secret, so not `skip_serializing`. + pub login_note: Option, + /// Whether the OS-managed service is actually running, queried live + /// (not inferred from file presence). `Unknown` on detection + /// error/unsupported platform; `status` must never fail over this. + pub service: ServiceStateDto, +} + +/// Live OS-service lifecycle state. Mirrors `commands::service::ServiceState`, +/// redefined here rather than deriving `Serialize` on that type directly +/// because it's gated behind `webui-v2-beta` and this DTO must exist (with +/// an `Unknown` fallback) on every build. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ServiceStateDto { + Running, + Stopped, + NotInstalled, + /// Detection failed, or `webui-v2-beta` isn't compiled in. Distinct + /// from `NotInstalled`: "we don't know" vs. "we know it isn't installed". + Unknown, } #[derive(Debug, Clone, Serialize)] diff --git a/crates/ironclaw_reborn_cli/src/render/tests.rs b/crates/ironclaw_reborn_cli/src/render/tests.rs index 659ad4d4bca..79a006914d4 100644 --- a/crates/ironclaw_reborn_cli/src/render/tests.rs +++ b/crates/ironclaw_reborn_cli/src/render/tests.rs @@ -3,7 +3,8 @@ use std::path::PathBuf; use super::Renderable; use crate::dto::{ CheckCategory, CheckOutcome, ComponentStatus, ConfigEntry, ConfigGetDto, ConfigListDto, - ConfigValue, DoctorCheck, DoctorDto, DoctorSummary, DriversSnapshot, FilePresence, StatusDto, + ConfigValue, DoctorCheck, DoctorDto, DoctorSummary, DriversSnapshot, FilePresence, + ServiceStateDto, StatusDto, }; fn render_to_string(dto: &impl Renderable) -> String { @@ -35,6 +36,9 @@ fn sample_status() -> StatusDto { }, planned_default_profile: ComponentStatus::Initialized, }, + login_link: Some("http://127.0.0.1:3000/login?token=sample-token".to_string()), + login_note: None, + service: ServiceStateDto::Running, } } @@ -109,6 +113,16 @@ fn status_json_round_trips() { parsed["drivers"]["subagent_planned"]["reason"], "missing loop family" ); + assert_eq!(parsed["service"], "running"); + // login_link embeds a live bearer token; must never reach JSON output. + assert!( + parsed.get("login_link").is_none(), + "login_link must be skipped in JSON serialization: {json}" + ); + assert!( + !json.contains("sample-token"), + "token leaked into JSON: {json}" + ); } #[test] @@ -128,11 +142,26 @@ fn status_render_text_contains_all_fields() { assert!(text.contains("(absent)")); assert!(text.contains("model_slots:")); assert!(text.contains("default, mission")); + assert!(text.contains("service:")); + assert!(text.contains("running")); assert!(text.contains("drivers:")); assert!(text.contains("text_only: initialized")); assert!(text.contains("planned: initialized")); assert!(text.contains("subagent_planned: unavailable (missing loop family)")); assert!(text.contains("planned_default_profile: initialized")); + assert!(text.contains("login_link:")); + assert!(text.contains("http://127.0.0.1:3000/login?token=sample-token")); +} + +#[test] +fn status_render_text_omits_login_link_line_when_absent() { + let mut status = sample_status(); + status.login_link = None; + let text = render_to_string(&status); + assert!( + !text.contains("login_link:"), + "no login_link line should be printed when the DTO carries None: {text}" + ); } #[test] diff --git a/crates/ironclaw_reborn_cli/src/runtime/mod.rs b/crates/ironclaw_reborn_cli/src/runtime/mod.rs index b789f7a64ca..d8b4966643b 100644 --- a/crates/ironclaw_reborn_cli/src/runtime/mod.rs +++ b/crates/ironclaw_reborn_cli/src/runtime/mod.rs @@ -543,6 +543,90 @@ fn apply_credential_refresh_override( Ok(settings) } +/// Resolve the Reborn runtime's default LLM selection, tolerating a +/// required-but-unset API key env var when a key is already durably stored +/// for that provider in the local secret store. +/// +/// - Without this, a key provisioned via `onboard` into the encrypted +/// secret store (never written to config.toml/env) would boot `serve` +/// into a fail-closed error, since `apply_startup_stored_llm_key` only +/// runs later once the async runtime is up. Providers with +/// `api_key_required = false` (e.g. `nearai`) never hit this path. +/// - Only `ApiKeyEnvUnset` is treated specially; every other resolution +/// failure surfaces unchanged, as does `ApiKeyEnvUnset` itself when no +/// key is stored — this can only turn a real fix into a successful boot, +/// never mask a misconfiguration. +/// - Scoped to `RuntimeInputCaller::Serve` only: opening the secret store +/// may fall through to the OS keychain (GUI prompt or indefinite block +/// with no GUI session). `onboard` already pays that cost interactively; +/// `serve` is the boot path this fix unblocks. `run` stays fail-fast so +/// a forgotten env var doesn't hang instead of erroring clearly. +#[cfg(all(feature = "libsql", feature = "root-llm-provider"))] +fn resolve_reborn_runtime_llm_with_stored_key_fallback( + config: &RebornBootConfig, + config_file: Option<&ironclaw_reborn_config::RebornConfigFile>, + caller: RuntimeInputCaller, +) -> anyhow::Result> { + let error = match ironclaw_reborn_composition::resolve_reborn_runtime_llm(config, config_file) { + Ok(resolved) => return Ok(resolved), + Err(error) => error, + }; + if caller != RuntimeInputCaller::Serve { + return Err(error.into()); + } + let ironclaw_reborn_composition::RebornLlmCatalogError::ApiKeyEnvUnset { ref provider, .. } = + error + else { + return Err(error.into()); + }; + // `ApiKeyEnvUnset` only comes from the config-file-selection branch, so + // a selection should be present; defensive fallback to original error. + let Some(selection) = config_file.and_then(|file| file.default_llm_slot()) else { + return Err(error.into()); + }; + let provider_id = provider.clone(); + let runtime_storage_root = local_runtime_storage_root(config, config.profile()); + // The runtime storage root is only created lazily (onboarding writing a + // key, or a prior `serve` boot). If it was never created there is + // definitely no stored key — fail through to the original error instead + // of letting the secret-store opener fail on a missing directory. + if !runtime_storage_root.exists() { + return Err(error.into()); + } + let has_stored_key = block_on_cli(async move { + let store = ironclaw_reborn_composition::open_local_dev_secret_store(&runtime_storage_root) + .await + .map_err(anyhow::Error::from)?; + ironclaw_reborn_composition::LlmKeyStore::new(store) + .exists(&provider_id) + .await + .map_err(anyhow::Error::from) + })?; + if !has_stored_key { + return Err(error.into()); + } + ironclaw_reborn_composition::resolve_llm_selection_allow_missing_key( + selection, + Some(config.home().providers_file_path().as_path()), + ) + .map(ironclaw_reborn_composition::ResolvedRebornLlm::from_llm_config) + .map(Some) + .map_err(Into::into) +} + +/// Feature-off fallback: without `libsql` there is no local-dev secret store +/// to check, so behavior here is byte-identical to calling +/// `resolve_reborn_runtime_llm` directly — a required-but-unset API key +/// still fails closed with `ApiKeyEnvUnset`. +#[cfg(all(feature = "root-llm-provider", not(feature = "libsql")))] +fn resolve_reborn_runtime_llm_with_stored_key_fallback( + config: &RebornBootConfig, + config_file: Option<&ironclaw_reborn_config::RebornConfigFile>, + _caller: RuntimeInputCaller, +) -> anyhow::Result> { + ironclaw_reborn_composition::resolve_reborn_runtime_llm(config, config_file).map_err(Into::into) +} + pub(crate) fn build_runtime_input_with_options( config: &RebornBootConfig, caller: RuntimeInputCaller, @@ -571,14 +655,16 @@ pub(crate) fn build_runtime_input_with_options( #[cfg(feature = "root-llm-provider")] { - match ironclaw_reborn_composition::resolve_reborn_runtime_llm( + match resolve_reborn_runtime_llm_with_stored_key_fallback( config, runtime_services.config_file.as_ref(), + caller, )? { Some(llm) => { tracing::debug!( provider_id = %llm.provider_id(), model = %llm.model(), + base_url = %llm.base_url().unwrap_or_default(), "resolved LLM selection for Reborn runtime" ); runtime_input = runtime_input.with_resolved_llm(llm); diff --git a/crates/ironclaw_reborn_cli/src/webui_token.rs b/crates/ironclaw_reborn_cli/src/webui_token.rs index c6f4162038b..a09498eb858 100644 --- a/crates/ironclaw_reborn_cli/src/webui_token.rs +++ b/crates/ironclaw_reborn_cli/src/webui_token.rs @@ -299,6 +299,48 @@ fn write_token_file(path: &Path, token: &str) -> anyhow::Result<()> { Ok(()) } +/// Which of [`resolve_webui_token`]'s two sources produced the resolved +/// bearer token. `serve` only mounts the CLI-printed `/login?token=` route +/// when the token came from the file — an env-sourced token (e.g. a +/// Railway-shaped deployment) must never appear in that route's query +/// string, where it would leak into edge/proxy access logs. See +/// `commands::serve::execute`'s `cli_login_mount` and the +/// `onboard`/`status` login-link printers, which need the source to avoid +/// advertising a link to an unmounted route. +#[cfg(feature = "webui-v2-beta")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WebuiTokenSource { + /// Resolved from the operator's env var (`[webui].env_token_var`, + /// default `IRONCLAW_REBORN_WEBUI_TOKEN`). + Env, + /// Resolved from the onboarding-provisioned + /// `/webui-token` fallback file. + File, +} + +/// [`resolve_webui_token`]'s result: the bearer value plus which source +/// produced it (see [`WebuiTokenSource`]). +/// +/// `Debug` is hand-written (not derived) to redact `value`: it doubles as +/// the WebChat v2 bearer credential *and* the session-signing HMAC key (see +/// the module doc), so a derived `Debug` would print the live secret +/// verbatim into any log line or panic message that formats this struct. +#[cfg(feature = "webui-v2-beta")] +pub(crate) struct ResolvedWebuiToken { + pub(crate) value: String, + pub(crate) source: WebuiTokenSource, +} + +#[cfg(feature = "webui-v2-beta")] +impl std::fmt::Debug for ResolvedWebuiToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResolvedWebuiToken") + .field("value", &"") + .field("source", &self.source) + .finish() + } +} + /// Resolve the WebChat v2 bearer token with precedence: /// /// 1. `env_value` — the value of the operator's `[webui].env_token_var` @@ -323,12 +365,15 @@ pub(crate) fn resolve_webui_token( env_var_name: &str, env_value: Option<&str>, reborn_home: &Path, -) -> anyhow::Result { +) -> anyhow::Result { if let Some(value) = env_value && !value.is_empty() { validate_token_entropy(value, env_var_name, reborn_home)?; - return Ok(value.to_string()); + return Ok(ResolvedWebuiToken { + value: value.to_string(), + source: WebuiTokenSource::Env, + }); } let file_path = webui_token_file_path(reborn_home); @@ -347,7 +392,10 @@ pub(crate) fn resolve_webui_token( // wrongly-permissioned file in place (see // `repair_token_file_mode`'s doc for why repair, not reject). repair_token_file_mode(&file_path)?; - Ok(token) + Ok(ResolvedWebuiToken { + value: token, + source: WebuiTokenSource::File, + }) } None => Err(anyhow::anyhow!( "{env_var_name} must be set to the WebChat v2 bearer token, or a token file must \ @@ -357,6 +405,82 @@ pub(crate) fn resolve_webui_token( } } +/// `true` when `serve` will source its webui bearer token from the env var +/// rather than the token file — the same precedence check +/// [`resolve_webui_token`] makes, exposed standalone so `onboard`/`status` +/// can decide whether printing a file-token link is even useful. +/// +/// `Ok(false)` only for a genuinely unset/empty var. A present-but-not-UTF-8 +/// value is `Err`, not `Ok(false)` — reuses +/// `commands::serve::present_unicode_env_var`'s unset-vs-not-unicode +/// distinction so `onboard`/`status` can't disagree with `serve` about +/// whether the var is "active". +#[cfg(feature = "webui-v2-beta")] +pub(crate) fn env_token_is_active(env_var_name: &str) -> anyhow::Result { + Ok( + crate::commands::serve::present_unicode_env_var(env_var_name)? + .is_some_and(|value| !value.is_empty()), + ) +} + +/// Resolve which env var name gates the webui bearer token: the operator's +/// `[webui].env_token_var` override when set, else +/// `commands::serve::DEFAULT_ENV_TOKEN_VAR`. Shared so `onboard`/`status` +/// check [`env_token_is_active`] against the same name `serve` resolves +/// against. +#[cfg(feature = "webui-v2-beta")] +pub(crate) fn resolve_env_token_var_name( + config_file: Option<&ironclaw_reborn_config::RebornConfigFile>, +) -> &str { + config_file + .and_then(|file| file.webui.as_ref()) + .and_then(|section| section.env_token_var.as_deref()) + .unwrap_or(crate::commands::serve::DEFAULT_ENV_TOKEN_VAR) +} + +/// What a login-link printer (`onboard`'s finale, `status`) should announce. +/// A file-token link is only useful — and only points at a route `serve` +/// actually mounts — when `serve` will source its bearer from the token file +/// rather than an env var (see [`WebuiTokenSource`]'s doc for why the CLI +/// login route is file-source-only). +#[cfg(feature = "webui-v2-beta")] +pub(crate) enum LoginLinkAnnouncement { + /// The CLI-token login link, ready to print. + Link(String), + /// The env var is active; printing a file-token link would advertise a + /// route `serve` won't mount. Carries the env var name so the caller can + /// name it in the note. + EnvTokenActive { env_var_name: String }, + /// Neither source is available yet (e.g. `onboard` hasn't provisioned + /// the token file, or it's invalid). + Unavailable, +} + +/// Resolve what a login-link printer should announce — see +/// [`LoginLinkAnnouncement`]. Checks the env var first (matching +/// `resolve_webui_token`'s own precedence): an active env var always wins, +/// regardless of whether a valid token file also happens to exist. +/// +/// Propagates a real error when the env var is set but not valid UTF-8 — +/// see [`env_token_is_active`] — rather than silently treating it as +/// inactive. +#[cfg(feature = "webui-v2-beta")] +pub(crate) fn resolve_login_link_announcement( + home: &ironclaw_reborn_config::RebornHome, + config_file: Option<&ironclaw_reborn_config::RebornConfigFile>, +) -> anyhow::Result { + let env_var_name = resolve_env_token_var_name(config_file); + if env_token_is_active(env_var_name)? { + return Ok(LoginLinkAnnouncement::EnvTokenActive { + env_var_name: env_var_name.to_string(), + }); + } + Ok(match login_link(home)? { + Some(link) => LoginLinkAnnouncement::Link(link), + None => LoginLinkAnnouncement::Unavailable, + }) +} + #[cfg(feature = "webui-v2-beta")] fn validate_token_entropy( value: &str, @@ -376,6 +500,29 @@ fn validate_token_entropy( )) } +/// The CLI-printed bootstrap link into the browser session — `Some` only +/// when a valid `webui-token` file is present (may not be true in +/// contexts like `status`, where onboarding may not have run). Uses +/// `serve`'s own default host:port constants. Shared by every caller that +/// prints a login link (`onboard`, `status`) so the construction lives in +/// one place. +#[cfg(feature = "webui-v2-beta")] +pub(crate) fn login_link( + home: &ironclaw_reborn_config::RebornHome, +) -> anyhow::Result> { + let token = read_token_file_checked(&webui_token_file_path(home.path()))?; + Ok(token + .filter(|contents| contents.trim().len() >= WEBUI_TOKEN_MIN_BYTES) + .map(|contents| { + format!( + "http://{}:{}/login?token={}", + crate::commands::serve::DEFAULT_SERVE_HOST, + crate::commands::serve::DEFAULT_SERVE_PORT, + contents.trim() + ) + })) +} + #[cfg(test)] mod tests { use super::*; @@ -446,9 +593,10 @@ mod tests { #[test] fn resolve_prefers_env_value_when_set() { let dir = tempfile::tempdir().expect("tempdir"); - let token = resolve_webui_token("SOME_TOKEN_VAR", Some(VALID_TOKEN), dir.path()) + let resolved = resolve_webui_token("SOME_TOKEN_VAR", Some(VALID_TOKEN), dir.path()) .expect("env value should resolve"); - assert_eq!(token, VALID_TOKEN); + assert_eq!(resolved.value, VALID_TOKEN); + assert_eq!(resolved.source, WebuiTokenSource::Env); } #[cfg(feature = "webui-v2-beta")] @@ -458,9 +606,32 @@ mod tests { let path = webui_token_file_path(dir.path()); fs::write(&path, format!(" {VALID_TOKEN} \n")).expect("seed token file"); - let token = resolve_webui_token("SOME_TOKEN_VAR", None, dir.path()) + let resolved = resolve_webui_token("SOME_TOKEN_VAR", None, dir.path()) .expect("file fallback should resolve"); - assert_eq!(token, VALID_TOKEN, "file value must be trimmed"); + assert_eq!(resolved.value, VALID_TOKEN, "file value must be trimmed"); + assert_eq!(resolved.source, WebuiTokenSource::File); + } + + #[cfg(feature = "webui-v2-beta")] + #[test] + fn resolved_webui_token_debug_redacts_the_value() { + let resolved = ResolvedWebuiToken { + value: VALID_TOKEN.to_string(), + source: WebuiTokenSource::Env, + }; + let debug_output = format!("{resolved:?}"); + assert!( + !debug_output.contains(VALID_TOKEN), + "Debug output must not contain the bearer token verbatim: {debug_output}" + ); + assert!( + debug_output.contains(""), + "Debug output should mark the value as redacted: {debug_output}" + ); + assert!( + debug_output.contains("Env"), + "Debug output should still show the (non-secret) source: {debug_output}" + ); } #[cfg(feature = "webui-v2-beta")] @@ -665,9 +836,9 @@ mod tests { fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)) .expect("loosen permissions to 0644"); - let token = resolve_webui_token("SOME_TOKEN_VAR", None, dir.path()) + let resolved = resolve_webui_token("SOME_TOKEN_VAR", None, dir.path()) .expect("a valid token with a wrong mode must still be accepted"); - assert_eq!(token, VALID_TOKEN); + assert_eq!(resolved.value, VALID_TOKEN); let mode = fs::metadata(&path) .expect("stat token file") .permissions() @@ -675,4 +846,69 @@ mod tests { & 0o777; assert_eq!(mode, 0o600, "mode must be repaired to 0600, got {mode:o}"); } + + #[cfg(feature = "webui-v2-beta")] + #[test] + fn env_token_is_active_true_when_env_var_set_and_non_empty() { + let _guard = crate::runtime::test_env::lock_runtime_env(); + const VAR: &str = "IRONCLAW_REBORN_CLI_TEST_TOKEN_SOURCE_ACTIVE_VAR"; + // SAFETY: serialized by `lock_runtime_env`; restored below. + unsafe { std::env::set_var(VAR, "some-token-value") }; + let active = env_token_is_active(VAR); + // SAFETY: serialized by `lock_runtime_env`. + unsafe { std::env::remove_var(VAR) }; + assert!( + active.expect("a present unicode var is not an error"), + "a non-empty env var must count as active" + ); + } + + #[cfg(feature = "webui-v2-beta")] + #[test] + fn env_token_is_active_false_when_unset_or_empty() { + let _guard = crate::runtime::test_env::lock_runtime_env(); + const VAR: &str = "IRONCLAW_REBORN_CLI_TEST_TOKEN_SOURCE_INACTIVE_VAR"; + // SAFETY: serialized by `lock_runtime_env`. + unsafe { std::env::remove_var(VAR) }; + assert!( + !env_token_is_active(VAR).expect("unset is not an error"), + "an unset env var is not active" + ); + + // SAFETY: serialized by `lock_runtime_env`; restored below. + unsafe { std::env::set_var(VAR, "") }; + let active_when_empty = env_token_is_active(VAR); + // SAFETY: serialized by `lock_runtime_env`. + unsafe { std::env::remove_var(VAR) }; + assert!( + !active_when_empty.expect("a present empty unicode var is not an error"), + "an empty-string env var must not count as active, matching \ + `resolve_webui_token`'s own non-empty check" + ); + } + + #[cfg(feature = "webui-v2-beta")] + #[cfg(unix)] + #[test] + fn env_token_is_active_propagates_not_unicode_instead_of_treating_it_as_inactive() { + // Mirrors `commands::serve::present_unicode_env_var_propagates_not_unicode_instead_of_treating_it_as_unset`: + // a mangled-UTF-8 token env var must not collapse to "inactive" + // here while `serve` itself fails closed on the same value. + use std::os::unix::ffi::OsStringExt as _; + + let _guard = crate::runtime::test_env::lock_runtime_env(); + const VAR: &str = "IRONCLAW_REBORN_CLI_TEST_TOKEN_SOURCE_NON_UNICODE_VAR"; + let invalid_utf8 = std::ffi::OsString::from_vec(vec![0xFF, 0xFE, 0xFD]); + // SAFETY: serialized by `lock_runtime_env`; restored below. + unsafe { std::env::set_var(VAR, &invalid_utf8) }; + let result = env_token_is_active(VAR); + // SAFETY: serialized by `lock_runtime_env`. + unsafe { std::env::remove_var(VAR) }; + + let error = result.expect_err("non-UTF-8 env value must be a real error, not `Ok(false)`"); + assert!( + error.to_string().contains(VAR), + "error should name the var: {error}" + ); + } } diff --git a/crates/ironclaw_reborn_cli/tests/extension.rs b/crates/ironclaw_reborn_cli/tests/extension.rs index 70c65b3f154..b16d5c6f371 100644 --- a/crates/ironclaw_reborn_cli/tests/extension.rs +++ b/crates/ironclaw_reborn_cli/tests/extension.rs @@ -56,6 +56,7 @@ fn extension_install_json_uses_reborn_home_without_v1_state() { .arg("zztest-mcp") .arg("--json") .env_clear() + .env("IRONCLAW_DISABLE_OS_KEYCHAIN", "1") .env("IRONCLAW_REBORN_HOME", &reborn_home) .env("IRONCLAW_BASE_DIR", &v1_base_dir) .output() @@ -100,6 +101,7 @@ fn extension_search_human_output_escapes_control_characters() { .arg("search") .arg("zztest-evil") .env_clear() + .env("IRONCLAW_DISABLE_OS_KEYCHAIN", "1") .env("IRONCLAW_REBORN_HOME", &reborn_home) .output() .expect("ironclaw-reborn extension search should run"); @@ -147,6 +149,7 @@ fn run_extension_json(reborn_home: &Path, args: &[&str]) -> serde_json::Value { .arg("extension") .args(args) .env_clear() + .env("IRONCLAW_DISABLE_OS_KEYCHAIN", "1") .env("IRONCLAW_REBORN_HOME", reborn_home) .output() .expect("ironclaw-reborn extension command should run"); diff --git a/crates/ironclaw_reborn_cli/tests/smoke.rs b/crates/ironclaw_reborn_cli/tests/smoke.rs index 90bbbcc47ae..1bf1286190d 100644 --- a/crates/ironclaw_reborn_cli/tests/smoke.rs +++ b/crates/ironclaw_reborn_cli/tests/smoke.rs @@ -13,6 +13,19 @@ fn reborn_bin() -> &'static str { env!("CARGO_BIN_EXE_ironclaw-reborn") } +/// Shared builder for every real-binary spawn in this file: `Command::new(reborn_bin())` +/// with `env_clear()` and `IRONCLAW_DISABLE_OS_KEYCHAIN=1` already applied — the +/// suppression a spawned real binary needs the same way `cfg!(test)` suppresses +/// keychain access for in-process unit tests (see +/// `ironclaw_secrets::keychain::os_keychain_suppressed`). Callers chain further +/// `.env(...)`/`.arg(...)` calls on top; this only centralizes the two lines every +/// site needs regardless of what else it sets up. +fn reborn_command() -> Command { + let mut command = Command::new(reborn_bin()); + command.env_clear().env("IRONCLAW_DISABLE_OS_KEYCHAIN", "1"); + command +} + fn assert_stdout_file_action(stdout: &str, file_name: &str, action: &str) { let prefix = format!("{action}: "); assert!( @@ -34,10 +47,9 @@ fn assert_stdout_labeled_action(stdout: &str, label: &str, action: &str) { } fn isolated_no_llm_command(workspace: &Path, reborn_home: &Path) -> Command { - let mut command = Command::new(reborn_bin()); + let mut command = reborn_command(); command .current_dir(workspace) - .env_clear() .env("HOME", workspace.join("isolated-home")) .env("LLM_USE_CODEX_AUTH", "false") .env("LLM_BACKEND", "") @@ -284,6 +296,7 @@ fn docker_reborn_entrypoint_uses_railway_volume_mount_for_home() { .arg(workspace_root().join("docker/reborn/entrypoint.sh")) .arg("--help") .env_clear() + .env("IRONCLAW_DISABLE_OS_KEYCHAIN", "1") .env("PATH", fake_bin_path(&bin_dir)) .env("HOME", temp.path().join("home")) .env("RAILWAY_ENVIRONMENT", "production") @@ -316,6 +329,7 @@ fn docker_reborn_entrypoint_rejects_ephemeral_railway_without_volume() { let output = Command::new("/bin/sh") .arg(workspace_root().join("docker/reborn/entrypoint.sh")) .env_clear() + .env("IRONCLAW_DISABLE_OS_KEYCHAIN", "1") .env("PATH", fake_bin_path(&bin_dir)) .env("HOME", temp.path().join("home")) .env("IRONCLAW_REBORN_HOME", &reborn_home) @@ -347,6 +361,7 @@ fn docker_reborn_entrypoint_rejects_sparse_config_as_local_dev_on_railway() { let output = Command::new("/bin/sh") .arg(workspace_root().join("docker/reborn/entrypoint.sh")) .env_clear() + .env("IRONCLAW_DISABLE_OS_KEYCHAIN", "1") .env("PATH", fake_bin_path(&bin_dir)) .env("HOME", temp.path().join("home")) .env("IRONCLAW_REBORN_HOME", &reborn_home) @@ -376,6 +391,7 @@ fn docker_reborn_entrypoint_rejects_local_dev_home_outside_railway_volume() { .arg(workspace_root().join("docker/reborn/entrypoint.sh")) .arg("--help") .env_clear() + .env("IRONCLAW_DISABLE_OS_KEYCHAIN", "1") .env("PATH", fake_bin_path(&bin_dir)) .env("HOME", temp.path().join("home")) .env("IRONCLAW_REBORN_HOME", &reborn_home) @@ -405,6 +421,7 @@ fn docker_reborn_entrypoint_allows_railway_production_without_volume() { .arg(workspace_root().join("docker/reborn/entrypoint.sh")) .arg("--help") .env_clear() + .env("IRONCLAW_DISABLE_OS_KEYCHAIN", "1") .env("PATH", fake_bin_path(&bin_dir)) .env("HOME", temp.path().join("home")) .env("IRONCLAW_REBORN_HOME", &reborn_home) @@ -439,6 +456,7 @@ fn docker_reborn_entrypoint_rejects_stale_local_dev_config_for_production() { .arg(workspace_root().join("docker/reborn/entrypoint.sh")) .arg("--help") .env_clear() + .env("IRONCLAW_DISABLE_OS_KEYCHAIN", "1") .env("PATH", fake_bin_path(&bin_dir)) .env("HOME", temp.path().join("home")) .env("IRONCLAW_REBORN_HOME", &reborn_home) @@ -456,6 +474,155 @@ fn docker_reborn_entrypoint_rejects_stale_local_dev_config_for_production() { assert!(stderr.contains("stale local-dev seed"), "stderr: {stderr}"); } +/// The exact `[llm.default]` bytes every shipped Docker profile config used +/// to bake in before this change (`docker/reborn/config.toml`, +/// `config.hosted-single-tenant.toml`, +/// `config.hosted-single-tenant-volume.toml`, `config.production.toml` all +/// carried this identical block) — a persistent Railway volume from before +/// this change still has it verbatim in its own `config.toml`, since the +/// entrypoint only installs a default config when `$config_path` doesn't +/// exist yet. +#[cfg(unix)] +const STALE_BAKED_LLM_DEFAULT_STUB: &str = "[llm.default]\nprovider_id = \"nearai\"\nmodel = \"deepseek-ai/DeepSeek-V4-Flash\"\napi_key_env = \"NEARAI_API_KEY\"\n"; + +/// RED (entrypoint one-time volume migration): a persistent volume's +/// `config.toml` carrying the EXACT old baked-in `[llm.default]` stub must +/// have that section stripped on boot, with the pre-migration file backed +/// up alongside as `config.toml.pre-llm-migration` — proving an existing +/// Railway deployment converges onto the "no implicit LLM slot" behavior +/// without an operator having to intervene. +#[cfg(unix)] +#[test] +fn docker_reborn_entrypoint_migrates_a_stale_baked_llm_default_stub() { + let temp = tempfile::tempdir().expect("tempdir"); + let bin_dir = temp.path().join("bin"); + fake_reborn_bin(&bin_dir); + let reborn_home = temp.path().join("reborn-home"); + std::fs::create_dir_all(&reborn_home).expect("reborn home"); + let original_config = format!( + "api_version = \"ironclaw.runtime/v1\"\n\n[boot]\nprofile = \"local-dev\"\n\n{STALE_BAKED_LLM_DEFAULT_STUB}\n[slack]\nenabled = false\n" + ); + let config_path = reborn_home.join("config.toml"); + std::fs::write(&config_path, &original_config).expect("write stale config"); + + let output = Command::new("/bin/sh") + .arg(workspace_root().join("docker/reborn/entrypoint.sh")) + .arg("--help") + .env_clear() + .env("IRONCLAW_DISABLE_OS_KEYCHAIN", "1") + .env("PATH", fake_bin_path(&bin_dir)) + .env("HOME", temp.path().join("home")) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .output() + .expect("entrypoint should run"); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Migrated a stale baked-in [llm.default] stub"), + "entrypoint must report the migration on stderr: {stderr}" + ); + + let migrated_config = std::fs::read_to_string(&config_path).expect("read migrated config"); + assert!( + !migrated_config.contains("[llm.default]"), + "the stale [llm.default] section must be stripped: {migrated_config}" + ); + assert!( + migrated_config.contains("profile = \"local-dev\"") && migrated_config.contains("[slack]"), + "unrelated sections must survive the migration untouched: {migrated_config}" + ); + + let backup_path = reborn_home.join("config.toml.pre-llm-migration"); + let backup_config = std::fs::read_to_string(&backup_path).expect("read backup config"); + assert_eq!( + backup_config, original_config, + "the backup must be a byte-for-byte copy of the pre-migration file" + ); + + // A second boot (backup already exists) must not clobber the backup + // with the now-already-migrated file, and must not re-run the + // migration (nothing left to strip). + let second_output = Command::new("/bin/sh") + .arg(workspace_root().join("docker/reborn/entrypoint.sh")) + .arg("--help") + .env_clear() + .env("IRONCLAW_DISABLE_OS_KEYCHAIN", "1") + .env("PATH", fake_bin_path(&bin_dir)) + .env("HOME", temp.path().join("home")) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .output() + .expect("entrypoint should run a second time"); + assert!( + second_output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&second_output.stderr) + ); + let second_stderr = String::from_utf8_lossy(&second_output.stderr); + assert!( + !second_stderr.contains("Migrated a stale baked-in [llm.default] stub"), + "a second boot must not re-run the migration: {second_stderr}" + ); + let backup_after_second_boot = + std::fs::read_to_string(&backup_path).expect("read backup config after second boot"); + assert_eq!( + backup_after_second_boot, original_config, + "the backup must never be overwritten once written" + ); +} + +/// Negative case: an operator-modified `[llm.default]` section (here, a +/// deliberately different model) must be left COMPLETELY untouched — the +/// migration only strips an EXACT match of the old baked-in stub, never a +/// section an operator has since edited in any way. +#[cfg(unix)] +#[test] +fn docker_reborn_entrypoint_does_not_migrate_an_operator_modified_llm_default() { + let temp = tempfile::tempdir().expect("tempdir"); + let bin_dir = temp.path().join("bin"); + fake_reborn_bin(&bin_dir); + let reborn_home = temp.path().join("reborn-home"); + std::fs::create_dir_all(&reborn_home).expect("reborn home"); + let original_config = "api_version = \"ironclaw.runtime/v1\"\n\n[boot]\nprofile = \"local-dev\"\n\n[llm.default]\nprovider_id = \"nearai\"\nmodel = \"an-operator-chosen-model\"\napi_key_env = \"NEARAI_API_KEY\"\n\n[slack]\nenabled = false\n"; + let config_path = reborn_home.join("config.toml"); + std::fs::write(&config_path, original_config).expect("write operator-modified config"); + + let output = Command::new("/bin/sh") + .arg(workspace_root().join("docker/reborn/entrypoint.sh")) + .arg("--help") + .env_clear() + .env("IRONCLAW_DISABLE_OS_KEYCHAIN", "1") + .env("PATH", fake_bin_path(&bin_dir)) + .env("HOME", temp.path().join("home")) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .output() + .expect("entrypoint should run"); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("Migrated a stale baked-in [llm.default] stub"), + "an operator-modified [llm.default] must never be migrated: {stderr}" + ); + let unchanged_config = std::fs::read_to_string(&config_path).expect("read config"); + assert_eq!( + unchanged_config, original_config, + "an operator-modified [llm.default] must be byte-for-byte unchanged" + ); + assert!( + !reborn_home.join("config.toml.pre-llm-migration").exists(), + "no backup should be written when nothing was migrated" + ); +} + #[test] fn help_mentions_reborn_commands() { let output = Command::new(reborn_bin()) @@ -523,14 +690,78 @@ fn service_help_lists_all_verbs() { } } +/// Onboard's OS-service finale (`OnboardCommand::finish_with_service_and_login_link`) +/// only calls `commands::service::install_and_start` when +/// `should_install_service` sees an interactive session — and every child +/// this suite spawns via `Command::output()`/`.spawn()` sees piped, non-tty +/// stdin, so `onboard` can never be driven into that install-attempt branch +/// through a real subprocess here (`onboard_then_serve_boots_in_degraded_mode_with_an_empty_environment` +/// below pins the non-interactive `service: skipped (non-interactive +/// session)` line onboard prints instead). There is no clean "break $HOME" +/// hook that forces onboard itself into the install branch in this +/// environment, so the next best equivalent — same file convention as +/// `onboard_dry_run_propagates_a_webui_token_io_error_without_mutating_home`, +/// which plants a wrong-type filesystem entry at a write target to force a +/// real I/O error — is to drive `service install` directly. That is the +/// exact call `install_and_start` makes on the interactive path +/// (`ServicePlatform::install` writes the same plist/unit file), so this +/// still pins that a blocked service-definition path surfaces as a clean +/// non-zero exit with a readable error, not a panic or a hang. +#[cfg(all( + feature = "webui-v2-beta", + any(target_os = "macos", target_os = "linux") +))] +#[test] +fn service_install_reports_error_when_service_definition_path_is_blocked() { + let temp = tempfile::tempdir().expect("tempdir"); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).expect("mkdir home"); + + // Plant a plain file where the service-definition directory needs to + // be created (`~/Library/LaunchAgents/...` on macOS, + // `~/.config/systemd/user/...` on Linux), so the install's + // `create_dir_all(parent)` hits a real "not a directory" I/O error + // instead of writing the plist/unit successfully. + #[cfg(target_os = "macos")] + let blocked_parent = home.join("Library"); + #[cfg(target_os = "linux")] + let blocked_parent = home.join(".config"); + std::fs::write(&blocked_parent, b"not a directory").expect("plant blocking file"); + + let output = Command::new(reborn_bin()) + .args(["service", "install"]) + .env_clear() + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", temp.path().join("reborn-home")) + .output() + .expect("ironclaw-reborn service install should run"); + + assert!( + !output.status.success(), + "a blocked service-definition path must fail `service install`, not silently succeed: \ + stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("panicked"), + "install failure must be a handled `anyhow` error surfaced on stderr, not a panic: \ + stderr={stderr}" + ); + assert!( + !stderr.trim().is_empty(), + "install failure must surface a readable error, not swallow it: stderr={stderr}" + ); +} + #[test] fn extension_search_does_not_seed_reborn_config() { let temp = tempfile::tempdir().expect("tempdir"); let reborn_home = temp.path().join("reborn-home"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .args(["extension", "search", "--json"]) - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .env("HOME", temp.path().join("home")) .output() @@ -549,10 +780,9 @@ fn extension_search_does_not_seed_reborn_config() { #[test] fn profile_list_shows_supported_profiles_without_reborn_home() { - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("profile") .arg("list") - .env_clear() .output() .expect("ironclaw-reborn profile list should run"); @@ -583,11 +813,10 @@ fn profile_list_shows_supported_profiles_without_reborn_home() { #[test] fn profile_list_json_is_stable_and_does_not_resolve_reborn_home() { - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("profile") .arg("list") .arg("--json") - .env_clear() .output() .expect("ironclaw-reborn profile list --json should run"); @@ -698,10 +927,9 @@ fn skills_list_reports_reborn_skill_data() { let v1_home = temp.path().join("v1-home"); write_reborn_skill(&reborn_home, "catalog-helper", "catalog helper"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("skills") .arg("list") - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .env("IRONCLAW_BASE_DIR", &v1_home) .output() @@ -750,11 +978,10 @@ fn skills_list_verbose_reports_reborn_skill_details() { let reborn_home = temp.path().join("reborn-home"); write_verbose_reborn_skill(&reborn_home, "verbose-helper", "verbose helper"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("skills") .arg("list") .arg("--verbose") - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .output() .expect("ironclaw-reborn skills list --verbose should run"); @@ -787,12 +1014,11 @@ fn skills_list_json_reports_reborn_skill_data() { let reborn_home = temp.path().join("reborn-home"); write_reborn_skill(&reborn_home, "json-helper", "json helper"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("skills") .arg("list") .arg("--json") .arg("--verbose") - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .output() .expect("ironclaw-reborn skills list --json should run"); @@ -832,10 +1058,9 @@ fn assert_skill_source(json: &serde_json::Value, name: &str, source: &str) { fn skills_list_rejects_unsupported_profiles() { for profile in ["production", "migration-dry-run"] { let temp = tempfile::tempdir().expect("tempdir"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("skills") .arg("list") - .env_clear() .env("IRONCLAW_REBORN_HOME", temp.path().join("reborn-home")) .env("IRONCLAW_REBORN_PROFILE", profile) .output() @@ -881,10 +1106,9 @@ fn logs_json_verbose_includes_status_details() { #[test] fn models_list_reports_reborn_provider_catalog_without_v1_state() { let temp = tempfile::tempdir().expect("tempdir"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("models") .arg("list") - .env_clear() .env("HOME", temp.path()) .output() .expect("ironclaw-reborn models list should run"); @@ -912,11 +1136,10 @@ fn models_list_reports_reborn_provider_catalog_without_v1_state() { #[test] fn models_status_json_reports_routes_not_configured_without_v1_state() { let temp = tempfile::tempdir().expect("tempdir"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("models") .arg("status") .arg("--json") - .env_clear() .env("HOME", temp.path()) .output() .expect("ironclaw-reborn models status --json should run"); @@ -950,11 +1173,10 @@ api_key_env = "OPENAI_API_KEY" ) .expect("write config"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("models") .arg("status") .arg("--json") - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .output() .expect("ironclaw-reborn models status --json should run"); @@ -979,13 +1201,12 @@ api_key_env = "OPENAI_API_KEY" fn models_set_provider_writes_reborn_config_without_v1_state() { let temp = tempfile::tempdir().expect("tempdir"); let reborn_home = temp.path().join("reborn-home"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("models") .arg("set-provider") .arg("openai") .arg("--model") .arg("gpt-5-mini") - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .output() .expect("ironclaw-reborn models set-provider should run"); @@ -1039,11 +1260,10 @@ api_key_env = "OPENAI_API_KEY" ) .expect("write config"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("models") .arg("set") .arg("gpt-5.3-codex") - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .output() .expect("ironclaw-reborn models set should run"); @@ -1069,11 +1289,10 @@ api_key_env = "OPENAI_API_KEY" fn models_set_without_provider_fails_without_panicking() { let temp = tempfile::tempdir().expect("tempdir"); let reborn_home = temp.path().join("reborn-home"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("models") .arg("set") .arg("gpt-5.3-codex") - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .output() .expect("ironclaw-reborn models set should run"); @@ -1090,10 +1309,9 @@ fn models_set_without_provider_fails_without_panicking() { #[cfg(not(feature = "root-llm-provider"))] #[test] fn models_list_no_default_features_does_not_resolve_reborn_home() { - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("models") .arg("list") - .env_clear() .output() .expect("ironclaw-reborn models list should run"); @@ -1113,11 +1331,10 @@ fn models_list_no_default_features_does_not_resolve_reborn_home() { #[cfg(not(feature = "root-llm-provider"))] #[test] fn models_status_no_default_features_does_not_resolve_reborn_home() { - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("models") .arg("status") .arg("--json") - .env_clear() .output() .expect("ironclaw-reborn models status should run"); @@ -1139,9 +1356,8 @@ fn models_write_commands_report_root_llm_provider_required_without_default_featu &["models", "set", "gpt-5.3-codex"][..], &["models", "set-provider", "openai"][..], ] { - let output = Command::new(reborn_bin()) + let output = reborn_command() .args(args) - .env_clear() .output() .expect("ironclaw-reborn models write command should run"); @@ -1165,9 +1381,8 @@ fn assert_empty_not_wired_surface( collection_key: &str, count_key: &str, ) { - let output = Command::new(reborn_bin()) + let output = reborn_command() .args(args) - .env_clear() .output() .expect("ironclaw-reborn command should run"); @@ -1187,9 +1402,8 @@ fn assert_empty_not_wired_surface( let mut json_args = args.to_vec(); json_args.push("--json"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .args(json_args) - .env_clear() .output() .expect("ironclaw-reborn JSON command should run"); assert!( @@ -1249,9 +1463,8 @@ fn reborn_cli_skill_root(reborn_home: &std::path::Path) -> std::path::PathBuf { } fn assert_verbose_detail(args: &[&str], expected_detail: &str) { - let output = Command::new(reborn_bin()) + let output = reborn_command() .args(args) - .env_clear() .output() .expect("ironclaw-reborn verbose command should run"); @@ -1270,9 +1483,8 @@ fn assert_json_verbose_detail( count_key: &str, expected_detail: &str, ) { - let output = Command::new(reborn_bin()) + let output = reborn_command() .args(args) - .env_clear() .output() .expect("ironclaw-reborn JSON verbose command should run"); @@ -1378,11 +1590,10 @@ fn config_path_reports_default_reborn_home_without_creating_directories() { #[test] fn completion_generates_zsh_script_without_reborn_home() { - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("completion") .arg("--shell") .arg("zsh") - .env_clear() .output() .expect("ironclaw-reborn completion should run"); @@ -1405,11 +1616,10 @@ fn completion_generates_zsh_script_without_reborn_home() { #[test] fn completion_generates_bash_script_without_reborn_home() { - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("completion") .arg("--shell") .arg("bash") - .env_clear() .output() .expect("ironclaw-reborn completion should run"); @@ -1426,10 +1636,9 @@ fn completion_generates_bash_script_without_reborn_home() { #[cfg(feature = "webui-v2-beta")] #[test] fn serve_help_mentions_host_and_port() { - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("serve") .arg("--help") - .env_clear() .output() .expect("ironclaw-reborn serve --help should run"); @@ -1480,39 +1689,167 @@ fn serve_fails_closed_when_env_bearer_token_var_is_unset() { #[cfg(feature = "webui-v2-beta")] #[test] -fn serve_fails_closed_when_env_user_id_var_is_unset() { +fn serve_boots_without_user_id_env_var() { + // A unit env with only HOME/PROFILE and no IRONCLAW_REBORN_WEBUI_USER_ID + // must fall back to [identity].default_owner (or "reborn-cli" when + // absent) instead of hard-failing before binding a listener. let temp = tempfile::tempdir().expect("tempdir"); - let output = Command::new(reborn_bin()) - .arg("serve") - .arg("--host") - .arg("127.0.0.1") - .arg("--port") - .arg("0") - .env("IRONCLAW_REBORN_HOME", temp.path().join("reborn-home")) + let reborn_home = temp.path().join("reborn-home"); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).expect("home dir"); + let _serve_port_guard = SERVE_PORT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let port = unused_local_port(); + + let mut child = reborn_command() + .args(["serve", "--host", "127.0.0.1", "--port"]) + .arg(port.to_string()) + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) .env_remove("IRONCLAW_REBORN_PROFILE") - // >=32 bytes: must clear the token's own entropy floor (enforced by - // `webui_token::resolve_webui_token` as soon as the token is - // resolved, before the user-id var is read) so this test isolates - // the user-id-var-missing failure it's meant to exercise. + // Token must be >=32 bytes so it clears its own entropy floor before + // the user-id fallback under test is reached. .env( "IRONCLAW_REBORN_WEBUI_TOKEN", "reborn-smoke-test-token-0123456789abcdef", ) .env_remove("IRONCLAW_REBORN_WEBUI_USER_ID") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("ironclaw-reborn serve should start"); + wait_for_serve_banner(&mut child); + + let _ = child.kill(); + let _ = child.wait(); +} + +/// Proves `serve` boots when launched with cwd=/workspace, the +/// cwd the installed launchd/systemd service now uses. +/// - Regression: unit-content tests only checked `WorkingDirectory` was +/// present, never that serve actually boots from it. +/// - Companion negative test below shows the prior cwd (reborn_home itself) +/// still fails, proving this test discriminates. +#[cfg(feature = "webui-v2-beta")] +#[test] +fn serve_boots_from_the_workspace_subdir_the_installed_service_now_uses_as_cwd() { + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + let home = temp.path().join("home"); + let working_directory = reborn_home.join("workspace"); + std::fs::create_dir_all(&home).expect("home dir"); + std::fs::create_dir_all(&working_directory).expect("working directory"); + let _serve_port_guard = SERVE_PORT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let port = unused_local_port(); + + let mut child = reborn_command() + .args(["serve", "--host", "127.0.0.1", "--port"]) + .arg(port.to_string()) + .current_dir(&working_directory) + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .env_remove("IRONCLAW_REBORN_PROFILE") + .env( + "IRONCLAW_REBORN_WEBUI_TOKEN", + "reborn-smoke-test-cwd-workspace-token-0123456789abcdef", + ) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("ironclaw-reborn serve should start"); + wait_for_serve_banner(&mut child); + + let _ = child.kill(); + let _ = child.wait(); +} + +/// Companion regression pin: cwd=reborn_home itself (the crate's first, +/// insufficient fix attempt) still fails, because reborn_home is an +/// ancestor of the default local-dev skill/extension roots and trips +/// composition's `paths_overlap` check. Guards against reverting the +/// installer back to cwd=reborn_home. +#[cfg(feature = "webui-v2-beta")] +#[test] +fn serve_crash_loops_with_skill_root_overlap_when_cwd_is_reborn_home_itself() { + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).expect("home dir"); + std::fs::create_dir_all(&reborn_home).expect("reborn home"); + let _serve_port_guard = SERVE_PORT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let port = unused_local_port(); + + let output = reborn_command() + .args(["serve", "--host", "127.0.0.1", "--port"]) + .arg(port.to_string()) + .current_dir(&reborn_home) + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .env_remove("IRONCLAW_REBORN_PROFILE") + .env( + "IRONCLAW_REBORN_WEBUI_TOKEN", + "reborn-smoke-test-cwd-reborn-home-token-0123456789abcdef", + ) .output() - .expect("ironclaw-reborn serve should run"); + .expect("ironclaw-reborn serve should run and exit"); assert!( !output.status.success(), - "serve must fail closed when the user-id env var is unset" + "serve launched with cwd=reborn_home must fail closed on the skill-root overlap, \ + not silently boot: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("IRONCLAW_REBORN_WEBUI_USER_ID must be set"), - "stderr should name the missing user-id env var: {stderr}" + stderr.contains("local-dev workspace root must not overlap default skill root"), + "expected the exact composition overlap error this fix eliminates for \ + /workspace: stderr={stderr}" ); } +#[cfg(feature = "webui-v2-beta")] +#[test] +fn a_real_env_var_beats_the_config_default_end_to_end() { + // Railway/service-install spine: operator sets IRONCLAW_REBORN_WEBUI_USER_ID + // explicitly with no [identity].default_owner configured; must still boot + // after user-id resolution moved into resolve_webui_user_id_raw. + // [identity] left unset deliberately — a configured default that diverges + // from env is a separate, already-covered misconfiguration case. + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).expect("home dir"); + let _serve_port_guard = SERVE_PORT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let port = unused_local_port(); + + let mut child = reborn_command() + .args(["serve", "--host", "127.0.0.1", "--port"]) + .arg(port.to_string()) + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .env( + "IRONCLAW_REBORN_WEBUI_TOKEN", + "reborn-smoke-test-token-0123456789abcdef", + ) + .env("IRONCLAW_REBORN_WEBUI_USER_ID", "env-user") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("ironclaw-reborn serve should start"); + wait_for_serve_banner(&mut child); + + let _ = child.kill(); + let _ = child.wait(); +} + #[cfg(feature = "webui-v2-beta")] #[test] fn serve_with_env_auth_seeds_reborn_config_before_binding() { @@ -1520,12 +1857,14 @@ fn serve_with_env_auth_seeds_reborn_config_before_binding() { let reborn_home = temp.path().join("reborn-home"); let home = temp.path().join("home"); std::fs::create_dir_all(&home).expect("home dir"); + let _serve_port_guard = SERVE_PORT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let port = unused_local_port(); - let mut child = Command::new(reborn_bin()) + let mut child = reborn_command() .args(["serve", "--host", "127.0.0.1", "--port"]) .arg(port.to_string()) - .env_clear() .env("HOME", &home) .env("IRONCLAW_REBORN_HOME", &reborn_home) .env( @@ -1660,12 +1999,14 @@ fn serve_resolves_bearer_token_from_reborn_home_webui_token_file() { "reborn-smoke-test-token-0123456789abcdef", ) .expect("seed webui-token file"); + let _serve_port_guard = SERVE_PORT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let port = unused_local_port(); - let mut child = Command::new(reborn_bin()) + let mut child = reborn_command() .args(["serve", "--host", "127.0.0.1", "--port"]) .arg(port.to_string()) - .env_clear() .env("HOME", &home) .env("IRONCLAW_REBORN_HOME", &reborn_home) .env_remove("IRONCLAW_REBORN_WEBUI_TOKEN") @@ -1722,12 +2063,14 @@ fn serve_env_slack_enabled_mounts_slack_events_route() { let reborn_home = temp.path().join("reborn-home"); let home = temp.path().join("home"); std::fs::create_dir_all(&home).expect("home dir"); + let _serve_port_guard = SERVE_PORT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let port = unused_local_port(); - let mut child = Command::new(reborn_bin()) + let mut child = reborn_command() .args(["serve", "--host", "127.0.0.1", "--port"]) .arg(port.to_string()) - .env_clear() .env("HOME", &home) .env("IRONCLAW_REBORN_HOME", &reborn_home) .env( @@ -1797,8 +2140,38 @@ fn serve_env_slack_enabled_mounts_slack_events_route() { ); } +/// `true` when `config_text` carries a live (uncommented) `provider_id =` +/// line — used to assert a de-seeded `config.toml` has no `[llm.default]` +/// slot. A plain `.contains("provider_id =")` also matches the stub's own +/// commented-out `# provider_id = "nearai"` example line, so this only +/// counts a line whose first non-whitespace character isn't `#`. #[cfg(feature = "webui-v2-beta")] -fn unused_local_port() -> u16 { +fn config_text_has_live_provider_id(config_text: &str) -> bool { + config_text.lines().any(|line| { + let trimmed = line.trim_start(); + trimmed.starts_with("provider_id =") || trimmed.starts_with("provider_id=") + }) +} + +/// Guards the bind-close-then-spawn window every `unused_local_port()` +/// caller below goes through: the helper binds a listener to port 0 to get +/// an OS-assigned free port, reads it back, and closes it — then hands +/// that port number to a spawned `serve` child to bind itself. Between the +/// helper's close and the child's own bind, `cargo test`'s parallel test +/// threads can race for the same freed port (one test's helper grabs the +/// port another test's child is about to bind), causing real cross-talk — +/// proven in CI logs, one test observing another test's HTTP response. +/// Every test that spawns a `serve`-mode child on a port from +/// `unused_local_port()` takes this lock before allocating its port and +/// holds it for the rest of the test (dropped at function end), so no two +/// of these tests' allocate-then-spawn windows can overlap. This is a +/// small serialization fix, not a port-reservation framework — do not +/// extend it into a pool or retry-with-backoff mechanism. +#[cfg(feature = "webui-v2-beta")] +static SERVE_PORT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(feature = "webui-v2-beta")] +fn unused_local_port() -> u16 { std::net::TcpListener::bind(("127.0.0.1", 0)) .expect("bind ephemeral local port") .local_addr() @@ -2004,6 +2377,451 @@ fn serve_fails_closed_when_session_token_lacks_entropy_without_sso() { ); } +/// Send `request` and read the full response: status line, headers, and +/// (best-effort, non-chunked) body. Used by the CLI-token-login tests below, +/// which need `Location`/JSON body content that [`http_status_line`] doesn't +/// capture. +#[cfg(feature = "webui-v2-beta")] +fn http_response(port: u16, request: &str, label: &str) -> Result { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let stream = loop { + match std::net::TcpStream::connect(("127.0.0.1", port)) { + Ok(stream) => break stream, + Err(_) if std::time::Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(50)); + } + Err(error) => return Err(format!("connect to serve listener failed: {error}")), + } + }; + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .map_err(|error| format!("set {label} read timeout failed: {error}"))?; + let mut stream = stream; + stream + .write_all(request.as_bytes()) + .map_err(|error| format!("write {label} failed: {error}"))?; + let mut reader = std::io::BufReader::new(stream); + let mut status_line = String::new(); + reader + .read_line(&mut status_line) + .map_err(|error| format!("read {label} status line failed: {error}"))?; + let mut headers = Vec::new(); + loop { + let mut line = String::new(); + reader + .read_line(&mut line) + .map_err(|error| format!("read {label} header line failed: {error}"))?; + let trimmed = line.trim_end_matches(['\r', '\n']); + if trimmed.is_empty() { + break; + } + if let Some((name, value)) = trimmed.split_once(':') { + headers.push((name.trim().to_ascii_lowercase(), value.trim().to_string())); + } + } + let mut body = String::new(); + std::io::Read::read_to_string(&mut reader, &mut body) + .map_err(|error| format!("read {label} body failed: {error}"))?; + Ok(HttpResponse { + status_line: status_line.trim_end_matches(['\r', '\n']).to_string(), + headers, + body, + }) +} + +#[cfg(feature = "webui-v2-beta")] +#[derive(Debug)] +struct HttpResponse { + status_line: String, + headers: Vec<(String, String)>, + body: String, +} + +#[cfg(feature = "webui-v2-beta")] +impl HttpResponse { + fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(header_name, _)| header_name == name) + .map(|(_, value)| value.as_str()) + } +} + +/// With no SSO provider and a file-sourced webui token, `serve` must mount +/// the CLI-printed `/login?token=` route plus `POST /auth/session/exchange`. +/// A valid token redirects into the ticket hand-off, which then resolves to +/// a real session bearer; an invalid token gets a flat 401. +#[cfg(feature = "webui-v2-beta")] +#[test] +fn serve_mounts_cli_login_route_without_sso() { + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).expect("home dir"); + let _serve_port_guard = SERVE_PORT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let port = unused_local_port(); + let webui_token = "reborn-smoke-test-token-0123456789abcdef"; + // File-sourced token, not `IRONCLAW_REBORN_WEBUI_TOKEN` — this is the + // one source `cli_login_mount` still mounts the route for. + std::fs::create_dir_all(&reborn_home).expect("reborn home dir"); + std::fs::write(reborn_home.join("webui-token"), webui_token).expect("seed webui-token file"); + + let mut child = reborn_command() + .args(["serve", "--host", "127.0.0.1", "--port"]) + .arg(port.to_string()) + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .env("IRONCLAW_REBORN_WEBUI_USER_ID", "test-user") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("ironclaw-reborn serve should start"); + wait_for_serve_banner(&mut child); + + let wrong_token_status = http_response( + port, + "GET /login?token=wrong HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", + "login wrong-token probe", + ); + let good_login = http_response( + port, + &format!( + "GET /login?token={webui_token} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n" + ), + "login probe", + ); + + let (wrong_token_status, good_login) = match (wrong_token_status, good_login) { + (Ok(a), Ok(b)) => (a, b), + (result_a, result_b) => { + let _ = child.kill(); + let _ = child.wait(); + panic!("login probe failed: {result_a:?} / {result_b:?}"); + } + }; + + assert!( + wrong_token_status.status_line.contains(" 401 "), + "wrong token must 401, got: {}", + wrong_token_status.status_line + ); + assert!( + good_login.status_line.contains(" 302 ") || good_login.status_line.contains(" 303 "), + "valid token must redirect into the ticket hand-off, got: {}", + good_login.status_line + ); + let location = good_login + .header("location") + .expect("redirect must carry a Location header"); + let ticket = location + .split("login_ticket=") + .nth(1) + .expect("redirect Location must carry a login_ticket query param"); + + let exchange_body = format!(r#"{{"ticket":"{ticket}"}}"#); + let exchange_request = format!( + "POST /auth/session/exchange HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{exchange_body}", + exchange_body.len() + ); + let exchange = http_response(port, &exchange_request, "session exchange probe"); + + let _ = child.kill(); + let _ = child.wait(); + + let exchange = exchange.expect("exchange probe must complete"); + assert!( + exchange.status_line.contains(" 200 "), + "ticket must exchange for a real bearer exactly once, got: {}; body: {}", + exchange.status_line, + exchange.body + ); + assert!( + exchange.body.contains("\"token\""), + "exchange response must carry the minted bearer: {}", + exchange.body + ); +} + +/// Security: an env-sourced webui bearer token must not get a mounted +/// CLI-token `/login?token=` route — that route puts the bearer in a public +/// URL query string, where an edge/proxy would capture it in access logs. +/// +/// Under root-path serving (#6152) an unmounted `/login` isn't a hard 404: +/// the root SPA wildcard (`static_router`) only fails closed for namespaces +/// composition actually reserved (derived from mounted route descriptors — +/// see `webui_serve.rs::static_router_config_from_descriptors`), so a route +/// nobody mounted here falls through to the ordinary SPA shell like any +/// other client-side path. The security property under test is narrower and +/// still holds: the response must be the generic SPA shell, not this +/// route's own handler (a 302/303 redirect carrying a freshly minted +/// session bearer's ticket). +#[cfg(feature = "webui-v2-beta")] +#[test] +fn serve_does_not_mount_cli_login_route_when_token_is_env_sourced() { + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).expect("home dir"); + let _serve_port_guard = SERVE_PORT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let port = unused_local_port(); + + let mut child = reborn_command() + .args(["serve", "--host", "127.0.0.1", "--port"]) + .arg(port.to_string()) + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .env( + "IRONCLAW_REBORN_WEBUI_TOKEN", + "reborn-smoke-test-env-token-0123456789abcdef", + ) + .env("IRONCLAW_REBORN_WEBUI_USER_ID", "test-user") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("ironclaw-reborn serve should start"); + wait_for_serve_banner(&mut child); + + let login_response = http_response( + port, + "GET /login?token=irrelevant HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", + "cli login probe with env-sourced token", + ); + + let _ = child.kill(); + let _ = child.wait(); + + let login_response = login_response.expect("login probe must complete"); + assert!( + login_response.status_line.contains(" 200 "), + "an unmounted /login must fall through to the ordinary SPA shell \ + (200), got: {}", + login_response.status_line + ); + assert!( + login_response.header("location").is_none(), + "the CLI-only /login route's redirect-with-session-ticket handler \ + must not run for an env-sourced token, got Location: {:?}", + login_response.header("location") + ); + assert_eq!( + login_response.header("content-type"), + Some("text/html; charset=utf-8"), + "must be the generic SPA shell response, not a route-specific body" + ); +} + +/// With an SSO provider configured, `serve` must not also mount the +/// CLI-token-login route's own `/auth/session/exchange` — that would +/// register the path twice. Proven by the CLI-only `/login?token=` route's +/// handler not running (see `serve_does_not_mount_cli_login_route_when_token_is_env_sourced` +/// for why an unmounted `/login` is a 200 SPA-shell fallthrough rather than a +/// 404 under root-path serving, not this route's own redirect) while +/// `/auth/providers` stays up. +#[cfg(feature = "webui-v2-beta")] +#[test] +fn serve_with_sso_does_not_double_mount_session_exchange() { + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).expect("home dir"); + let _serve_port_guard = SERVE_PORT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let port = unused_local_port(); + + let mut child = reborn_command() + .args(["serve", "--host", "127.0.0.1", "--port"]) + .arg(port.to_string()) + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .env( + "IRONCLAW_REBORN_WEBUI_TOKEN", + "reborn-smoke-test-token-0123456789abcdef", + ) + .env("IRONCLAW_REBORN_WEBUI_USER_ID", "test-user") + .env("IRONCLAW_REBORN_WEBUI_GOOGLE_CLIENT_ID", "client-id") + .env( + "IRONCLAW_REBORN_WEBUI_GOOGLE_CLIENT_SECRET", + "client-secret", + ) + .env("IRONCLAW_REBORN_WEBUI_ALLOWED_EMAIL_DOMAINS", "example.com") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("ironclaw-reborn serve should start"); + wait_for_serve_banner(&mut child); + + let login_response = http_response( + port, + "GET /login?token=irrelevant HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", + "cli login probe under SSO", + ); + let providers_status = http_status_line( + port, + concat!( + "GET /auth/providers HTTP/1.1\r\n", + "Host: 127.0.0.1\r\n", + "Connection: close\r\n", + "\r\n", + ), + "providers route probe", + ); + + let _ = child.kill(); + let _ = child.wait(); + + let login_response = login_response.expect("cli login probe must complete"); + let providers_status = providers_status.expect("providers probe must complete"); + assert!( + login_response.status_line.contains(" 200 ") && login_response.header("location").is_none(), + "the CLI-only /login route's own redirect-with-session-ticket \ + handler must not run when SSO is configured (its own \ + /auth/session/exchange would collide with the SSO surface's) — \ + expected the ordinary SPA-shell fallthrough, got: {} location={:?}", + login_response.status_line, + login_response.header("location") + ); + assert!( + providers_status.contains(" 200 "), + "the SSO surface's own routes (including its /auth/session/exchange) \ + must still be the sole mount, got: {providers_status}" + ); +} + +/// Strip ANSI SGR escape sequences (`\x1b[...m`) from `text`. `init_tracing`'s +/// stderr `fmt::layer()` colorizes its output unconditionally — it does not +/// gate on the writer being a real terminal — so a piped `Child::stderr` +/// still carries color codes interleaved between field names and values +/// (e.g. `provider_id` and `=openai` are split by a reset/dim escape pair). +/// Assertions on structured-log field text must strip these first or a +/// plain `contains("provider_id=openai")` silently never matches. +#[cfg(feature = "webui-v2-beta")] +fn strip_ansi(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars(); + while let Some(ch) = chars.next() { + if ch == '\u{1b}' && chars.clone().next() == Some('[') { + chars.next(); // consume '[' + for next in chars.by_ref() { + if next == 'm' { + break; + } + } + } else { + out.push(ch); + } + } + out +} + +/// Blocks until `child`'s stderr carries the ready banner. Returns +/// everything captured up to and including the banner line, so callers can +/// also assert on pre-banner diagnostics without their own drain thread. +#[cfg(feature = "webui-v2-beta")] +fn wait_for_serve_banner(child: &mut std::process::Child) -> String { + let stderr = child.stderr.take().expect("stderr should be piped"); + let (stderr_tx, stderr_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + for line in std::io::BufReader::new(stderr).lines() { + if stderr_tx.send(line).is_err() { + break; + } + } + }); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + let mut stderr_text = String::new(); + loop { + if let Some(status) = child.try_wait().expect("serve child status") { + panic!("serve exited before binding with {status}; stderr: {stderr_text}"); + } + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("serve did not reach listener banner; stderr: {stderr_text}"); + } + match stderr_rx.recv_timeout(std::time::Duration::from_millis(100)) { + Ok(Ok(line)) => { + stderr_text.push_str(&line); + stderr_text.push('\n'); + if stderr_text.contains("ironclaw-reborn: WebChat v2 listener") { + break; + } + } + Ok(Err(error)) => panic!("failed to read serve stderr: {error}"), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + panic!("serve stderr closed before banner; stderr: {stderr_text}"); + } + } + } + stderr_text +} + +/// Like [`wait_for_serve_banner`], but keeps capturing `child`'s stderr for +/// the rest of the test (returned as a live-updating buffer) instead of +/// dropping the reader once the banner line is seen. The real-turn tests +/// that use this drive several more HTTP round trips after the banner and +/// occasionally hit a `Connection refused` under CPU-contended parallel test +/// runs — the banner line is flushed just before the listener starts +/// accepting, not after, so a loaded box can have a brief gap. Keeping the +/// capture alive (rather than a one-shot channel that's dropped as soon as +/// the caller returns) means a failure can print what `serve` actually did +/// in that window instead of only "connection refused". +#[cfg(feature = "webui-v2-beta")] +fn wait_for_serve_banner_with_capture( + child: &mut std::process::Child, + label: &str, +) -> std::sync::Arc> { + let stderr_all = std::sync::Arc::new(std::sync::Mutex::new(String::new())); + let stderr = child.stderr.take().expect("stderr should be piped"); + let collector = std::sync::Arc::clone(&stderr_all); + std::thread::spawn(move || { + for line in std::io::BufReader::new(stderr) + .lines() + .map_while(Result::ok) + { + let mut guard = collector + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + guard.push_str(&line); + guard.push('\n'); + } + }); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + loop { + if stderr_all + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .contains("ironclaw-reborn: WebChat v2 listener") + { + return stderr_all; + } + if let Some(status) = child.try_wait().expect("serve child status") { + panic!( + "{label}: serve exited before binding with {status}; stderr: {}", + stderr_all + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + ); + } + if std::time::Instant::now() >= deadline { + panic!( + "{label}: serve did not reach listener banner; stderr: {}", + stderr_all + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + ); + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } +} + // Note: port `0` is intentionally accepted now — it lets the kernel // pick a free port, which is the path the caller-level serve test // uses to avoid hard-coding a port. The earlier zero-port rejection @@ -2117,10 +2935,9 @@ fn doctor_uses_reborn_home_override_without_touching_v1_state() { #[test] fn repl_help_mentions_composed_runtime() { - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("repl") .arg("--help") - .env_clear() .output() .expect("ironclaw-reborn repl --help should run"); @@ -2143,9 +2960,8 @@ fn repl_exit_command_seeds_reborn_config() { let home_dir = temp.path().join("home"); let v1_base_dir = temp.path().join("v1-state"); - let mut child = Command::new(reborn_bin()) + let mut child = reborn_command() .arg("repl") - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .env("HOME", &home_dir) .env("IRONCLAW_BASE_DIR", &v1_base_dir) @@ -2224,9 +3040,8 @@ fn repl_resolves_codex_auth_env_without_openai_api_key() { ) .expect("write codex auth fixture"); - let mut child = Command::new(reborn_bin()) + let mut child = reborn_command() .arg("repl") - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .env("HOME", &home_dir) .env("LLM_BACKEND", "openai_codex") @@ -2280,9 +3095,8 @@ fn repl_resolves_codex_api_key_auth_env_without_openai_api_key() { ) .expect("write codex auth fixture"); - let mut child = Command::new(reborn_bin()) + let mut child = reborn_command() .arg("repl") - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .env("HOME", &home_dir) .env("LLM_BACKEND", "openai_codex") @@ -2330,9 +3144,8 @@ fn run_rejects_codex_backend_when_auth_file_is_missing() { let reborn_home = temp.path().join("reborn-home"); let missing_codex_auth_path = temp.path().join("missing-codex-auth.json"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .args(["run", "-m", "ping"]) - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .env("LLM_BACKEND", "openai_codex") .env("CODEX_AUTH_PATH", &missing_codex_auth_path) @@ -2359,9 +3172,8 @@ fn run_rejects_codex_backend_when_auth_file_is_missing() { fn repl_help_command_prints_repl_commands_and_exits_on_exit() { let temp = tempfile::tempdir().expect("tempdir"); - let mut child = Command::new(reborn_bin()) + let mut child = reborn_command() .arg("repl") - .env_clear() .env("IRONCLAW_REBORN_HOME", temp.path().join("reborn-home")) .env("HOME", temp.path().join("home")) .stdin(Stdio::piped()) @@ -2394,9 +3206,8 @@ fn repl_help_command_prints_repl_commands_and_exits_on_exit() { fn run_help_command_prints_repl_commands_and_exits_on_quit() { let temp = tempfile::tempdir().expect("tempdir"); - let mut child = Command::new(reborn_bin()) + let mut child = reborn_command() .arg("run") - .env_clear() .env("IRONCLAW_REBORN_HOME", temp.path().join("reborn-home")) .env("HOME", temp.path().join("home")) .stdin(Stdio::piped()) @@ -2432,9 +3243,8 @@ fn repl_piped_message_exits_nonzero_when_runtime_does_not_produce_reply() { let temp = tempfile::tempdir().expect("tempdir"); let reborn_home = temp.path().join("reborn-home"); - let mut child = Command::new(reborn_bin()) + let mut child = reborn_command() .arg("repl") - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .env("HOME", temp.path().join("home")) .stdin(Stdio::piped()) @@ -2489,11 +3299,10 @@ fn run_message_exits_nonzero_when_runtime_does_not_produce_reply() { let temp = tempfile::tempdir().expect("tempdir"); let reborn_home = temp.path().join("reborn-home"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("run") .arg("--message") .arg("hello") - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .env("HOME", temp.path().join("home")) .output() @@ -2536,9 +3345,8 @@ fn run_message_exits_nonzero_when_runtime_does_not_produce_reply() { fn run_piped_stdin_exits_nonzero_when_runtime_does_not_produce_reply() { let temp = tempfile::tempdir().expect("tempdir"); - let mut child = Command::new(reborn_bin()) + let mut child = reborn_command() .arg("run") - .env_clear() .env("IRONCLAW_REBORN_HOME", temp.path().join("reborn-home")) .env("HOME", temp.path().join("home")) .stdin(Stdio::piped()) @@ -2773,9 +3581,8 @@ fn doctor_rejects_reborn_home_equal_to_relative_explicit_v1_base_dir() { #[test] fn doctor_rejects_empty_reborn_home_override() { - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("doctor") - .env_clear() .env("IRONCLAW_REBORN_HOME", "") .output() .expect("ironclaw-reborn doctor should run"); @@ -2790,9 +3597,8 @@ fn doctor_rejects_empty_reborn_home_override() { #[test] fn doctor_rejects_relative_reborn_home_override() { - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("doctor") - .env_clear() .env("IRONCLAW_REBORN_HOME", "relative/reborn") .output() .expect("ironclaw-reborn doctor should run"); @@ -2810,9 +3616,8 @@ fn doctor_rejects_relative_reborn_home_override() { #[test] fn doctor_rejects_missing_home_for_default_reborn_home() { - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("doctor") - .env_clear() .output() .expect("ironclaw-reborn doctor should run"); @@ -2995,9 +3800,8 @@ fn onboard_bootstraps_reborn_home_without_touching_v1_state() { let reborn_home = temp.path().join("reborn-home"); let v1_home = temp.path().join("v1-home"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("onboard") - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .env("IRONCLAW_BASE_DIR", &v1_home) .output() @@ -3059,16 +3863,13 @@ fn onboard_bootstraps_reborn_home_without_touching_v1_state() { #[test] fn onboard_is_idempotent_for_the_webui_token_file() { - // The token doubles as `serve`'s session-signing key, so a re-run of - // `onboard` must never clobber a valid existing token — that would - // invalidate every signed session and any env var an operator copied - // from the first run. + // Token doubles as serve's session-signing key: re-running onboard must + // preserve it, or every signed session and copied env var breaks. let temp = tempfile::tempdir().expect("tempdir"); let reborn_home = temp.path().join("reborn-home"); - let first = Command::new(reborn_bin()) + let first = reborn_command() .arg("onboard") - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .output() .expect("first onboard should run"); @@ -3080,9 +3881,8 @@ fn onboard_is_idempotent_for_the_webui_token_file() { let token_path = reborn_home.join("webui-token"); let first_token = std::fs::read_to_string(&token_path).expect("read webui-token"); - let second = Command::new(reborn_bin()) + let second = reborn_command() .arg("onboard") - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .output() .expect("second onboard should run"); @@ -3100,14 +3900,1478 @@ fn onboard_is_idempotent_for_the_webui_token_file() { ); } +/// Full daemon-case journey: headless `onboard` (no LLM env set) followed by +/// `serve` booted with no LLM overrides. +/// - `onboard` must write NO `[llm.default]` slot when nothing is detected +/// in env — config.toml is the single source of truth, seeded only by an +/// explicit act. Non-LLM artifacts (webui-token, marker, login link) are +/// still provisioned, and output teaches how to configure an LLM later. +/// - `serve`'s runtime-LLM resolution is unchanged: no slot + no env means +/// `resolve_reborn_runtime_llm` returns `Ok(None)`, which is not a +/// boot-time hard failure — serve still binds but logs a `warn!`. +/// - Pins both halves: onboard's teaching output/de-seeded config, and +/// serve's warn-but-still-bind behavior. +#[cfg(feature = "webui-v2-beta")] +#[test] +fn onboard_then_serve_boots_in_degraded_mode_with_an_empty_environment() { + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).expect("home dir"); + + let onboard_output = reborn_command() + .arg("onboard") + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .output() + .expect("ironclaw-reborn onboard should run"); + assert!( + onboard_output.status.success(), + "onboard must succeed non-interactively even with no LLM configured; stderr: {}", + String::from_utf8_lossy(&onboard_output.stderr) + ); + let onboard_stdout = String::from_utf8_lossy(&onboard_output.stdout); + assert!( + onboard_stdout.contains("service: skipped (non-interactive session)"), + "headless onboarding must not attempt a launchd/systemd install; stdout: {onboard_stdout}" + ); + assert!( + onboard_stdout.contains("login_link: http://127.0.0.1:3000/login?token="), + "onboard must print the CLI-token login link even with no LLM configured; stdout: \ + {onboard_stdout}" + ); + assert!( + onboard_stdout.contains("llm_credentials: skipped (non-interactive session)"), + "onboard must report the LLM step as skipped (nothing detected in env); stdout: \ + {onboard_stdout}" + ); + assert!( + onboard_stdout.contains("configure LLM credentials:") + && onboard_stdout.contains("export a provider's LLM environment variables"), + "onboard must teach how to configure an LLM afterward; stdout: {onboard_stdout}" + ); + assert!( + reborn_home.join("webui-token").exists(), + "onboard must provision the webui-token file `serve` reads as a fallback" + ); + + let config_path = reborn_home.join("config.toml"); + let config_text = std::fs::read_to_string(&config_path).expect("read seeded config.toml"); + assert!( + !config_text_has_live_provider_id(&config_text), + "config.toml is the single source of truth for `[llm.default]`, written only by an \ + explicit act — a fresh headless onboard with nothing detected in env must not seed a \ + provider: {config_text}" + ); + + let _serve_port_guard = SERVE_PORT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let port = unused_local_port(); + let mut child = reborn_command() + .args(["serve", "--host", "127.0.0.1", "--port"]) + .arg(port.to_string()) + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("ironclaw-reborn serve should start"); + let pre_banner_stderr = wait_for_serve_banner(&mut child); + assert!( + pre_banner_stderr.contains("no LLM selection configured"), + "serve must still bind (runtime resolution is unchanged: Ok(None) is not a boot-time \ + hard failure) but warn that runs will fail until an LLM is wired; stderr: \ + {pre_banner_stderr}" + ); + + let _ = child.kill(); + let _ = child.wait(); +} + +/// Sibling of `onboard_then_serve_boots_in_degraded_mode_with_an_empty_environment`: +/// a headless onboard run WITH a complete `openai`-shape env (API key + +/// model) must silently WRITE `[llm.default]` to config.toml, and a later +/// `serve` booted without `OPENAI_MODEL` set must resolve the PERSISTED +/// model, not a fresh env re-resolution (which would fall back to openai's +/// catalog default). +/// - Uses `openai`, not `nearai`: `nearai`'s `api_key_required = false` +/// would hit the pre-existing idempotency short-circuit that resolves +/// straight from env and never reaches the new write path. `openai`'s +/// idempotency check only looks at the persisted secret store, so a +/// fresh store here reaches the write and proves it happens. +#[cfg(feature = "webui-v2-beta")] +#[test] +fn onboard_with_complete_llm_env_then_serve_boots_from_the_env_seeded_slot() { + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).expect("home dir"); + const ENV_DETECTED_MODEL: &str = "gpt-test-env-detected-model"; + + let onboard_output = reborn_command() + .arg("onboard") + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .env("OPENAI_API_KEY", "sk-smoke-test-env-detected-openai-key") + .env("OPENAI_MODEL", ENV_DETECTED_MODEL) + .output() + .expect("ironclaw-reborn onboard should run"); + assert!( + onboard_output.status.success(), + "onboard must succeed non-interactively; stderr: {}", + String::from_utf8_lossy(&onboard_output.stderr) + ); + let onboard_stdout = String::from_utf8_lossy(&onboard_output.stdout); + assert!( + onboard_stdout.contains("llm_credentials: configured provider `openai`") + && onboard_stdout.contains("from environment"), + "onboard must report the env-detected provider was silently seeded; stdout: \ + {onboard_stdout}" + ); + + let config_path = reborn_home.join("config.toml"); + let config_text = std::fs::read_to_string(&config_path).expect("read env-seeded config.toml"); + assert!( + config_text.contains("provider_id = \"openai\"") + && config_text.contains(&format!("model = \"{ENV_DETECTED_MODEL}\"")), + "headless onboard with a complete openai-shape env must seed the openai slot with the \ + env-detected model: {config_text}" + ); + + // serve, booted without OPENAI_MODEL (only the key), must resolve the + // PERSISTED model from config.toml, not fall back to openai's catalog + // default. IRONCLAW_REBORN_LOG scopes the resolved-LLM debug! trace. + let _serve_port_guard = SERVE_PORT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let port = unused_local_port(); + let mut child = reborn_command() + .args(["serve", "--host", "127.0.0.1", "--port"]) + .arg(port.to_string()) + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .env("OPENAI_API_KEY", "sk-smoke-test-env-detected-openai-key") + .env("IRONCLAW_REBORN_LOG", "info,ironclaw_reborn=debug") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("ironclaw-reborn serve should start"); + let pre_banner_stderr = strip_ansi(&wait_for_serve_banner(&mut child)); + assert!( + !pre_banner_stderr.contains("no LLM selection configured"), + "serve must boot against the env-seeded slot without the degraded-mode warning; \ + stderr: {pre_banner_stderr}" + ); + assert!( + pre_banner_stderr.contains(&format!("model={ENV_DETECTED_MODEL}")), + "serve must resolve the model PERSISTED in the env-seeded slot, not a fresh \ + env-fallback re-resolution (`OPENAI_MODEL` is deliberately unset at `serve` time, so \ + a fresh env-fallback would resolve openai's catalog default `gpt-5-mini` instead); \ + stderr: {pre_banner_stderr}" + ); + + let _ = child.kill(); + let _ = child.wait(); +} + +/// Full-chain capstone: onboard's printed CLI-token login link must +/// actually work once `serve` is up, and the minted session must authorize +/// a real request against the composed WebChat v2 API — not just bind a +/// listener. +/// - Uses onboard's OWN provisioned token file (not a hand-seeded one) to +/// drive the login → ticket → exchange flow, then goes one step further +/// and uses the exchanged bearer to call the real `RebornServicesApi`, +/// proving the session is mintable AND usable. +#[cfg(feature = "webui-v2-beta")] +#[test] +fn onboard_login_link_then_bearer_authorizes_a_protected_request() { + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).expect("home dir"); + + let onboard_output = reborn_command() + .arg("onboard") + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .env("NEARAI_MODEL", "deepseek-ai/DeepSeek-V4-Flash") + .output() + .expect("ironclaw-reborn onboard should run"); + assert!( + onboard_output.status.success(), + "onboard must succeed non-interactively; stderr: {}", + String::from_utf8_lossy(&onboard_output.stderr) + ); + let onboard_stdout = String::from_utf8_lossy(&onboard_output.stdout); + assert!( + onboard_stdout.contains("login_link: http://127.0.0.1:3000/login?token="), + "onboard must print the CLI-token login link; stdout: {onboard_stdout}" + ); + let token_path = reborn_home.join("webui-token"); + assert!( + token_path.exists(), + "onboard must provision the webui-token file `serve` reads as a fallback" + ); + let webui_token = std::fs::read_to_string(&token_path) + .expect("read onboard-provisioned webui-token") + .trim() + .to_string(); + assert!( + !webui_token.is_empty(), + "onboard-provisioned webui-token must not be empty" + ); + + // Not required for serve to boot (nearai's api_key_required = false) but + // exercises the stored-key overlay path a real interactive run takes. + seed_stored_llm_key(&reborn_home, "nearai", "nearai-smoke-test-session"); + + let _serve_port_guard = SERVE_PORT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let port = unused_local_port(); + let mut child = reborn_command() + .args(["serve", "--host", "127.0.0.1", "--port"]) + .arg(port.to_string()) + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("ironclaw-reborn serve should start"); + wait_for_serve_banner(&mut child); + + // 1. Onboard-provisioned token at /login?token= must redirect into the + // ticket hand-off. + let login = http_response( + port, + &format!( + "GET /login?token={webui_token} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n" + ), + "onboard login-link probe", + ); + let login = match login { + Ok(response) => response, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + panic!("login-link probe failed: {error}"); + } + }; + assert!( + login.status_line.contains(" 302 ") || login.status_line.contains(" 303 "), + "onboard's login link must redirect into the ticket hand-off, got: {}", + login.status_line + ); + let location = login + .header("location") + .expect("redirect must carry a Location header"); + assert!( + location.starts_with("/?login_ticket="), + "redirect must land on the SPA with a login_ticket, got: {location}" + ); + let ticket = location + .split("login_ticket=") + .nth(1) + .expect("redirect Location must carry a login_ticket query param"); + + // 2. Exchange the ticket for the real session bearer. + let exchange_body = format!(r#"{{"ticket":"{ticket}"}}"#); + let exchange_request = format!( + "POST /auth/session/exchange HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{exchange_body}", + exchange_body.len() + ); + let exchange = http_response(port, &exchange_request, "session exchange probe"); + let exchange = match exchange { + Ok(response) => response, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + panic!("session exchange probe failed: {error}"); + } + }; + assert!( + exchange.status_line.contains(" 200 "), + "ticket must exchange for a real bearer, got: {}; body: {}", + exchange.status_line, + exchange.body + ); + #[derive(serde::Deserialize)] + struct ExchangeResponse { + token: String, + } + let bearer: ExchangeResponse = + serde_json::from_str(&exchange.body).expect("exchange response body must be valid JSON"); + assert!( + !bearer.token.is_empty(), + "exchanged bearer must not be empty" + ); + + // 3. The exchanged bearer must authorize a real request against the + // production RebornServicesApi, not just be well-formed — catches a + // bearer the auth middleware rejects. + let api_request = format!( + "GET /api/webchat/v2/threads HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {}\r\nConnection: close\r\n\r\n", + bearer.token + ); + let api_response = http_response(port, &api_request, "authenticated protected-route probe"); + + // 4. Authenticating with the webui token = operator/admin, whether via + // raw Bearer or this login link — the bearer must also authorize an + // operator-gated route, not just an ordinary authenticated one. + let operator_request = format!( + "GET /api/webchat/v2/operator/setup HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {}\r\nConnection: close\r\n\r\n", + bearer.token + ); + let operator_response = http_response(port, &operator_request, "operator-gated route probe"); + + let _ = child.kill(); + let _ = child.wait(); + + let api_response = api_response.expect("authenticated protected-route probe must complete"); + assert!( + !api_response.status_line.contains(" 401 ") && !api_response.status_line.contains(" 403 "), + "the login link's exchanged bearer must authorize a real WebChat v2 request, got: {}; body: {}", + api_response.status_line, + api_response.body + ); + assert!( + api_response.status_line.contains(" 200 "), + "GET /api/webchat/v2/threads with a valid bearer should succeed, got: {}; body: {}", + api_response.status_line, + api_response.body + ); + + let operator_response = operator_response.expect("operator-gated route probe must complete"); + assert!( + !operator_response.status_line.contains(" 403 "), + "the login link's exchanged bearer must authorize an operator-gated \ + request per the USER-DECIDED LAW (webui-token auth = operator), \ + got: {}; body: {}", + operator_response.status_line, + operator_response.body + ); + assert!( + operator_response.status_line.contains(" 200 "), + "GET /api/webchat/v2/operator/setup with a valid operator bearer should succeed, got: {}; body: {}", + operator_response.status_line, + operator_response.body + ); +} + +/// Minimal blocking chat-completions stub standing in for a real LLM +/// provider. Accepts NEAR AI-shaped (`POST /v1/chat/completions`) requests on +/// an OS-assigned local port, captures each request's raw `Authorization` +/// header on `auth_rx`, and answers with a fixed non-streaming completion — +/// enough for the turn loop to finish successfully without ever reaching a +/// real network endpoint. Any other path (e.g. a models-list probe) gets an +/// empty JSON object so an unexpected preflight call doesn't hang the +/// connection. +/// +/// Runs on a background `std::thread` (not tokio) because `smoke.rs` tests +/// spawn `ironclaw-reborn` as a real child process and drive it over plain +/// blocking sockets, matching `http_response`'s style above. +#[cfg(feature = "webui-v2-beta")] +fn spawn_chat_completion_stub() -> (String, std::sync::mpsc::Receiver>) { + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("bind stub listener"); + let base_url = format!( + "http://127.0.0.1:{}", + listener.local_addr().expect("stub local addr").port() + ); + let (auth_tx, auth_rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { break }; + stream + .set_read_timeout(Some(std::time::Duration::from_secs(10))) + .ok(); + let mut reader = std::io::BufReader::new(&mut stream); + let mut request_line = String::new(); + if std::io::BufRead::read_line(&mut reader, &mut request_line).unwrap_or(0) == 0 { + continue; + } + let mut headers = Vec::new(); + let mut content_length = 0usize; + let mut auth_header = None; + loop { + let mut line = String::new(); + if std::io::BufRead::read_line(&mut reader, &mut line).unwrap_or(0) == 0 { + break; + } + let trimmed = line.trim_end_matches(['\r', '\n']); + if trimmed.is_empty() { + break; + } + if let Some((name, value)) = trimmed.split_once(':') { + let name = name.trim().to_ascii_lowercase(); + let value = value.trim().to_string(); + if name == "content-length" { + content_length = value.parse().unwrap_or(0); + } + if name == "authorization" { + auth_header = Some(value.clone()); + } + headers.push((name, value)); + } + } + let mut body = vec![0u8; content_length]; + if content_length > 0 { + let _ = std::io::Read::read_exact(&mut reader, &mut body); + } + let is_chat_completion = request_line.starts_with("POST /v1/chat/completions"); + // Only report auth for the chat-completions request itself — an + // authenticated non-chat probe (e.g. a models-list preflight) + // must not be able to satisfy an assertion meant for the chat call. + if is_chat_completion { + let _ = auth_tx.send(auth_header); + } + // The reborn turn loop always drives the provider through its + // streaming method when a progress sink is wired (which it is for + // a real WebUI-driven turn, unlike an in-process `send_user_ + // message` call) — it sends `"stream": true` and expects an SSE + // body, not a plain JSON one. Detect it and answer accordingly, + // mirroring `start_nearai_auth_capture_server` in + // `ironclaw_reborn_composition`'s runtime tests. + let wants_stream = serde_json::from_slice::(&body) + .ok() + .and_then(|value| value.get("stream").and_then(serde_json::Value::as_bool)) + .unwrap_or(false); + + let response = if is_chat_completion && wants_stream { + let sse_body = concat!( + r#"data: {"choices":[{"delta":{"content":"stub reply: stored key reached the live provider"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}"#, + "\n\n", + "data: [DONE]\n\n" + ); + format!( + "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncache-control: no-cache\r\nconnection: close\r\n\r\n{sse_body}" + ) + } else { + let response_body = if is_chat_completion { + serde_json::json!({ + "id": "chatcmpl-smoke-stub", + "choices": [{ + "message": { + "role": "assistant", + "content": "stub reply: stored key reached the live provider" + }, + "finish_reason": "stop" + }], + "usage": { "prompt_tokens": 1, "completion_tokens": 1 } + }) + } else { + serde_json::json!({}) + }; + let body_text = response_body.to_string(); + format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body_text.len(), + body_text + ) + }; + let _ = std::io::Write::write_all(&mut stream, response.as_bytes()); + } + }); + + (base_url, auth_rx) +} + +/// Insert `base_url = ""` into the active (uncommented) +/// `[llm.default]` section written by `models set-provider`, which never +/// carries a `base_url` line itself (see the fixture dumped by that +/// command). Targets the one active `api_key_env = "NEARAI_API_KEY"` line +/// (the commented reference blocks further down the file are prefixed with +/// `#` and don't match this exact, unindented text). +#[cfg(feature = "webui-v2-beta")] +fn patch_config_base_url(reborn_home: &Path, base_url: &str) { + let config_path = reborn_home.join("config.toml"); + let original = std::fs::read_to_string(&config_path).expect("read config.toml to patch"); + let anchor = "api_key_env = \"NEARAI_API_KEY\"\n"; + assert!( + original.contains(anchor), + "expected an active `[llm.default]` NEAR AI selection to patch; config: {original}" + ); + let patched = original.replacen(anchor, &format!("{anchor}base_url = \"{base_url}\"\n"), 1); + std::fs::write(&config_path, patched).expect("write patched config.toml"); +} + +/// Drive a real turn through the WebChat v2 HTTP API against a running +/// `serve` process: exchange the onboard-provisioned webui token for a +/// session bearer (mirrors `onboard_login_link_then_bearer_authorizes_a_ +/// protected_request`'s steps 1-2), create a thread, send a message, and +/// poll the timeline until the assistant reply lands. Returns the reply +/// text, or `Err` with the last observed timeline body on timeout. +#[cfg(feature = "webui-v2-beta")] +fn drive_real_turn_via_webui(port: u16, webui_token: &str, label: &str) -> Result { + let login = http_response( + port, + &format!( + "GET /login?token={webui_token} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n" + ), + "login-link probe", + )?; + let location = login + .header("location") + .ok_or_else(|| format!("redirect must carry a Location header, got: {login:?}"))?; + let ticket = location + .split("login_ticket=") + .nth(1) + .ok_or_else(|| format!("redirect must carry a login_ticket, got: {location}"))? + .to_string(); + + let exchange_body = format!(r#"{{"ticket":"{ticket}"}}"#); + let exchange_request = format!( + "POST /auth/session/exchange HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{exchange_body}", + exchange_body.len() + ); + let exchange = http_response(port, &exchange_request, "session exchange probe")?; + let exchange_json: serde_json::Value = + serde_json::from_str(&exchange.body).map_err(|error| format!("exchange body: {error}"))?; + let bearer = exchange_json["token"] + .as_str() + .ok_or_else(|| format!("exchange response missing token: {exchange_json}"))? + .to_string(); + + let create_thread_body = format!(r#"{{"client_action_id":"smoke-create-thread-{label}"}}"#); + let create_thread_request = format!( + "POST /api/webchat/v2/threads HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {bearer}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{create_thread_body}", + create_thread_body.len() + ); + let created = http_response(port, &create_thread_request, "create thread")?; + let created_json: serde_json::Value = serde_json::from_str(&created.body) + .map_err(|error| format!("create-thread body: {error}"))?; + let thread_id = created_json["thread"]["thread_id"] + .as_str() + .ok_or_else(|| format!("create-thread response missing thread_id: {created_json}"))? + .to_string(); + + let message_body = + format!(r#"{{"content":"hi","client_action_id":"smoke-send-message-{label}"}}"#); + let send_request = format!( + "POST /api/webchat/v2/threads/{thread_id}/messages HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {bearer}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{message_body}", + message_body.len() + ); + let sent = http_response(port, &send_request, "send message")?; + let sent_json: serde_json::Value = + serde_json::from_str(&sent.body).map_err(|error| format!("send-message body: {error}"))?; + if let Some(outcome) = sent_json["outcome"].as_str() + && outcome != "submitted" + { + return Err(format!("turn was not submitted: {sent_json}")); + } + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + let mut last_timeline = String::new(); + while std::time::Instant::now() < deadline { + let timeline_request = format!( + "GET /api/webchat/v2/threads/{thread_id}/timeline HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {bearer}\r\nConnection: close\r\n\r\n" + ); + let timeline = http_response(port, &timeline_request, "timeline poll")?; + last_timeline = timeline.body.clone(); + if let Ok(timeline_json) = serde_json::from_str::(&timeline.body) + && let Some(messages) = timeline_json["messages"].as_array() + { + for message in messages { + let kind = message["kind"].as_str().unwrap_or_default(); + let status = message["status"].as_str().unwrap_or_default(); + if kind == "assistant" && status == "finalized" { + return Ok(message["content"].as_str().unwrap_or_default().to_string()); + } + if kind == "assistant" && status == "interrupted" { + return Err(format!( + "turn failed (assistant message interrupted): {timeline_json}" + )); + } + } + } + std::thread::sleep(std::time::Duration::from_millis(200)); + } + Err(format!( + "timed out waiting for the assistant reply; last timeline: {last_timeline}" + )) +} + +/// Journey-critical fix (PR #6174) regression pin: a provider selected +/// through `config.toml` (mirrors `models set-provider` / onboard) with its +/// key living ONLY in the encrypted secret store (never an env var) must +/// reach the turn-serving provider for a REAL turn driven through the same +/// WebUI HTTP API the browser uses — not just a boot-time trace. +/// +/// Before the fix, cold boot resolved the LLM config, then a separate +/// `apply_startup_stored_llm_key` mechanism was supposed to overlay the +/// stored key onto the model gateway directly — but demonstrably never +/// reached the turn-serving provider (see the runtime.rs fix). This test +/// pins the fix: the stub HTTP server captures the `Authorization` header +/// the live provider actually sends, and asserts it carries the stored key. +#[cfg(feature = "webui-v2-beta")] +#[test] +fn stored_key_reaches_real_turn_via_webui_api() { + const STORED_KEY: &str = "sk-smoke-real-turn-stored-nearai-key"; + + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).expect("home dir"); + + let onboard_output = reborn_command() + .arg("onboard") + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .output() + .expect("ironclaw-reborn onboard should run"); + assert!( + onboard_output.status.success(), + "onboard must succeed non-interactively; stderr: {}", + String::from_utf8_lossy(&onboard_output.stderr) + ); + let onboard_stdout = String::from_utf8_lossy(&onboard_output.stdout); + let webui_token = std::fs::read_to_string(reborn_home.join("webui-token")) + .expect("read onboard-provisioned webui-token") + .trim() + .to_string(); + assert!( + !webui_token.is_empty(), + "onboard-provisioned webui-token must not be empty; stdout: {onboard_stdout}" + ); + + let set_provider_output = reborn_command() + .args(["models", "set-provider", "nearai", "--model", "test-model"]) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .output() + .expect("ironclaw-reborn models set-provider should run"); + assert!( + set_provider_output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&set_provider_output.stderr) + ); + + // The stored key lives ONLY in the encrypted secret store — this is the + // onboard-style credential path (`onboard`'s interactive prompt / the + // webui settings surface), never an env var. + seed_stored_llm_key_at_runtime_root(&reborn_home, "nearai", STORED_KEY); + + let (stub_base_url, auth_rx) = spawn_chat_completion_stub(); + patch_config_base_url(&reborn_home, &stub_base_url); + + let _serve_port_guard = SERVE_PORT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let port = unused_local_port(); + let mut child = reborn_command() + .args(["serve", "--host", "127.0.0.1", "--port"]) + .arg(port.to_string()) + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + // No NEARAI_API_KEY / NEARAI_SESSION_TOKEN: the stored key is the + // ONLY thing that can authenticate the live provider. + .env_remove("NEARAI_API_KEY") + .env_remove("NEARAI_SESSION_TOKEN") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("ironclaw-reborn serve should start"); + let stderr_all = wait_for_serve_banner_with_capture(&mut child, "single-boot"); + + let turn_result = drive_real_turn_via_webui(port, &webui_token, "single-boot"); + if turn_result.is_err() { + eprintln!("full stderr:\n{}", stderr_all.lock().unwrap()); + } + + let _ = child.kill(); + let _ = child.wait(); + + let reply = turn_result.expect("real turn through the WebUI API must succeed"); + assert!( + reply.contains("stub reply"), + "assistant reply should come from the stub provider; got: {reply}" + ); + + let captured_auth = auth_rx + .recv_timeout(std::time::Duration::from_millis(10)) + .expect("stub must have captured at least one request's Authorization header"); + let captured_auth = captured_auth.expect("captured request must carry an Authorization header"); + assert_eq!( + captured_auth, + format!("Bearer {STORED_KEY}"), + "the live provider must authenticate with the stored key, not a session token or nothing" + ); +} + +/// Companion to `stored_key_reaches_real_turn_via_webui_api`: proves the +/// stored-key path is not a one-boot fluke by driving TWO independent `serve` +/// boots (fresh child process each time, same `reborn_home`, no `onboard` or +/// `models set-provider` run again in between) and asserting the second boot +/// also authenticates a real turn with the stored key. This is the cheaper, +/// faithful stand-in for "save settings while serve is running, then +/// restart": both scenarios exercise the same boot-reads-from-disk-and-store +/// path with no env var and no in-process state carried over — driving the +/// live `LlmConfigService` HTTP save route while `serve` is already up would +/// additionally require standing up its multipart auth/session flow, which +/// buys no extra coverage of the fix (the boot-time reload chokepoint is +/// identical either way). +#[cfg(feature = "webui-v2-beta")] +#[test] +fn stored_key_reaches_real_turn_across_fresh_boots() { + const STORED_KEY: &str = "sk-smoke-restart-stored-nearai-key"; + + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).expect("home dir"); + + let onboard_output = reborn_command() + .arg("onboard") + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .output() + .expect("ironclaw-reborn onboard should run"); + assert!( + onboard_output.status.success(), + "onboard must succeed non-interactively; stderr: {}", + String::from_utf8_lossy(&onboard_output.stderr) + ); + let webui_token = std::fs::read_to_string(reborn_home.join("webui-token")) + .expect("read onboard-provisioned webui-token") + .trim() + .to_string(); + + let set_provider_output = reborn_command() + .args(["models", "set-provider", "nearai", "--model", "test-model"]) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .output() + .expect("ironclaw-reborn models set-provider should run"); + assert!( + set_provider_output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&set_provider_output.stderr) + ); + + seed_stored_llm_key_at_runtime_root(&reborn_home, "nearai", STORED_KEY); + + let _serve_port_guard = SERVE_PORT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + for boot in 1..=2 { + let (stub_base_url, auth_rx) = spawn_chat_completion_stub(); + patch_config_base_url_replacing_previous(&reborn_home, &stub_base_url); + + let port = unused_local_port(); + let mut child = reborn_command() + .args(["serve", "--host", "127.0.0.1", "--port"]) + .arg(port.to_string()) + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .env_remove("NEARAI_API_KEY") + .env_remove("NEARAI_SESSION_TOKEN") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap_or_else(|error| { + panic!("boot {boot}: ironclaw-reborn serve should start: {error}") + }); + let stderr_all = wait_for_serve_banner_with_capture(&mut child, &format!("boot {boot}")); + + let turn_result = drive_real_turn_via_webui(port, &webui_token, &format!("boot-{boot}")); + if turn_result.is_err() { + eprintln!("boot {boot} full stderr:\n{}", stderr_all.lock().unwrap()); + } + + let _ = child.kill(); + let _ = child.wait(); + + turn_result.unwrap_or_else(|error| { + panic!("boot {boot}: real turn through the WebUI API must succeed: {error}") + }); + + let captured_auth = auth_rx + .recv_timeout(std::time::Duration::from_millis(10)) + .unwrap_or_else(|_| panic!("boot {boot}: stub must have captured a request")) + .unwrap_or_else(|| { + panic!("boot {boot}: captured request missing Authorization header") + }); + assert_eq!( + captured_auth, + format!("Bearer {STORED_KEY}"), + "boot {boot}: the live provider must authenticate with the stored key on every fresh \ + boot, not just the first" + ); + } +} + +/// Like [`patch_config_base_url`], but replaces whichever `base_url` line is +/// currently in `[llm.default]` — the previous boot's stub port is dead by +/// the time the next boot patches it in, so the second call in +/// `stored_key_reaches_real_turn_across_fresh_boots` must overwrite rather +/// than duplicate the line `patch_config_base_url` already inserted. +#[cfg(feature = "webui-v2-beta")] +fn patch_config_base_url_replacing_previous(reborn_home: &Path, base_url: &str) { + let config_path = reborn_home.join("config.toml"); + let original = std::fs::read_to_string(&config_path).expect("read config.toml to patch"); + if let Some(start) = original.find("base_url = \"http://127.0.0.1:") { + let rest = &original[start..]; + let end = rest + .find('\n') + .map(|index| start + index + 1) + .unwrap_or(original.len()); + let mut patched = String::with_capacity(original.len()); + patched.push_str(&original[..start]); + patched.push_str(&format!("base_url = \"{base_url}\"\n")); + patched.push_str(&original[end..]); + std::fs::write(&config_path, patched).expect("write patched config.toml"); + } else { + patch_config_base_url(reborn_home, base_url); + } +} + +/// Seed the local-dev encrypted secret store with an LLM API key for +/// `provider_id`, through the same `open_local_dev_secret_store` + +/// `LlmKeyStore::put` opener `onboard`'s interactive credential prompt uses +/// — bypassing the prompt UI. Also seeds the cached master-key dotfile first +/// so the resolver never reaches the OS keychain (a headless run would +/// otherwise hang on a GUI keychain prompt — see +/// `onboard_with_complete_llm_env_then_serve_boots_from_the_env_seeded_slot`'s +/// call site for the same rationale). +#[cfg(feature = "webui-v2-beta")] +fn seed_stored_llm_key(reborn_home: &Path, provider_id: &str, key: &str) { + std::fs::write( + reborn_home.join(ironclaw_reborn_composition::LOCAL_DEV_SECRETS_MASTER_KEY_PATH), + ironclaw_secrets::keychain::generate_master_key_hex(), + ) + .expect("seed cached master key dotfile"); + let seed_rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread tokio runtime for LLM key seed"); + let provider_id = provider_id.to_string(); + let key = key.to_string(); + let reborn_home = reborn_home.to_path_buf(); + seed_rt.block_on(async move { + let store = ironclaw_reborn_composition::open_local_dev_secret_store(&reborn_home) + .await + .expect("open local dev secret store"); + ironclaw_reborn_composition::LlmKeyStore::new(store) + .put(&provider_id, ironclaw_secrets::SecretMaterial::from(key)) + .await + .expect("seed provider key"); + }); +} + +/// Seed the stored LLM key at the SAME secret-store root `serve` actually +/// reads from for a `local-dev` boot — `/local-dev/…` (see +/// `local_runtime_storage_root` / `RebornProfile::local_runtime_storage_ +/// subdir`), NOT the bare `reborn_home` root [`seed_stored_llm_key`] (and +/// `onboard`'s own interactive credential prompt) write to. +/// +/// This distinction matters for these real-turn tests specifically: they +/// pin the fix to `RebornLlmReloadAdapter::reload`, which reads through +/// `RebornRuntime`'s own `services.secret_store()` — rooted at the +/// `local-dev` subdirectory. Seeding through the bare-root opener instead +/// (matching `onboard`'s CLI path) would silently miss that store and fail +/// for an unrelated reason (a pre-existing root mismatch between `onboard`'s +/// credential prompt and the runtime's own store, out of scope here — filed +/// as a follow-up). The webui settings-save path this fix's reload +/// mechanism mirrors always writes through `services.secret_store()` +/// directly, so this is the faithful root to seed for these tests. +#[cfg(feature = "webui-v2-beta")] +fn seed_stored_llm_key_at_runtime_root(reborn_home: &Path, provider_id: &str, key: &str) { + let runtime_root = reborn_home.join("local-dev"); + std::fs::create_dir_all(&runtime_root).expect("runtime local-dev root dir"); + seed_stored_llm_key(&runtime_root, provider_id, key); +} + +/// A key stored via `onboard`/`models set-provider` for an +/// `api_key_required = true` provider (openai/anthropic) must reach +/// `serve`'s runtime resolution — `apply_startup_stored_llm_key` must run +/// before `resolve_reborn_runtime_llm` fails closed on `ApiKeyEnvUnset`. +/// `nearai` (`api_key_required = false`) never exercises this path, so the +/// existing daemon-case capstones didn't catch it. +/// - Crate smoke tier (real binary spawn): the bug lives in serve's +/// pre-async-runtime boot sequence, so only a real-process boot proves +/// the fix ordering. +/// - Also proves the model scripted via `models set-provider --model` is +/// the model `serve` actually resolves, not just A model — asserted via +/// the resolved-LLM `debug!` trace, scoped into view with +/// `IRONCLAW_REBORN_LOG` (never `info!`/`warn!` per the REPL/TUI logging +/// rule). Uses a non-default model name to rule out a hardcoded fallback. +#[cfg(feature = "webui-v2-beta")] +#[test] +fn onboard_openai_key_then_serve_boots_with_env_var_unset() { + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).expect("home dir"); + + let onboard_output = reborn_command() + .arg("onboard") + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .output() + .expect("ironclaw-reborn onboard should run"); + assert!( + onboard_output.status.success(), + "onboard must succeed non-interactively; stderr: {}", + String::from_utf8_lossy(&onboard_output.stderr) + ); + + // models set-provider is the non-interactive equivalent of onboard's + // credential prompt; SCRIPTED_MODEL is deliberately non-default. + const SCRIPTED_MODEL: &str = "gpt-test-model"; + let set_provider_output = reborn_command() + .args([ + "models", + "set-provider", + "openai", + "--model", + SCRIPTED_MODEL, + ]) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .output() + .expect("ironclaw-reborn models set-provider should run"); + assert!( + set_provider_output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&set_provider_output.stderr) + ); + + seed_stored_llm_key_at_runtime_root(&reborn_home, "openai", "sk-smoke-test-stored-openai-key"); + + let _serve_port_guard = SERVE_PORT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let port = unused_local_port(); + let mut child = reborn_command() + .args(["serve", "--host", "127.0.0.1", "--port"]) + .arg(port.to_string()) + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + // No OPENAI_API_KEY: the stored key must be what makes this boot. + // Target is `ironclaw_reborn` (the bin's normalized crate name, not + // the `ironclaw_reborn_cli` package this test itself compiles as). + // `ironclaw_reborn_composition=debug` is also needed to observe + // `RebornLlmReloadAdapter::reload`'s own `key_applied` trace below — + // that's the mechanism that actually swaps the placeholder gateway + // for the stored-key-backed openai provider (PR #6174 item A). + .env( + "IRONCLAW_REBORN_LOG", + "info,ironclaw_reborn=debug,ironclaw_reborn_composition=debug", + ) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("ironclaw-reborn serve should start"); + let pre_banner_stderr = strip_ansi(&wait_for_serve_banner(&mut child)); + + let _ = child.kill(); + let _ = child.wait(); + + assert!( + pre_banner_stderr.contains("resolved LLM selection for Reborn runtime"), + "serve must emit the resolved-LLM debug trace before binding; stderr: {pre_banner_stderr}" + ); + // Regression pin for PR #6174 item A: `openai` has `api_key_required = + // true`, so unlike `nearai` it used to hit the STRICT + // `resolve_reborn_runtime_llm` inside `RebornLlmReloadAdapter::reload` + // (boot-time and Settings -> Inference save both go through it) and fail + // closed on `ApiKeyEnvUnset` before ever reaching the stored-key lookup + // — the placeholder gateway was silently never replaced. This asserts + // the live reload actually ran and applied the seeded key. + assert!( + pre_banner_stderr.contains("LLM reload applied to the live provider") + && pre_banner_stderr.contains("key_applied=true"), + "the seeded openai key must be found and applied to the live provider by the boot-time \ + LLM reload, not left on the placeholder gateway; stderr: {pre_banner_stderr}" + ); + assert!( + pre_banner_stderr.contains("provider_id=openai"), + "resolved-LLM trace must name the openai provider; stderr: {pre_banner_stderr}" + ); + assert!( + pre_banner_stderr.contains(&format!("model={SCRIPTED_MODEL}")), + "resolved-LLM trace must carry the scripted model `{SCRIPTED_MODEL}`, proving the \ + operator's `models set-provider --model` answer reached the runtime `serve` actually \ + boots with, not a hardcoded default; stderr: {pre_banner_stderr}" + ); +} + +/// Regression pin: nearai's coded default base URL used to key on API-key +/// presence at resolve time (cloud when a key was already attached, private +/// otherwise). An operator-stored key (attached to the runtime *after* +/// config resolution, via `apply_startup_stored_llm_key`) always missed that +/// window, so a key stored through `onboard`/`models set-provider` — the +/// exact path the webui settings surface uses — left the live runtime +/// pinned to the keyless private endpoint even though the same key made the +/// admin `test_connection` probe and the settings-panel catalog snapshot +/// correctly report cloud. Fixed by making nearai's coded default +/// unconditionally cloud (`ironclaw_llm::resolution::default_nearai_base_url`), +/// so resolution order no longer matters. +/// +/// `nearai`'s `api_key_required = false`, so unlike `openai` this boots +/// successfully either way — the only observable symptom was the wrong base +/// URL — asserted here on the resolved-LLM `debug!` trace's `base_url` +/// field, which fires during boot-time config resolution (no stored-key +/// application needed to observe the fix: it holds even in the fully +/// keyless case this test drives). +#[cfg(feature = "webui-v2-beta")] +#[test] +fn onboard_nearai_then_serve_boots_with_cloud_base_url() { + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).expect("home dir"); + + let onboard_output = reborn_command() + .arg("onboard") + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .output() + .expect("ironclaw-reborn onboard should run"); + assert!( + onboard_output.status.success(), + "onboard must succeed non-interactively; stderr: {}", + String::from_utf8_lossy(&onboard_output.stderr) + ); + + let set_provider_output = reborn_command() + .args(["models", "set-provider", "nearai"]) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .output() + .expect("ironclaw-reborn models set-provider should run"); + assert!( + set_provider_output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&set_provider_output.stderr) + ); + + let _serve_port_guard = SERVE_PORT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let port = unused_local_port(); + let mut child = reborn_command() + .args(["serve", "--host", "127.0.0.1", "--port"]) + .arg(port.to_string()) + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + // No NEARAI_API_KEY, no NEARAI_BASE_URL: nothing pins the base URL — + // proving the coded default itself is cloud, not a key-presence race. + .env_remove("NEARAI_API_KEY") + .env_remove("NEARAI_BASE_URL") + .env("IRONCLAW_REBORN_LOG", "info,ironclaw_reborn=debug") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("ironclaw-reborn serve should start"); + let pre_banner_stderr = strip_ansi(&wait_for_serve_banner(&mut child)); + + let _ = child.kill(); + let _ = child.wait(); + + assert!( + pre_banner_stderr.contains("resolved LLM selection for Reborn runtime"), + "serve must emit the resolved-LLM debug trace before binding; stderr: {pre_banner_stderr}" + ); + assert!( + pre_banner_stderr.contains("provider_id=nearai"), + "resolved-LLM trace must name the nearai provider; stderr: {pre_banner_stderr}" + ); + assert!( + pre_banner_stderr.contains("base_url=https://cloud-api.near.ai"), + "nearai's coded default must be the cloud endpoint even fully keyless; \ + stderr: {pre_banner_stderr}" + ); + assert!( + !pre_banner_stderr.contains("base_url=https://private.near.ai"), + "resolved-LLM trace must never carry the retired keyless-private default; \ + stderr: {pre_banner_stderr}" + ); +} + +/// Companion to `onboard_nearai_then_serve_boots_with_cloud_base_url`: that +/// test only pins the fully-keyless coded default, since `onboard`/`models +/// set-provider` never stores a NearAI key non-interactively — a regression +/// in the boot-time LLM reload (`RebornLlmReloadAdapter::reload`) or its +/// ordering relative to config resolution would still pass it. This test +/// seeds an encrypted NearAI credential AFTER provider selection, AT THE +/// SAME RUNTIME STORAGE ROOT `serve` actually opens +/// (`local_runtime_storage_root`, i.e. `/local-dev` — +/// `seed_stored_llm_key_at_runtime_root`, not the bare-root +/// `seed_stored_llm_key`, which writes to a root `serve` never reads), with +/// both NearAI env overrides removed. Asserts through the resolved-LLM +/// boot-trace seam that the late-attached stored credential still resolves +/// to the cloud endpoint, AND — the discriminating half, since +/// `default_nearai_base_url` is unconditionally cloud regardless of +/// key-presence — through `RebornLlmReloadAdapter::reload`'s own +/// `key_applied` debug trace that the seeded credential was actually found +/// and applied to the live provider, not silently skipped. +#[cfg(feature = "webui-v2-beta")] +#[test] +fn onboard_nearai_stored_key_then_serve_boots_with_cloud_base_url() { + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).expect("home dir"); + + let onboard_output = reborn_command() + .arg("onboard") + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .output() + .expect("ironclaw-reborn onboard should run"); + assert!( + onboard_output.status.success(), + "onboard must succeed non-interactively; stderr: {}", + String::from_utf8_lossy(&onboard_output.stderr) + ); + + let set_provider_output = reborn_command() + .args(["models", "set-provider", "nearai"]) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .output() + .expect("ironclaw-reborn models set-provider should run"); + assert!( + set_provider_output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&set_provider_output.stderr) + ); + + seed_stored_llm_key_at_runtime_root( + &reborn_home, + "nearai", + "session-smoke-test-stored-nearai-key", + ); + + let _serve_port_guard = SERVE_PORT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let port = unused_local_port(); + let mut child = reborn_command() + .args(["serve", "--host", "127.0.0.1", "--port"]) + .arg(port.to_string()) + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + // No NEARAI_API_KEY, no NEARAI_BASE_URL: only the stored credential + // (applied by the boot-time LLM reload) is present — proving the + // late-attached path also lands on cloud. + .env_remove("NEARAI_API_KEY") + .env_remove("NEARAI_BASE_URL") + // `key_applied` is emitted by `ironclaw_reborn_composition`'s + // `RebornLlmReloadAdapter::reload`, not the `ironclaw_reborn_cli` + // binary crate — the default filter caps that crate at `info`, so + // it must be named explicitly. + .env( + "IRONCLAW_REBORN_LOG", + "info,ironclaw_reborn=debug,ironclaw_reborn_composition=debug", + ) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("ironclaw-reborn serve should start"); + let pre_banner_stderr = strip_ansi(&wait_for_serve_banner(&mut child)); + + let _ = child.kill(); + let _ = child.wait(); + + assert!( + pre_banner_stderr.contains("resolved LLM selection for Reborn runtime"), + "serve must emit the resolved-LLM debug trace before binding; stderr: {pre_banner_stderr}" + ); + assert!( + pre_banner_stderr.contains("provider_id=nearai"), + "resolved-LLM trace must name the nearai provider; stderr: {pre_banner_stderr}" + ); + assert!( + pre_banner_stderr.contains("base_url=https://cloud-api.near.ai"), + "a NearAI key stored after provider selection and applied via the boot-time LLM reload \ + must still resolve to the cloud endpoint; stderr: {pre_banner_stderr}" + ); + assert!( + !pre_banner_stderr.contains("base_url=https://private.near.ai"), + "resolved-LLM trace must never carry the retired keyless-private default, even on the \ + late-stored-key path; stderr: {pre_banner_stderr}" + ); + assert!( + pre_banner_stderr.contains("LLM reload applied to the live provider") + && pre_banner_stderr.contains("key_applied=true"), + "the seeded credential must actually be found and applied to the live provider — a \ + discriminating signal independent of the unconditionally-cloud coded default: \ + stderr: {pre_banner_stderr}" + ); +} + +/// RAILWAY PIN 1: the production Railway deployment boots with an +/// `api_key_required = false` provider (`nearai`) and its API key env var +/// (`NEARAI_API_KEY`) set — never a stored key, never an unset-key error. +/// This pins that the stored-key fallback added for the regression above is +/// additive only: the ordinary env-var-set boot path must behave exactly as +/// before, with the secret store never even opened (an empty store, as +/// Railway's is, must not matter here — it is only ever consulted on the +/// `ApiKeyEnvUnset` error path, which this scenario never reaches). +#[cfg(feature = "webui-v2-beta")] +#[test] +fn serve_boots_with_env_api_key_set_and_empty_secret_store() { + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).expect("home dir"); + + let onboard_output = reborn_command() + .arg("onboard") + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .output() + .expect("ironclaw-reborn onboard should run"); + assert!( + onboard_output.status.success(), + "onboard must succeed non-interactively; stderr: {}", + String::from_utf8_lossy(&onboard_output.stderr) + ); + // Sanity: config carries no [llm.default] slot; this boots purely off + // NEARAI_API_KEY via the env-fallback path, matching Railway's shape. + let config_text = + std::fs::read_to_string(reborn_home.join("config.toml")).expect("read seeded config.toml"); + assert!( + !config_text_has_live_provider_id(&config_text), + "config: {config_text}" + ); + + let _serve_port_guard = SERVE_PORT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let port = unused_local_port(); + let mut child = reborn_command() + .args(["serve", "--host", "127.0.0.1", "--port"]) + .arg(port.to_string()) + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .env("NEARAI_API_KEY", "railway-shape-smoke-test-nearai-key") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("ironclaw-reborn serve should start"); + wait_for_serve_banner(&mut child); + + let _ = child.kill(); + let _ = child.wait(); +} + +/// RAILWAY PIN 2: an `api_key_required = true` provider with neither the env +/// var set nor a key in the secret store must still fail closed at boot with +/// the same `ApiKeyEnvUnset` error text as before this fix — the +/// stored-key fallback must never mask a genuine misconfiguration. +#[cfg(feature = "webui-v2-beta")] +#[test] +fn serve_fails_closed_when_neither_env_nor_store_has_the_key() { + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).expect("home dir"); + + let onboard_output = reborn_command() + .arg("onboard") + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .output() + .expect("ironclaw-reborn onboard should run"); + assert!( + onboard_output.status.success(), + "onboard must succeed non-interactively; stderr: {}", + String::from_utf8_lossy(&onboard_output.stderr) + ); + let set_provider_output = reborn_command() + .args(["models", "set-provider", "openai", "--model", "gpt-5-mini"]) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .output() + .expect("ironclaw-reborn models set-provider should run"); + assert!( + set_provider_output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&set_provider_output.stderr) + ); + // No `seed_stored_llm_key` call: the secret store stays empty. + + // Spawn + poll try_wait() with a deadline instead of blocking .output(): + // a regression that binds a listener instead of exiting must not hang + // this test (and CI) forever. + let mut child = reborn_command() + .args(["serve", "--host", "127.0.0.1", "--port", "0"]) + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + // No OPENAI_API_KEY set. + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("ironclaw-reborn serve should start"); + + // Drain stdout/stderr on background threads so a full pipe buffer can't + // block the child from exiting. + let mut stdout_reader = child.stdout.take().expect("stdout should be piped"); + let stdout_thread = std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = std::io::Read::read_to_end(&mut stdout_reader, &mut buf); + buf + }); + let mut stderr_reader = child.stderr.take().expect("stderr should be piped"); + let stderr_thread = std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = std::io::Read::read_to_end(&mut stderr_reader, &mut buf); + buf + }); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + let status = loop { + if let Some(status) = child.try_wait().expect("serve child status") { + break status; + } + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!( + "serve did not exit within the deadline; expected a fail-closed exit with \ + neither an env key nor a stored key — it may have regressed into binding a \ + listener instead" + ); + } + std::thread::sleep(std::time::Duration::from_millis(50)); + }; + + let stdout_bytes = stdout_thread.join().expect("stdout reader thread panicked"); + let stderr_bytes = stderr_thread.join().expect("stderr reader thread panicked"); + let stderr = String::from_utf8_lossy(&stderr_bytes); + + assert!( + !status.success(), + "serve must fail closed with neither an env key nor a stored key; stdout: {}", + String::from_utf8_lossy(&stdout_bytes) + ); + assert!( + stderr + .contains("llm provider `openai` requires API key env var `OPENAI_API_KEY` to be set"), + "stderr must carry the same ApiKeyEnvUnset error text as before this fix: {stderr}" + ); +} + +/// Security fix companion: `onboard`'s finale must not print a CLI-token +/// login link when the operator's env var is active — that link would point +/// at a route `serve` no longer mounts for an env-sourced token (see +/// `serve_does_not_mount_cli_login_route_when_token_is_env_sourced`). It +/// must instead note that the env token is in charge. +#[cfg(feature = "webui-v2-beta")] +#[test] +fn onboard_prints_env_token_note_instead_of_login_link_when_env_token_is_set() { + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).expect("home dir"); + + let onboard_output = reborn_command() + .arg("onboard") + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .env( + "IRONCLAW_REBORN_WEBUI_TOKEN", + "reborn-smoke-test-onboard-env-token-0123456789abcdef", + ) + .output() + .expect("ironclaw-reborn onboard should run"); + assert!( + onboard_output.status.success(), + "onboard must succeed non-interactively; stderr: {}", + String::from_utf8_lossy(&onboard_output.stderr) + ); + let stdout = String::from_utf8_lossy(&onboard_output.stdout); + assert!( + stdout.contains("login_note: IRONCLAW_REBORN_WEBUI_TOKEN is set"), + "stdout must note the active env var instead of a login link: {stdout}" + ); + assert!( + !stdout.contains("login_link:"), + "stdout must not print a login link that points at an unmounted route: {stdout}" + ); +} + +/// `status` companion: reprinting the login link must also respect +/// env-token precedence, unless `status` already knows the OS service isn't +/// running, in which case that takes priority — no point advertising a +/// login credential into a `serve` that isn't listening. +/// - The service-state query is host-wide, not scoped to this test's temp +/// $HOME, so the exact `service:` value can't be pinned (CI reads "not +/// installed"; a dev host with the real service may read differently). +/// Assert the invariant instead: `login_link` is always absent, and +/// `login_note` matches whichever branch the observed state took. +#[cfg(feature = "webui-v2-beta")] +#[test] +fn status_prints_env_token_note_instead_of_login_link_when_env_token_is_set() { + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).expect("home dir"); + + let onboard_output = reborn_command() + .arg("onboard") + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .output() + .expect("ironclaw-reborn onboard should run"); + assert!( + onboard_output.status.success(), + "onboard must succeed non-interactively; stderr: {}", + String::from_utf8_lossy(&onboard_output.stderr) + ); + + let status_output = reborn_command() + .arg("status") + .env("HOME", &home) + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .env( + "IRONCLAW_REBORN_WEBUI_TOKEN", + "reborn-smoke-test-status-env-token-0123456789abcdef", + ) + .output() + .expect("ironclaw-reborn status should run"); + assert!( + status_output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&status_output.stderr) + ); + let stdout = String::from_utf8_lossy(&status_output.stdout); + let service_line = stdout + .lines() + .find(|line| line.starts_with("service:")) + .unwrap_or_else(|| panic!("status text must include a `service:` line: {stdout}")); + let service_state = service_line.trim_start_matches("service:").trim(); + match service_state { + // `apply_service_suppression` passes `Running`/`Unknown` through + // unchanged by design — `Unknown` is what CI runners actually hit + // (no user dbus session to query), so this arm is load-bearing for + // CI, not just local-dev symmetry. + "running" | "unknown" => assert!( + stdout.contains("login_note:") && stdout.contains("IRONCLAW_REBORN_WEBUI_TOKEN is set"), + "service is running or unknown, so the env-token note must still win: {stdout}" + ), + "stopped" | "not installed" => assert!( + stdout.contains("login_note:") && stdout.contains("service is not running"), + "a known not-running service must take priority over the env-token note — \ + there is no login credential (env-sourced or not) worth advertising into \ + a `serve` process that isn't listening: {stdout}" + ), + other => panic!("unexpected service: state {other:?}: {stdout}"), + } + assert!( + !stdout.contains("login_link:"), + "stdout must not print a login link that points at an unmounted route \ + (a valid webui-token file exists from onboard, but the env var takes \ + precedence): {stdout}" + ); +} + #[test] fn onboard_dry_run_is_read_only() { let temp = tempfile::tempdir().expect("tempdir"); let reborn_home = temp.path().join("reborn-home"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .args(["onboard", "--dry-run", "--import-history"]) - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .output() .expect("ironclaw-reborn onboard --dry-run should run"); @@ -3137,9 +5401,8 @@ fn onboard_dry_run_reports_existing_marker_as_preserved() { let marker_path = reborn_home.join(".onboard-completed.json"); std::fs::write(&marker_path, "custom marker\n").expect("write marker"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .args(["onboard", "--dry-run"]) - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .output() .expect("ironclaw-reborn onboard --dry-run should run"); @@ -3207,9 +5470,8 @@ fn onboard_import_history_records_pending_step() { let temp = tempfile::tempdir().expect("tempdir"); let reborn_home = temp.path().join("reborn-home"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .args(["onboard", "--import-history"]) - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .output() .expect("ironclaw-reborn onboard --import-history should run"); @@ -3231,6 +5493,19 @@ fn onboard_import_history_records_pending_step() { ); } +/// `config.toml`'s content here ("custom config\n") is deliberately not +/// valid TOML — it used to prove Preserve-policy writes are byte-for-byte, +/// unvalidated. That's still true (the Preserve write runs before LLM- +/// credential provisioning ever inspects the file), but +/// `already_configured_outcome` now fails loud on an unparseable +/// `config.toml` rather than silently treating it as "not yet configured" +/// (see that function's doc): a corrupt config is a real problem onboard +/// must surface, not quietly succeed over. So the overall command now fails +/// — this test pins that (a) it fails with a parse-error message, and (b) +/// the artifacts written/preserved BEFORE that failure (config.toml's exact +/// bytes, the marker, providers.json) are still correct on disk, since +/// `write_default_config_files` and the marker/master-key steps all run +/// ahead of the LLM-credential step that fails. #[test] fn onboard_preserves_existing_config_without_force() { let temp = tempfile::tempdir().expect("tempdir"); @@ -3243,31 +5518,37 @@ fn onboard_preserves_existing_config_without_force() { ) .expect("write marker"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .arg("onboard") - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .output() .expect("ironclaw-reborn onboard should run"); assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) + !output.status.success(), + "onboard must fail when a pre-existing config.toml can't be parsed, not silently \ + succeed over a broken config; stdout: {}", + String::from_utf8_lossy(&output.stdout) ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert_stdout_file_action(&stdout, "config.toml", "preserved"); - assert_stdout_file_action(&stdout, "providers.json", "wrote"); - assert_stdout_labeled_action(&stdout, "onboarding_marker:", "preserved"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("could not parse config file"), + "stderr must explain the config.toml parse failure: {stderr}" + ); + let config_text = std::fs::read_to_string(reborn_home.join("config.toml")).expect("read config"); - assert_eq!(config_text, "custom config\n"); + assert_eq!( + config_text, "custom config\n", + "the Preserve write runs before the LLM-credential step, so the malformed file on disk \ + must stay untouched even though the overall command later fails" + ); let marker_text = std::fs::read_to_string(reborn_home.join(".onboard-completed.json")).expect("read marker"); assert_eq!(marker_text, "custom marker\n"); assert!( reborn_home.join("providers.json").exists(), - "missing providers file" + "missing providers file — write_default_config_files runs before the failing step" ); assert!( reborn_home.join(".onboard-completed.json").exists(), @@ -3289,9 +5570,8 @@ fn onboard_with_force_overwrites_existing_files_and_marker() { ) .expect("write marker"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .args(["onboard", "--force"]) - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .output() .expect("ironclaw-reborn onboard --force should run"); @@ -3321,6 +5601,98 @@ fn onboard_with_force_overwrites_existing_files_and_marker() { assert_eq!(marker["schema_version"], "ironclaw.reborn.onboarding/v1"); } +/// With no cached master-key dotfile and the OS keychain suppressed +/// (`IRONCLAW_DISABLE_OS_KEYCHAIN=1`, this test's real-binary equivalent of +/// a headless Linux / denied prompt), onboard must print the +/// SECRETS_MASTER_KEY/dotfile fallback note and still exit 0 — never fail +/// onboarding just because the keychain step provisioned nothing. A real +/// successful keychain write needs an actual OS keychain and stays +/// manual/E2E only. +#[test] +fn onboard_reports_suppressed_master_key_fallback_and_still_succeeds() { + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + + let output = reborn_command() + .arg("onboard") + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .output() + .expect("ironclaw-reborn onboard should run"); + + assert!( + output.status.success(), + "onboard must exit 0 even when the OS keychain is suppressed; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("master_key: OS keychain unavailable; falling back to env/dotfile"), + "stdout must report the suppressed keychain outcome: {stdout}" + ); + assert!( + stdout.contains("master_key_note:") && stdout.contains("SECRETS_MASTER_KEY"), + "stdout must print the SECRETS_MASTER_KEY/dotfile fallback note: {stdout}" + ); + // Dotfile creation stays the resolver's own auto-gen-on-first-boot job, + // not onboarding's. + assert!( + !reborn_home + .join(".reborn-local-dev-secrets-master-key") + .exists(), + "onboard's suppressed keychain path must not write the dotfile itself" + ); +} + +/// A second `onboard` run over the same Reborn home must not attempt the +/// keychain again once a cached dotfile exists (from a prior `serve`/onboard +/// boot) — it's a no-op that reports `cached dotfile already present`. +/// +/// The dotfile is seeded at the RUNTIME storage root +/// (`/local-dev/…`, `local_runtime_storage_root`'s subdir for +/// `RebornProfile::LocalDev`) — the same root the real resolver +/// (`resolve_local_dev_secret_master_key_with_env`) reads/writes and +/// `serve` actually boots against — not the bare `reborn_home` root (PR +/// #6174 item D: `provision_master_key` used to check the bare root, so its +/// `exists()` check was always false and it re-attempted keychain +/// provisioning on every rerun). +#[test] +fn onboard_master_key_provisioning_is_a_noop_once_a_dotfile_is_cached() { + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + let runtime_root = reborn_home.join("local-dev"); + std::fs::create_dir_all(&runtime_root).expect("mkdir"); + std::fs::write( + runtime_root.join(".reborn-local-dev-secrets-master-key"), + "a".repeat(64), + ) + .expect("seed cached master-key dotfile"); + + let output = reborn_command() + .arg("onboard") + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .output() + .expect("ironclaw-reborn onboard should run"); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("master_key: cached dotfile already present"), + "stdout must report the dotfile no-op: {stdout}" + ); + let dotfile_text = + std::fs::read_to_string(runtime_root.join(".reborn-local-dev-secrets-master-key")) + .expect("read cached dotfile"); + assert_eq!( + dotfile_text, + "a".repeat(64), + "an existing cached dotfile must not be rewritten by onboard" + ); +} + #[test] fn config_path_reports_file_presence() { let temp = tempfile::tempdir().expect("tempdir"); @@ -3746,9 +6118,8 @@ fn run_confirm_host_access_requires_home_or_userprofile() { let reborn_home = temp.path().join("reborn-home"); std::fs::create_dir_all(&reborn_home).expect("reborn home"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .args(["run", "--confirm-host-access", "-m", "ping"]) - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .env("IRONCLAW_REBORN_PROFILE", "local-dev-yolo") .output() @@ -3771,9 +6142,8 @@ fn run_confirm_host_access_uses_userprofile_when_home_is_absent() { std::fs::create_dir_all(&reborn_home).expect("reborn home"); std::fs::create_dir_all(&host_home).expect("host home"); - let output = Command::new(reborn_bin()) + let output = reborn_command() .args(["run", "--confirm-host-access", "-m", "ping"]) - .env_clear() .env("IRONCLAW_REBORN_HOME", &reborn_home) .env("IRONCLAW_REBORN_PROFILE", "local-dev-yolo") .env("USERPROFILE", &host_home) @@ -4300,10 +6670,9 @@ fn local_yolo_command(temp: &tempfile::TempDir, args: &[&str]) -> Command { let home = temp.path().join("home"); std::fs::create_dir_all(&reborn_home).expect("reborn home"); std::fs::create_dir_all(&home).expect("home"); - let mut command = Command::new(reborn_bin()); + let mut command = reborn_command(); command .args(args) - .env_clear() .env("IRONCLAW_REBORN_HOME", reborn_home) .env("IRONCLAW_REBORN_PROFILE", "local-dev-yolo") .env("HOME", home); diff --git a/crates/ironclaw_reborn_composition/src/factory.rs b/crates/ironclaw_reborn_composition/src/factory.rs index c156e54fded..c6d25d4e2d8 100644 --- a/crates/ironclaw_reborn_composition/src/factory.rs +++ b/crates/ironclaw_reborn_composition/src/factory.rs @@ -250,8 +250,12 @@ type LocalDevWorkspaceFilesystems = ( const LOCAL_DEV_DEFAULT_SYSTEM_PROMPT_PATH: &str = "system/prompts/default-system.md"; const LOCAL_DEV_LEGACY_SKILLS_BACKFILL_MARKER: &str = ".legacy-skills-backfilled"; const LOCAL_DEV_LEGACY_SKILLS_BACKFILL_MAX_DEPTH: usize = 64; +/// Filename of the cached local-dev secrets master-key dotfile under a +/// Reborn home / local-dev root directory. `pub` (re-exported from `lib.rs`) +/// so onboarding (`ironclaw_reborn_cli::commands::onboard`) can check for its +/// presence without duplicating the literal. #[cfg(any(feature = "libsql", feature = "postgres"))] -const LOCAL_DEV_SECRETS_MASTER_KEY_PATH: &str = ".reborn-local-dev-secrets-master-key"; +pub const LOCAL_DEV_SECRETS_MASTER_KEY_PATH: &str = ".reborn-local-dev-secrets-master-key"; #[cfg(any(test, feature = "test-support"))] #[derive(Clone)] @@ -1690,7 +1694,8 @@ async fn build_local_runtime(input: RebornBuildInput) -> Result = local_dev_secret_bundle.0.clone(); #[cfg(not(any(feature = "libsql", feature = "postgres")))] @@ -3561,7 +3566,7 @@ fn mount_local_dev_project_roots( } #[cfg(any(feature = "libsql", feature = "postgres"))] -pub(crate) fn build_local_dev_secret_store( +pub(crate) async fn build_local_dev_secret_store( root: &Path, scoped_filesystem: Arc>, explicit_master_key: Option, @@ -3577,7 +3582,7 @@ where { let master_key = match explicit_master_key { Some(master_key) => master_key, - None => resolve_local_dev_secret_master_key(root)?, + None => resolve_local_dev_secret_master_key(root).await?, }; // The crypto is returned alongside the store so the admin secret // provisioner (`admin_secrets.rs`) can build per-target-user stores that @@ -3591,12 +3596,41 @@ where Ok((store, crypto)) } +/// Open the `/secrets` store alone, without building the rest of the +/// local-dev [`CompositeRootFilesystem`] (project mounts, extension mounts, +/// trigger/project repositories, …). +/// +/// - Pre-composition entry point `ironclaw-reborn onboard` needs: it must +/// write a provider API key before a full build-input-driven build exists, +/// and reconstructing the whole composite just to reach one mount is +/// heavy and risks silently diverging from `serve`'s copy. +/// - `/secrets`'s physical backing is the same local-dev libSQL file +/// `build_local_dev_root_filesystem` opens for `/tenants` in production — +/// a key written here is immediately visible to `serve`, no extra +/// coordination needed. +/// - Uses the same resolver chain as production (env -> cached dotfile -> +/// OS keychain -> generate-and-cache, via [`build_local_dev_secret_store`]). +/// - `run_migrations()` here and again on `serve`'s later open is safe — +/// already relied on as idempotent elsewhere in this module's tests. +#[cfg(feature = "libsql")] +pub async fn open_local_dev_secret_store( + root: &Path, +) -> Result, RebornBuildError> { + let db = open_local_dev_libsql_database(root).await?; + let filesystem = Arc::new(LibSqlRootFilesystem::new(db)); + filesystem.run_migrations().await?; + let scoped = crate::wrap_scoped(filesystem); + let (store, _crypto) = build_local_dev_secret_store(root, scoped, None).await?; + Ok(store as Arc) +} + /// Where a resolved local-dev master key came from, used to name the source in /// fail-loud error messages. #[cfg(any(feature = "libsql", feature = "postgres"))] enum MasterKeySource { File(PathBuf), Env, + Keychain, } /// Validate a resolved master key against the same rules `SecretsCrypto::new` @@ -3620,6 +3654,7 @@ fn validate_resolved_master_key( "env var {}", ironclaw_secrets::keychain::SECRETS_MASTER_KEY_ENV ), + MasterKeySource::Keychain => "the OS keychain".to_string(), }; RebornBuildError::InvalidConfig { reason: format!( @@ -3632,7 +3667,7 @@ fn validate_resolved_master_key( } #[cfg(any(feature = "libsql", feature = "postgres"))] -fn resolve_local_dev_secret_master_key( +async fn resolve_local_dev_secret_master_key( root: &Path, ) -> Result { // Fail closed on an explicitly-set-but-unusable master key: only an @@ -3652,15 +3687,27 @@ fn resolve_local_dev_secret_master_key( }); } }; - resolve_local_dev_secret_master_key_with_env(root, env_key) + resolve_local_dev_secret_master_key_with_env(root, env_key).await } /// Inner resolver that takes the `SECRETS_MASTER_KEY` env value as a parameter /// so the write-before-validate invariant can be exercised through this real /// caller in tests without mutating process-global env (which is racy under /// `cargo test`'s parallel harness). +/// +/// Resolution order: cached dotfile -> explicit/env key -> OS keychain +/// (suppressed under test/CI, see +/// `ironclaw_secrets::keychain::get_master_key`) -> generate a fresh key and +/// persist it to the dotfile. The env key is VALIDATED up front so a bad +/// explicit value fails closed regardless of cached state, but a valid cached +/// dotfile deliberately wins over it: the existing secret store is encrypted +/// under the cached key, and silently switching to a different env key would +/// make that store undecryptable. A keychain hit is returned as-is and never +/// written to the dotfile — the dotfile and keychain are alternative sources +/// for the same secret, not layered, so writing both would mean the two +/// copies must agree forever. #[cfg(any(feature = "libsql", feature = "postgres"))] -fn resolve_local_dev_secret_master_key_with_env( +async fn resolve_local_dev_secret_master_key_with_env( root: &Path, env_key: Option, ) -> Result { @@ -3706,19 +3753,53 @@ fn resolve_local_dev_secret_master_key_with_env( } } - // No cached file. Prefer the explicit (already-validated) env key; otherwise - // generate a fresh one. - match env_key { - Some(key) => { - write_local_dev_secret_master_key(&key_path, &key)?; - Ok(ironclaw_secrets::SecretMaterial::from(key)) + // No cached file. Prefer the explicit (already-validated) env key. + if let Some(key) = env_key { + write_local_dev_secret_master_key(&key_path, &key)?; + return Ok(ironclaw_secrets::SecretMaterial::from(key)); + } + + // No env key either. Try the OS keychain next (suppressed under test/CI — + // see `ironclaw_secrets::keychain::get_master_key`, which returns + // `NotFound` when suppressed so this falls through exactly as it would + // for a genuinely empty keychain). Deliberately calling `get_master_key` + // directly rather than `resolve_master_key_material`: this resolver + // already owns the env-var branch above, and `resolve_master_key_material` + // re-checks the env var itself — calling it here would mean two + // independent env-precedence implementations that could disagree. + match ironclaw_secrets::keychain::get_master_key().await { + Ok(key_bytes) => { + let key_hex = key_bytes + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + validate_resolved_master_key(&key_hex, &MasterKeySource::Keychain)?; + // Keychain hit: return as-is, do not also write the dotfile — the + // dotfile and keychain are alternative sources, not layered. + return Ok(ironclaw_secrets::SecretMaterial::from(key_hex)); } - None => { - let key = ironclaw_secrets::keychain::generate_master_key_hex(); - write_local_dev_secret_master_key(&key_path, &key)?; - Ok(ironclaw_secrets::SecretMaterial::from(key)) + Err(_) => { + // Miss or error (including suppressed-under-test): fall through + // to generating a fresh key, unchanged from prior behavior. + // + // Accepted risk: intentionally blanket — this collapses "no key + // in the keychain yet" and "keychain unreachable" into the same + // fallback. Headless containers (e.g. Railway) have no + // secret-service daemon at all, so `get_master_key` returns a + // generic `SecretError::KeychainError` there, not a distinguishable + // `NotFound`; narrowing this match to only fall through on + // `NotFound` would make every container boot fail closed instead + // of falling back to the dotfile. Worst case of the current + // broad match: a transient keychain error on a real desktop + // causes a wrongly-regenerated dotfile key, which just means + // re-entering one API key on the next `onboard`/`serve` run. } } + + // No cached file, no env key, no keychain hit. Generate a fresh key. + let key = ironclaw_secrets::keychain::generate_master_key_hex(); + write_local_dev_secret_master_key(&key_path, &key)?; + Ok(ironclaw_secrets::SecretMaterial::from(key)) } #[cfg(any(feature = "libsql", feature = "postgres"))] @@ -3804,6 +3885,58 @@ fn write_local_dev_secret_master_key(path: &Path, key: &str) -> Result<(), Rebor } } +/// Outcome of provisioning a local-dev secrets master key directly into the +/// OS keychain (as opposed to `resolve_local_dev_secret_master_key_with_env`'s +/// full resolution chain, which is only consulted at boot time). Used by +/// `onboard`'s standalone keychain-provisioning step. +#[cfg(any(feature = "libsql", feature = "postgres"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeychainMasterKeyOutcome { + /// The OS keychain already has a master key from a prior onboarding run. + AlreadyPresent, + /// A fresh key was generated and stored in the OS keychain. + Provisioned, + /// The OS keychain is unavailable (suppressed under test/CI, or the OS + /// denied the write). + Suppressed, +} + +/// Facade over `ironclaw_secrets::keychain` for onboarding's OS-keychain +/// master-key provisioning step. +/// +/// - Lets callers outside this crate (`ironclaw_reborn_cli`) avoid their own +/// `ironclaw_secrets` dependency — pinned by +/// `reborn_dependency_boundaries.rs::reborn_cli_binary_crate_stays_separate_from_v1_root`. +/// - No key yet -> generate + store; already populated -> no-op `AlreadyPresent`. +/// - Never returns an error: unavailable/denied keychain reports `Suppressed`, +/// matching `resolve_local_dev_secret_master_key_with_env`'s env/dotfile fallback. +#[cfg(any(feature = "libsql", feature = "postgres"))] +pub async fn provision_local_dev_keychain_master_key() -> KeychainMasterKeyOutcome { + // `has_master_key()` collapses "no key yet" and "backend/permission/locked + // error probing the keychain" into the same `false` — a false negative + // here falls through to `generate` + `store` below, which overwrites + // whatever key the keychain actually holds. Same accepted-risk class as + // the TOCTOU documented on this function's only caller + // (`ironclaw_reborn_cli::commands::onboard::master_key::provision_master_key`): + // LocalDev, single-operator, run-once-by-hand; worst case is a + // wrongly-regenerated key recoverable by re-entering one API key. + if ironclaw_secrets::keychain::has_master_key().await { + return KeychainMasterKeyOutcome::AlreadyPresent; + } + let key = ironclaw_secrets::keychain::generate_master_key(); + match ironclaw_secrets::keychain::store_master_key(&key).await { + Ok(()) => KeychainMasterKeyOutcome::Provisioned, + Err(error) => { + tracing::debug!( + %error, + "OS keychain store of local-dev secrets master key failed during onboarding; \ + falling back to env/dotfile resolution" + ); + KeychainMasterKeyOutcome::Suppressed + } + } +} + // Intentionally uncfg'd: called from both libsql and no-libsql local-dev root // filesystem paths. fn local_dev_mount_descriptor( @@ -6287,6 +6420,7 @@ mod tests { local_dev_scoped_filesystem(rebuilt_filesystem), None, ) + .await .expect("local-dev secret store rebuild"); let lease = rebuilt_secret_store .lease_once(&scope.resource, &access_secret) @@ -6399,8 +6533,8 @@ mod tests { /// real all-zeros key an `[env] SECRETS_MASTER_KEY = "000...0"` cargo /// override writes into the cached key file. #[cfg(any(feature = "libsql", feature = "postgres"))] - #[test] - fn resolve_local_dev_secret_master_key_rejects_malformed_file_with_path_context() { + #[tokio::test] + async fn resolve_local_dev_secret_master_key_rejects_malformed_file_with_path_context() { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path(); let key_path = root.join(LOCAL_DEV_SECRETS_MASTER_KEY_PATH); @@ -6409,6 +6543,7 @@ mod tests { std::fs::write(&key_path, "0".repeat(64)).expect("write malformed key"); let error = resolve_local_dev_secret_master_key(root) + .await .expect_err("malformed local-dev master key must be rejected"); match error { @@ -6433,8 +6568,8 @@ mod tests { /// write-before-validate invariant: a rejected env key must never be /// persisted to the cached `.reborn-local-dev-secrets-master-key` file. #[cfg(any(feature = "libsql", feature = "postgres"))] - #[test] - fn resolve_local_dev_secret_master_key_rejects_malformed_env_without_persisting() { + #[tokio::test] + async fn resolve_local_dev_secret_master_key_rejects_malformed_env_without_persisting() { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path(); let key_path = root.join(LOCAL_DEV_SECRETS_MASTER_KEY_PATH); @@ -6446,6 +6581,7 @@ mod tests { // 64 zero chars: passes the length floor but has a single distinct byte, // so the entropy check rejects it. let error = resolve_local_dev_secret_master_key_with_env(root, Some("0".repeat(64))) + .await .expect_err("malformed env master key must be rejected"); match error { @@ -6471,9 +6607,9 @@ mod tests { ); } - #[test] + #[tokio::test] #[cfg(any(feature = "libsql", feature = "postgres"))] - fn resolve_local_dev_secret_master_key_rejects_set_but_empty_env_without_persisting() { + async fn resolve_local_dev_secret_master_key_rejects_set_but_empty_env_without_persisting() { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path(); let key_path = root.join(LOCAL_DEV_SECRETS_MASTER_KEY_PATH); @@ -6483,6 +6619,7 @@ mod tests { // generate + persist a fresh key the operator never chose. for empty in ["", " ", "\n\t "] { let error = resolve_local_dev_secret_master_key_with_env(root, Some(empty.to_string())) + .await .expect_err("set-but-empty env master key must be rejected"); match error { RebornBuildError::InvalidConfig { reason } => assert!( @@ -6499,9 +6636,9 @@ mod tests { } } - #[test] + #[tokio::test] #[cfg(any(feature = "libsql", feature = "postgres"))] - fn resolve_local_dev_secret_master_key_rejects_empty_env_even_with_cached_file() { + async fn resolve_local_dev_secret_master_key_rejects_empty_env_even_with_cached_file() { // Regression: the empty-env rejection must run BEFORE the cached-file // read, so an explicitly-set-but-empty SECRETS_MASTER_KEY fails closed // on a rebuild even when `.reborn-local-dev-secrets-master-key` already @@ -6510,13 +6647,20 @@ mod tests { let root = dir.path(); let key_path = root.join(LOCAL_DEV_SECRETS_MASTER_KEY_PATH); - // Seed a valid cached key first (no env value -> generated + persisted). - resolve_local_dev_secret_master_key_with_env(root, None) - .expect("seed a valid cached master key"); + // Seed directly (not through the resolver): this test is about + // empty-env/cached-file precedence, not the keychain step, and this + // crate can't suppress the OS keychain in-process (`forbid(unsafe_code)` + // blocks `set_var`; see the fallthrough test in `tests/facade_factory.rs`). + std::fs::write( + &key_path, + ironclaw_secrets::keychain::generate_master_key_hex(), + ) + .expect("seed a valid cached master key file"); assert!(key_path.exists(), "precondition: cached key file exists"); let cached_before = std::fs::read_to_string(&key_path).expect("read cached key"); let error = resolve_local_dev_secret_master_key_with_env(root, Some(" ".to_string())) + .await .expect_err("empty env must fail closed even with a cached file"); match error { RebornBuildError::InvalidConfig { reason } => assert!( @@ -6533,9 +6677,9 @@ mod tests { ); } - #[test] + #[tokio::test] #[cfg(any(feature = "libsql", feature = "postgres"))] - fn resolve_local_dev_secret_master_key_rejects_malformed_env_even_with_cached_file() { + async fn resolve_local_dev_secret_master_key_rejects_malformed_env_even_with_cached_file() { // A non-empty-but-malformed env value must also fail closed BEFORE the // cached-file read, so `SECRETS_MASTER_KEY=0000...` is not silently // ignored in favor of a valid cached key on a rebuild. @@ -6543,12 +6687,20 @@ mod tests { let root = dir.path(); let key_path = root.join(LOCAL_DEV_SECRETS_MASTER_KEY_PATH); - resolve_local_dev_secret_master_key_with_env(root, None) - .expect("seed a valid cached master key"); + // Seed directly, not through the resolver — see the comment in + // `resolve_local_dev_secret_master_key_rejects_empty_env_even_with_cached_file` + // for why a `None`-env resolver call here would hit the real OS + // keychain in-process. + std::fs::write( + &key_path, + ironclaw_secrets::keychain::generate_master_key_hex(), + ) + .expect("seed a valid cached master key file"); let cached_before = std::fs::read_to_string(&key_path).expect("read cached key"); // 64 zero chars: passes the length floor but fails the entropy check. let error = resolve_local_dev_secret_master_key_with_env(root, Some("0".repeat(64))) + .await .expect_err("malformed env must fail closed even with a cached file"); match error { RebornBuildError::InvalidConfig { reason } => assert!( @@ -6566,17 +6718,101 @@ mod tests { /// A well-formed cached key file passes through unchanged. #[cfg(any(feature = "libsql", feature = "postgres"))] - #[test] - fn resolve_local_dev_secret_master_key_accepts_valid_cached_file() { + #[tokio::test] + async fn resolve_local_dev_secret_master_key_accepts_valid_cached_file() { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path(); let valid = ironclaw_secrets::keychain::generate_master_key_hex(); std::fs::write(root.join(LOCAL_DEV_SECRETS_MASTER_KEY_PATH), &valid) .expect("write valid key"); - resolve_local_dev_secret_master_key(root).expect("valid cached key must be accepted"); + resolve_local_dev_secret_master_key(root) + .await + .expect("valid cached key must be accepted"); + } + + /// `open_local_dev_secret_store` is the narrow pre-composition opener + /// onboard needs: no full [`CompositeRootFilesystem`], just the physical + /// libSQL file backing `/secrets`. A cached master-key dotfile is seeded + /// up front so the resolver never touches the OS keychain or env (see the + /// `forbid(unsafe_code)` note above — this crate's inline tests cannot + /// mutate process env, and a cached dotfile is the non-env-mutating way + /// to make the resolver deterministic here). + #[cfg(all(feature = "libsql", feature = "root-llm-provider"))] + #[tokio::test] + async fn open_local_dev_secret_store_opens_a_working_store_over_the_bare_root() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + let valid = ironclaw_secrets::keychain::generate_master_key_hex(); + std::fs::write(root.join(LOCAL_DEV_SECRETS_MASTER_KEY_PATH), &valid) + .expect("seed cached master key"); + + let store = open_local_dev_secret_store(root) + .await + .expect("opener must succeed over a bare root"); + + let keys = crate::LlmKeyStore::new(store); + keys.put( + "nearai", + ironclaw_secrets::SecretMaterial::from("sk-test-value"), + ) + .await + .expect("put through the opened store"); + let read = keys + .read("nearai") + .await + .expect("read through the opened store") + .expect("value must be present"); + assert_eq!(secrecy::ExposeSecret::expose_secret(&read), "sk-test-value"); + } + + /// The opener is idempotent: reopening over the same root (same physical + /// db file, same cached master key) must decrypt a value written by a + /// prior open — this is the "onboard writes, serve reads" contract B2 + /// exists to satisfy. + #[cfg(all(feature = "libsql", feature = "root-llm-provider"))] + #[tokio::test] + async fn open_local_dev_secret_store_is_visible_across_reopens_of_the_same_root() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + let valid = ironclaw_secrets::keychain::generate_master_key_hex(); + std::fs::write(root.join(LOCAL_DEV_SECRETS_MASTER_KEY_PATH), &valid) + .expect("seed cached master key"); + + let first = open_local_dev_secret_store(root) + .await + .expect("first open must succeed"); + crate::LlmKeyStore::new(first) + .put( + "nearai", + ironclaw_secrets::SecretMaterial::from("sk-reopen-value"), + ) + .await + .expect("put through the first open"); + + let second = open_local_dev_secret_store(root) + .await + .expect("second open (simulating `serve`) must succeed"); + let read = crate::LlmKeyStore::new(second) + .read("nearai") + .await + .expect("read through the second open") + .expect("value written by the first open must be visible"); + assert_eq!( + secrecy::ExposeSecret::expose_secret(&read), + "sk-reopen-value" + ); } + // The keychain-fallthrough + idempotency test for + // `resolve_local_dev_secret_master_key_with_env` lives in + // `tests/facade_factory.rs` + // (`local_dev_secret_store_falls_through_suppressed_keychain_to_dotfile`): + // proving it needs the real process env var `IRONCLAW_DISABLE_OS_KEYCHAIN` + // set, and `set_var` is `unsafe` — blocked here by this crate's + // `forbid(unsafe_code)` even in `#[cfg(test)]`. `tests/*.rs` binaries are + // separate crates the `forbid` doesn't reach. + #[tokio::test] async fn local_dev_gsuite_installs_activates_and_dispatches_through_host_runtime() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/ironclaw_reborn_composition/src/lib.rs b/crates/ironclaw_reborn_composition/src/lib.rs index eb952db8632..28c73006cc5 100644 --- a/crates/ironclaw_reborn_composition/src/lib.rs +++ b/crates/ironclaw_reborn_composition/src/lib.rs @@ -81,10 +81,16 @@ pub use extension_host::gsuite::{ pub use extension_host::skill_listing::{RebornSkillListError, list_reborn_local_skills}; #[cfg(feature = "test-support")] pub use factory::AttachmentTestSupport; +#[cfg(any(feature = "libsql", feature = "postgres"))] +pub use factory::LOCAL_DEV_SECRETS_MASTER_KEY_PATH; #[cfg(feature = "test-support")] pub use factory::RebornLocalDevApprovalTestParts; #[cfg(feature = "migration-support")] pub use factory::extension_installation_store_for_migration; +#[cfg(feature = "libsql")] +pub use factory::open_local_dev_secret_store; +#[cfg(any(feature = "libsql", feature = "postgres"))] +pub use factory::{KeychainMasterKeyOutcome, provision_local_dev_keychain_master_key}; pub use factory::{RebornServices, build_reborn_services, builtin_first_party_trust_policy}; pub use failure_lane::{ALL_RUN_FAILURE_CATEGORIES, FailureLane, failure_lane}; pub use failure_summary::reborn_failure_summary_for_category; @@ -119,8 +125,8 @@ pub use ironclaw_turns::TurnStatus; #[cfg(feature = "root-llm-provider")] pub use llm_admin::llm_catalog::{ ProviderCatalogValidationError, RebornLlmCatalogError, resolve_against_registry, - resolve_llm_selection_against_catalog, resolve_reborn_runtime_llm, - validate_reborn_provider_catalog_contents, + resolve_llm_selection_against_catalog, resolve_llm_selection_allow_missing_key, + resolve_reborn_runtime_llm, validate_reborn_provider_catalog_contents, }; #[cfg(feature = "root-llm-provider")] pub use llm_admin::llm_config_service::{LlmReloadTrigger, RebornLlmConfigService}; @@ -140,6 +146,7 @@ pub use llm_admin::openai_compat_serve::build_openai_compat_route_mount; pub use ironclaw_product_adapters::mark_bearer_token_verified_for_tenant; #[cfg(feature = "root-llm-provider")] pub use llm_admin::provider_admin::{ + DetectedEnvLlm, EXAMPLE_OVERLAY_PROVIDER_ID, ProviderMenuEntry, ProviderProbeOutcome, RebornModelRoutesState, RebornProviderAdmin, RebornProviderAdminError, RebornProviderInfo, RebornProviderList, RebornProviderMetadata, RebornProviderSelection, RebornProviderStatus, RebornProviderWriteOutcome, RebornV1State, diff --git a/crates/ironclaw_reborn_composition/src/llm_admin/llm_catalog.rs b/crates/ironclaw_reborn_composition/src/llm_admin/llm_catalog.rs index f070c3eae6f..b3e0850a185 100644 --- a/crates/ironclaw_reborn_composition/src/llm_admin/llm_catalog.rs +++ b/crates/ironclaw_reborn_composition/src/llm_admin/llm_catalog.rs @@ -155,6 +155,72 @@ pub fn resolve_llm_selection_against_catalog( resolve_against_registry(selection, ®istry) } +/// Resolve a selection like [`resolve_llm_selection_against_catalog`], but +/// tolerate a required key whose env var isn't set — treats the provider as +/// keyless for this resolution only. +/// +/// - Caller: CLI runtime seam (`build_runtime_input_with_options`), only +/// after it has independently confirmed a key is durably stored locally. +/// - Store-agnostic by design: the store lookup stays at the call site; +/// `apply_startup_stored_llm_key` overlays the stored key at startup. +/// - Retries keyless only on the exact `ApiKeyEnvUnset` outcome. Any other +/// error (e.g. `ApiKeyEnvUnconfigured`) propagates unchanged — never masks +/// a genuine catalog misconfiguration. +pub fn resolve_llm_selection_allow_missing_key( + selection: &LlmSlotSelection, + user_providers_path: Option<&Path>, +) -> Result { + let registry = ProviderRegistry::try_load_from_path(user_providers_path) + .map_err(|source| RebornLlmCatalogError::CatalogLoad { source })?; + resolve_allow_missing_key_against_registry(selection, ®istry) +} + +/// Registry-level counterpart of [`resolve_llm_selection_allow_missing_key`], +/// unit-testable against a synthetic registry without touching the filesystem. +fn resolve_allow_missing_key_against_registry( + selection: &LlmSlotSelection, + registry: &ProviderRegistry, +) -> Result { + match resolve_against_registry(selection, registry) { + Ok(config) => Ok(config), + Err(RebornLlmCatalogError::ApiKeyEnvUnset { .. }) => { + let provider_id = selection + .provider_id + .as_deref() + .ok_or(RebornLlmCatalogError::MissingProviderId)?; + let keyless_registry = registry_with_provider_treated_as_keyless(registry, provider_id); + resolve_against_registry(selection, &keyless_registry) + } + Err(other) => Err(other), + } +} + +/// Clones `registry`, marking the entry matching `provider_id` (by id or +/// alias, case-insensitive, matching [`ProviderRegistry::find`]) as +/// `api_key_required = false`. Nothing else changes. +fn registry_with_provider_treated_as_keyless( + registry: &ProviderRegistry, + provider_id: &str, +) -> ProviderRegistry { + let providers = registry + .all() + .iter() + .cloned() + .map(|mut provider| { + if provider.id.eq_ignore_ascii_case(provider_id) + || provider + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(provider_id)) + { + provider.api_key_required = false; + } + provider + }) + .collect(); + ProviderRegistry::new(providers) +} + /// Validate provider-overlay bytes with the same typed definitions and /// Reborn-specific catalog checks used by runtime composition. #[derive(Debug, thiserror::Error)] @@ -550,6 +616,76 @@ mod tests { assert!(matches!(err, RebornLlmCatalogError::ApiKeyEnvUnset { .. })); } + /// Companion to `missing_required_api_key_env_fails_closed`: with the + /// keyless override applied, the same unset-env-var setup must resolve + /// (no API key on the resulting config). Drives + /// `resolve_allow_missing_key_against_registry` itself (not a + /// hand-built keyless registry) so the test fails if the + /// `ApiKeyEnvUnset` -> keyless-retry control flow regresses. + #[test] + fn allow_missing_key_resolves_a_required_key_provider_without_the_env_var_set() { + let env_name = "REBORN_TEST_UNSET_API_KEY_ALLOW_MISSING_DO_NOT_SET_7d2b"; + debug_assert!( + std::env::var(env_name).is_err(), + "test depends on `{env_name}` being unset" + ); + let registry = ProviderRegistry::new(vec![provider_with_required_key("alpha", env_name)]); + let selection = LlmSlotSelection { + provider_id: Some("alpha".to_string()), + ..Default::default() + }; + let config = resolve_allow_missing_key_against_registry(&selection, ®istry) + .expect("keyless resolution must succeed even though the API key env var is unset"); + let provider = config.provider.expect("registry provider config"); + assert!( + provider.api_key.is_none(), + "no api key should be resolved from the (still-unset) env var" + ); + } + + /// A provider with `api_key_required = true` but no `api_key_env` must + /// surface `ApiKeyEnvUnconfigured`, not be silently retried keyless — + /// only the exact `ApiKeyEnvUnset` outcome may retry keyless. + #[test] + fn allow_missing_key_does_not_mask_a_missing_api_key_env_configuration() { + let malformed = ProviderDefinition { + api_key_env: None, + ..provider_with_required_key("alpha", "UNUSED_ENV_NAME") + }; + let registry = ProviderRegistry::new(vec![malformed]); + let selection = LlmSlotSelection { + provider_id: Some("alpha".to_string()), + ..Default::default() + }; + + let err = resolve_allow_missing_key_against_registry(&selection, ®istry) + .expect_err("a provider requiring a key but missing `api_key_env` must surface, not resolve keyless"); + assert!( + matches!(err, RebornLlmCatalogError::ApiKeyEnvUnconfigured { .. }), + "unexpected error: {err:?}" + ); + } + + #[test] + fn registry_with_provider_treated_as_keyless_only_affects_the_matched_provider() { + let registry = ProviderRegistry::new(vec![ + provider_with_required_key("alpha", "REBORN_TEST_ALPHA_KEY_DO_NOT_SET_7d2b"), + provider_with_required_key("beta", "REBORN_TEST_BETA_KEY_DO_NOT_SET_7d2b"), + ]); + let keyless = registry_with_provider_treated_as_keyless(®istry, "alpha"); + assert!( + !keyless + .find("alpha") + .expect("alpha present") + .api_key_required, + "the requested provider must be treated as keyless" + ); + assert!( + keyless.find("beta").expect("beta present").api_key_required, + "every other provider's api_key_required must be untouched" + ); + } + #[test] fn malformed_api_key_env_fails_without_echoing_value() { let pasted_secret = format!("{}{}", "s", "k-proj-1234567890abcdef1234567890"); diff --git a/crates/ironclaw_reborn_composition/src/llm_admin/llm_config_service.rs b/crates/ironclaw_reborn_composition/src/llm_admin/llm_config_service.rs index ca1e5b4ad19..02c9e93ecdd 100644 --- a/crates/ironclaw_reborn_composition/src/llm_admin/llm_config_service.rs +++ b/crates/ironclaw_reborn_composition/src/llm_admin/llm_config_service.rs @@ -40,6 +40,10 @@ use crate::{LlmKeyStore, ProviderRepo, RebornProviderAdmin}; const NEARAI_LOGIN_STATE_TTL: Duration = Duration::from_secs(15 * 60); const CODEX_LOGIN_ATTEMPT_TTL: Duration = Duration::from_secs(15 * 60); +/// Bounds memory if callers mint NEAR AI login redirects but never complete +/// them; mirrors `LoginTicketStore::MAX_TICKETS` in +/// `ironclaw_webui::cli_token_login`. +const MAX_NEARAI_LOGIN_STATES: usize = 1024; /// In-memory CSRF state for NEAR AI browser redirects. The login start endpoint /// issues a state token, and the public callback must consume it before any @@ -58,6 +62,14 @@ impl NearAiLoginStateStore { let state = uuid::Uuid::new_v4().to_string(); let mut states = self.states.lock().await; prune_expired(&mut states, Instant::now()); + if states.len() >= MAX_NEARAI_LOGIN_STATES + && let Some(oldest) = states + .iter() + .min_by_key(|(_, expires_at)| **expires_at) + .map(|(key, _)| key.clone()) + { + states.remove(&oldest); + } states.insert(state.clone(), Instant::now() + NEARAI_LOGIN_STATE_TTL); state } @@ -74,14 +86,96 @@ impl NearAiLoginStateStore { } } +/// `user_code`/`verification_uri` are `None` while a reservation is +/// in-flight (see [`reserve_codex_login_slot`]) and `Some` once the device +/// code has been minted. #[derive(Debug, Clone)] struct CodexLoginAttempt { id: uuid::Uuid, - user_code: String, - verification_uri: String, + user_code: Option, + verification_uri: Option, expires_at: Instant, } +/// Outcome of reserving the codex-login slot for a key. +enum CodexLoginReserveOutcome { + /// A device code was already minted for this key; return it. + Ready(CodexLoginStart), + /// A device-code request for this key is already in flight. + InFlight, + /// No attempt existed for this key; the caller now owns a placeholder + /// reservation (inserted under the same lock) and must request a device + /// code, then finalize or remove it. + Reserved, +} + +/// Checks/reserves the codex-login slot for `key` under the map lock held +/// by the caller. A placeholder (`user_code: None`) is inserted before the +/// lock is released so a concurrent caller for the same key sees it and +/// does not also request a device code from OpenAI (each request is a +/// distinct, differently-coded grant — two in flight for one login attempt +/// would leave the tracked attempt id pointing at whichever finalized last, +/// orphaning the other flow even if the user completes it). +fn reserve_codex_login_slot( + attempts: &mut HashMap, + key: &str, + attempt_id: uuid::Uuid, + now: Instant, +) -> CodexLoginReserveOutcome { + attempts.retain(|_, attempt| attempt.expires_at > now); + if let Some(attempt) = attempts.get(key) { + return match (&attempt.user_code, &attempt.verification_uri) { + (Some(user_code), Some(verification_uri)) => { + CodexLoginReserveOutcome::Ready(CodexLoginStart { + user_code: user_code.clone(), + verification_uri: verification_uri.clone(), + }) + } + _ => CodexLoginReserveOutcome::InFlight, + }; + } + attempts.insert( + key.to_string(), + CodexLoginAttempt { + id: attempt_id, + user_code: None, + verification_uri: None, + expires_at: now + CODEX_LOGIN_ATTEMPT_TTL, + }, + ); + CodexLoginReserveOutcome::Reserved +} + +/// Finalizes a codex-login reservation with the minted device code, but only +/// if the map entry for `key` is still present and belongs to `attempt_id`. +/// If this attempt's reservation TTL lapsed before its device-code +/// initiation returned, a newer caller may have since reserved (or removed, +/// e.g. after its own failure) the same key — in either case ownership is +/// gone, so this fails rather than clobbering the newer state or reinserting +/// a reservation nothing still owns. Returns `false` when the caller must +/// abort (no polling, no insert). +fn finalize_codex_login_slot( + attempts: &mut HashMap, + key: &str, + attempt_id: uuid::Uuid, + login: &CodexLoginStart, + expires_at: Instant, +) -> bool { + if !matches!(attempts.get(key), Some(current) if current.id == attempt_id) { + return false; + } + attempts.insert( + key.to_string(), + CodexLoginAttempt { + id: attempt_id, + user_code: Some(login.user_code.clone()), + verification_uri: Some(login.verification_uri.clone()), + expires_at, + }, + ); + true +} + fn prune_expired(states: &mut HashMap, now: Instant) { states.retain(|_, expires_at| *expires_at > now); } @@ -188,7 +282,7 @@ impl RebornLlmConfigService { let metadata = info.metadata; let env_key_set = metadata.as_ref().is_some_and(metadata_env_key_set); let api_key_set = stored_key_set || env_key_set; - let base_url = provider_snapshot_base_url(&info.id, metadata.as_ref(), api_key_set); + let base_url = provider_snapshot_base_url(&info.id, metadata.as_ref()); if info.active && active.is_none() { active = Some(LlmActiveSelection { provider_id: info.id.clone(), @@ -684,16 +778,17 @@ impl LlmConfigService for RebornLlmConfigService { caller: WebUiAuthenticatedCaller, ) -> Result { let attempt_key = codex_login_attempt_key(&caller); - let now = Instant::now(); - { + let attempt_id = uuid::Uuid::new_v4(); + let reserved = { let mut attempts = self.codex_login_attempts.lock().await; - attempts.retain(|_, attempt| attempt.expires_at > now); - if let Some(attempt) = attempts.get(&attempt_key) { - return Ok(CodexLoginStart { - user_code: attempt.user_code.clone(), - verification_uri: attempt.verification_uri.clone(), - }); - } + reserve_codex_login_slot(&mut attempts, &attempt_key, attempt_id, Instant::now()) + }; + match reserved { + CodexLoginReserveOutcome::Ready(login) => return Ok(login), + // A device-code request for this key is already in flight from + // another concurrent call; don't start a second one. + CodexLoginReserveOutcome::InFlight => return Err(LlmConfigServiceError::Internal), + CodexLoginReserveOutcome::Reserved => {} } // Point the login manager at the same session file the live openai_codex @@ -707,29 +802,52 @@ impl LlmConfigService for RebornLlmConfigService { nonempty_env("OPENAI_CODEX_SESSION_PATH").map(std::path::PathBuf::from), None, ); - let manager = OpenAiCodexSessionManager::new(codex_config) - .map_err(|_| LlmConfigServiceError::Internal)?; - let start = manager - .initiate_device_code() - .await - .map_err(|_| LlmConfigServiceError::Internal)?; + let manager = match OpenAiCodexSessionManager::new(codex_config) { + Ok(manager) => manager, + Err(_) => { + remove_codex_attempt_if_current( + &self.codex_login_attempts, + &attempt_key, + attempt_id, + ) + .await; + return Err(LlmConfigServiceError::Internal); + } + }; + let start = match manager.initiate_device_code().await { + Ok(start) => start, + Err(_) => { + remove_codex_attempt_if_current( + &self.codex_login_attempts, + &attempt_key, + attempt_id, + ) + .await; + return Err(LlmConfigServiceError::Internal); + } + }; let login = CodexLoginStart { user_code: start.user_code.clone(), verification_uri: start.verification_uri.clone(), }; - let attempt_id = uuid::Uuid::new_v4(); { let mut attempts = self.codex_login_attempts.lock().await; - attempts.insert( - attempt_key.clone(), - CodexLoginAttempt { - id: attempt_id, - user_code: login.user_code.clone(), - verification_uri: login.verification_uri.clone(), - expires_at: Instant::now() + CODEX_LOGIN_ATTEMPT_TTL, - }, + let finalized = finalize_codex_login_slot( + &mut attempts, + &attempt_key, + attempt_id, + &login, + Instant::now() + CODEX_LOGIN_ATTEMPT_TTL, ); + if !finalized { + // A newer caller superseded this reservation (or removed it + // after its own failure) before our device-code request + // returned; we no longer own the slot, so abort rather than + // spawn a poller or hand back a code that isn't tracked. + tracing::debug!("codex login: reservation no longer owned at finalize; aborting"); + return Err(LlmConfigServiceError::Internal); + } } // Poll for authorization off-thread: persist the tokens, make Codex the @@ -937,7 +1055,6 @@ fn env_var_present(name: &str) -> bool { fn provider_snapshot_base_url( provider_id: &str, metadata: Option<&crate::RebornProviderMetadata>, - api_key_set: bool, ) -> Option { if let Some(base_url) = metadata .and_then(|meta| meta.base_url.as_deref()) @@ -949,7 +1066,6 @@ fn provider_snapshot_base_url( if provider_id.eq_ignore_ascii_case("nearai") { return Some(default_nearai_base_url( - api_key_set, ironclaw_common::env_helpers::env_or_override("NEARAI_BASE_URL"), )); } @@ -964,6 +1080,103 @@ fn normalized_endpoint(value: Option<&str>) -> Option { .map(|value| value.trim_end_matches('/').to_string()) } +/// Build a transient provider from a not-yet-persisted provider/key/model +/// combination and list its models — used by +/// [`crate::RebornProviderAdmin::probe_candidate`] (onboard's pre-write +/// key/model verification step). +/// +/// - Reuses [`RebornLlmConfigService::probe_provider`]'s `custom_definition` +/// + `resolve_against_registry` + `build_static_provider_chain` machinery, +/// minus its stored-key fallback: nothing is persisted yet, so only the +/// caller's inline candidate key (or none) can ever apply. +/// - Never returns `Err`: every failure folds into `ok: false` with a +/// user-safe `message`, matching `test_connection`/`list_models` — callers +/// (onboard's `provision_via_menu`) must not invent a separate error +/// channel. +pub(crate) async fn probe_candidate_provider( + request: &LlmProbeRequest, +) -> crate::ProviderProbeOutcome { + let endpoint = probe_endpoint_label(request); + let Some(protocol) = parse_adapter(&request.adapter) else { + return crate::ProviderProbeOutcome { + ok: false, + models: Vec::new(), + message: format!("unknown adapter `{}`", request.adapter), + }; + }; + let base_url = request + .base_url + .clone() + .filter(|url| !url.trim().is_empty()); + let model = request + .model + .clone() + .filter(|model| !model.trim().is_empty()) + .unwrap_or_default(); + let definition = custom_definition(&request.provider_id, protocol, base_url.clone(), model); + let registry = ProviderRegistry::new(vec![definition]); + let selection = LlmSlotSelection { + provider_id: Some(request.provider_id.clone()), + model: request + .model + .clone() + .filter(|model| !model.trim().is_empty()), + api_key_env: None, + base_url, + }; + let mut config = match resolve_against_registry(&selection, ®istry) { + Ok(config) => config, + Err(error) => { + return crate::ProviderProbeOutcome { + ok: false, + models: Vec::new(), + message: error.to_string(), + }; + } + }; + if let Some(key) = request.api_key.clone() { + apply_stored_api_key(&mut config, key); + } + + let session = ironclaw_llm::create_session_manager(config.session.clone()).await; + let provider = match ironclaw_llm::build_static_provider_chain(&config, session).await { + Ok(provider) => provider, + Err(error) => { + tracing::debug!( + provider_id = %request.provider_id, + adapter = %request.adapter, + %error, + "onboard candidate probe: provider build failed" + ); + return crate::ProviderProbeOutcome { + ok: false, + models: Vec::new(), + message: format!("could not reach {endpoint} with these settings"), + }; + } + }; + match provider.list_models().await { + Ok(models) => crate::ProviderProbeOutcome { + ok: true, + models, + message: String::new(), + }, + Err(error) => { + tracing::debug!( + provider_id = %request.provider_id, + adapter = %request.adapter, + %error, + "onboard candidate probe: list_models failed" + ); + crate::ProviderProbeOutcome { + ok: false, + models: Vec::new(), + message: format!("could not reach {endpoint} with these settings"), + } + } + } +} + /// Human-readable endpoint for probe-result messages so a connectivity failure /// names the address that was actually tried (e.g. `http://localhost:11434`) /// rather than a generic "the provider". Prefers the caller's override URL, @@ -1110,9 +1323,10 @@ fn map_admin_error(error: crate::RebornProviderAdminError) -> LlmConfigServiceEr field: None, reason, }, - E::LoadRegistry { .. } | E::LoadConfig { .. } | E::UpdateConfig { .. } => { - LlmConfigServiceError::Unavailable - } + E::LoadRegistry { .. } + | E::LoadConfig { .. } + | E::UpdateConfig { .. } + | E::EnvDetection { .. } => LlmConfigServiceError::Unavailable, } } @@ -1122,7 +1336,7 @@ mod tests { use super::*; use ironclaw_host_api::{AgentId, ProjectId, ResourceScope, SecretHandle, TenantId, UserId}; - use ironclaw_llm::{NEARAI_CLOUD_DEFAULT_BASE_URL, NEARAI_PRIVATE_DEFAULT_BASE_URL}; + use ironclaw_llm::NEARAI_CLOUD_DEFAULT_BASE_URL; use ironclaw_reborn_config::{RebornHome, RebornProfile}; use ironclaw_secrets::{ InMemorySecretStore, SecretLease, SecretLeaseId, SecretMaterial, SecretMetadata, @@ -1422,26 +1636,20 @@ mod tests { } } + /// nearai has exactly one default now — cloud — regardless of whether an + /// API key is present at resolve time. This is what fixes the runtime + /// twin of the `candidate_probe_base_url` bug: a key stored through the + /// secret store (applied *after* resolution) no longer needs to win a + /// race with base-URL selection, because there is nothing left to race. #[test] - fn nearai_default_base_url_selects_private_without_api_key() { - assert_eq!( - default_nearai_base_url(false, None), - NEARAI_PRIVATE_DEFAULT_BASE_URL - ); - } - - #[test] - fn nearai_default_base_url_selects_cloud_with_api_key() { - assert_eq!( - default_nearai_base_url(true, None), - NEARAI_CLOUD_DEFAULT_BASE_URL - ); + fn nearai_default_base_url_always_selects_cloud() { + assert_eq!(default_nearai_base_url(None), NEARAI_CLOUD_DEFAULT_BASE_URL); } #[test] fn nearai_default_base_url_prefers_explicit_base_url() { assert_eq!( - default_nearai_base_url(false, Some("https://nearai.example.test/v1".to_string())), + default_nearai_base_url(Some("https://nearai.example.test/v1".to_string())), "https://nearai.example.test/v1" ); } @@ -1492,6 +1700,25 @@ mod tests { } } + /// An unknown adapter (validated locally, not via the network) must + /// report `ok: false` like every other probe failure — never `Err` — + /// since `provision_via_menu` treats all `ok: false` the same way. + #[tokio::test] + async fn probe_candidate_provider_reports_unknown_adapter_as_not_ok() { + let mut request = probe_request("acme", "https://api.acme.test/v1", Some("sk-test")); + request.adapter = "not-a-real-adapter".to_string(); + + let outcome = probe_candidate_provider(&request).await; + + assert!(!outcome.ok); + assert!(outcome.models.is_empty()); + assert!( + outcome.message.contains("not-a-real-adapter"), + "message should name the invalid adapter: {}", + outcome.message + ); + } + #[cfg(feature = "webui-v2-beta")] #[tokio::test] async fn nearai_login_state_is_single_use() { @@ -1506,6 +1733,194 @@ mod tests { assert!(!store.consume("missing-state").await); } + // `start_codex_login`'s real device-code request hits a live network + // endpoint (OpenAI), so a deterministic two-caller concurrency test + // through the full method would need an HTTP stub server — scaffolding + // disproportionate to this fix. Instead these pin the reservation + // primitive directly: a second reserve for the same key must observe + // `InFlight` (never `Reserved` again) while the first is outstanding, + // and a failed/removed reservation must free the key for a retry. + #[test] + fn codex_login_reservation_blocks_concurrent_same_key_start() { + let mut attempts = HashMap::new(); + let key = "tenant:user"; + let now = Instant::now(); + + let first = reserve_codex_login_slot(&mut attempts, key, uuid::Uuid::new_v4(), now); + assert!(matches!(first, CodexLoginReserveOutcome::Reserved)); + + // A second concurrent caller for the same key must not also reserve + // (and thus must not also request a device code from OpenAI). + let second = reserve_codex_login_slot(&mut attempts, key, uuid::Uuid::new_v4(), now); + assert!(matches!(second, CodexLoginReserveOutcome::InFlight)); + } + + #[test] + fn codex_login_reservation_ready_after_finalize() { + let mut attempts = HashMap::new(); + let key = "tenant:user"; + let now = Instant::now(); + let attempt_id = uuid::Uuid::new_v4(); + + reserve_codex_login_slot(&mut attempts, key, attempt_id, now); + attempts.insert( + key.to_string(), + CodexLoginAttempt { + id: attempt_id, + user_code: Some("ABCD-1234".to_string()), + verification_uri: Some("https://example.test/device".to_string()), + expires_at: now + CODEX_LOGIN_ATTEMPT_TTL, + }, + ); + + let reserved = reserve_codex_login_slot(&mut attempts, key, uuid::Uuid::new_v4(), now); + match reserved { + CodexLoginReserveOutcome::Ready(login) => { + assert_eq!(login.user_code, "ABCD-1234"); + } + _ => panic!("expected Ready after finalize, got a non-Ready outcome instead"), + } + } + + /// Regression pin (PR #6174 item C): if a device-code initiation + /// outlives the reservation TTL, a newer caller can reserve the same + /// key before the stale caller's finalize runs. The stale finalize must + /// not clobber the newer caller's reservation. + #[test] + fn codex_login_finalize_does_not_clobber_a_newer_reservation() { + let mut attempts = HashMap::new(); + let key = "tenant:user"; + let now = Instant::now(); + let stale_attempt_id = uuid::Uuid::new_v4(); + + // Stale caller reserves the slot, but its TTL lapses before its + // device-code initiation returns. + reserve_codex_login_slot(&mut attempts, key, stale_attempt_id, now); + + // A newer caller reserves the same key after the stale entry expired. + let newer_now = now + CODEX_LOGIN_ATTEMPT_TTL + Duration::from_secs(1); + let newer = reserve_codex_login_slot(&mut attempts, key, uuid::Uuid::new_v4(), newer_now); + assert!(matches!(newer, CodexLoginReserveOutcome::Reserved)); + let newer_attempt_id = attempts.get(key).expect("newer reservation present").id; + + // The stale caller's device-code initiation now finally returns and + // attempts to finalize under its own (now-abandoned) attempt id. + let stale_login = CodexLoginStart { + user_code: "STALE-CODE".to_string(), + verification_uri: "https://example.test/stale".to_string(), + }; + let finalized = finalize_codex_login_slot( + &mut attempts, + key, + stale_attempt_id, + &stale_login, + newer_now + CODEX_LOGIN_ATTEMPT_TTL, + ); + assert!( + !finalized, + "a stale finalize must report failure when a newer reservation owns the key" + ); + + let current = attempts + .get(key) + .expect("newer reservation must still be present"); + assert_eq!( + current.id, newer_attempt_id, + "a stale finalize must not overwrite a newer caller's reservation" + ); + assert!( + current.user_code.is_none(), + "the newer reservation must remain in-flight (not finalized with the stale device code)" + ); + } + + /// Regression pin (PR #6174 review thread 3607817325): the prior guard + /// only blocked finalize when the entry belonged to a *different* + /// attempt id — an absent entry (newer reservation superseded the stale + /// one, then failed and removed itself before the stale device-code + /// request returned) passed through and let the stale finalize reinsert + /// itself. Ownership must require the entry to still be present and + /// match this attempt id; absent-or-mismatched must abort. + #[test] + fn codex_login_finalize_does_not_reinsert_after_removal() { + let mut attempts = HashMap::new(); + let key = "tenant:user"; + let now = Instant::now(); + let attempt_id = uuid::Uuid::new_v4(); + + reserve_codex_login_slot(&mut attempts, key, attempt_id, now); + // Simulate a newer reservation superseding this one, then failing + // and removing itself — the key is now absent entirely. + attempts.remove(key); + + let stale_login = CodexLoginStart { + user_code: "STALE-CODE".to_string(), + verification_uri: "https://example.test/stale".to_string(), + }; + let finalized = finalize_codex_login_slot( + &mut attempts, + key, + attempt_id, + &stale_login, + now + CODEX_LOGIN_ATTEMPT_TTL, + ); + + assert!( + !finalized, + "finalize must fail when the reservation is no longer owned" + ); + assert!( + !attempts.contains_key(key), + "a stale finalize must not reinsert an entry after its reservation was removed" + ); + } + + #[tokio::test] + async fn codex_login_reservation_removed_on_error_frees_key_for_retry() { + let attempts = tokio::sync::Mutex::new(HashMap::new()); + let key = "tenant:user"; + let attempt_id = uuid::Uuid::new_v4(); + { + let mut guard = attempts.lock().await; + reserve_codex_login_slot(&mut guard, key, attempt_id, Instant::now()); + } + + assert!(remove_codex_attempt_if_current(&attempts, key, attempt_id).await); + + let mut guard = attempts.lock().await; + let retried = + reserve_codex_login_slot(&mut guard, key, uuid::Uuid::new_v4(), Instant::now()); + assert!( + matches!(retried, CodexLoginReserveOutcome::Reserved), + "removing a failed reservation must free the key for a fresh attempt" + ); + } + + #[cfg(feature = "webui-v2-beta")] + #[tokio::test] + async fn nearai_login_state_store_evicts_oldest_at_capacity() { + // Unexpired states within the 15-min TTL must still be bounded, or a + // caller that mints login redirects without ever completing them + // grows this map unboundedly. Fill to capacity, mint one more, and + // confirm the oldest state was evicted while the newest survives. + let store = NearAiLoginStateStore::new(); + let mut states = Vec::with_capacity(MAX_NEARAI_LOGIN_STATES); + for _ in 0..MAX_NEARAI_LOGIN_STATES { + states.push(store.issue().await); + } + let newest = store.issue().await; + + let oldest = &states[0]; + assert!( + !store.consume(oldest).await, + "oldest state must be evicted once the store is at capacity" + ); + assert!( + store.consume(&newest).await, + "newest state must survive eviction" + ); + } + #[test] fn sanitize_origin_accepts_bare_origins_only() { assert_eq!( @@ -1927,6 +2342,8 @@ mod tests { ); } + /// NEAR AI has one default base URL — cloud — whether or not a key is + /// configured yet, so a fresh (keyless) snapshot must already show it. #[tokio::test] #[allow(clippy::await_holding_lock)] async fn nearai_snapshot_exposes_effective_default_base_url() { @@ -1942,11 +2359,15 @@ mod tests { assert_eq!( nearai.base_url.as_deref(), - Some(NEARAI_PRIVATE_DEFAULT_BASE_URL), + Some(NEARAI_CLOUD_DEFAULT_BASE_URL), "NEAR AI snapshot should show the same effective default base URL the runtime resolves" ); } + /// Same cloud default holds once a key is stored — proving stored-key + /// presence never changes the base URL (the runtime twin of this: a key + /// stored via `onboard`/`models set-provider`, applied after config + /// resolution, no longer races the base-URL default). #[tokio::test] #[allow(clippy::await_holding_lock)] async fn nearai_snapshot_uses_cloud_default_with_stored_api_key() { diff --git a/crates/ironclaw_reborn_composition/src/llm_admin/llm_key_store.rs b/crates/ironclaw_reborn_composition/src/llm_admin/llm_key_store.rs index 9b8ebae106d..1e0dac89e54 100644 --- a/crates/ironclaw_reborn_composition/src/llm_admin/llm_key_store.rs +++ b/crates/ironclaw_reborn_composition/src/llm_admin/llm_key_store.rs @@ -45,6 +45,19 @@ impl LlmKeyStore { Ok(()) } + /// [`Self::put`] taking a plain `String` rather than [`SecretMaterial`] — + /// for callers outside this crate (namely `ironclaw_reborn_cli::onboard`) + /// that must not depend on `ironclaw_secrets` directly (see + /// `crates/ironclaw_architecture/tests/reborn_dependency_boundaries.rs::reborn_cli_binary_crate_stays_separate_from_v1_root`, + /// which pins `ironclaw_reborn_cli`'s allowed workspace dependency set). + pub async fn put_plaintext( + &self, + provider_id: &str, + value: String, + ) -> Result<(), LlmKeyStoreError> { + self.put(provider_id, SecretMaterial::from(value)).await + } + /// Whether a stored key exists for `provider_id` (without revealing it). pub async fn exists(&self, provider_id: &str) -> Result { let handle = handle_for(provider_id)?; diff --git a/crates/ironclaw_reborn_composition/src/llm_admin/llm_reload.rs b/crates/ironclaw_reborn_composition/src/llm_admin/llm_reload.rs index 96be3a7f8f9..6c5198144f1 100644 --- a/crates/ironclaw_reborn_composition/src/llm_admin/llm_reload.rs +++ b/crates/ironclaw_reborn_composition/src/llm_admin/llm_reload.rs @@ -4,8 +4,12 @@ use async_trait::async_trait; use ironclaw_reborn_config::{RebornBootConfig, RebornConfigFile}; use crate::LlmKeyStore; -use crate::llm_admin::llm_catalog::{apply_stored_api_key, resolve_reborn_runtime_llm}; +use crate::llm_admin::llm_catalog::{ + RebornLlmCatalogError, apply_stored_api_key, resolve_llm_selection_allow_missing_key, + resolve_reborn_runtime_llm, +}; use crate::llm_admin::llm_config_service::LlmReloadTrigger; +use crate::runtime_input::ResolvedRebornLlm; /// Live-reload adapter wired by the runtime. Re-resolves the LLM config from /// `config.toml` + `providers.json` + the stored key, then hot-swaps the @@ -31,6 +35,52 @@ impl RebornLlmReloadAdapter { keys, } } + + /// Resolve the effective LLM selection, tolerating a required-but-unset + /// API key env var when a stored key already exists for that provider. + /// + /// Without this, a provider selected purely through `config.toml` with a + /// key that only lives in the encrypted secret store (never written to + /// an env var — the onboarding menu's stored-key path) would fail closed + /// here with `ApiKeyEnvUnset` before this adapter ever reaches the + /// stored-key lookup below, leaving the placeholder gateway wired + /// forever. Mirrors `resolve_reborn_runtime_llm_with_stored_key_fallback` + /// in the CLI's `serve` boot path — this adapter is the *other* caller of + /// the same resolution (live settings-save reload and boot-time reload), + /// so it needs the same tolerance. Only `ApiKeyEnvUnset` is treated + /// specially, and only when a stored key genuinely exists; every other + /// failure (including `ApiKeyEnvUnset` with nothing stored) surfaces + /// unchanged. + async fn resolve_effective_llm( + &self, + config_file: Option<&RebornConfigFile>, + ) -> Result, String> { + let error = match resolve_reborn_runtime_llm(&self.boot, config_file) { + Ok(resolved) => return Ok(resolved), + Err(error) => error, + }; + let RebornLlmCatalogError::ApiKeyEnvUnset { ref provider, .. } = error else { + return Err(error.to_string()); + }; + let Some(selection) = config_file.and_then(|file| file.default_llm_slot()) else { + return Err(error.to_string()); + }; + if !self + .keys + .exists(provider) + .await + .map_err(|store_error| store_error.to_string())? + { + return Err(error.to_string()); + } + resolve_llm_selection_allow_missing_key( + selection, + Some(self.boot.home().providers_file_path().as_path()), + ) + .map(ResolvedRebornLlm::from_llm_config) + .map(Some) + .map_err(|error| error.to_string()) + } } #[async_trait] @@ -38,25 +88,37 @@ impl LlmReloadTrigger for RebornLlmReloadAdapter { async fn reload(&self) -> Result<(), String> { let config_file = RebornConfigFile::load(&self.boot.home().config_file_path()) .map_err(|error| error.to_string())?; - let Some(resolved) = resolve_reborn_runtime_llm(&self.boot, config_file.as_ref()) - .map_err(|error| error.to_string())? - else { + let Some(resolved) = self.resolve_effective_llm(config_file.as_ref()).await? else { // No provider selected yet, so there is nothing to swap. return Ok(()); }; let provider_id = resolved.provider_id().to_string(); let mut config = resolved.config; - if let Some(stored) = self + let key_applied = match self .keys .read(&provider_id) .await .map_err(|error| error.to_string())? { - apply_stored_api_key(&mut config, stored); - } - self.reload_handle + Some(stored) => { + apply_stored_api_key(&mut config, stored); + true + } + None => false, + }; + let result = self + .reload_handle .reload(&config, Arc::clone(&self.session)) .await - .map_err(|error| error.to_string()) + .map_err(|error| error.to_string()); + // Never log key material — only provider id and whether a stored key + // was applied. + tracing::debug!( + provider_id = %provider_id, + key_applied, + succeeded = result.is_ok(), + "LLM reload applied to the live provider" + ); + result } } diff --git a/crates/ironclaw_reborn_composition/src/llm_admin/provider_admin.rs b/crates/ironclaw_reborn_composition/src/llm_admin/provider_admin.rs index 0b9366fae07..ac78a43c1c5 100644 --- a/crates/ironclaw_reborn_composition/src/llm_admin/provider_admin.rs +++ b/crates/ironclaw_reborn_composition/src/llm_admin/provider_admin.rs @@ -164,6 +164,55 @@ impl RebornProviderAdmin { }) } + /// Resolve `provider` (an id or alias) to its canonical registry id + /// without writing anything. + /// + /// - For callers that must land a provider-keyed secret (e.g. an + /// onboarding API-key prompt) *before* committing the `config.toml` + /// selection via [`Self::set_provider`], so a store failure never + /// leaves `config.toml` pointing at a provider with no durable key. + /// - Resolves the same canonical id `set_provider` would land in + /// `[llm.default]`, so the secret-store handle and the config + /// selection always agree. + pub fn resolve_provider_id(&self, provider: &str) -> Result { + let provider = provider.trim(); + if provider.is_empty() { + return Err(RebornProviderAdminError::InvalidRequest { + reason: "provider id cannot be empty".to_string(), + }); + } + let home = self.boot.home(); + let registry = self.load_registry()?; + let def = + registry + .find(provider) + .ok_or_else(|| RebornProviderAdminError::UnknownProvider { + provider: provider.to_string(), + providers_file: home.providers_file_path(), + known: known_provider_ids(®istry), + })?; + Ok(def.id.clone()) + } + + /// The MENU-LEVEL "requires an API key" value for `provider_id` — see + /// [`effective_api_key_required`]'s doc for the menu-level override. + /// + /// - Not restricted to menu-eligible providers: `[llm.default]` may name + /// a provider excluded from the numbered menu (e.g. set via `models + /// set-provider`), and onboard's idempotent-rerun check + /// (`already_configured_outcome`) must answer "does the currently + /// configured provider need a key" for any provider id. + /// - Returns `Ok(None)` when `provider_id` isn't in the registry at all + /// — the genuinely "can't tell" case, mirroring + /// [`Self::detect_env_llm`]'s read-only contract. + pub fn effective_api_key_required( + &self, + provider_id: &str, + ) -> Result, RebornProviderAdminError> { + let registry = self.load_registry()?; + Ok(registry.find(provider_id).map(effective_api_key_required)) + } + pub fn set_provider( &self, provider: &str, @@ -223,6 +272,165 @@ impl RebornProviderAdmin { }) } + /// Providers offered on the interactive `onboard` numbered menu, in + /// `providers.json` order (`nearai` is entry 0, so always menu item 1). + /// + /// - Filters [`ironclaw_llm::ProviderRegistry::selectable`] to + /// `SetupHint` kind `ApiKey`/`SessionToken` only — excludes `ollama`, + /// `bedrock`, `gemini_oauth`, `openai_codex` by kind, and + /// `github_copilot` by id (it declares `kind: "api_key"` like a normal + /// provider). Onboarding-scope decision: these stay reachable via + /// `ironclaw-reborn config set` / `models set-provider`, just not on + /// the numbered menu. + /// - Also excludes `OpenAiCompatible` kind (`openai_compatible`, + /// `cloudflare`) for a correctness reason, not scope: it requires a + /// base URL the menu never prompts for, so selecting it here would + /// "succeed" at onboard time and fail `serve` boot with + /// `LLM_BASE_URL` unset. + /// - Also excludes [`EXAMPLE_OVERLAY_PROVIDER_ID`] — the tenant-pinned + /// OpenRouter example `config::init` seeds into a fresh + /// `providers.json` (`PROVIDERS_STUB`), meant to show the overlay + /// file's shape, not to be picked live. Matched by id (the only + /// stable marker available). + /// - Returns the serializable [`ProviderMenuEntry`] DTO, not + /// `&ProviderDefinition`: `ironclaw_reborn_cli` must never see the + /// `ironclaw_llm` setup-hint taxonomy (pinned by + /// `reborn_dependency_boundaries`). + /// - `api_key_required` is a MENU-LEVEL value — see + /// [`effective_api_key_required`]'s doc for the `nearai` override. + pub fn menu_entries(&self) -> Result, RebornProviderAdminError> { + let registry = self.load_registry()?; + Ok(registry + .selectable() + .into_iter() + .filter(|def| def.id != "github_copilot") + .filter(|def| def.id != EXAMPLE_OVERLAY_PROVIDER_ID) + .filter(|def| { + matches!( + def.setup.as_ref(), + Some(ironclaw_llm::registry::SetupHint::ApiKey { .. }) + | Some(ironclaw_llm::registry::SetupHint::SessionToken { .. }) + ) + }) + .map(|def| ProviderMenuEntry { + id: def.id.clone(), + display_name: def + .setup + .as_ref() + .map(|setup| setup.display_name().to_string()) + .unwrap_or_else(|| def.id.clone()), + api_key_required: effective_api_key_required(def), + description: def.description.clone(), + aliases: def.aliases.clone(), + }) + .collect()) + } + + /// Detect an LLM provider configured purely through environment + /// variables — the same env resolution `resolve_reborn_runtime_llm`'s + /// fallback path and `run`/`serve`'s stub-gateway warning both use + /// (`ironclaw_llm::resolve_provider_config_from_env`), wrapped here so + /// `ironclaw_reborn_cli` (excluded from depending on `ironclaw_llm` + /// directly, per `reborn_dependency_boundaries`) can offer onboard's + /// env-detect-and-confirm/silent-seed step. + /// + /// Three outcomes, matching onboard's three branches: + /// - `Ok(Some(detected))`: a complete provider configuration was found + /// in the environment (either `LLM_BACKEND` naming a known provider, + /// Codex CLI auth, or a provider whose own env vars — API key, base + /// URL, or model — are set). + /// - `Ok(None)`: no LLM environment variables are set at all. + /// - `Err(_)`: some LLM environment configuration was present (e.g. a + /// provider's `*_MODEL` env var set) but incomplete or invalid (e.g. + /// the same provider's required API key env var unset, or + /// `LLM_BACKEND` naming an unknown provider) — a "partial env" state + /// onboard must not silently seed from. + /// + /// Never writes anything — pure detection, mirroring + /// [`Self::resolve_provider_id`]'s read-only contract. + pub fn detect_env_llm(&self) -> Result, RebornProviderAdminError> { + let providers_path = self.boot.home().providers_file_path(); + ironclaw_llm::resolve_provider_config_from_env(Some(providers_path.as_path())) + .map(|resolved| { + resolved.map(|resolved| DetectedEnvLlm { + provider_id: resolved.provider_id().to_string(), + model: resolved.model().to_string(), + }) + }) + .map_err(|source| RebornProviderAdminError::EnvDetection { + source: Box::new(source), + }) + } + + /// Re-resolve the API key for a provider previously reported by + /// [`Self::detect_env_llm`] — a second, targeted env re-read rather than + /// widening [`DetectedEnvLlm`] (which derives `Serialize` and must never + /// carry a raw secret) with the key. Used by onboard's env-accept path + /// (interactive confirm-yes and headless seed) to persist the key into + /// the encrypted secret store: the installed service only inherits + /// `IRONCLAW_REBORN_HOME`, not the operator's shell env, so a key left + /// only in env is invisible to it at boot. + /// + /// `Ok(None)` covers both "nothing resolvable from env" and "env now + /// resolves to a different provider than `provider_id`" (env changed + /// between the two calls) — callers must not persist a key under the + /// wrong provider id. + pub fn resolve_env_api_key( + &self, + provider_id: &str, + ) -> Result, RebornProviderAdminError> { + let providers_path = self.boot.home().providers_file_path(); + let resolved = + ironclaw_llm::resolve_provider_config_from_env(Some(providers_path.as_path())) + .map_err(|source| RebornProviderAdminError::EnvDetection { + source: Box::new(source), + })?; + Ok(resolved + .filter(|resolved| resolved.provider_id() == provider_id) + .and_then(|resolved| resolved.api_key().cloned())) + } + + /// Probe a candidate provider/key/model combination BEFORE it is + /// persisted — onboard's `provision_via_menu` calls this before either + /// durable write (secret store, then `[llm.default]`), so a rejected or + /// unreachable key never lands in config. `api_key` is the caller's + /// inline candidate (`None` for a keyless provider); `model` is the + /// caller's override, or `None` to probe the catalog default. + /// + /// Reuses the webui2 "test connection"/"list models" probe machinery + /// ([`crate::llm_admin::llm_config_service::probe_candidate_provider`]), + /// minus its stored-key fallback (nothing is persisted yet here). + /// + /// Only errors when `provider_id` isn't in the registry — a + /// network/auth/adapter failure during the probe itself reports inside + /// `Ok(ProviderProbeOutcome { ok: false, .. })`, matching that shared + /// helper's no-separate-error-channel contract. + pub async fn probe_candidate( + &self, + provider_id: &str, + api_key: Option, + model: Option<&str>, + ) -> Result { + let home = self.boot.home(); + let registry = self.load_registry()?; + let definition = registry.find(provider_id).ok_or_else(|| { + RebornProviderAdminError::UnknownProvider { + provider: provider_id.to_string(), + providers_file: home.providers_file_path(), + known: known_provider_ids(®istry), + } + })?; + let base_url = candidate_probe_base_url(definition); + let request = ironclaw_product_workflow::LlmProbeRequest { + adapter: provider_protocol_wire_name(definition.protocol), + base_url, + provider_id: provider_id.to_string(), + model: model.map(str::to_string), + api_key, + }; + Ok(crate::llm_admin::llm_config_service::probe_candidate_provider(&request).await) + } + fn load_registry(&self) -> Result { let providers_path = self.boot.home().providers_file_path(); ironclaw_llm::ProviderRegistry::try_load_from_path(Some(providers_path.as_path())).map_err( @@ -304,6 +512,69 @@ pub struct RebornProviderWriteOutcome { pub v1_state: RebornV1State, } +/// An LLM provider fully resolvable from environment variables alone — see +/// [`RebornProviderAdmin::detect_env_llm`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DetectedEnvLlm { + pub provider_id: String, + pub model: String, +} + +/// Result of probing a not-yet-persisted provider/key/model combination — +/// see [`RebornProviderAdmin::probe_candidate`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderProbeOutcome { + pub ok: bool, + pub models: Vec, + pub message: String, +} + +/// Id of the tenant-pinned OpenRouter example overlay entry +/// `ironclaw_reborn_cli::commands::config::init::PROVIDERS_STUB` seeds into +/// a fresh `providers.json` — see [`RebornProviderAdmin::menu_entries`]'s +/// doc for why this is filtered off the numbered menu. Named constant (not +/// an inline literal) because the two crates aren't type-linked (the stub +/// is a raw JSON string literal); `menu_entries_excludes_the_example_overlay_provider` +/// pins them staying in sync. +pub const EXAMPLE_OVERLAY_PROVIDER_ID: &str = "acme-openrouter"; + +/// One entry on the interactive `onboard` numbered provider menu — see +/// [`RebornProviderAdmin::menu_entries`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProviderMenuEntry { + pub id: String, + pub display_name: String, + /// A MENU-LEVEL value — see [`effective_api_key_required`]'s doc for + /// why this can differ from the raw `providers.json` `api_key_required` + /// field for `session_token`-kind providers (`nearai`). + pub api_key_required: bool, + pub description: String, + pub aliases: Vec, +} + +/// Whether `definition` should be treated as requiring an API key for +/// reborn onboarding purposes — the value [`RebornProviderAdmin::menu_entries`] +/// puts in [`ProviderMenuEntry::api_key_required`], and what +/// [`RebornProviderAdmin::effective_api_key_required`] returns for a single +/// provider id (used by onboard's idempotent-rerun check, which must agree +/// with the menu on what "requires a key" means — see that method's doc). +/// +/// A `session_token`-kind definition (`nearai`) is overridden to `true` +/// here even though the raw catalog field is `false`: session-token auth is +/// not wired in reborn (no `SessionRenewer` attaches at `serve` boot), so +/// nearai requires an API key (cloud-api.near.ai) exactly like every other +/// menu-eligible provider. Every other kind (`api_key`) passes its raw +/// `api_key_required` value through unchanged. +fn effective_api_key_required(definition: &ironclaw_llm::registry::ProviderDefinition) -> bool { + if matches!( + definition.setup.as_ref(), + Some(ironclaw_llm::registry::SetupHint::SessionToken { .. }) + ) { + return true; + } + definition.api_key_required +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub enum RebornV1State { #[serde(rename = "not-used")] @@ -369,6 +640,13 @@ pub enum RebornProviderAdminError { path: PathBuf, source: Box, }, + /// [`RebornProviderAdmin::detect_env_llm`]'s "partial env" outcome: some + /// LLM environment configuration was present but incomplete or invalid. + #[error("environment LLM configuration is incomplete: {source}")] + EnvDetection { + #[source] + source: Box, + }, } #[derive(Debug, Clone)] @@ -528,6 +806,31 @@ fn provider_protocol_wire_name(protocol: ironclaw_llm::registry::ProviderProtoco .unwrap_or_else(|| "unknown".to_string()) } +/// Base URL to probe for a not-yet-persisted candidate provider. +/// +/// `providers.json` leaves `default_base_url` unset for protocols whose +/// default lives in code rather than the catalog (today: only `nearai`, +/// which defaults to the cloud NEAR AI endpoint — see +/// [`ironclaw_llm::default_nearai_base_url`]). Passing `None` straight +/// through here would make the resolver derive an empty base URL for those +/// protocols. Every other protocol either carries its own +/// `default_base_url` in the catalog or (Bedrock, GeminiOauth, +/// OpenAiCodex) never consumes `base_url` at all, so they need no +/// fallback here. +fn candidate_probe_base_url( + definition: &ironclaw_llm::registry::ProviderDefinition, +) -> Option { + if let Some(base_url) = definition.default_base_url.clone() { + return Some(base_url); + } + if definition.protocol == ironclaw_llm::registry::ProviderProtocol::NearAi { + return Some(ironclaw_llm::default_nearai_base_url( + ironclaw_common::env_helpers::env_or_override("NEARAI_BASE_URL"), + )); + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -602,4 +905,261 @@ mod tests { assert_eq!(active.provider_id.as_deref(), Some("anthropic")); assert_eq!(active.model.as_deref(), Some("claude-pinned-by-config")); } + + fn test_admin() -> RebornProviderAdmin { + let temp = tempfile::tempdir().expect("tempdir"); + let home = ironclaw_reborn_config::RebornHome::resolve_from_env_parts( + Some(temp.path().join("reborn-home").as_os_str().to_os_string()), + None, + None, + ) + .expect("valid reborn home"); + // Leak the tempdir so the on-disk stub outlives this function; only + // needs to exist as a valid, empty root (nothing is ever written). + std::mem::forget(temp); + RebornProviderAdmin::new(RebornBootConfig::new( + home, + ironclaw_reborn_config::RebornProfile::LocalDev, + )) + } + + #[test] + fn menu_entries_lists_nearai_first_and_requires_an_api_key() { + let admin = test_admin(); + let entries = admin.menu_entries().expect("menu entries load"); + let first = entries.first().expect("at least one menu entry"); + assert_eq!( + first.id, "nearai", + "nearai must be menu item 1: {entries:?}" + ); + // Menu-level override: no session-token auth wired in reborn, so + // nearai requires a key here despite the raw catalog entry. + assert!( + first.api_key_required, + "nearai must require an API key on the reborn onboard menu (no session-token auth \ + wired): {first:?}" + ); + } + + /// `effective_api_key_required` must agree with `menu_entries`'s + /// override for `nearai` (`true`, not raw catalog `false`), pass + /// `openai`'s raw value through, and return `None` for an unknown id. + #[test] + fn effective_api_key_required_overrides_session_token_providers() { + let admin = test_admin(); + assert_eq!( + admin + .effective_api_key_required("nearai") + .expect("nearai known"), + Some(true) + ); + assert_eq!( + admin + .effective_api_key_required("openai") + .expect("openai known"), + Some(true) + ); + assert_eq!( + admin + .effective_api_key_required("not-a-real-provider") + .expect("lookup succeeds even when unknown"), + None + ); + } + + #[test] + fn menu_entries_excludes_non_menu_setup_kinds() { + let admin = test_admin(); + let entries = admin.menu_entries().expect("menu entries load"); + let ids: Vec<&str> = entries.iter().map(|entry| entry.id.as_str()).collect(); + for excluded in [ + "ollama", + "bedrock", + "gemini_oauth", + "openai_codex", + "github_copilot", + "openai_compatible", + "cloudflare", + ] { + assert!( + !ids.contains(&excluded), + "{excluded} must be excluded from the onboard menu: {ids:?}" + ); + } + } + + /// `openai_compatible` requires a base URL the numbered menu never + /// prompts for; selecting it would "succeed" at onboard time and fail + /// `serve` boot with `LLM_BASE_URL` unset. Pinned separately from the + /// scope exclusions above — this is a correctness bug, not scope. + #[test] + fn menu_entries_excludes_openai_compatible_base_url_trap() { + let admin = test_admin(); + let entries = admin.menu_entries().expect("menu entries load"); + assert!( + entries.iter().all(|entry| entry.id != "openai_compatible"), + "openai_compatible must never appear on the onboard menu: {entries:?}" + ); + } + + #[test] + fn menu_entries_populate_aliases() { + let admin = test_admin(); + let entries = admin.menu_entries().expect("menu entries load"); + let github_copilot_absent = entries.iter().find(|entry| entry.id == "github_copilot"); + assert!(github_copilot_absent.is_none()); + let openai = entries + .iter() + .find(|entry| entry.id == "openai") + .expect("openai present on menu"); + assert!( + !openai.aliases.is_empty(), + "openai should carry its registry aliases: {openai:?}" + ); + } + + /// The tenant-pinned OpenRouter example overlay entry (same shape + /// `PROVIDERS_STUB` writes: id `acme-openrouter`, kind `api_key`, + /// otherwise indistinguishable from a real menu-eligible provider) must + /// never appear on the numbered menu. Pins the id-equality filter + /// against [`EXAMPLE_OVERLAY_PROVIDER_ID`]. + #[test] + fn menu_entries_excludes_the_example_overlay_provider() { + let temp = tempfile::tempdir().expect("tempdir"); + let home = ironclaw_reborn_config::RebornHome::resolve_from_env_parts( + Some(temp.path().join("reborn-home").as_os_str().to_os_string()), + None, + None, + ) + .expect("valid reborn home"); + std::fs::create_dir_all(home.path()).expect("create reborn home dir"); + + // Built from the REAL `ironclaw_reborn_cli::commands::config::init:: + // PROVIDERS_STUB` JSON (not a hand-typed duplicate) so this test + // catches drift between that stub's id and + // `EXAMPLE_OVERLAY_PROVIDER_ID` instead of two disjoint fixtures + // agreeing by coincidence. + let stub_definitions: Vec = + serde_json::from_str(providers_stub_json()).expect("PROVIDERS_STUB must parse as JSON"); + assert_eq!( + stub_definitions.len(), + 1, + "this test assumes PROVIDERS_STUB seeds exactly one overlay entry: {stub_definitions:?}" + ); + let overlay_definition = stub_definitions.into_iter().next().expect("checked above"); + assert_eq!( + overlay_definition.id, EXAMPLE_OVERLAY_PROVIDER_ID, + "PROVIDERS_STUB's overlay id has drifted from EXAMPLE_OVERLAY_PROVIDER_ID" + ); + crate::ProviderRepo::new(home.providers_file_path()) + .upsert(overlay_definition) + .expect("write example overlay"); + + let admin = RebornProviderAdmin::new(RebornBootConfig::new( + home, + ironclaw_reborn_config::RebornProfile::LocalDev, + )); + let entries = admin.menu_entries().expect("menu entries load"); + assert!( + entries + .iter() + .all(|entry| entry.id != EXAMPLE_OVERLAY_PROVIDER_ID), + "the tenant-pinned OpenRouter example overlay must never appear on the onboard \ + menu: {entries:?}" + ); + } + + /// Extract the raw JSON text of `PROVIDERS_STUB` from + /// `ironclaw_reborn_cli::commands::config::init`'s source, via + /// `include_str!` — composition can't depend on `ironclaw_reborn_cli` + /// (only the reverse), so this reads the file text directly rather than + /// duplicating the JSON literal, keeping the fixture used above tied to + /// the actual stub `config init`/`onboard` write. + fn providers_stub_json() -> &'static str { + const INIT_RS: &str = + include_str!("../../../ironclaw_reborn_cli/src/commands/config/init.rs"); + const START_MARKER: &str = "const PROVIDERS_STUB: &str = r#\""; + let start = INIT_RS.find(START_MARKER).unwrap_or_else(|| { + panic!( + "PROVIDERS_STUB definition not found in ironclaw_reborn_cli's init.rs — this \ + test's extraction marker has drifted from the real source" + ) + }) + START_MARKER.len(); + let end = INIT_RS[start..] + .find("\"#;") + .expect("PROVIDERS_STUB closing delimiter `\"#;` not found"); + &INIT_RS[start..start + end] + } + + /// `detect_env_llm` must return `Ok(None)` with no LLM env vars set — + /// the fresh-onboard case that must fall through to the full menu. + /// The `Ok(Some(_))`/`Err(_)` branches can't be covered in-process + /// (`forbid(unsafe_code)` blocks `set_var`); covered at the CLI smoke + /// tier instead via `Command::env` on a real child process. + #[test] + fn detect_env_llm_is_none_with_no_llm_env_vars_set() { + let admin = test_admin(); + let detected = admin + .detect_env_llm() + .expect("detection must not error with a clean environment"); + assert!( + detected.is_none(), + "detect_env_llm must report no detection with no LLM env vars set: {detected:?}" + ); + } + + /// `nearai`'s catalog entry carries no `default_base_url` — its default + /// lives in code and is now unconditionally the cloud endpoint (no more + /// has-key branch to thread through the probe). A candidate probe must + /// resolve to the cloud endpoint, not `None`/empty, or the resolver + /// falls through to an empty base URL and every probe reports "could + /// not reach the provider endpoint". + #[test] + fn candidate_probe_base_url_defaults_nearai_to_cloud() { + let registry = ProviderRegistry::try_load_from_path(None).expect("builtin registry"); + let nearai = registry.find("nearai").expect("nearai in builtin registry"); + assert_eq!( + nearai.default_base_url, None, + "fixture assumption: nearai's catalog entry has no default_base_url" + ); + + let base_url = candidate_probe_base_url(nearai); + + assert_eq!( + base_url.as_deref(), + Some(ironclaw_llm::NEARAI_CLOUD_DEFAULT_BASE_URL), + "a nearai probe must target the cloud endpoint, got {base_url:?}" + ); + } + + /// Every other builtin provider either carries its own + /// `default_base_url` (openai, anthropic, ollama, …) or never consumes + /// `base_url` at all (bedrock, gemini_oauth, openai_codex) — none of + /// them should gain a synthesized fallback here. + #[test] + fn candidate_probe_base_url_only_special_cases_nearai() { + let registry = ProviderRegistry::try_load_from_path(None).expect("builtin registry"); + for definition in unique_provider_definitions(®istry) { + if definition.protocol == ProviderProtocol::NearAi { + continue; + } + assert_eq!( + candidate_probe_base_url(definition), + definition.default_base_url.clone(), + "provider `{}` must not gain a synthesized probe base URL", + definition.id + ); + } + } + + // `probe_candidate`'s live-stub tests (`spawn_models_stub`, + // `write_stub_provider`, and the two probe tests) moved to + // `tests/provider_admin_probe.rs`: the architecture boundary test + // `reborn_product_api_crates_do_not_bind_http_ingress` greps every + // `.rs` file under this crate's `src/` for a loopback-listener bind + // call with no `#[cfg(test)]` awareness (by design — it's a text + // scan, not a compile-aware check), so an in-module stub server + // trips it even though it never runs in production. `tests/` sits + // outside the scanned roots; `webui_v2_serve.rs` already binds a + // loopback listener there for the same reason. } diff --git a/crates/ironclaw_reborn_composition/src/llm_admin/provider_admin_product_command.rs b/crates/ironclaw_reborn_composition/src/llm_admin/provider_admin_product_command.rs index 059f6393a44..4b27226338d 100644 --- a/crates/ironclaw_reborn_composition/src/llm_admin/provider_admin_product_command.rs +++ b/crates/ironclaw_reborn_composition/src/llm_admin/provider_admin_product_command.rs @@ -172,6 +172,17 @@ fn provider_admin_workflow_error(error: RebornProviderAdminError) -> ProductWork config_update_error_reason(source.as_ref()) ), }, + RebornProviderAdminError::EnvDetection { source } => { + tracing::debug!( + error = %source, + "environment LLM detection failed while handling a product LLM-admin command" + ); + ProductWorkflowError::InvalidBindingRequest { + reason: "environment provider detection failed; check provider environment \ + variables" + .to_string(), + } + } } } @@ -225,3 +236,27 @@ fn config_update_error_reason( } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// `EnvDetection` denotes incomplete/invalid operator env configuration + /// (`RebornProviderAdmin::detect_env_llm`'s "partial env" outcome), not a + /// transient backend failure — it must map to `InvalidBindingRequest` so + /// callers don't retry a config problem as if it were flaky. + #[test] + fn env_detection_maps_to_invalid_binding_request_not_transient() { + let error = RebornProviderAdminError::EnvDetection { + source: Box::new(ironclaw_llm::LlmError::InvalidResponse { + provider: "openai".to_string(), + reason: "OPENAI_API_KEY is unset but OPENAI_MODEL is set".to_string(), + }), + }; + let mapped = provider_admin_workflow_error(error); + assert!( + matches!(mapped, ProductWorkflowError::InvalidBindingRequest { .. }), + "EnvDetection must map to InvalidBindingRequest, not Transient: {mapped:?}" + ); + } +} diff --git a/crates/ironclaw_reborn_composition/src/runtime.rs b/crates/ironclaw_reborn_composition/src/runtime.rs index 8f02691079e..c75105dea3e 100644 --- a/crates/ironclaw_reborn_composition/src/runtime.rs +++ b/crates/ironclaw_reborn_composition/src/runtime.rs @@ -3210,13 +3210,20 @@ pub async fn build_reborn_runtime( invocation_id: InvocationId::new(), }; let mut services = build_reborn_services(services_input).await?; - #[cfg(feature = "root-llm-provider")] - let llm = - apply_startup_stored_llm_key(llm, crate::LlmKeyStore::new(services.secret_store())).await?; + // The stored key no longer feeds the model gateway here (see the + // post-construction reload below); the NEAR AI MCP bootstrap check is a + // separate consumer that inspects `llm.config.nearai.api_key` directly, + // so it still needs the key overlaid onto a local clone. #[cfg(feature = "root-llm-provider")] if !has_nearai_mcp_bootstrap_config { - bootstrap_nearai_mcp_from_effective_llm(&services, llm.as_ref(), nearai_mcp_owner_scope) - .await?; + let llm_for_mcp_bootstrap = + overlay_stored_llm_key_for_nearai_mcp_bootstrap(llm.clone(), &services).await?; + bootstrap_nearai_mcp_from_effective_llm( + &services, + llm_for_mcp_bootstrap.as_ref(), + nearai_mcp_owner_scope, + ) + .await?; } enforce_runtime_cutover_gate(profile, &services.readiness)?; @@ -3360,10 +3367,11 @@ pub async fn build_reborn_runtime( // building a real gateway only to discard it wastes startup work (and, on // the cold-boot path, an LLM session manager), which made // timeout-sensitive tests flaky. When no override is set, build normally. - // Build the (optional) skill-learning provider from the resolved LLM config - // BEFORE the gateway consumes `llm`. Distillation/refinement runs against a - // stronger model (IRONCLAW_SKILL_LEARNING_MODEL), reusing the run's NEAR AI - // credentials with only the model overridden. + // Build the (optional) skill-learning provider from the resolved LLM config. + // Distillation/refinement runs against a stronger model + // (IRONCLAW_SKILL_LEARNING_MODEL), reusing the run's NEAR AI credentials + // with only the model overridden. `llm` no longer feeds the model gateway + // build below (see `build_production_model_gateway`). #[cfg(feature = "root-llm-provider")] let skill_learning_provider = match llm.as_ref() { Some(resolved) => build_skill_learning_provider(&resolved.config).await, @@ -3372,13 +3380,13 @@ pub async fn build_reborn_runtime( #[cfg(all(feature = "root-llm-provider", any(test, feature = "test-support")))] let (model_gateway, llm_cost_table, llm_reload) = match model_gateway_override { Some(override_gateway) => (override_gateway, None, None), - None => build_production_model_gateway(llm).await?, + None => build_production_model_gateway().await?, }; #[cfg(all( feature = "root-llm-provider", not(any(test, feature = "test-support")) ))] - let (model_gateway, llm_cost_table, llm_reload) = build_production_model_gateway(llm).await?; + let (model_gateway, llm_cost_table, llm_reload) = build_production_model_gateway().await?; #[cfg(all( not(feature = "root-llm-provider"), any(test, feature = "test-support") @@ -4124,6 +4132,29 @@ pub async fn build_reborn_runtime( // doesn't disturb its later use. #[cfg(feature = "inmemory-turn-state")] let turn_state_flush = local_runtime.map(|lr| Arc::clone(&lr.turn_state)); + + // Apply the effective LLM config (config.toml/env selection + any stored + // key) to the placeholder gateway exactly once, via the same live-reload + // path the settings UI uses (see `webui_llm_reload_trigger`). Failure + // degrades like a boot with no LLM configured: placeholder stays wired, + // operator retries through Settings -> Inference without a restart. + #[cfg(feature = "root-llm-provider")] + if let (Some(boot_config), Some(reload_parts)) = (boot.as_ref(), llm_reload.as_ref()) { + let boot_reload_adapter = crate::llm_admin::llm_reload::RebornLlmReloadAdapter::new( + boot_config.clone(), + Arc::clone(&reload_parts.reload_handle), + Arc::clone(&reload_parts.session), + crate::LlmKeyStore::new(services.secret_store()), + ); + if let Err(error) = crate::LlmReloadTrigger::reload(&boot_reload_adapter).await { + tracing::warn!( + %error, + "boot-time LLM reload failed; the placeholder provider stays active until the \ + next successful reload (e.g. through Settings -> Inference)" + ); + } + } + Ok(RebornRuntime { services, turn_coordinator, @@ -4405,15 +4436,21 @@ fn local_dev_filesystem_skill_context_source( }) } +/// Overlay the stored LLM key (if any) onto a clone of `llm`, scoped to +/// feeding [`bootstrap_nearai_mcp_from_effective_llm`]'s `api_key` presence +/// check (it inspects the config directly, not the live provider). NOT the +/// general "stored key -> live provider" mechanism — that's +/// [`RebornLlmReloadAdapter::reload`], invoked once after boot construction. #[cfg(feature = "root-llm-provider")] -async fn apply_startup_stored_llm_key( +async fn overlay_stored_llm_key_for_nearai_mcp_bootstrap( llm: Option, - keys: crate::LlmKeyStore, + services: &RebornServices, ) -> Result, RebornRuntimeError> { let Some(mut llm) = llm else { return Ok(None); }; + let keys = crate::LlmKeyStore::new(services.secret_store()); if let Some(stored) = keys .read(llm.provider_id()) .await @@ -4523,13 +4560,17 @@ impl CapabilitySurfaceProfileResolver for AllowAllCapabilitySurfaceResolver { } } -/// Build the production model gateway and its (optional) LLM-derived -/// cost table. Cfg-gated so off-feature builds short-circuit to the -/// stub without referencing types that don't exist. +/// Build the production model gateway. Cfg-gated so off-feature builds +/// short-circuit to the stub without referencing types that don't exist. +/// +/// Cold boot ALWAYS starts from the placeholder-backed swappable gateway, +/// even when an LLM was resolved at boot — the effective config (including +/// any stored key) is applied exactly once, right after construction, +/// through the same live-reload path the settings UI uses +/// (`RebornLlmReloadAdapter::reload`). No cost table is derived here: there's +/// no real model to cost until that reload swaps in a real provider. #[cfg(feature = "root-llm-provider")] -async fn build_production_model_gateway( - llm: Option, -) -> Result< +async fn build_production_model_gateway() -> Result< ( Arc, Option, @@ -4537,31 +4578,10 @@ async fn build_production_model_gateway( ), RebornRuntimeError, > { - // Even with no LLM configured at boot we build a real swappable gateway - // around a placeholder provider (which errors until swapped) plus a reload - // handle. That way the FIRST configuration made through the settings UI - // hot-swaps the placeholder into a working provider without a restart — - // otherwise a cold boot would wire a dead stub with no reload seam. - match llm { - Some(cfg) => { - let LlmGatewayBundle { - gateway, - policy, - reload, - } = build_llm_gateway(cfg).await?; - Ok((gateway, Some(policy.build_cost_table()), Some(reload))) - } - None => { - let LlmGatewayBundle { - gateway, reload, .. - } = build_placeholder_llm_gateway().await?; - // No cost table for the placeholder: there is no real model to cost, - // and a synthetic table would gate budgets against a model that - // isn't actually in use. The budget cost table is (re)derived when a - // real provider is configured + the binary restarts. - Ok((gateway, None, Some(reload))) - } - } + let LlmGatewayBundle { + gateway, reload, .. + } = build_placeholder_llm_gateway().await?; + Ok((gateway, None, Some(reload))) } /// Build a dedicated provider for the skill-learning model, when configured. @@ -4616,10 +4636,6 @@ fn build_production_model_gateway() -> Result< #[cfg(feature = "root-llm-provider")] struct LlmGatewayBundle { gateway: Arc, - /// Policy used to derive the budget accountant's cost table — kept - /// alongside the gateway so the composer doesn't re-derive the - /// `ModelProfileId → provider-model` mapping in two places. - policy: ironclaw_runner::model_gateway::LlmModelProfilePolicy, /// Hot-swap handle + session for the live-reload path. The model gateway /// wraps a [`SwappableLlmProvider`], so the settings service can rebuild /// the provider chain from updated config and atomically swap the inner @@ -4638,22 +4654,6 @@ pub(crate) struct RebornLlmReloadParts { Arc, } -#[cfg(feature = "root-llm-provider")] -async fn build_llm_gateway(llm: ResolvedRebornLlm) -> Result { - let session = ironclaw_llm::create_session_manager(llm.config.session.clone()).await; - // Config is always the construction source. A caller-supplied factory (e.g. - // an instrumentation wrapper) then decorates the built provider; without one - // the config-built provider is driven as-is. - let built = ironclaw_llm::build_static_provider_chain(&llm.config, Arc::clone(&session)) - .await - .map_err(|error| RebornRuntimeError::LlmProvider(error.to_string()))?; - // The factory is applied *inside* `wrap_swappable_gateway` — over the - // swappable wrapper, not the bare config provider — so a live config reload - // (which swaps the swappable's inner) keeps the factory's wrapper in the - // call path. See `wrap_swappable_gateway`. - wrap_swappable_gateway(built, session, llm.provider_factory.clone()) -} - /// Cold-boot gateway: no LLM configured yet. Wraps a placeholder provider (which /// errors until swapped) so the model-gateway + reload seam exist from the /// start; the first configuration applied through the settings UI swaps the @@ -4701,10 +4701,9 @@ fn wrap_swappable_gateway( RebornRuntimeError::LlmProvider(format!("invalid interactive model profile id: {reason}")) })?; let policy = LlmModelProfilePolicy::new().allow_model_profile(model_profile_id, None); - let gateway = LlmProviderModelGateway::new(provider, policy.clone()); + let gateway = LlmProviderModelGateway::new(provider, policy); Ok(LlmGatewayBundle { gateway: Arc::new(gateway), - policy, reload: RebornLlmReloadParts { reload_handle, session, diff --git a/crates/ironclaw_reborn_composition/src/runtime/tests/core.rs b/crates/ironclaw_reborn_composition/src/runtime/tests/core.rs index 0649da4dcfc..7ff28cc4e46 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/tests/core.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/tests/core.rs @@ -476,6 +476,8 @@ use crate::runtime_input::{ }; use crate::webui::facade::build_webui_services; use crate::{RebornCompositionProfile, RebornReadiness, RebornReadinessState, RebornRuntimeError}; +#[cfg(all(feature = "root-llm-provider", feature = "libsql"))] +use ironclaw_reborn_config::{RebornBootConfig, RebornHome, RebornProfile}; use super::{ RebornSkillSourceKind, TRUSTED_LAPTOP_ACCESS_AUDIT_KIND, TRUSTED_LAPTOP_ACCESS_AUDIT_STATUS, @@ -1696,9 +1698,11 @@ async fn root_llm_gateway_bootstraps_nearai_session_token_from_env() { response_cache_ttl_secs: 3600, response_cache_max_entries: 1000, }; - let llm = crate::runtime_input::ResolvedRebornLlm::from_llm_config(config); - - let bundle = super::build_llm_gateway(llm).await.expect("gateway builds"); + let session = ironclaw_llm::create_session_manager(config.session.clone()).await; + let built = ironclaw_llm::build_static_provider_chain(&config, Arc::clone(&session)) + .await + .expect("provider chain builds from config"); + let bundle = super::wrap_swappable_gateway(built, session, None).expect("gateway builds"); let response = bundle .gateway .stream_model(nearai_gateway_test_request()) @@ -2114,7 +2118,7 @@ impl ironclaw_llm::LlmProvider for CountingOverrideProvider { /// provider (dead endpoint) instead of returning the mock's sentinel. #[cfg(feature = "root-llm-provider")] #[tokio::test] -async fn build_llm_gateway_applies_provider_factory() { +async fn wrap_swappable_gateway_applies_provider_factory() { let session_dir = tempfile::tempdir().expect("session tempdir"); let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let mock: Arc = Arc::new(CountingOverrideProvider { @@ -2159,11 +2163,16 @@ async fn build_llm_gateway_applies_provider_factory() { }; let factory_mock = Arc::clone(&mock); - let llm = crate::runtime_input::ResolvedRebornLlm::from_llm_config(config) - .with_provider_factory(Arc::new(move |_built| Arc::clone(&factory_mock))); - let bundle = super::build_llm_gateway(llm) + let session = ironclaw_llm::create_session_manager(config.session.clone()).await; + let built = ironclaw_llm::build_static_provider_chain(&config, Arc::clone(&session)) .await - .expect("gateway builds with the provider factory"); + .expect("provider chain builds from config"); + let bundle = super::wrap_swappable_gateway( + built, + session, + Some(Arc::new(move |_built| Arc::clone(&factory_mock))), + ) + .expect("gateway builds with the provider factory"); let response = bundle .gateway @@ -2285,10 +2294,11 @@ async fn provider_factory_survives_live_reload() { }); let config = dead_endpoint_nearai_config(session_dir.path().join("session.json")); - let llm = crate::runtime_input::ResolvedRebornLlm::from_llm_config(config.clone()) - .with_provider_factory(factory); - let bundle = super::build_llm_gateway(llm) + let session = ironclaw_llm::create_session_manager(config.session.clone()).await; + let built = ironclaw_llm::build_static_provider_chain(&config, Arc::clone(&session)) .await + .expect("provider chain builds from config"); + let bundle = super::wrap_swappable_gateway(built, session, Some(factory)) .expect("gateway builds with the provider factory"); // First model call routes through the instrumentation wrapper. The dead @@ -2328,15 +2338,35 @@ async fn provider_factory_survives_live_reload() { ); } +/// Regression pin for the journey-critical fix (PR #6174): a provider +/// selected purely through `config.toml` + a stored API key (no env var set) +/// must reach the turn-serving provider. This exercises the ONLY mechanism +/// that now applies a stored key to the live gateway — the post-construction +/// `RebornLlmReloadAdapter::reload()` invoked once inside +/// `build_reborn_runtime` — by supplying a real `boot` config (so the +/// reload adapter can re-resolve `[llm.default]` from disk) instead of +/// pre-baking the stored key into a directly-supplied `ResolvedRebornLlm` +/// (which no longer feeds the gateway at all). #[cfg(all(feature = "root-llm-provider", feature = "libsql"))] #[tokio::test] async fn local_dev_runtime_startup_uses_stored_nearai_api_key_after_restart() { + // NOTE on isolation: this test does not need to override + // `NEARAI_SESSION_PATH` / `NEARAI_AUTH_URL` (both env-only inputs to + // `ironclaw_llm::resolution::nearai_session_config`, which the reload + // adapter's config-file re-resolution invokes). `NearAiChatProvider:: + // resolve_bearer_token` checks `config.nearai.api_key` FIRST, before + // ever touching the session manager — and `apply_stored_api_key` (called + // by `RebornLlmReloadAdapter::reload`) sets exactly that field from the + // seeded key below. So the session/auth-url defaults are constructed but + // never read from disk or contacted over the network. let _env_guard = RuntimeEnvGuard::with([("NEARAI_SESSION_TOKEN", None), ("NEARAI_API_KEY", None)]).await; + let (base_url, auth_rx) = start_nearai_auth_capture_server().await; + let root = tempfile::tempdir().expect("tempdir"); let local_dev_root = root.path().join("local-dev"); - let session_dir = tempfile::tempdir().expect("session tempdir"); - let (base_url, auth_rx) = start_nearai_auth_capture_server().await; + let config_home_dir = root.path().join("config-home"); + std::fs::create_dir_all(&config_home_dir).expect("config home dir"); let services = crate::build_reborn_services( RebornBuildInput::local_dev("runtime-nearai-stored-key-owner", local_dev_root.clone()) @@ -2353,49 +2383,39 @@ async fn local_dev_runtime_startup_uses_stored_nearai_api_key_after_restart() { .expect("stored key seeded"); drop(services); - let config = ironclaw_llm::LlmConfig { - backend: "nearai".to_string(), - session: ironclaw_llm::SessionConfig { - auth_base_url: base_url.clone(), - session_path: session_dir.path().join("session.json"), - }, - nearai: ironclaw_llm::NearAiConfig { - model: "test-model".to_string(), - cheap_model: None, - base_url, - api_key: None, - fallback_model: None, - max_retries: 0, - circuit_breaker_threshold: None, - circuit_breaker_recovery_secs: 30, - response_cache_enabled: false, - response_cache_ttl_secs: 3600, - response_cache_max_entries: 1000, - failover_cooldown_secs: 300, - failover_cooldown_threshold: 3, - smart_routing_cascade: false, - }, - provider: None, - bedrock: None, - gemini_oauth: None, - openai_codex: None, - request_timeout_secs: 5, - cheap_model: None, - smart_routing_cascade: false, - max_retries: 0, - circuit_breaker_threshold: None, - circuit_breaker_recovery_secs: 30, - response_cache_enabled: false, - response_cache_ttl_secs: 3600, - response_cache_max_entries: 1000, - }; - let llm = crate::runtime_input::ResolvedRebornLlm::from_llm_config(config); + // Provider selection lives entirely in config.toml (mirrors an + // onboard-style setup): no env var carries the key, only the + // encrypted secret store does. `base_url` is overridden to the local + // capture server so the live reload's re-built provider chain actually + // calls it. + std::fs::write( + RebornHome::resolve_from_env_parts( + Some(config_home_dir.as_os_str().to_os_string()), + None, + None, + ) + .expect("valid reborn home") + .config_file_path(), + format!( + "[llm.default]\nprovider_id = \"nearai\"\nmodel = \"test-model\"\nbase_url = \"{base_url}\"\n" + ), + ) + .expect("write config.toml"); + let boot = RebornBootConfig::new( + RebornHome::resolve_from_env_parts( + Some(config_home_dir.as_os_str().to_os_string()), + None, + None, + ) + .expect("valid reborn home"), + RebornProfile::LocalDev, + ); let input = RebornRuntimeInput::from_services( RebornBuildInput::local_dev("runtime-nearai-stored-key-owner", local_dev_root) .with_runtime_policy(local_dev_runtime_policy()), ) - .with_resolved_llm(llm) + .with_boot_config(boot) .with_identity(RebornRuntimeIdentity { tenant_id: "runtime-nearai-stored-key-tenant".to_string(), agent_id: "runtime-nearai-stored-key-agent".to_string(), diff --git a/crates/ironclaw_reborn_composition/src/runtime_input.rs b/crates/ironclaw_reborn_composition/src/runtime_input.rs index 9882a28f4d6..89e7f2c64bf 100644 --- a/crates/ironclaw_reborn_composition/src/runtime_input.rs +++ b/crates/ironclaw_reborn_composition/src/runtime_input.rs @@ -170,6 +170,12 @@ impl ResolvedRebornLlm { &self.model } + /// Base URL of the backend `serve` actually boots with, when the + /// backend has one. See [`ironclaw_llm::LlmConfig::active_base_url`]. + pub fn base_url(&self) -> Option { + self.config.active_base_url() + } + pub fn from_llm_config(config: ironclaw_llm::LlmConfig) -> Self { Self { provider_id: config.active_provider_id(), diff --git a/crates/ironclaw_reborn_composition/src/test_support/local_dev_boot.rs b/crates/ironclaw_reborn_composition/src/test_support/local_dev_boot.rs index 8b2680d0e02..59190102f08 100644 --- a/crates/ironclaw_reborn_composition/src/test_support/local_dev_boot.rs +++ b/crates/ironclaw_reborn_composition/src/test_support/local_dev_boot.rs @@ -76,7 +76,7 @@ where /// secret written by the first. For tests only — zero bytes shipped in /// production builds. #[cfg(any(feature = "libsql", feature = "postgres"))] -pub fn build_local_dev_secret_store_for_test( +pub async fn build_local_dev_secret_store_for_test( root: &std::path::Path, scoped: std::sync::Arc>, ) -> Result>, crate::RebornBuildError> @@ -85,7 +85,7 @@ where { // `build_local_dev_secret_store` also returns the crypto (for the admin // secret provisioner); this test helper only needs the store. - let (store, _crypto) = crate::factory::build_local_dev_secret_store(root, scoped, None)?; + let (store, _crypto) = crate::factory::build_local_dev_secret_store(root, scoped, None).await?; Ok(store) } diff --git a/crates/ironclaw_reborn_composition/tests/admin_api_e2e.rs b/crates/ironclaw_reborn_composition/tests/admin_api_e2e.rs index 3fbdfe4fa1c..94d5853d29c 100644 --- a/crates/ironclaw_reborn_composition/tests/admin_api_e2e.rs +++ b/crates/ironclaw_reborn_composition/tests/admin_api_e2e.rs @@ -77,7 +77,12 @@ struct SessionTokenMinter { impl AdminApiTokenMinter for SessionTokenMinter { async fn mint(&self, tenant: &TenantId, user_id: &UserId) -> Result { self.store - .create_session(tenant.clone(), user_id.clone(), chrono::Duration::days(365)) + .create_session( + tenant.clone(), + user_id.clone(), + chrono::Duration::days(365), + false, + ) .await .map_err(|error| error.to_string()) } @@ -659,7 +664,12 @@ async fn forged_and_expired_tokens_are_rejected() { // Mint a genuine, in-secret bearer (validates under the harness). let good_store = session_store_with_secret(OPERATOR_TOKEN); let good_token = good_store - .create_session(tenant.clone(), user.clone(), chrono::Duration::days(1)) + .create_session( + tenant.clone(), + user.clone(), + chrono::Duration::days(1), + false, + ) .await .expect("mint valid token") .expose_secret() @@ -683,7 +693,12 @@ async fn forged_and_expired_tokens_are_rejected() { // (c) a token minted under a DIFFERENT operator secret → 401 (foreign key). let foreign_token = session_store_with_secret("a-totally-different-operator-secret") - .create_session(tenant.clone(), user.clone(), chrono::Duration::days(1)) + .create_session( + tenant.clone(), + user.clone(), + chrono::Duration::days(1), + false, + ) .await .expect("mint foreign token") .expose_secret() @@ -704,14 +719,19 @@ async fn forged_and_expired_tokens_are_rejected() { // minimum 1s token and let it lapse (exp is second-granularity, so wait // past the next whole second). let zero = good_store - .create_session(tenant.clone(), user.clone(), chrono::Duration::zero()) + .create_session( + tenant.clone(), + user.clone(), + chrono::Duration::zero(), + false, + ) .await; assert!( zero.is_err(), "create_session refuses a zero/negative lifetime rather than minting a dead token" ); let expiring = good_store - .create_session(tenant, user, chrono::Duration::seconds(1)) + .create_session(tenant, user, chrono::Duration::seconds(1), false) .await .expect("mint short-lived token") .expose_secret() diff --git a/crates/ironclaw_reborn_composition/tests/facade_factory.rs b/crates/ironclaw_reborn_composition/tests/facade_factory.rs index 90b3d5f5bcf..cd05303bd7a 100644 --- a/crates/ironclaw_reborn_composition/tests/facade_factory.rs +++ b/crates/ironclaw_reborn_composition/tests/facade_factory.rs @@ -1289,6 +1289,66 @@ async fn production_libsql_resolved_secret_master_key_rejects_invalid_env_key() )); } +/// With no cached dotfile and no `SECRETS_MASTER_KEY` env var, +/// `resolve_local_dev_secret_master_key` (`src/factory.rs`) tries the OS +/// keychain before generating a fresh key. +/// +/// - Under `IRONCLAW_DISABLE_OS_KEYCHAIN` the keychain lookup returns +/// `NotFound`, so the resolver must fall through to "generate + persist a +/// dotfile"; a second open over the same root must read that cached +/// dotfile rather than re-generating. +/// - Lives here, not as a `factory.rs` inline unit test: proving the +/// fallthrough needs the real process env var `IRONCLAW_DISABLE_OS_KEYCHAIN` +/// set (`keychain` reads raw `std::env`), and `set_var` is `unsafe` under +/// edition 2024 — `ironclaw_reborn_composition` is `#![forbid(unsafe_code)]`, +/// which even `#[cfg(test)]` can't locally downgrade. This `tests/*.rs` +/// binary is a separate crate the `forbid` doesn't reach, and already uses +/// the `EnvVarGuard`/`SECRETS_MASTER_KEY_ENV_LOCK` convention for this. +#[cfg(feature = "libsql")] +#[tokio::test] +async fn local_dev_secret_store_falls_through_suppressed_keychain_to_dotfile() { + let _guard = SECRETS_MASTER_KEY_ENV_LOCK.lock().await; + let _env = EnvVarGuard::set("IRONCLAW_DISABLE_OS_KEYCHAIN", "1"); + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let key_path = root.join(".reborn-local-dev-secrets-master-key"); + assert!( + !key_path.exists(), + "precondition: no cached dotfile before the first open" + ); + + let mut composite = ironclaw_filesystem::CompositeRootFilesystem::new(); + ironclaw_reborn_composition::test_support::build_default_local_dev_database_roots_for_test( + root, + &mut composite, + ) + .await + .expect("build default local-dev db roots"); + let composite = std::sync::Arc::new(composite); + let scoped = ironclaw_reborn_composition::wrap_scoped(std::sync::Arc::clone(&composite)); + + ironclaw_reborn_composition::test_support::build_local_dev_secret_store_for_test( + root, + std::sync::Arc::clone(&scoped), + ) + .await + .expect("first store build must fall through the suppressed keychain to a dotfile"); + assert!( + key_path.exists(), + "the fallthrough must persist a dotfile so subsequent boots don't hit the keychain again" + ); + let cached = std::fs::read_to_string(&key_path).expect("read generated dotfile"); + + ironclaw_reborn_composition::test_support::build_local_dev_secret_store_for_test(root, scoped) + .await + .expect("second store build must read the now-cached dotfile idempotently"); + assert_eq!( + std::fs::read_to_string(&key_path).expect("read dotfile again"), + cached, + "the cached dotfile must not be rewritten on the idempotent second open" + ); +} + #[cfg(feature = "libsql")] #[tokio::test] async fn production_libsql_services_wire_first_party_runtime_http_egress() { diff --git a/crates/ironclaw_reborn_composition/tests/provider_admin_probe.rs b/crates/ironclaw_reborn_composition/tests/provider_admin_probe.rs new file mode 100644 index 00000000000..38e728d5514 --- /dev/null +++ b/crates/ironclaw_reborn_composition/tests/provider_admin_probe.rs @@ -0,0 +1,196 @@ +//! Caller-level tests for [`RebornProviderAdmin::probe_candidate`] against a +//! live loopback HTTP stub. +//! +//! Lives outside `src/` on purpose: the architecture boundary test +//! `reborn_product_api_crates_do_not_bind_http_ingress` +//! (`crates/ironclaw_architecture/tests/reborn_dependency_boundaries.rs`) +//! greps every `.rs` file under this crate's `src/` for +//! `TcpListener::bind` with no `#[cfg(test)]` awareness — a text scan, not +//! a compile-aware check, by design. A stub server that never runs in +//! production still trips it if it lives in-module. `tests/` sits outside +//! the scanned roots; `webui_v2_serve.rs` in this same crate already binds +//! a loopback listener here for the same reason. + +#![cfg(feature = "root-llm-provider")] + +use ironclaw_llm::ProviderProtocol; +use ironclaw_reborn_composition::{ProviderRepo, RebornProviderAdmin}; +use ironclaw_reborn_config::{RebornBootConfig, RebornHome, RebornProfile}; + +/// One captured request: method, path, and the raw `Authorization` header +/// value (if any). +struct CapturedProbeRequest { + method_and_path: String, + authorization: Option, +} + +/// Serve one canned model-listing response on a loopback port, capturing +/// the request that reached it. Mirrors `rig_adapter`'s +/// `endpoint_against_canned_response` test helper, plus request capture so +/// this test can assert `probe_candidate` actually reached the CONFIGURED +/// base URL with the ENTERED key — the seam that regressed once already +/// (probe used an empty base URL and always reported "could not reach"). +async fn spawn_models_stub( + status_line: &'static str, + body: &'static str, +) -> (String, tokio::sync::oneshot::Receiver) { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind loopback"); + let base_url = format!("http://{}", listener.local_addr().expect("addr")); + let (tx, rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + let Ok((sock, _)) = listener.accept().await else { + return; + }; + let mut reader = BufReader::new(sock); + let mut request_line = String::new(); + let _ = reader.read_line(&mut request_line).await; + let mut authorization = None; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).await.unwrap_or(0) == 0 { + break; + } + let trimmed = line.trim_end_matches(['\r', '\n']); + if trimmed.is_empty() { + break; + } + if let Some((name, value)) = trimmed.split_once(':') + && name.trim().eq_ignore_ascii_case("authorization") + { + authorization = Some(value.trim().to_string()); + } + } + let _ = tx.send(CapturedProbeRequest { + method_and_path: request_line.trim_end_matches(['\r', '\n']).to_string(), + authorization, + }); + let response = format!( + "{status_line}\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n{body}", + body.len() + ); + let mut sock = reader.into_inner(); + let _ = sock.write_all(response.as_bytes()).await; + let _ = sock.flush().await; + }); + (base_url, rx) +} + +/// Write a `providers.json` overlay entry pointed at `base_url`, mirroring +/// how a real onboard candidate is built from the registry: a fresh id +/// (never a builtin), OpenAI-compatible protocol, and no `api_key_env` +/// (the "entered" key is the inline candidate `probe_candidate` takes, +/// never persisted to env or overlay). +fn write_stub_provider(home: &RebornHome, base_url: &str) { + std::fs::create_dir_all(home.path()).expect("create reborn home dir"); + ProviderRepo::new(home.providers_file_path()) + .upsert(ironclaw_llm::registry::ProviderDefinition { + id: "stub-probe-provider".to_string(), + aliases: Vec::new(), + protocol: ProviderProtocol::OpenAiCompletions, + default_base_url: Some(base_url.to_string()), + base_url_env: None, + base_url_required: false, + api_key_env: None, + api_key_required: true, + model_env: "STUB_PROBE_MODEL".to_string(), + default_model: "stub-model".to_string(), + description: "stub probe provider".to_string(), + extra_headers_env: None, + setup: None, + unsupported_params: Vec::new(), + }) + .expect("write stub provider overlay entry"); +} + +/// Pins the bug this test was written to catch: `probe_candidate` must +/// build its request against the provider's CONFIGURED base URL (from the +/// registry/overlay) carrying the ENTERED key (the inline candidate +/// argument), not a blank/default URL. A regression back to an empty base +/// URL would make the stub never see a connection and this test would time +/// out / fail on `ok`. +#[tokio::test] +async fn probe_candidate_hits_the_configured_base_url_with_the_entered_key() { + let (base_url, request_rx) = + spawn_models_stub("HTTP/1.1 200 OK", r#"{"data":[{"id":"stub-model-1"}]}"#).await; + + let temp = tempfile::tempdir().expect("tempdir"); + let home = RebornHome::resolve_from_env_parts( + Some(temp.path().join("reborn-home").as_os_str().to_os_string()), + None, + None, + ) + .expect("valid reborn home"); + write_stub_provider(&home, &base_url); + + let admin = RebornProviderAdmin::new(RebornBootConfig::new(home, RebornProfile::LocalDev)); + + let outcome = admin + .probe_candidate( + "stub-probe-provider", + Some(secrecy::SecretString::from("sk-entered-key")), + None, + ) + .await + .expect("stub-probe-provider is registered"); + + assert!( + outcome.ok, + "probe against the live stub must succeed: {outcome:?}" + ); + assert_eq!(outcome.models, vec!["stub-model-1".to_string()]); + + let captured = request_rx + .await + .expect("stub must have received exactly one request"); + assert_eq!(captured.method_and_path, "GET /v1/models HTTP/1.1"); + assert_eq!( + captured.authorization.as_deref(), + Some("Bearer sk-entered-key"), + "probe must carry the entered key as a Bearer token" + ); +} + +/// A 401 from the configured endpoint must surface as `ok: false` (never a +/// bare `Err`) — matching `probe_candidate_provider`'s no-separate-error- +/// channel contract that `probe_candidate` forwards. Awaits the captured +/// request (not just `outcome.ok`) so a transport failure (wrong URL, +/// connection refused) can't pass identically to a real 401. +#[tokio::test] +async fn probe_candidate_reports_401_as_not_ok() { + let (base_url, request_rx) = spawn_models_stub("HTTP/1.1 401 Unauthorized", "").await; + + let temp = tempfile::tempdir().expect("tempdir"); + let home = RebornHome::resolve_from_env_parts( + Some(temp.path().join("reborn-home").as_os_str().to_os_string()), + None, + None, + ) + .expect("valid reborn home"); + write_stub_provider(&home, &base_url); + + let admin = RebornProviderAdmin::new(RebornBootConfig::new(home, RebornProfile::LocalDev)); + + let outcome = admin + .probe_candidate( + "stub-probe-provider", + Some(secrecy::SecretString::from("sk-wrong-key")), + None, + ) + .await + .expect("stub-probe-provider is registered"); + + assert!(!outcome.ok, "a 401 must report ok: false, got {outcome:?}"); + + let captured = request_rx + .await + .expect("stub must have received exactly one request"); + assert_eq!(captured.method_and_path, "GET /v1/models HTTP/1.1"); + assert_eq!( + captured.authorization.as_deref(), + Some("Bearer sk-wrong-key"), + "probe must carry the entered key as a Bearer token" + ); +} diff --git a/crates/ironclaw_secrets/src/keychain.rs b/crates/ironclaw_secrets/src/keychain.rs index d890db915b8..675c844fa4a 100644 --- a/crates/ironclaw_secrets/src/keychain.rs +++ b/crates/ironclaw_secrets/src/keychain.rs @@ -153,7 +153,7 @@ mod platform { const ERR_SEC_ITEM_NOT_FOUND: i32 = -25300; /// Store the master key in the macOS Keychain. - pub async fn store_master_key(key: &[u8]) -> Result<(), SecretError> { + pub(super) async fn store_master_key(key: &[u8]) -> Result<(), SecretError> { // Convert to hex for storage (keychain prefers strings) let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); @@ -162,7 +162,7 @@ mod platform { } /// Retrieve the master key from the macOS Keychain. - pub async fn get_master_key() -> Result, SecretError> { + pub(super) async fn get_master_key() -> Result, SecretError> { let password = get_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).map_err(|error| { if error.code() == ERR_SEC_ITEM_NOT_FOUND { SecretError::NotFound("master key".to_string()) @@ -179,14 +179,14 @@ mod platform { } /// Delete the master key from the macOS Keychain. - pub async fn delete_master_key() -> Result<(), SecretError> { + pub(super) async fn delete_master_key() -> Result<(), SecretError> { delete_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).map_err(|e| { SecretError::KeychainError(format!("failed to delete from keychain: {}", e)) }) } /// Check if a master key exists in the keychain. - pub async fn has_master_key() -> bool { + pub(super) async fn has_master_key() -> bool { get_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).is_ok() } } @@ -202,7 +202,7 @@ mod platform { use super::*; /// Store the master key in the Linux secret service (GNOME Keyring, KWallet). - pub async fn store_master_key(key: &[u8]) -> Result<(), SecretError> { + pub(super) async fn store_master_key(key: &[u8]) -> Result<(), SecretError> { let ss = SecretService::connect(EncryptionType::Dh) .await .map_err(|e| { @@ -241,7 +241,7 @@ mod platform { } /// Retrieve the master key from the Linux secret service. - pub async fn get_master_key() -> Result, SecretError> { + pub(super) async fn get_master_key() -> Result, SecretError> { let ss = SecretService::connect(EncryptionType::Dh) .await .map_err(|e| { @@ -282,7 +282,7 @@ mod platform { } /// Delete the master key from the Linux secret service. - pub async fn delete_master_key() -> Result<(), SecretError> { + pub(super) async fn delete_master_key() -> Result<(), SecretError> { let ss = SecretService::connect(EncryptionType::Dh) .await .map_err(|e| { @@ -308,7 +308,7 @@ mod platform { } /// Check if a master key exists in the secret service. - pub async fn has_master_key() -> bool { + pub(super) async fn has_master_key() -> bool { let ss = match SecretService::connect(EncryptionType::Dh).await { Ok(ss) => ss, Err(_) => return false, @@ -338,29 +338,70 @@ mod platform { mod platform { use super::*; - pub async fn store_master_key(_key: &[u8]) -> Result<(), SecretError> { + pub(super) async fn store_master_key(_key: &[u8]) -> Result<(), SecretError> { Err(SecretError::KeychainError( "keychain not supported on this platform. use SECRETS_MASTER_KEY env var.".to_string(), )) } - pub async fn get_master_key() -> Result, SecretError> { + pub(super) async fn get_master_key() -> Result, SecretError> { Err(SecretError::NotFound("master key".to_string())) } - pub async fn delete_master_key() -> Result<(), SecretError> { + pub(super) async fn delete_master_key() -> Result<(), SecretError> { Err(SecretError::KeychainError( "keychain not supported on this platform".to_string(), )) } - pub async fn has_master_key() -> bool { + pub(super) async fn has_master_key() -> bool { false } } -// Re-export platform-specific functions -pub use platform::{delete_master_key, get_master_key, has_master_key, store_master_key}; +/// Whether OS-keychain access should be suppressed. +/// +/// - why: real keychain pops a macOS auth dialog / blocks on a locked Linux +/// Secret Service, which derails `cargo test` +/// - triggers: `cfg!(test)` (this crate's unit tests) OR +/// `IRONCLAW_DISABLE_OS_KEYCHAIN` env (integration/e2e/CI, and the +/// compiled binary under test, which links the non-`cfg(test)` lib) +/// - effect: lookups report "no key present", writes fail closed — callers +/// fall through to their next key source as if the keychain were empty +fn os_keychain_suppressed() -> bool { + cfg!(test) || std::env::var_os("IRONCLAW_DISABLE_OS_KEYCHAIN").is_some() +} + +const SUPPRESSED_MESSAGE: &str = + "OS keychain access suppressed (cfg!(test) or IRONCLAW_DISABLE_OS_KEYCHAIN)"; + +pub async fn get_master_key() -> Result, SecretError> { + if os_keychain_suppressed() { + return Err(SecretError::NotFound("master key".to_string())); + } + platform::get_master_key().await +} + +pub async fn has_master_key() -> bool { + if os_keychain_suppressed() { + return false; + } + platform::has_master_key().await +} + +pub async fn store_master_key(key: &[u8]) -> Result<(), SecretError> { + if os_keychain_suppressed() { + return Err(SecretError::KeychainError(SUPPRESSED_MESSAGE.to_string())); + } + platform::store_master_key(key).await +} + +pub async fn delete_master_key() -> Result<(), SecretError> { + if os_keychain_suppressed() { + return Err(SecretError::KeychainError(SUPPRESSED_MESSAGE.to_string())); + } + platform::delete_master_key().await +} /// Parse a hex string to bytes. fn hex_to_bytes(hex: &str) -> Result, SecretError> { @@ -561,4 +602,29 @@ mod tests { Err(SecretError::InvalidMasterKey) )); } + + static IRONCLAW_DISABLE_OS_KEYCHAIN_LOCK: Mutex<()> = Mutex::const_new(()); + + // edge: pins the explicit-env-var suppression path (cfg!(test) already + // covers this crate's own unit tests) — protects a compiled binary under + // test from popping a real OS keychain prompt. + #[tokio::test] + async fn suppressed_keychain_lookup_and_write_never_touch_the_platform_module() { + let _guard = IRONCLAW_DISABLE_OS_KEYCHAIN_LOCK.lock().await; + let _env = EnvVarGuard::set("IRONCLAW_DISABLE_OS_KEYCHAIN", "1"); + + assert!(matches!( + get_master_key().await, + Err(SecretError::NotFound(_)) + )); + assert!(matches!( + store_master_key(&[0xab; 32]).await, + Err(SecretError::KeychainError(_)) + )); + assert!(!has_master_key().await); + assert!(matches!( + delete_master_key().await, + Err(SecretError::KeychainError(_)) + )); + } } diff --git a/crates/ironclaw_webui/src/auth/mod.rs b/crates/ironclaw_webui/src/auth/mod.rs index bd78e3c0354..df578a1b577 100644 --- a/crates/ironclaw_webui/src/auth/mod.rs +++ b/crates/ironclaw_webui/src/auth/mod.rs @@ -24,7 +24,8 @@ mod config; mod error; mod github; mod google; -mod pending; +// pub(crate): cli_token_login reuses pending::sanitize_redirect (see its doc). +pub(crate) mod pending; mod profile; mod provider; mod provider_http; diff --git a/crates/ironclaw_webui/src/auth/pending.rs b/crates/ironclaw_webui/src/auth/pending.rs index f61e1850516..ea3560ed6f9 100644 --- a/crates/ironclaw_webui/src/auth/pending.rs +++ b/crates/ironclaw_webui/src/auth/pending.rs @@ -240,11 +240,11 @@ fn mint_state_token() -> String { /// to the server, and it would also leave the SPA on a confusing /// post-login URL. The percent-decoded form is also checked so `%23` /// smuggling fails. -pub(super) fn sanitize_redirect(input: Option) -> Option { +pub(crate) fn sanitize_redirect(input: Option) -> Option { input.filter(|raw| is_safe_redirect(raw)) } -pub(super) fn is_safe_redirect(url: &str) -> bool { +pub(crate) fn is_safe_redirect(url: &str) -> bool { if !check_redirect_chars(url) { return false; } diff --git a/crates/ironclaw_webui/src/auth/routes.rs b/crates/ironclaw_webui/src/auth/routes.rs index 71cfebae0a2..80e51c02dd9 100644 --- a/crates/ironclaw_webui/src/auth/routes.rs +++ b/crates/ironclaw_webui/src/auth/routes.rs @@ -506,9 +506,18 @@ async fn callback_handler( } }; + // USER-DECIDED LAW: SSO/OAuth sessions stay non-operator; only a bearer + // verified via the host's operator-capable authenticator (env token or + // CLI's /login?token= link) may mint one. Counterpart with the + // caller-derived operator bit: cli_token_login.rs's login_handler. let bearer = match state .session_store - .create_session(state.tenant_id.clone(), user_id, state.session_lifetime) + .create_session( + state.tenant_id.clone(), + user_id, + state.session_lifetime, + false, + ) .await { Ok(token) => token, diff --git a/crates/ironclaw_webui/src/cli_token_login.rs b/crates/ironclaw_webui/src/cli_token_login.rs new file mode 100644 index 00000000000..6b8d50fb913 --- /dev/null +++ b/crates/ironclaw_webui/src/cli_token_login.rs @@ -0,0 +1,494 @@ +//! `GET /login?token=` — CLI-printed bootstrap link into the +//! browser session, sharing the OAuth callback's bearer/ticket-exchange +//! contract (see `signed_session_login.rs` module docs for why this +//! wiring lives in this crate, not the command crate). +//! +//! Flow (mirrors `auth::routes::callback_handler`): +//! - `GET /login?token=...`: verify via [`crate::EnvBearerAuthenticator`], +//! mint a bearer via [`SessionStore`], redirect to +//! `?login_ticket=` (same convention as OAuth). +//! - `POST /auth/session/exchange`: consumes the ticket, returns +//! `{ "token": "..." }` — byte-for-byte the same contract as +//! `auth::routes::session_exchange_handler`, so the SPA's +//! `exchangeLoginTicket` needs no new frontend code. +//! +//! Owns its own one-time ticket store rather than `auth::routes`'s +//! (private to that module) because CLI-token login must work even with +//! no OAuth provider configured. +//! +//! Integration note: both this mount and the OAuth mount register `POST +//! /auth/session/exchange` — attach at most one per deployment or routes +//! collide at merge time. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use axum::Json; +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::routing::{get, post}; +use chrono::Duration as ChronoDuration; +use ironclaw_host_api::NetworkMethod; +use ironclaw_host_api::TenantId; +use ironclaw_host_api::ingress::{ + AllowedEffectPath, AuditTraceClass, BodyLimitPolicy, CorsPolicy, IngressAuthPolicy, + IngressJustification, IngressPolicy, IngressPolicyParts, IngressRouteDescriptor, ListenerClass, + RateLimitPolicy, RateLimitScope, StreamingMode, WebSocketOriginPolicy, +}; +use ironclaw_reborn_composition::PublicRouteMount; +use parking_lot::Mutex; +use rand::RngExt as _; +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize}; + +use crate::WebuiAuthenticator; +use crate::session::SessionStore; + +/// Matches the OAuth callback's default so the SPA lands in the same +/// place regardless of which flow authenticated it. +const DEFAULT_REDIRECT_AFTER: &str = "/"; + +/// Matches the OAuth login surface's default (30 days). +const DEFAULT_SESSION_LIFETIME: ChronoDuration = ChronoDuration::seconds(30 * 24 * 60 * 60); + +/// Same TTL as the OAuth surface's session tickets. +const TICKET_TTL: Duration = Duration::from_secs(60); +/// Bounds memory if callers mint links but never redeem them. +const MAX_TICKETS: usize = 1024; + +const PATH_LOGIN: &str = "/login"; +const PATH_SESSION_EXCHANGE: &str = "/auth/session/exchange"; + +const ROUTE_ID_LOGIN: &str = "webui.cli_token_login.login"; +const ROUTE_ID_SESSION_EXCHANGE: &str = "webui.cli_token_login.session_exchange"; + +const RATE_WINDOW_SECONDS: std::num::NonZeroU32 = std::num::NonZeroU32::new(60).expect("60 != 0"); // safety: const-evaluated, literal non-zero +const LOGIN_MAX_REQUESTS: std::num::NonZeroU32 = std::num::NonZeroU32::new(30).expect("30 != 0"); // safety: const-evaluated, literal non-zero +const EXCHANGE_MAX_REQUESTS: std::num::NonZeroU32 = std::num::NonZeroU32::new(60).expect("60 != 0"); // safety: const-evaluated, literal non-zero +const EXCHANGE_BODY_LIMIT_BYTES: std::num::NonZeroU64 = + std::num::NonZeroU64::new(1024).expect("1024 != 0"); // safety: const-evaluated, literal non-zero + +/// Host-supplied input to [`build_cli_token_login`]. +pub struct CliTokenLoginConfig { + /// Host-trusted installation tenant; never browser-influenced. + pub tenant_id: TenantId, + /// Constant-time verifier for the presented `?token=`, resolving the + /// authenticated `UserId`. Prod passes the same + /// [`crate::EnvBearerAuthenticator`] used for the API bearer — no + /// second secret to configure. + pub authenticator: Arc, + /// Store the minted session bearer is created through. Prod passes + /// [`crate::signed_session_store`] built from the same operator + /// secret + tenant as the CLI's admin bearer minter, so the bearer + /// validates anywhere that store is reconstructed. + pub session_store: Arc, + /// Session lifetime for the minted bearer. Defaults to 30 days. + pub session_lifetime: ChronoDuration, + /// Path the SPA lands on after the ticket is placed in the + /// redirect query string. Defaults to `/`. + pub redirect_after: String, +} + +impl CliTokenLoginConfig { + pub fn new( + tenant_id: TenantId, + authenticator: Arc, + session_store: Arc, + ) -> Self { + Self { + tenant_id, + authenticator, + session_store, + session_lifetime: DEFAULT_SESSION_LIFETIME, + redirect_after: DEFAULT_REDIRECT_AFTER.to_string(), + } + } + + pub fn with_session_lifetime(mut self, lifetime: ChronoDuration) -> Self { + self.session_lifetime = lifetime; + self + } + + // `redirect_after` is fixed to `DEFAULT_REDIRECT_AFTER` until a real + // caller needs it — a `with_redirect_after` setter was removed as + // speculative surface (zero production callers) in a security-sensitive + // module; re-add it (with `auth::pending::sanitize_redirect`) if one + // shows up. +} + +struct RouterState { + tenant_id: TenantId, + authenticator: Arc, + session_store: Arc, + session_lifetime: ChronoDuration, + redirect_after: String, + tickets: LoginTicketStore, +} + +type RouterStateHandle = Arc; + +/// Build the CLI-token login mount: `GET /login?token=...` plus the +/// `POST /auth/session/exchange` that redeems the ticket it mints. +pub fn build_cli_token_login(config: CliTokenLoginConfig) -> PublicRouteMount { + let state: RouterStateHandle = Arc::new(RouterState { + tenant_id: config.tenant_id, + authenticator: config.authenticator, + session_store: config.session_store, + session_lifetime: config.session_lifetime, + redirect_after: config.redirect_after, + tickets: LoginTicketStore::new(), + }); + + let router = axum::Router::new() + .route(PATH_LOGIN, get(login_handler)) + .route(PATH_SESSION_EXCHANGE, post(session_exchange_handler)) + .with_state(state); + + PublicRouteMount::new(router, route_descriptors()) +} + +fn route_descriptors() -> Vec { + vec![ + descriptor( + ROUTE_ID_LOGIN, + NetworkMethod::Get, + PATH_LOGIN, + public_policy(BodyLimitPolicy::NoBody, LOGIN_MAX_REQUESTS), + ), + descriptor( + ROUTE_ID_SESSION_EXCHANGE, + NetworkMethod::Post, + PATH_SESSION_EXCHANGE, + public_policy( + BodyLimitPolicy::Limited { + max_bytes: EXCHANGE_BODY_LIMIT_BYTES, + }, + EXCHANGE_MAX_REQUESTS, + ), + ), + ] +} + +fn descriptor( + route_id: &str, + method: NetworkMethod, + pattern: &str, + policy: IngressPolicy, +) -> IngressRouteDescriptor { + IngressRouteDescriptor::new(route_id.to_string(), method, pattern.to_string(), policy) + .expect("CLI-token login route descriptor must validate at startup") // safety: ids/patterns are crate-local literals and policies are constructed by the sibling helper below. +} + +fn public_policy(body_limit: BodyLimitPolicy, max_requests: std::num::NonZeroU32) -> IngressPolicy { + IngressPolicy::new(IngressPolicyParts { + listener_class: ListenerClass::LocalGateway, + auth: IngressAuthPolicy::Public { + justification: login_justification(), + }, + scope_source: ironclaw_host_api::IngressScopeSource::PublicRoute, + body_limit, + rate_limit: RateLimitPolicy::Limited { + scope: RateLimitScope::PerIp, + max_requests, + window_seconds: RATE_WINDOW_SECONDS, + }, + cors: CorsPolicy::SameOriginOnly, + websocket_origin: WebSocketOriginPolicy::NotApplicable, + streaming: StreamingMode::None, + audit: AuditTraceClass::PublicCallback, + effect_path: AllowedEffectPath::NoEffect, + }) + .expect("CLI-token login public policy must validate") // safety: LocalGateway + Public + NoEffect is permitted; rate-limit window/max are non-zero by construction. +} + +fn login_justification() -> IngressJustification { + IngressJustification::new( + "webui-v2 cli-token-login", + "the CLI-printed /login?token= link is unauthenticated by design — \ + the handler itself verifies the presented token before minting a \ + session, exactly like the OAuth callback it mirrors", + ) + .expect("CLI-token login justification literal must validate") // safety: non-empty, no leading/trailing whitespace. +} + +// ─── GET /login ───────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct LoginParams { + token: Option, +} + +/// `GET /login?token=...` — verify the presented token, mint a +/// session bearer, place a one-time ticket for it, and redirect the +/// SPA to redeem the ticket via `POST /auth/session/exchange`. +async fn login_handler( + State(state): State, + Query(params): Query, +) -> Response { + let Some(token) = params.token.filter(|t| !t.is_empty()) else { + return StatusCode::UNAUTHORIZED.into_response(); + }; + + // `authenticate` already does a constant-time compare (reused, not + // reimplemented). + let Some(auth) = state.authenticator.authenticate(&token).await else { + return StatusCode::UNAUTHORIZED.into_response(); + }; + + // USER-DECIDED LAW: webui-token auth = operator/admin, same as raw + // `Authorization: Bearer`. + // - `auth.capabilities.operator_webui_config` is the provenance signal + // `create_session` wants — never hardcode `true`, never re-derive + // operator-ness later at validation time. + let bearer = match state + .session_store + .create_session( + state.tenant_id.clone(), + auth.user_id, + state.session_lifetime, + auth.capabilities.operator_webui_config, + ) + .await + { + Ok(bearer) => bearer, + Err(err) => { + tracing::error!( + target = "ironclaw::reborn::webui_ingress::cli_token_login", + error = %err, + "session store create_session failed", + ); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + let ticket = state.tickets.insert(bearer); + let location = build_redirect(&state.redirect_after, &ticket); + Redirect::to(&location).into_response() +} + +fn build_redirect(redirect_after: &str, ticket: &str) -> String { + let separator = if redirect_after.contains('?') { + '&' + } else { + '?' + }; + format!( + "{redirect_after}{separator}login_ticket={}", + urlencoding::encode(ticket) + ) +} + +// ─── POST /auth/session/exchange ───────────────────────────────────── + +#[derive(Deserialize)] +struct SessionExchangeRequest { + ticket: String, +} + +#[derive(Serialize)] +struct SessionExchangeResponse { + token: String, +} + +/// `POST /auth/session/exchange` — consume the one-time ticket minted +/// by `login_handler` and return the real session bearer. Identical +/// contract to `auth::routes::session_exchange_handler`, so the SPA's +/// `exchangeLoginTicket` needs no new code to talk to either surface. +async fn session_exchange_handler( + State(state): State, + Json(request): Json, +) -> Response { + let ticket = request.ticket.trim(); + if ticket.is_empty() { + return StatusCode::UNAUTHORIZED.into_response(); + } + let Some(bearer) = state.tickets.take(ticket) else { + return StatusCode::UNAUTHORIZED.into_response(); + }; + Json(SessionExchangeResponse { + token: bearer.expose_secret().to_string(), + }) + .into_response() +} + +// ─── one-time ticket store ─────────────────────────────────────────── + +struct LoginTicket { + bearer: SecretString, + created_at: Instant, +} + +/// Bounded, TTL'd, single-use bearer exchange store — same shape as +/// `auth::pending::SessionTicketStore`, duplicated because that store is +/// private to the OAuth login surface and this mount must work with no +/// OAuth provider (and thus no OAuth ticket store) present. +struct LoginTicketStore { + inner: Mutex>, +} + +impl LoginTicketStore { + fn new() -> Self { + Self { + inner: Mutex::new(HashMap::new()), + } + } + + fn insert(&self, bearer: SecretString) -> String { + let ticket = mint_ticket(); + let entry = LoginTicket { + bearer, + created_at: Instant::now(), + }; + + let mut guard = self.inner.lock(); + if guard.len() >= MAX_TICKETS { + guard.retain(|_, ticket| ticket.created_at.elapsed() < TICKET_TTL); + } + if guard.len() >= MAX_TICKETS + && let Some(oldest) = guard + .iter() + .min_by_key(|(_, ticket)| ticket.created_at) + .map(|(k, _)| k.clone()) + { + guard.remove(&oldest); + } + guard.insert(ticket.clone(), entry); + ticket + } + + fn take(&self, ticket: &str) -> Option { + let mut guard = self.inner.lock(); + let entry = guard.remove(ticket)?; + if entry.created_at.elapsed() >= TICKET_TTL { + return None; + } + Some(entry.bearer) + } +} + +fn mint_ticket() -> String { + let mut bytes = [0u8; 32]; + rand::rng().fill(&mut bytes); + hex::encode(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ticket_store_is_single_use() { + let store = LoginTicketStore::new(); + let ticket = store.insert(SecretString::from("bearer-1".to_string())); + assert_eq!( + store.take(&ticket).map(|s| s.expose_secret().to_string()), + Some("bearer-1".to_string()) + ); + assert!(store.take(&ticket).is_none()); + } + + #[test] + fn unknown_ticket_returns_none() { + let store = LoginTicketStore::new(); + assert!(store.take("nonexistent").is_none()); + } + + #[test] + fn expired_ticket_returns_none_and_is_removed() { + let store = LoginTicketStore::new(); + let ticket = "expired-ticket".to_string(); + { + let mut guard = store.inner.lock(); + guard.insert( + ticket.clone(), + LoginTicket { + bearer: SecretString::from("expired-bearer".to_string()), + created_at: Instant::now() - TICKET_TTL - Duration::from_secs(1), + }, + ); + } + assert!(store.take(&ticket).is_none()); + assert!(!store.inner.lock().contains_key(&ticket)); + } + + #[test] + fn ticket_store_evicts_oldest_at_capacity() { + let store = LoginTicketStore::new(); + // Seed to capacity with deterministic, strictly increasing + // timestamps so eviction order is unambiguous regardless of + // `Instant::now()` resolution on this platform. + let mut tickets = Vec::with_capacity(MAX_TICKETS); + { + let mut guard = store.inner.lock(); + let base = Instant::now(); + for i in 0..MAX_TICKETS { + let ticket = format!("ticket-{i}"); + guard.insert( + ticket.clone(), + LoginTicket { + bearer: SecretString::from(format!("bearer-{i}")), + created_at: base + Duration::from_millis(i as u64), + }, + ); + tickets.push(ticket); + } + } + + let newest = store.insert(SecretString::from("bearer-newest".to_string())); + + let oldest = &tickets[0]; + assert!( + store.take(oldest).is_none(), + "oldest ticket must be evicted once the store is at capacity" + ); + assert_eq!( + store.take(&newest).map(|s| s.expose_secret().to_string()), + Some("bearer-newest".to_string()) + ); + } + + #[test] + fn ticket_store_single_redemption_under_concurrent_take() { + // `take()` holds the store's `parking_lot::Mutex` for its whole + // critical section, so redemption is atomic by construction — this + // proves it holds under real concurrent access (Arc-shared across + // OS threads with a barrier to force contention), pinning the + // single-redemption invariant rather than just calling `take()` + // twice sequentially from one thread. + let store = Arc::new(LoginTicketStore::new()); + let ticket = store.insert(SecretString::from("bearer-1".to_string())); + let barrier = Arc::new(std::sync::Barrier::new(2)); + + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..2) + .map(|_| { + let store = Arc::clone(&store); + let ticket = ticket.clone(); + let barrier = Arc::clone(&barrier); + scope.spawn(move || { + barrier.wait(); + store.take(&ticket) + }) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let redeemed = results.iter().filter(|r| r.is_some()).count(); + assert_eq!( + redeemed, 1, + "exactly one concurrent taker must redeem the ticket" + ); + } + + #[test] + fn build_redirect_appends_login_ticket_query_param() { + assert_eq!(build_redirect("/", "abc"), "/?login_ticket=abc"); + assert_eq!( + build_redirect("/?tab=settings", "abc"), + "/?tab=settings&login_ticket=abc" + ); + } +} diff --git a/crates/ironclaw_webui/src/lib.rs b/crates/ironclaw_webui/src/lib.rs index bf5e1e34de3..d99113a4e64 100644 --- a/crates/ironclaw_webui/src/lib.rs +++ b/crates/ironclaw_webui/src/lib.rs @@ -22,6 +22,7 @@ //! reads v1 secrets / settings / DB. mod auth; +mod cli_token_login; mod oidc; mod session; mod signed_session_login; @@ -60,6 +61,11 @@ pub use auth::{ ProviderInitError, PublicRouteMount, UserDirectory, UserDirectoryError, empty_webui_v2_auth_providers_mount, webui_v2_auth_router, }; +// Host-owned CLI-token bootstrap login (`GET /login?token=`); shares the +// OAuth surface's bearer/ticket-exchange contract (`POST +// /auth/session/exchange`) — no new SPA code needed. See +// `cli_token_login.rs` module docs. +pub use cli_token_login::{CliTokenLoginConfig, build_cli_token_login}; pub use oidc::{ AudienceClaim, ClaimToUserIdFn, IdTokenClaims, OidcAuthenticator, OidcAuthenticatorConfig, OidcAuthenticatorError, diff --git a/crates/ironclaw_webui/src/session.rs b/crates/ironclaw_webui/src/session.rs index 265e834018d..36cf4a60f94 100644 --- a/crates/ironclaw_webui/src/session.rs +++ b/crates/ironclaw_webui/src/session.rs @@ -73,6 +73,22 @@ pub struct SessionRecord { pub user_id: UserId, pub created_at: DateTime, pub expires_at: DateTime, + /// Whether this session carries the single-trusted-operator capability + /// (`WebuiAuthentication::operator`, `WebUiV2Capabilities::operator_webui_config`). + /// Stamped once at mint time by the caller of [`SessionStore::create_session`] + /// — provenance-based, never re-derived from the bearer at validation time — + /// per the invariant that only a token verified against the host's + /// operator-capable authenticator (the raw `Authorization: Bearer` env + /// token, or the CLI's `/login?token=` link that verifies against the same + /// authenticator) may mint an operator session. SSO/OAuth and any other + /// multi-user login path must always pass `false`. + /// + /// `#[serde(default)]` so a pre-existing session record persisted before + /// this field existed (or a `create_session` call site that hasn't been + /// updated) deserializes to `false` — fails closed to non-operator rather + /// than accidentally granting escalation. + #[serde(default)] + pub operator: bool, } impl SessionRecord { @@ -98,11 +114,20 @@ pub trait SessionStore: Send + Sync + 'static { /// Issue a new session bound to the supplied caller and lifetime. /// Returns the freshly minted bearer token; persist `record` keyed /// on this token (or whatever lookup encoding the backend prefers). + /// + /// `operator` is stamped onto the resulting [`SessionRecord`] and MUST be + /// `true` only when the caller has independently verified the credential + /// that authorized this mint against an operator-capable authenticator + /// (e.g. the host's env-bearer token). Every other login path — OAuth/SSO, + /// admin-provisioned per-user bearers — passes `false`. This is a + /// provenance stamp recorded once at mint time, never re-derived from the + /// bearer itself at validation time. async fn create_session( &self, tenant_id: TenantId, user_id: UserId, lifetime: ChronoDuration, + operator: bool, ) -> Result; /// Look up the session record bound to `candidate`. Implementations @@ -165,6 +190,7 @@ impl SessionStore for InMemorySessionStore { tenant_id: TenantId, user_id: UserId, lifetime: ChronoDuration, + operator: bool, ) -> Result { // Two distinct UUIDs: one is the operator-visible audit id // (`SessionId`, OK to log), the other is the bearer token @@ -187,6 +213,7 @@ impl SessionStore for InMemorySessionStore { expires_at: now .checked_add_signed(lifetime) .ok_or_else(|| SessionStoreError::Backend("session lifetime overflow".into()))?, + operator, }; self.inner.write().insert(bearer.clone(), record); Ok(SecretString::from(bearer)) @@ -280,7 +307,13 @@ impl WebuiAuthenticator for SessionAuthenticator { ); return None; } - Some(WebuiAuthentication::user(record.user_id)) + // Never re-derive operator-ness from the bearer; only stamp what + // was recorded at mint time (see SessionRecord::operator doc). + if record.operator { + Some(WebuiAuthentication::operator(record.user_id)) + } else { + Some(WebuiAuthentication::user(record.user_id)) + } } } @@ -300,7 +333,7 @@ mod tests { async fn create_then_lookup_returns_session() { let store = InMemorySessionStore::new(); let token = store - .create_session(tenant(), user(), ChronoDuration::hours(1)) + .create_session(tenant(), user(), ChronoDuration::hours(1), false) .await .expect("create"); let record = store @@ -315,7 +348,7 @@ mod tests { async fn expired_session_is_rejected_by_authenticator() { let store = Arc::new(InMemorySessionStore::new()); let token = store - .create_session(tenant(), user(), ChronoDuration::seconds(-1)) + .create_session(tenant(), user(), ChronoDuration::seconds(-1), false) .await .expect("create"); let auth = SessionAuthenticator::new(store.clone()); @@ -333,7 +366,7 @@ mod tests { async fn live_session_resolves_to_caller_user_id() { let store = Arc::new(InMemorySessionStore::new()); let token = store - .create_session(tenant(), user(), ChronoDuration::hours(1)) + .create_session(tenant(), user(), ChronoDuration::hours(1), false) .await .expect("create"); let auth = SessionAuthenticator::new(store); @@ -345,6 +378,74 @@ mod tests { assert!(!resolved.capabilities.operator_webui_config); } + // operator = true (webui-token-authenticated) must resolve to + // WebuiAuthentication::operator, not just ::user. + #[tokio::test] + async fn session_minted_as_operator_resolves_to_operator_capabilities() { + let store = Arc::new(InMemorySessionStore::new()); + let token = store + .create_session(tenant(), user(), ChronoDuration::hours(1), true) + .await + .expect("create"); + let auth = SessionAuthenticator::new(store); + let resolved = auth + .authenticate(token.expose_secret()) + .await + .expect("authenticated"); + assert_eq!(resolved.user_id.as_str(), "alice"); + assert!( + resolved.capabilities.operator_webui_config, + "a session minted with operator = true must authenticate with \ + operator capabilities", + ); + } + + // Escalation-guard tripwire (USER-DECIDED LAW: SSO/multi-user sessions + // stay non-operator): a session minted with `operator = false` — the + // shape every OAuth/SSO callback and admin-provisioned-user mint uses — + // must NEVER resolve to operator capabilities, regardless of how the + // `SessionRecord` is otherwise constructed. This is the permanent + // regression pin for the escalation guard the crate docs describe. + #[tokio::test] + async fn session_minted_as_non_operator_never_escalates() { + let store = Arc::new(InMemorySessionStore::new()); + let token = store + .create_session(tenant(), user(), ChronoDuration::hours(1), false) + .await + .expect("create"); + let auth = SessionAuthenticator::new(store); + let resolved = auth + .authenticate(token.expose_secret()) + .await + .expect("authenticated"); + assert!( + !resolved.capabilities.operator_webui_config, + "a session minted with operator = false must never authenticate \ + with operator capabilities", + ); + } + + // Fail-closed: a SessionRecord persisted before `operator` existed must + // deserialize with operator = false, never silently escalate. + #[test] + fn pre_fix_session_record_json_without_operator_field_deserializes_non_operator() { + let json = serde_json::json!({ + "session_id": "11111111-1111-1111-1111-111111111111", + "tenant_id": "tenant-a", + "user_id": "alice", + "created_at": "2024-01-01T00:00:00Z", + "expires_at": "2024-01-02T00:00:00Z", + }) + .to_string(); + let record: SessionRecord = + serde_json::from_str(&json).expect("pre-fix record shape must still deserialize"); + assert!( + !record.operator, + "a pre-fix SessionRecord JSON with no `operator` field must default to \ + non-operator", + ); + } + // Regression for the session-token-leak review (Medium): the // bearer token is the durable store's lookup key, never a field // on `SessionRecord`. `Debug` and `Serialize` of a record must @@ -356,7 +457,7 @@ mod tests { async fn session_record_debug_and_serialize_do_not_contain_bearer() { let store = InMemorySessionStore::new(); let token = store - .create_session(tenant(), user(), ChronoDuration::hours(1)) + .create_session(tenant(), user(), ChronoDuration::hours(1), false) .await .expect("create"); let bearer = token.expose_secret().to_string(); @@ -402,6 +503,7 @@ mod tests { _tenant_id: TenantId, _user_id: UserId, _lifetime: ChronoDuration, + _operator: bool, ) -> Result { unreachable!() } diff --git a/crates/ironclaw_webui/src/signed_session_login.rs b/crates/ironclaw_webui/src/signed_session_login.rs index aab8fb7b78a..5b9bb31b094 100644 --- a/crates/ironclaw_webui/src/signed_session_login.rs +++ b/crates/ironclaw_webui/src/signed_session_login.rs @@ -225,6 +225,11 @@ struct TokenPayload { user: String, iat: i64, exp: i64, + /// Operator-capability stamp (see `SessionRecord::operator`). Defaults + /// to non-operator so pre-existing tokens fail closed rather than + /// retroactively escalating. + #[serde(default)] + op: bool, } #[async_trait] @@ -234,6 +239,7 @@ impl SessionStore for SignedTokenSessionStore { tenant_id: TenantId, user_id: UserId, lifetime: ChronoDuration, + operator: bool, ) -> Result { // A non-positive lifetime would mint a token whose `exp <= iat`; // `lookup` then rejects it immediately, so the caller would get @@ -253,6 +259,7 @@ impl SessionStore for SignedTokenSessionStore { user: user_id.as_str().to_string(), iat: now.timestamp(), exp: expires_at.timestamp(), + op: operator, }; let payload_json = serde_json::to_vec(&payload) .map_err(|err| SessionStoreError::Backend(format!("encode token payload: {err}")))?; @@ -300,6 +307,7 @@ impl SessionStore for SignedTokenSessionStore { user_id, created_at, expires_at, + operator: payload.op, })) } @@ -420,6 +428,7 @@ mod tests { tenant(), UserId::new("operator").expect("user"), ChronoDuration::hours(1), + false, ) .await .expect("create"); @@ -440,6 +449,7 @@ mod tests { tenant(), UserId::new("operator").expect("user"), ChronoDuration::hours(1), + false, ) .await .expect("create"); @@ -472,6 +482,7 @@ mod tests { tenant_a.clone(), UserId::new("alice").expect("user"), ChronoDuration::hours(1), + false, ) .await .expect("create"); @@ -500,6 +511,7 @@ mod tests { user: "operator".to_string(), iat: now - 100, exp: now - 10, + op: false, }, ); assert!(store.lookup(&token).await.expect("lookup").is_none()); @@ -515,6 +527,7 @@ mod tests { tenant(), UserId::new("operator").expect("user"), ChronoDuration::hours(1), + false, ) .await .expect("create"); @@ -546,6 +559,7 @@ mod tests { tenant(), UserId::new("operator").expect("user"), ChronoDuration::hours(1), + false, ) .await .expect("create"); @@ -591,6 +605,7 @@ mod tests { user: "operator".to_string(), iat: base, exp, + op: false, }, ) }; @@ -644,7 +659,12 @@ mod tests { let store = signed_store("operator-secret"); for lifetime in [ChronoDuration::zero(), ChronoDuration::seconds(-1)] { let err = store - .create_session(tenant(), UserId::new("operator").expect("user"), lifetime) + .create_session( + tenant(), + UserId::new("operator").expect("user"), + lifetime, + false, + ) .await .expect_err("a non-positive lifetime must error, not mint a dead token"); assert!(matches!(err, SessionStoreError::Backend(_))); @@ -659,6 +679,7 @@ mod tests { tenant(), UserId::new("operator").expect("user"), ChronoDuration::MAX, + false, ) .await .expect_err("a lifetime that overflows the expiry instant must error"); @@ -677,6 +698,7 @@ mod tests { user: "operator".to_string(), iat: now, exp: now + 3600, + op: false, }, ); let err = store @@ -702,6 +724,7 @@ mod tests { user: String::new(), iat: now, exp: now + 3600, + op: false, }, ); let err = store diff --git a/crates/ironclaw_webui/tests/auth_route_contract.rs b/crates/ironclaw_webui/tests/auth_route_contract.rs index 58817a6ae9b..d7a0ef28f1c 100644 --- a/crates/ironclaw_webui/tests/auth_route_contract.rs +++ b/crates/ironclaw_webui/tests/auth_route_contract.rs @@ -262,6 +262,7 @@ async fn revoked_session_bearer_rejected() { TenantId::new(TENANT).expect("tenant"), UserId::new("session-user").expect("user"), ChronoDuration::hours(1), + false, ) .await .expect("create_session") @@ -312,6 +313,7 @@ async fn expired_session_bearer_rejected_on_route() { // Already expired: `SessionRecord::is_expired` is `now >= // expires_at`, so a negative lifetime is unambiguously past. ChronoDuration::seconds(-1), + false, ) .await .expect("create_session") @@ -355,6 +357,7 @@ async fn session_minted_for_one_tenant_does_not_authenticate_another_deployment( TenantId::new(TENANT).expect("tenant"), UserId::new("session-user").expect("user"), ChronoDuration::hours(1), + false, ) .await .expect("create_session") @@ -442,6 +445,7 @@ async fn query_token_honored_on_sse_events_route() { TenantId::new(TENANT).expect("tenant"), UserId::new("session-user").expect("user"), ChronoDuration::hours(1), + false, ) .await .expect("create_session") @@ -543,6 +547,7 @@ async fn expired_query_token_rejected_on_sse_route() { TenantId::new(TENANT).expect("tenant"), UserId::new("session-user").expect("user"), ChronoDuration::seconds(-1), + false, ) .await .expect("create_session") @@ -580,6 +585,7 @@ async fn query_token_rejected_on_mutation_route() { TenantId::new(TENANT).expect("tenant"), UserId::new("session-user").expect("user"), ChronoDuration::hours(1), + false, ) .await .expect("create_session") @@ -641,6 +647,7 @@ async fn query_token_rejected_on_websocket_route() { TenantId::new(TENANT).expect("tenant"), UserId::new("session-user").expect("user"), ChronoDuration::hours(1), + false, ) .await .expect("create_session") @@ -673,6 +680,7 @@ async fn cookie_session_not_honored_on_protected_route() { TenantId::new(TENANT).expect("tenant"), UserId::new("session-user").expect("user"), ChronoDuration::hours(1), + false, ) .await .expect("create_session") diff --git a/crates/ironclaw_webui/tests/cli_token_login_route.rs b/crates/ironclaw_webui/tests/cli_token_login_route.rs new file mode 100644 index 00000000000..0f7ff8628b3 --- /dev/null +++ b/crates/ironclaw_webui/tests/cli_token_login_route.rs @@ -0,0 +1,396 @@ +//! Caller-level tests for the CLI-token `/login?token=` route (B4). +//! +//! Drives the unauthenticated `Router` from [`build_cli_token_login`] via +//! `tower::ServiceExt::oneshot`, mirroring `google_oauth_routes.rs`'s +//! OAuth-callback pattern: +//! - valid token → mints a session, redirects with a one-time `login_ticket` +//! - `POST /auth/session/exchange` redeems it for the real bearer (same +//! contract the SPA's `exchangeLoginTicket` already uses) +//! - wrong token → 401, no ticket minted; a redeemed ticket is single-use + +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::State; +use axum::http::{Request, StatusCode, header}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; +use chrono::Duration as ChronoDuration; +use http_body_util::BodyExt; +use ironclaw_host_api::{TenantId, UserId}; +use ironclaw_webui::{ + CliTokenLoginConfig, EnvBearerAuthenticator, SessionAuthenticator, SessionStore, + build_cli_token_login, signed_session_store, +}; +use secrecy::SecretString; +use serde::Deserialize; +use tower::ServiceExt; + +const CLI_TOKEN: &str = "cli-secret-token"; +const OPERATOR_USER: &str = "operator"; + +fn tenant() -> TenantId { + TenantId::new("tenant-a").expect("tenant") +} + +fn build_router() -> (axum::Router, Arc) { + let session_store = signed_session_store( + &SecretString::from("operator-secret".to_string()), + &tenant(), + ); + let authenticator = Arc::new( + EnvBearerAuthenticator::new( + SecretString::from(CLI_TOKEN.to_string()), + UserId::new(OPERATOR_USER).expect("user"), + ) + .expect("env bearer authenticator"), + ); + let config = CliTokenLoginConfig::new(tenant(), authenticator, session_store.clone()) + .with_session_lifetime(ChronoDuration::hours(1)); + (build_cli_token_login(config).router, session_store) +} + +async fn body_string(body: Body) -> String { + let bytes = body.collect().await.expect("collect body").to_bytes(); + String::from_utf8(bytes.to_vec()).expect("utf-8") +} + +#[derive(Deserialize)] +struct SessionExchangeResponse { + token: String, +} + +fn login_request(token: &str) -> Request { + Request::builder() + .method("GET") + .uri(format!("/login?token={token}")) + .body(Body::empty()) + .expect("request") +} + +fn ticket_from_location(location: &str) -> String { + let query = location.split_once('?').expect("query").1; + for pair in query.split('&') { + if let Some(value) = pair.strip_prefix("login_ticket=") { + return urlencoding::decode(value).expect("urldecode").into_owned(); + } + } + panic!("no login_ticket in {location}"); +} + +async fn exchange_ticket(router: axum::Router, ticket: &str) -> axum::response::Response { + router + .oneshot( + Request::builder() + .method("POST") + .uri("/auth/session/exchange") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::json!({ "ticket": ticket }).to_string(), + )) + .expect("request"), + ) + .await + .expect("oneshot") +} + +#[tokio::test] +async fn valid_token_redirects_with_ticket_that_exchanges_for_an_authenticating_bearer() { + let (router, session_store) = build_router(); + + let login = router + .clone() + .oneshot(login_request(CLI_TOKEN)) + .await + .expect("oneshot"); + assert_eq!(login.status(), StatusCode::SEE_OTHER); + let location = login + .headers() + .get(header::LOCATION) + .expect("Location header") + .to_str() + .expect("utf-8") + .to_string(); + assert!(location.starts_with("/?login_ticket="), "got {location}"); + + let ticket = ticket_from_location(&location); + let exchange = exchange_ticket(router.clone(), &ticket).await; + assert_eq!(exchange.status(), StatusCode::OK); + let body = body_string(exchange.into_body()).await; + let payload: SessionExchangeResponse = serde_json::from_str(&body).expect("json"); + assert!(!payload.token.is_empty()); + + // Must authenticate against the store the route minted through — not + // just an opaque value. + let record = session_store + .lookup(&payload.token) + .await + .expect("lookup") + .expect("session must resolve"); + assert_eq!(record.user_id.as_str(), OPERATOR_USER); + assert_eq!(record.tenant_id.as_str(), "tenant-a"); + + // Single-use: redeeming the same ticket again must fail. + let replay = exchange_ticket(router, &ticket).await; + assert_eq!( + replay.status(), + StatusCode::UNAUTHORIZED, + "login ticket must be single-use", + ); +} + +#[tokio::test] +async fn wrong_token_is_rejected_and_mints_no_ticket() { + let (router, _session_store) = build_router(); + + let login = router + .clone() + .oneshot(login_request("not-the-token")) + .await + .expect("oneshot"); + assert_eq!(login.status(), StatusCode::UNAUTHORIZED); + assert!( + login.headers().get(header::LOCATION).is_none(), + "a rejected login must not redirect (no ticket minted)", + ); + + // Store was never populated by the failed attempt. + let exchange = exchange_ticket(router, "made-up-ticket").await; + assert_eq!(exchange.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn missing_token_is_rejected() { + let (router, _session_store) = build_router(); + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri("/login") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("oneshot"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +// ─── post-exchange bearer authorizes a protected route ──────────────── +// +// `build_router` above only proves the exchange returns a well-formed +// bearer, not that it authenticates anything. Front a minimal protected +// route with the real prod auth layer, [`SessionAuthenticator`] (same as +// `serve.rs`'s `WebuiServeConfig`), fed the SAME `session_store` the login +// mount mints through. +// - deliberately NOT the full `webui_v2_app` composition +// `session_round_trip.rs` uses — that facade is scoped to the OAuth +// round-trip; duplicating it here is unneeded machinery. +// - `SessionAuthenticator` IS the seam that decides "does this bearer +// authenticate" in production, so a route behind it suffices. + +const PROTECTED_PATH: &str = "/protected/ping"; + +async fn require_session_bearer( + State(authenticator): State>, + request: Request, + next: Next, +) -> Response { + let token = request + .headers() + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")); + let Some(token) = token else { + return StatusCode::UNAUTHORIZED.into_response(); + }; + // Reuses the real WebuiAuthenticator::authenticate contract (same call + // as webui_serve's authenticate_request) rather than reimplementing. + if ironclaw_webui::WebuiAuthenticator::authenticate(&*authenticator, token) + .await + .is_none() + { + return StatusCode::UNAUTHORIZED.into_response(); + } + next.run(request).await +} + +fn build_protected_router(session_store: Arc) -> axum::Router { + let authenticator = Arc::new(SessionAuthenticator::new(session_store)); + axum::Router::new() + .route( + PROTECTED_PATH, + axum::routing::get(|| async { StatusCode::OK }), + ) + .route_layer(middleware::from_fn_with_state( + authenticator, + require_session_bearer, + )) +} + +fn protected_request(bearer: Option<&str>) -> Request { + let mut builder = Request::builder().method("GET").uri(PROTECTED_PATH); + if let Some(bearer) = bearer { + builder = builder.header(header::AUTHORIZATION, format!("Bearer {bearer}")); + } + builder.body(Body::empty()).expect("request") +} + +// USER-DECIDED LAW: webui-token auth = operator/admin, whether via raw +// `Authorization: Bearer` or this `/login?token=` link. A session minted +// through this route must authenticate with `operator_webui_config = true` +// so the caller gets the same admin capabilities as a raw bearer check. +#[tokio::test] +async fn exchanged_bearer_from_cli_token_login_is_operator_capable() { + let (router, session_store) = build_router(); + let authenticator = SessionAuthenticator::new(session_store); + + let login = router + .clone() + .oneshot(login_request(CLI_TOKEN)) + .await + .expect("oneshot"); + assert_eq!(login.status(), StatusCode::SEE_OTHER); + let location = login + .headers() + .get(header::LOCATION) + .expect("Location header") + .to_str() + .expect("utf-8") + .to_string(); + let ticket = ticket_from_location(&location); + + let exchange = exchange_ticket(router, &ticket).await; + assert_eq!(exchange.status(), StatusCode::OK); + let body = body_string(exchange.into_body()).await; + let payload: SessionExchangeResponse = serde_json::from_str(&body).expect("json"); + + let auth = ironclaw_webui::WebuiAuthenticator::authenticate(&authenticator, &payload.token) + .await + .expect("exchanged bearer must authenticate"); + assert!( + auth.capabilities.operator_webui_config, + "a session minted via the CLI-token /login link, from a token that \ + verified against the operator-capable authenticator, must \ + authenticate with operator capabilities", + ); +} + +// Non-operator mirror of `exchanged_bearer_from_cli_token_login_is_operator_capable`: +// `EnvBearerAuthenticator::authenticate` always returns an operator +// `WebuiAuthentication`, so it alone can't prove `login_handler` actually +// carries the token's own `operator_webui_config` bit through to +// `create_session` rather than hardcoding `true`. This authenticator +// returns a non-operator identity so a session minted through it must NOT +// come out operator-capable. +struct NonOperatorAuthenticator { + token: &'static str, + user_id: UserId, +} + +#[async_trait::async_trait] +impl ironclaw_webui::WebuiAuthenticator for NonOperatorAuthenticator { + async fn authenticate(&self, candidate: &str) -> Option { + (candidate == self.token) + .then(|| ironclaw_webui::WebuiAuthentication::user(self.user_id.clone())) + } + + fn mounts_operator_webui_config_routes(&self) -> bool { + false + } +} + +#[tokio::test] +async fn exchanged_bearer_from_a_non_operator_authenticator_is_not_operator_capable() { + let session_store = signed_session_store( + &SecretString::from("operator-secret".to_string()), + &tenant(), + ); + let authenticator = Arc::new(NonOperatorAuthenticator { + token: CLI_TOKEN, + user_id: UserId::new("member").expect("user"), + }); + let config = CliTokenLoginConfig::new(tenant(), authenticator, session_store.clone()) + .with_session_lifetime(ChronoDuration::hours(1)); + let router = build_cli_token_login(config).router; + let session_authenticator = SessionAuthenticator::new(session_store); + + let login = router + .clone() + .oneshot(login_request(CLI_TOKEN)) + .await + .expect("oneshot"); + assert_eq!(login.status(), StatusCode::SEE_OTHER); + let location = login + .headers() + .get(header::LOCATION) + .expect("Location header") + .to_str() + .expect("utf-8") + .to_string(); + let ticket = ticket_from_location(&location); + + let exchange = exchange_ticket(router, &ticket).await; + assert_eq!(exchange.status(), StatusCode::OK); + let body = body_string(exchange.into_body()).await; + let payload: SessionExchangeResponse = serde_json::from_str(&body).expect("json"); + + let auth = + ironclaw_webui::WebuiAuthenticator::authenticate(&session_authenticator, &payload.token) + .await + .expect("exchanged bearer must authenticate"); + assert!( + !auth.capabilities.operator_webui_config, + "a session minted from a non-operator-capable authenticator must never come out \ + operator-capable — login_handler must carry the token's own \ + `operator_webui_config` bit through, not hardcode `true`", + ); +} + +#[tokio::test] +async fn exchanged_bearer_authenticates_a_protected_route() { + let (login_router, session_store) = build_router(); + let protected_router = build_protected_router(session_store); + let app = login_router.merge(protected_router.clone()); + + // RED first: proves the route enforces auth before a later 200 means + // anything. + let unauthenticated = protected_router + .clone() + .oneshot(protected_request(None)) + .await + .expect("oneshot"); + assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED); + + let login = app + .clone() + .oneshot(login_request(CLI_TOKEN)) + .await + .expect("oneshot"); + assert_eq!(login.status(), StatusCode::SEE_OTHER); + let location = login + .headers() + .get(header::LOCATION) + .expect("Location header") + .to_str() + .expect("utf-8") + .to_string(); + let ticket = ticket_from_location(&location); + + let exchange = exchange_ticket(app.clone(), &ticket).await; + assert_eq!(exchange.status(), StatusCode::OK); + let body = body_string(exchange.into_body()).await; + let payload: SessionExchangeResponse = serde_json::from_str(&body).expect("json"); + assert!(!payload.token.is_empty()); + + // GREEN: the exchanged bearer must authenticate the protected route. + let authenticated = app + .oneshot(protected_request(Some(&payload.token))) + .await + .expect("oneshot"); + assert_eq!( + authenticated.status(), + StatusCode::OK, + "the login mount's exchanged bearer must authorize a request through the real \ + SessionAuthenticator layer, not just decode as JSON", + ); +} diff --git a/crates/ironclaw_webui/tests/google_oauth_routes.rs b/crates/ironclaw_webui/tests/google_oauth_routes.rs index 4aaf10d2901..84535271bd2 100644 --- a/crates/ironclaw_webui/tests/google_oauth_routes.rs +++ b/crates/ironclaw_webui/tests/google_oauth_routes.rs @@ -1272,6 +1272,7 @@ mod session_store_failure { _tenant_id: TenantId, _user_id: UserId, _lifetime: ChronoDuration, + _operator: bool, ) -> Result { Err(SessionStoreError::Backend("simulated outage".into())) } @@ -1371,6 +1372,7 @@ mod logout_revoke_failure { _tenant_id: TenantId, _user_id: UserId, _lifetime: ChronoDuration, + _operator: bool, ) -> Result { unreachable!("test does not drive create_session") } diff --git a/docker/reborn/config.toml b/docker/reborn/config.toml index 23dcc915873..da6b992f4b3 100644 --- a/docker/reborn/config.toml +++ b/docker/reborn/config.toml @@ -27,10 +27,11 @@ regex_activation_enabled = true env_token_var = "IRONCLAW_REBORN_WEBUI_TOKEN" env_user_id_var = "IRONCLAW_REBORN_WEBUI_USER_ID" -[llm.default] -provider_id = "nearai" -model = "deepseek-ai/DeepSeek-V4-Flash" -api_key_env = "NEARAI_API_KEY" +# No [llm.default] slot shipped (intentional): +# - config.toml is sole source of truth for [llm.default], set only explicitly +# - Railway env (NEARAI_API_KEY et al) is complete, so env-fallback +# (resolve_reborn_runtime_llm) resolves the LLM with no slot needed +# - existing volumes with the old baked-in stub: see entrypoint.sh migration [slack] enabled = false diff --git a/docker/reborn/entrypoint.sh b/docker/reborn/entrypoint.sh index 29ee6ae9e42..2afce4d6349 100755 --- a/docker/reborn/entrypoint.sh +++ b/docker/reborn/entrypoint.sh @@ -81,6 +81,70 @@ if [ ! -f "$config_path" ]; then trap - EXIT HUP INT TERM fi +# One-time volume migration: `config.toml` is now the single source of +# truth for `[llm.default]`, written only by an explicit act (onboard, +# `config set`/`models set-provider`, or the WebUI settings page) — never +# implicitly baked into a shipped default config (see this repo's +# `docker/reborn/config.toml` comment). Before this change, EVERY shipped +# profile config (`config.toml`, `config.hosted-single-tenant.toml`, +# `config.hosted-single-tenant-volume.toml`, `config.production.toml`) +# baked in the identical `[llm.default]` stub below, and the block above +# only installs a default config when `$config_path` doesn't exist yet — so +# a pre-existing Railway volume from before this change still carries that +# stale baked-in stub verbatim and would otherwise never pick up the new +# "no implicit slot" behavior. This check strips the section ONLY when it +# is an EXACT, byte-for-byte match of the known old stub (header + exactly +# these three fields, immediately followed by a blank line, a new `[section]` +# header, or EOF) — an operator who has since edited `[llm.default]` in any +# way (different model, added fields, a deliberately-kept `nearai` pin, +# etc.) is left completely untouched, matching the entrypoint's existing +# narrowly-gated legacy-Slack-field migration just below. A backup of the +# pre-migration file is written alongside as `config.toml.pre-llm-migration` +# (once — never overwritten by a later boot) before any change is made. +if [ -f "$config_path" ]; then + llm_stub_migration_needed="$(awk ' + BEGIN { state = 0; found = 0 } + /^\[llm\.default\][[:space:]]*$/ { state = 1; next } + state == 1 { + if ($0 == "provider_id = \"nearai\"") { state = 2; next } + state = 0 + } + state == 2 { + if ($0 == "model = \"deepseek-ai/DeepSeek-V4-Flash\"") { state = 3; next } + state = 0 + } + state == 3 { + if ($0 == "api_key_env = \"NEARAI_API_KEY\"") { state = 4; next } + state = 0 + } + state == 4 { + if ($0 == "" || $0 ~ /^\[/) { found = 1 } + state = 0 + } + END { + if (state == 4) { found = 1 } + print found + } + ' "$config_path")" + if [ "$llm_stub_migration_needed" = "1" ]; then + backup_path="${config_path}.pre-llm-migration" + if [ ! -f "$backup_path" ]; then + cp "$config_path" "$backup_path" + fi + tmp_config="${config_path}.tmp.$$" + trap 'rm -f "$tmp_config"' EXIT HUP INT TERM + awk ' + BEGIN { skip = 0 } + /^\[llm\.default\][[:space:]]*$/ { skip = 4; next } + skip > 0 { skip--; next } + { print } + ' "$config_path" > "$tmp_config" + mv "$tmp_config" "$config_path" + trap - EXIT HUP INT TERM + echo "Migrated a stale baked-in [llm.default] stub out of $config_path (backup: $backup_path); LLM environment variables now drive runtime resolution directly. See docker/reborn/config.toml's comment." >&2 + fi +fi + if ! is_truthy "${IRONCLAW_REBORN_SLACK_ENABLED:-}" \ && awk ' /^[[:space:]]*\[/ { diff --git a/docs/plans/composition-pubuse.snapshot b/docs/plans/composition-pubuse.snapshot index 46ff6f5b9b8..3585c2dbe05 100644 --- a/docs/plans/composition-pubuse.snapshot +++ b/docs/plans/composition-pubuse.snapshot @@ -14,10 +14,16 @@ pub use extension_host::gsuite::{ pub use extension_host::skill_listing::{RebornSkillListError, list_reborn_local_skills}; #[cfg(feature = "test-support")] pub use factory::AttachmentTestSupport; +#[cfg(any(feature = "libsql", feature = "postgres"))] +pub use factory::LOCAL_DEV_SECRETS_MASTER_KEY_PATH; #[cfg(feature = "test-support")] pub use factory::RebornLocalDevApprovalTestParts; #[cfg(feature = "migration-support")] pub use factory::extension_installation_store_for_migration; +#[cfg(feature = "libsql")] +pub use factory::open_local_dev_secret_store; +#[cfg(any(feature = "libsql", feature = "postgres"))] +pub use factory::{KeychainMasterKeyOutcome, provision_local_dev_keychain_master_key}; pub use factory::{RebornServices, build_reborn_services, builtin_first_party_trust_policy}; pub use failure_lane::{ALL_RUN_FAILURE_CATEGORIES, FailureLane, failure_lane}; pub use failure_summary::reborn_failure_summary_for_category; @@ -43,8 +49,8 @@ pub use ironclaw_turns::TurnStatus; #[cfg(feature = "root-llm-provider")] pub use llm_admin::llm_catalog::{ ProviderCatalogValidationError, RebornLlmCatalogError, resolve_against_registry, - resolve_llm_selection_against_catalog, resolve_reborn_runtime_llm, - validate_reborn_provider_catalog_contents, + resolve_llm_selection_against_catalog, resolve_llm_selection_allow_missing_key, + resolve_reborn_runtime_llm, validate_reborn_provider_catalog_contents, }; #[cfg(feature = "root-llm-provider")] pub use llm_admin::llm_config_service::{LlmReloadTrigger, RebornLlmConfigService}; @@ -59,6 +65,7 @@ pub use llm_admin::openai_compat_serve::build_openai_compat_route_mount; pub use ironclaw_product_adapters::mark_bearer_token_verified_for_tenant; #[cfg(feature = "root-llm-provider")] pub use llm_admin::provider_admin::{ + DetectedEnvLlm, EXAMPLE_OVERLAY_PROVIDER_ID, ProviderMenuEntry, ProviderProbeOutcome, RebornModelRoutesState, RebornProviderAdmin, RebornProviderAdminError, RebornProviderInfo, RebornProviderList, RebornProviderMetadata, RebornProviderSelection, RebornProviderStatus, RebornProviderWriteOutcome, RebornV1State, diff --git a/tests/integration/secrets.rs b/tests/integration/secrets.rs index 604b3e127f8..35fcce2b355 100644 --- a/tests/integration/secrets.rs +++ b/tests/integration/secrets.rs @@ -49,6 +49,7 @@ async fn secret_persists_across_libsql_reopen() { let composite = Arc::new(composite); let scoped = wrap_scoped(Arc::clone(&composite)); let store = build_local_dev_secret_store_for_test(dir.path(), Arc::clone(&scoped)) + .await .expect("build first secret store"); let scope = test_product_scope( @@ -93,6 +94,7 @@ async fn secret_persists_across_libsql_reopen() { let fresh_composite = Arc::new(fresh_composite); let fresh_scoped = wrap_scoped(Arc::clone(&fresh_composite)); let fresh_store = build_local_dev_secret_store_for_test(dir.path(), fresh_scoped) + .await .expect("build fresh secret store (same root → same crypto key)"); // --- Read back: the material must survive the reopen --- @@ -125,8 +127,9 @@ async fn secret_read_back_fails_for_unknown_handle() { .expect("build default local-dev db roots"); let composite = Arc::new(composite); let scoped = wrap_scoped(Arc::clone(&composite)); - let store = - build_local_dev_secret_store_for_test(dir.path(), scoped).expect("build secret store"); + let store = build_local_dev_secret_store_for_test(dir.path(), scoped) + .await + .expect("build secret store"); let scope = test_product_scope( "tenant-itest", @@ -171,8 +174,9 @@ async fn secret_read_back_fails_for_wrong_tenant_scope() { .expect("build default local-dev db roots"); let composite = Arc::new(composite); let scoped = wrap_scoped(Arc::clone(&composite)); - let store = - build_local_dev_secret_store_for_test(dir.path(), scoped).expect("build secret store"); + let store = build_local_dev_secret_store_for_test(dir.path(), scoped) + .await + .expect("build secret store"); let scope = test_product_scope( "tenant-itest",