diff --git a/crates/ironclaw_product_workflow/src/reborn_services.rs b/crates/ironclaw_product_workflow/src/reborn_services.rs index 91f0827447f..e83fd31f4d2 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services.rs @@ -35,7 +35,7 @@ use ironclaw_turns::{ ResumeTurnRequest, SanitizedCancelReason, SubmitTurnRequest, SubmitTurnResponse, TurnActor, TurnCoordinator, TurnError, TurnRunId, TurnScope, TurnStatus, }; -use secrecy::SecretString; +use secrecy::{ExposeSecret as _, SecretString}; use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard}; use url::Url; use uuid::Uuid; @@ -704,6 +704,14 @@ impl GateResolutionRoute { } } +fn operator_setup_validation_error(field: &str) -> RebornServicesError { + WebUiInboundValidationError { + field: field.to_string(), + code: WebUiInboundValidationCode::InvalidValue, + } + .into() +} + /// Stable WebUI-facing facade surface for beta Reborn routes. fn operator_setup_diagnostic( key: &str, @@ -722,46 +730,63 @@ fn operator_setup_diagnostic( } } -fn operator_setup_info_diagnostic( - key: &str, - reason_code: &str, - message: &str, - remediation: &str, -) -> RebornOperatorConfigDiagnostic { - operator_setup_diagnostic( - key, - RebornOperatorConfigDiagnosticSeverity::Info, - reason_code, - message, - remediation, - ) +const OPERATOR_SETUP_PROFILE_ID_MAX_BYTES: usize = 128; +const OPERATOR_SETUP_WEBUI_TOKEN_MIN_BYTES: usize = 32; +const OPERATOR_SETUP_WEBUI_TOKEN_MAX_BYTES: usize = 4096; +const OPERATOR_SETUP_REDACTED_SECRET_SENTINEL: &str = "••••••••"; + +fn validate_operator_setup_profile_id( + profile_id: Option<&str>, +) -> Result, RebornServicesError> { + let Some(profile_id) = profile_id else { + return Ok(None); + }; + let trimmed = profile_id.trim(); + if trimmed.is_empty() || trimmed.len() > OPERATOR_SETUP_PROFILE_ID_MAX_BYTES { + return Err(operator_setup_validation_error("profile_id")); + } + Ok(Some(trimmed.to_string())) } -fn operator_setup_validation_error(field: &str) -> RebornServicesError { - WebUiInboundValidationError { - field: field.to_string(), - code: WebUiInboundValidationCode::InvalidValue, +fn validate_operator_setup_webui_access_token( + webui_access_token: Option<&SecretString>, +) -> Result { + let Some(token) = webui_access_token else { + return Ok(false); + }; + let token = token.expose_secret().trim(); + if token == OPERATOR_SETUP_REDACTED_SECRET_SENTINEL { + return Ok(false); } - .into() + if token.len() < OPERATOR_SETUP_WEBUI_TOKEN_MIN_BYTES + || token.len() > OPERATOR_SETUP_WEBUI_TOKEN_MAX_BYTES + { + return Err(operator_setup_validation_error("webui_access_token")); + } + Ok(true) +} + +fn reject_unwired_operator_setup_host_mutation( + profile_id: Option, + webui_access_token_updated: bool, +) -> Result<(), RebornServicesError> { + if profile_id.is_some() || webui_access_token_updated { + return Err(RebornServicesError::service_unavailable(false)); + } + Ok(()) +} + +#[derive(Debug, Clone, Default)] +struct OperatorSetupHostState { + profile_id: Option, + webui_access_token_updated: bool, } fn setup_response_from_llm_snapshot( snapshot: LlmConfigSnapshot, - mut diagnostics: Vec, + diagnostics: Vec, + host_state: OperatorSetupHostState, ) -> RebornOperatorSetupResponse { - diagnostics.push(operator_setup_info_diagnostic( - "profile_id", - "operator_setup_profile_not_wired", - "Profile setup is not wired into the operator setup API yet.", - "Continue using the existing profile setup path until profile persistence is exposed through Reborn services.", - )); - diagnostics.push(operator_setup_info_diagnostic( - "webui_access", - "operator_setup_webui_access_not_wired", - "WebUI access setup is not wired into the operator setup API yet.", - "Configure WebUI access through host bootstrap settings until operator access management is exposed through Reborn services.", - )); - let active_provider_id = snapshot .active .as_ref() @@ -772,6 +797,16 @@ fn setup_response_from_llm_snapshot( .and_then(|active| active.model.clone()); let provider_complete = active_provider_id.is_some(); let model_complete = active_model.is_some(); + let profile_message = host_state.profile_id.as_deref().map_or_else( + || "Runtime profile is selected by the current host configuration.".to_string(), + |profile_id| format!("Runtime profile `{profile_id}` was accepted by the setup API."), + ); + let webui_access_message = if host_state.webui_access_token_updated { + "WebUI access token was accepted without echoing the secret value.".to_string() + } else { + "Current authenticated operator already has WebUI access.".to_string() + }; + let status = if provider_complete && model_complete { RebornOperatorSetupStatus::Complete } else { @@ -817,13 +852,13 @@ fn setup_response_from_llm_snapshot( }, RebornOperatorSetupStep { name: "profile".to_string(), - status: RebornOperatorSetupStepStatus::Unsupported, - message: "Profile setup is not wired into this API yet.".to_string(), + status: RebornOperatorSetupStepStatus::Complete, + message: profile_message, }, RebornOperatorSetupStep { name: "webui_access".to_string(), - status: RebornOperatorSetupStepStatus::Unsupported, - message: "WebUI access setup is not wired into this API yet.".to_string(), + status: RebornOperatorSetupStepStatus::Complete, + message: webui_access_message, }, ], diagnostics, @@ -2129,7 +2164,11 @@ impl RebornServicesApi for RebornServices { .snapshot(caller) .await .map_err(llm_config::map_llm_config_error)?; - Ok(setup_response_from_llm_snapshot(snapshot, Vec::new())) + Ok(setup_response_from_llm_snapshot( + snapshot, + Vec::new(), + OperatorSetupHostState::default(), + )) } async fn run_operator_setup( @@ -2158,6 +2197,14 @@ impl RebornServicesApi for RebornServices { return Err(operator_setup_validation_error("api_key")); } validate_llm_base_url(request.base_url.as_deref())?; + let profile_id = validate_operator_setup_profile_id(request.profile_id.as_deref())?; + let webui_access_token_updated = + validate_operator_setup_webui_access_token(request.webui_access_token.as_ref())?; + reject_unwired_operator_setup_host_mutation(profile_id, webui_access_token_updated)?; + let host_state = OperatorSetupHostState { + profile_id: None, + webui_access_token_updated: false, + }; let snapshot = match (request.provider_id, request.adapter) { (Some(provider_id), Some(adapter)) => llm_config @@ -2192,7 +2239,11 @@ impl RebornServicesApi for RebornServices { .map_err(llm_config::map_llm_config_error)?, }; - Ok(setup_response_from_llm_snapshot(snapshot, Vec::new())) + Ok(setup_response_from_llm_snapshot( + snapshot, + Vec::new(), + host_state, + )) } /// `requested_thread_id` makes the caller's choice authoritative. @@ -3298,8 +3349,14 @@ impl RebornServicesApi for RebornServices { if let Some(llm_config) = &self.llm_config { match llm_config.snapshot(caller).await { Ok(snapshot) => { - diagnostics - .extend(setup_response_from_llm_snapshot(snapshot, Vec::new()).diagnostics); + diagnostics.extend( + setup_response_from_llm_snapshot( + snapshot, + Vec::new(), + OperatorSetupHostState::default(), + ) + .diagnostics, + ); } Err(err) => { tracing::debug!( diff --git a/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs b/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs index f20c771503d..3a4feb2aa35 100644 --- a/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs +++ b/crates/ironclaw_product_workflow/tests/reborn_services_contract.rs @@ -7840,13 +7840,14 @@ async fn get_operator_setup_returns_snapshot_from_llm_config() { assert_eq!(response.active_provider_id.as_deref(), Some("openai")); assert_eq!(response.active_model.as_deref(), Some("gpt-5-mini")); assert_eq!(response.status, RebornOperatorSetupStatus::Complete); - assert!(response.diagnostics.iter().any(|diagnostic| { - diagnostic.reason_code == "operator_setup_profile_not_wired" - && diagnostic.severity == RebornOperatorConfigDiagnosticSeverity::Info + assert!(response.diagnostics.is_empty()); + assert!(response.steps.iter().any(|step| { + step.name == "profile" + && step.status == ironclaw_product_workflow::RebornOperatorSetupStepStatus::Complete })); - assert!(response.diagnostics.iter().any(|diagnostic| { - diagnostic.reason_code == "operator_setup_webui_access_not_wired" - && diagnostic.severity == RebornOperatorConfigDiagnosticSeverity::Info + assert!(response.steps.iter().any(|step| { + step.name == "webui_access" + && step.status == ironclaw_product_workflow::RebornOperatorSetupStepStatus::Complete })); } @@ -7889,6 +7890,14 @@ async fn setup_response_reflects_active_provider_and_model() { step.name == "model" && step.status == ironclaw_product_workflow::RebornOperatorSetupStepStatus::Complete })); + assert!(response.steps.iter().any(|step| { + step.name == "profile" + && step.status == ironclaw_product_workflow::RebornOperatorSetupStepStatus::Complete + })); + assert!(response.steps.iter().any(|step| { + step.name == "webui_access" + && step.status == ironclaw_product_workflow::RebornOperatorSetupStepStatus::Complete + })); } #[tokio::test] @@ -8021,6 +8030,133 @@ async fn run_operator_setup_rejects_internal_base_url_before_upsert() { assert_eq!(llm_config.upsert_provider_count(), 0); } +#[tokio::test] +async fn run_operator_setup_rejects_blank_profile_before_provider_write() { + let llm_config = Arc::new(SetupRecordingLlmConfigService::default()); + let services = services_with_setup_llm_config(llm_config.clone()); + + let err = services + .run_operator_setup( + caller(), + RebornOperatorSetupRequest { + provider_id: Some("openai".to_string()), + adapter: Some("open_ai_completions".to_string()), + profile_id: Some(" ".to_string()), + ..Default::default() + }, + ) + .await + .expect_err("blank profile id is rejected"); + + assert_setup_validation(err, "profile_id", WebUiInboundValidationCode::InvalidValue); + assert_eq!(llm_config.upsert_provider_count(), 0); + assert_eq!(llm_config.set_active_count(), 0); +} + +#[tokio::test] +async fn run_operator_setup_rejects_oversized_profile_before_provider_write() { + let llm_config = Arc::new(SetupRecordingLlmConfigService::default()); + let services = services_with_setup_llm_config(llm_config.clone()); + + let err = services + .run_operator_setup( + caller(), + RebornOperatorSetupRequest { + provider_id: Some("openai".to_string()), + adapter: Some("open_ai_completions".to_string()), + profile_id: Some("x".repeat(129)), + ..Default::default() + }, + ) + .await + .expect_err("oversized profile id is rejected"); + + assert_setup_validation(err, "profile_id", WebUiInboundValidationCode::InvalidValue); + assert_eq!(llm_config.upsert_provider_count(), 0); + assert_eq!(llm_config.set_active_count(), 0); +} + +#[tokio::test] +async fn run_operator_setup_rejects_short_webui_access_token_before_provider_write() { + let llm_config = Arc::new(SetupRecordingLlmConfigService::default()); + let services = services_with_setup_llm_config(llm_config.clone()); + + let err = services + .run_operator_setup( + caller(), + RebornOperatorSetupRequest { + provider_id: Some("openai".to_string()), + adapter: Some("open_ai_completions".to_string()), + webui_access_token: Some(SecretString::from("too-short".to_string())), + ..Default::default() + }, + ) + .await + .expect_err("short WebUI token is rejected"); + + assert_setup_validation( + err, + "webui_access_token", + WebUiInboundValidationCode::InvalidValue, + ); + assert_eq!(llm_config.upsert_provider_count(), 0); + assert_eq!(llm_config.set_active_count(), 0); +} + +#[tokio::test] +async fn run_operator_setup_rejects_serve_weak_webui_access_token_before_provider_write() { + let llm_config = Arc::new(SetupRecordingLlmConfigService::default()); + let services = services_with_setup_llm_config(llm_config.clone()); + + let err = services + .run_operator_setup( + caller(), + RebornOperatorSetupRequest { + provider_id: Some("openai".to_string()), + adapter: Some("open_ai_completions".to_string()), + webui_access_token: Some(SecretString::from("x".repeat(16))), + ..Default::default() + }, + ) + .await + .expect_err("16-byte WebUI token is rejected"); + + assert_setup_validation( + err, + "webui_access_token", + WebUiInboundValidationCode::InvalidValue, + ); + assert_eq!(llm_config.upsert_provider_count(), 0); + assert_eq!(llm_config.set_active_count(), 0); +} + +#[tokio::test] +async fn run_operator_setup_rejects_oversized_webui_access_token_before_provider_write() { + let llm_config = Arc::new(SetupRecordingLlmConfigService::default()); + let services = services_with_setup_llm_config(llm_config.clone()); + + let err = services + .run_operator_setup( + caller(), + RebornOperatorSetupRequest { + provider_id: Some("openai".to_string()), + adapter: Some("open_ai_completions".to_string()), + webui_access_token: Some(SecretString::from("x".repeat(4097))), + ..Default::default() + }, + ) + .await + .expect_err("oversized WebUI token is rejected"); + + assert_setup_validation( + err, + "webui_access_token", + WebUiInboundValidationCode::InvalidValue, + ); + assert_eq!(llm_config.upsert_provider_count(), 0); + assert_eq!(llm_config.set_active_count(), 0); +} + #[tokio::test] async fn upsert_llm_provider_allows_loopback_base_url_for_self_hosted() { // Loopback/private endpoints are the primary self-hosted use case (Ollama, @@ -8162,6 +8298,126 @@ async fn run_operator_setup_upserts_and_activates_provider_config() { assert_eq!(llm_config.set_active_count(), 0); } +#[tokio::test] +async fn run_operator_setup_ignores_redacted_webui_access_token_sentinel() { + let llm_config = Arc::new(SetupRecordingLlmConfigService::default()); + llm_config.use_active_snapshot("openai", "gpt-5-mini"); + let services = services_with_setup_llm_config(llm_config.clone()); + + let response = services + .run_operator_setup( + caller(), + RebornOperatorSetupRequest { + provider_id: Some("openai".to_string()), + model: Some("gpt-5-mini".to_string()), + webui_access_token: Some(SecretString::from("••••••••".to_string())), + ..Default::default() + }, + ) + .await + .expect("setup response"); + + assert_eq!(response.status, RebornOperatorSetupStatus::Complete); + let webui_step = response + .steps + .iter() + .find(|step| step.name == "webui_access") + .expect("webui access step"); + assert_eq!( + webui_step.status, + ironclaw_product_workflow::RebornOperatorSetupStepStatus::Complete + ); + assert_eq!( + llm_config.set_active_calls.lock().expect("lock").as_slice(), + [SetupSetActiveCall { + provider_id: "openai".to_string(), + model: Some("gpt-5-mini".to_string()), + }] + ); + let serialized = serde_json::to_string(&response).expect("serialize setup response"); + assert!(!serialized.contains("••••••••")); +} + +#[tokio::test] +async fn run_operator_setup_rejects_unwired_host_mutations_before_provider_write() { + let llm_config = Arc::new(SetupRecordingLlmConfigService::default()); + let services = services_with_setup_llm_config(llm_config.clone()); + + let err = services + .run_operator_setup( + caller(), + RebornOperatorSetupRequest { + provider_id: Some("openai".to_string()), + adapter: Some("open_ai_completions".to_string()), + profile_id: Some("production".to_string()), + webui_access_token: Some(SecretString::from( + "webui-secret-token-value-32-bytes".to_string(), + )), + ..Default::default() + }, + ) + .await + .expect_err("unwired host mutations fail closed"); + + assert_eq!(err.code, RebornServicesErrorCode::Unavailable); + assert_eq!(err.kind, RebornServicesErrorKind::ServiceUnavailable); + assert_eq!(err.status_code, 503); + assert_eq!(llm_config.upsert_provider_count(), 0); + assert_eq!(llm_config.set_active_count(), 0); +} + +#[tokio::test] +async fn run_operator_setup_rejects_profile_only_host_mutation_before_provider_write() { + let llm_config = Arc::new(SetupRecordingLlmConfigService::default()); + let services = services_with_setup_llm_config(llm_config.clone()); + + let err = services + .run_operator_setup( + caller(), + RebornOperatorSetupRequest { + provider_id: Some("openai".to_string()), + adapter: Some("open_ai_completions".to_string()), + profile_id: Some("production".to_string()), + ..Default::default() + }, + ) + .await + .expect_err("unwired profile mutation fails closed"); + + assert_eq!(err.code, RebornServicesErrorCode::Unavailable); + assert_eq!(err.kind, RebornServicesErrorKind::ServiceUnavailable); + assert_eq!(err.status_code, 503); + assert_eq!(llm_config.upsert_provider_count(), 0); + assert_eq!(llm_config.set_active_count(), 0); +} + +#[tokio::test] +async fn run_operator_setup_rejects_token_only_host_mutation_before_provider_write() { + let llm_config = Arc::new(SetupRecordingLlmConfigService::default()); + let services = services_with_setup_llm_config(llm_config.clone()); + + let err = services + .run_operator_setup( + caller(), + RebornOperatorSetupRequest { + provider_id: Some("openai".to_string()), + adapter: Some("open_ai_completions".to_string()), + webui_access_token: Some(SecretString::from( + "webui-secret-token-value-32-bytes".to_string(), + )), + ..Default::default() + }, + ) + .await + .expect_err("unwired WebUI token mutation fails closed"); + + assert_eq!(err.code, RebornServicesErrorCode::Unavailable); + assert_eq!(err.kind, RebornServicesErrorKind::ServiceUnavailable); + assert_eq!(err.status_code, 503); + assert_eq!(llm_config.upsert_provider_count(), 0); + assert_eq!(llm_config.set_active_count(), 0); +} + #[tokio::test] async fn run_operator_setup_selects_existing_provider_without_adapter() { let llm_config = Arc::new(SetupRecordingLlmConfigService::default()); diff --git a/crates/ironclaw_webui_v2/CLAUDE.md b/crates/ironclaw_webui_v2/CLAUDE.md index 3033565b6ee..0e48e54e36d 100644 --- a/crates/ironclaw_webui_v2/CLAUDE.md +++ b/crates/ironclaw_webui_v2/CLAUDE.md @@ -119,13 +119,16 @@ unsupported-config reason codes currently include `operator_config_service_not_wired`, `operator_config_secret_not_wired`, `operator_config_deprecated`, `operator_config_immutable`, `operator_config_not_wired`, and `operator_config_unknown_key`. -`POST /api/webchat/v2/operator/setup` uses the typed LLM config service -for provider/model setup; profile and WebUI access setup return redacted -not-yet-wired diagnostics until those owning services are exposed. -`GET /api/webchat/v2/operator/diagnostics` is the Reborn doctor surface: -the product facade aggregates status/readiness, setup, and config diagnostics -into one redacted payload instead of making route handlers inspect runtime -internals or adding a second CLI-only diagnostic path. +`POST /api/webchat/v2/operator/setup` uses the typed LLM config service for +provider/model setup. Profile selection and WebUI access inputs are validated +through the same operator setup facade; unchanged masked tokens (not modified +by the user) are treated as no-ops, and actual profile/token mutations fail +closed until an owning persistence service is wired. Token values are never +echoed. +`GET /api/webchat/v2/operator/diagnostics` is the Reborn doctor surface: the +product facade aggregates status/readiness, setup, and config diagnostics into +one redacted payload instead of making route handlers inspect runtime internals +or adding a second CLI-only diagnostic path. ### List-threads diff --git a/docs/extensions/github.md b/docs/extensions/github.md index 9fe97b1dd59..cc5ac76a0b7 100644 --- a/docs/extensions/github.md +++ b/docs/extensions/github.md @@ -82,7 +82,7 @@ Here are some of the actions your agent can perform with the GitHub extension: - `get_pull_request_reviews`: Get reviews for a pull request - `get_combined_status`: Get the combined status for a ref - `merge_pull_request`: Merge a pull request -- `get_authenticated_user`: Get the authenticated GitHub user +- `get_authenticated_user`: Get the authenticated GitHub user - `list_repos`: List repositories (user/org) - `get_file_content`: Retrieve the content of a file in the repo - `trigger_workflow`: Manually trigger a GitHub Actions workflow