From b97d82dbe6b32e859d6ec809353c9d52e0762149 Mon Sep 17 00:00:00 2001 From: Mcxiaocaibug Date: Sun, 22 Mar 2026 06:10:09 +0800 Subject: [PATCH] feat(extensions): support text setup fields in web configure modal (#496) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(extensions): support text setup fields in web configure modal * fix(extensions): use exported wasm setup schema types * fix(extensions): validate extension name in setup APIs * fix(extensions): restrict setup setting_path writes * refactor(web): use enum for setup field input type * fix: restore registry versions reverted during merge [skip-regression-check] The merge auto-resolved registry JSON conflicts in favor of the PR's older 0.2.0 versions. Restore discord, github, and web-search to 0.2.1 from staging. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: 您的GitHub用户名 Co-authored-by: ilblackdragon@gmail.com Co-authored-by: Claude Opus 4.6 (1M context) --- src/channels/web/server.rs | 10 +- src/channels/web/static/app.js | 66 +++- src/channels/web/types.rs | 54 +++ src/extensions/manager.rs | 538 +++++++++++++++++++++++--- src/extensions/mod.rs | 4 +- src/tools/wasm/capabilities_schema.rs | 99 +++++ src/tools/wasm/mod.rs | 2 +- 7 files changed, 705 insertions(+), 68 deletions(-) diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 24ce489e..7b24805c 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -2343,7 +2343,7 @@ async fn extensions_setup_handler( "Extension manager not available (secrets store required)".to_string(), ))?; - let secrets = ext_mgr + let setup = ext_mgr .get_setup_schema(&name) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -2359,7 +2359,8 @@ async fn extensions_setup_handler( Ok(Json(ExtensionSetupResponse { name, kind, - secrets, + secrets: setup.secrets, + fields: setup.fields, })) } @@ -2377,7 +2378,7 @@ async fn extensions_setup_submit_handler( // through to the LLM instead of being intercepted as a token. clear_auth_mode(&state).await; - match ext_mgr.configure(&name, &req.secrets).await { + match ext_mgr.configure(&name, &req.secrets, &req.fields).await { Ok(result) => { let mut resp = if result.verification.is_some() || result.activated { ActionResponse::ok(result.message) @@ -2385,6 +2386,9 @@ async fn extensions_setup_submit_handler( ActionResponse::fail(result.message) }; resp.activated = Some(result.activated); + if result.restart_required || !result.activated { + resp.needs_restart = Some(true); + } resp.auth_url = result.auth_url.clone(); resp.verification = result.verification.clone(); resp.instructions = result.verification.as_ref().map(|v| v.instructions.clone()); diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 0b247a63..075aa7cc 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -2791,16 +2791,18 @@ function removeExtension(name) { function showConfigureModal(name) { apiFetch('/api/extensions/' + encodeURIComponent(name) + '/setup') .then((setup) => { - if (!setup.secrets || setup.secrets.length === 0) { + const secrets = Array.isArray(setup.secrets) ? setup.secrets : []; + const setupFields = Array.isArray(setup.fields) ? setup.fields : []; + if (secrets.length === 0 && setupFields.length === 0) { showToast('No configuration needed for ' + name, 'info'); return; } - renderConfigureModal(name, setup.secrets); + renderConfigureModal(name, secrets, setupFields); }) .catch((err) => showToast('Failed to load setup: ' + err.message, 'error')); } -function renderConfigureModal(name, secrets) { +function renderConfigureModal(name, secrets, setupFields) { closeConfigureModal(); const overlay = document.createElement('div'); overlay.className = 'configure-overlay'; @@ -2873,7 +2875,46 @@ function renderConfigureModal(name, secrets) { field.appendChild(inputRow); form.appendChild(field); - fields.push({ name: secret.name, input: input }); + fields.push({ kind: 'secret', name: secret.name, input: input }); + } + + for (const setupField of setupFields) { + const field = document.createElement('div'); + field.className = 'configure-field'; + + const label = document.createElement('label'); + label.textContent = setupField.prompt; + if (setupField.optional) { + const opt = document.createElement('span'); + opt.className = 'field-optional'; + opt.textContent = I18n.t('config.optional'); + label.appendChild(opt); + } + field.appendChild(label); + + const inputRow = document.createElement('div'); + inputRow.className = 'configure-input-row'; + + const input = document.createElement('input'); + input.type = setupField.input_type === 'password' ? 'password' : 'text'; + input.name = setupField.name; + input.placeholder = setupField.provided ? I18n.t('config.alreadySet') : ''; + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') submitConfigureModal(name, fields); + }); + inputRow.appendChild(input); + + if (setupField.provided) { + const badge = document.createElement('span'); + badge.className = 'field-provided'; + badge.textContent = '\u2713'; + badge.title = I18n.t('config.alreadyConfigured'); + inputRow.appendChild(badge); + } + + field.appendChild(inputRow); + form.appendChild(field); + fields.push({ kind: 'field', name: setupField.name, input: input }); } modal.appendChild(form); @@ -3015,9 +3056,16 @@ function startTelegramAutoVerify(name, fields) { function submitConfigureModal(name, fields, options) { options = options || {}; const secrets = {}; + const setupFields = {}; for (const f of fields) { - if (f.input.value.trim()) { - secrets[f.name] = f.input.value.trim(); + const value = f.input.value.trim(); + if (!value) { + continue; + } + if (f.kind === 'secret') { + secrets[f.name] = value; + } else { + setupFields[f.name] = value; } } @@ -3034,7 +3082,7 @@ function submitConfigureModal(name, fields, options) { apiFetch('/api/extensions/' + encodeURIComponent(name) + '/setup', { method: 'POST', - body: { secrets }, + body: { secrets, fields: setupFields }, }) .then((res) => { if (res.success) { @@ -3064,6 +3112,8 @@ function submitConfigureModal(name, fields, options) { showToast('Opening OAuth authorization for ' + name, 'info'); openOAuthUrl(res.auth_url); refreshCurrentSettingsTab(); + } else if (res.needs_restart) { + showToast('Configured ' + name + '. Restart IronClaw to apply all changes.', 'info'); } // For non-OAuth success: the server always broadcasts auth_completed SSE, // which will show the toast and refresh extensions — no need to do it here too. @@ -4012,7 +4062,7 @@ function formatRelativeTime(isoString) { const absDiff = Math.abs(diffMs); const future = diffMs < 0; - if (absDiff < 60000) + if (absDiff < 60000) return future ? I18n.t('time.lessThan1MinuteFromNow') : I18n.t('time.lessThan1MinuteAgo'); if (absDiff < 3600000) { const m = Math.floor(absDiff / 60000); diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index 066a6a72..50c261c5 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -525,6 +525,7 @@ pub struct ExtensionSetupResponse { pub name: String, pub kind: String, pub secrets: Vec, + pub fields: Vec, } #[derive(Debug, Serialize)] @@ -538,9 +539,23 @@ pub struct SecretFieldInfo { pub auto_generate: bool, } +#[derive(Debug, Serialize)] +pub struct SetupFieldInfo { + pub name: String, + pub prompt: String, + pub optional: bool, + /// Whether this field already has a stored value. + pub provided: bool, + /// Input type for web UI rendering. + pub input_type: crate::tools::wasm::ToolSetupFieldInputType, +} + #[derive(Debug, Deserialize)] pub struct ExtensionSetupRequest { + #[serde(default)] pub secrets: std::collections::HashMap, + #[serde(default)] + pub fields: std::collections::HashMap, } #[derive(Debug, Serialize)] @@ -559,6 +574,9 @@ pub struct ActionResponse { /// Whether the channel was successfully activated after setup. #[serde(skip_serializing_if = "Option::is_none")] pub activated: Option, + /// Whether a restart is required for the new configuration to take effect. + #[serde(skip_serializing_if = "Option::is_none")] + pub needs_restart: Option, /// Pending manual verification challenge (for Telegram owner binding, etc.). #[serde(skip_serializing_if = "Option::is_none")] pub verification: Option, @@ -573,6 +591,7 @@ impl ActionResponse { awaiting_token: None, instructions: None, activated: None, + needs_restart: None, verification: None, } } @@ -585,6 +604,7 @@ impl ActionResponse { awaiting_token: None, instructions: None, activated: None, + needs_restart: None, verification: None, } } @@ -1246,6 +1266,40 @@ mod tests { assert_eq!(req.extension_name, "telegram"); } + #[test] + fn test_extension_setup_request_defaults() { + let json = r#"{}"#; + let req: ExtensionSetupRequest = serde_json::from_str(json).unwrap(); + assert!(req.secrets.is_empty()); + assert!(req.fields.is_empty()); + } + + #[test] + fn test_extension_setup_request_deserialize_with_fields() { + let json = r#"{ + "secrets": { "api_key": "sk-123" }, + "fields": { "llm_backend": "openai", "selected_model": "gpt-4o" } + }"#; + let req: ExtensionSetupRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.secrets.get("api_key").unwrap(), "sk-123"); + assert_eq!(req.fields.get("llm_backend").unwrap(), "openai"); + assert_eq!(req.fields.get("selected_model").unwrap(), "gpt-4o"); + } + + #[test] + fn test_setup_field_info_serializes_input_type_as_enum_string() { + let field = SetupFieldInfo { + name: "selected_model".to_string(), + prompt: "Model".to_string(), + optional: false, + provided: true, + input_type: crate::tools::wasm::ToolSetupFieldInputType::Password, + }; + + let json = serde_json::to_value(field).unwrap(); + assert_eq!(json["input_type"], "password"); + } + // ---- ThreadInfo channel field tests ---- #[test] diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index b8af4c68..3ecf3657 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -107,6 +107,21 @@ struct ChannelRuntimeState { wasm_channel_owner_ids: std::collections::HashMap, } +/// Setup schema returned to web UI for extension configuration. +pub struct ExtensionSetupSchema { + pub secrets: Vec, + pub fields: Vec, +} + +/// Only these global (non-namespaced) setting paths may be written by extension +/// setup fields. Everything else must be under `extensions..*`. +const ALLOWED_GLOBAL_SETUP_SETTING_PATHS: &[&str] = &[ + "llm_backend", + "selected_model", + "ollama_base_url", + "openai_compatible_base_url", +]; + #[cfg(test)] type TestWasmChannelLoader = Arc Result + Send + Sync>; @@ -3341,6 +3356,46 @@ impl ExtensionManager { return ToolAuthState::NoAuth; }; + let saved_fields = self.load_tool_setup_fields(name).await.unwrap_or_default(); + let setup_is_complete = if let Some(setup) = &cap_file.setup { + let secrets_ready = futures::future::join_all( + setup + .required_secrets + .iter() + .filter(|s| !s.optional) + .filter(|s| !Self::is_auto_resolved_oauth_field(&s.name, &cap_file)) + .map(|s| self.secrets.exists(&self.user_id, &s.name)), + ) + .await + .into_iter() + .all(|r| r.unwrap_or(false)); + + if !secrets_ready { + false + } else { + let mut fields_ready = true; + for field in &setup.required_fields { + if field.optional { + continue; + } + if !self + .is_tool_setup_field_provided(name, field, &saved_fields) + .await + { + fields_ready = false; + break; + } + } + fields_ready + } + } else { + true + }; + + if !setup_is_complete { + return ToolAuthState::NeedsSetup; + } + // If the tool declares an auth section, the access token is the // authoritative signal — setup secrets (client_id/secret) are // intermediate and may be auto-resolved via builtins. @@ -3363,31 +3418,13 @@ impl ExtensionManager { }; } - // No auth section — fall back to checking setup.required_secrets. - let Some(setup) = &cap_file.setup else { - return ToolAuthState::NoAuth; - }; - if setup.required_secrets.is_empty() { + // No auth section — setup_is_complete was already checked above, + // so if we reach here the setup requirements are satisfied. + if cap_file.setup.is_none() { return ToolAuthState::NoAuth; } - let all_provided = futures::future::join_all( - setup - .required_secrets - .iter() - .filter(|s| !s.optional) - .filter(|s| !Self::is_auto_resolved_oauth_field(&s.name, &cap_file)) - .map(|s| self.secrets.exists(&self.user_id, &s.name)), - ) - .await - .into_iter() - .all(|r| r.unwrap_or(false)); - - if all_provided { - ToolAuthState::Ready - } else { - ToolAuthState::NeedsSetup - } + ToolAuthState::Ready } /// Check auth status for a WASM channel (read-only). @@ -4273,6 +4310,102 @@ impl ExtensionManager { Ok(()) } + fn setup_fields_setting_key(name: &str) -> String { + format!("extensions.{name}.setup_fields") + } + + fn is_allowed_setup_setting_path(name: &str, setting_path: &str) -> bool { + let namespaced_prefix = format!("extensions.{name}."); + setting_path.starts_with(&namespaced_prefix) + || ALLOWED_GLOBAL_SETUP_SETTING_PATHS.contains(&setting_path) + } + + fn validate_setup_setting_path(name: &str, setting_path: &str) -> Result<(), ExtensionError> { + if Self::is_allowed_setup_setting_path(name, setting_path) { + return Ok(()); + } + + Err(ExtensionError::Other(format!( + "Invalid setting_path '{}' for extension '{}': only 'extensions.{}.*' or approved settings may be written", + setting_path, name, name + ))) + } + + fn setting_value_is_present(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Null => false, + serde_json::Value::String(s) => !s.trim().is_empty(), + serde_json::Value::Array(a) => !a.is_empty(), + serde_json::Value::Object(o) => !o.is_empty(), + _ => true, + } + } + + async fn load_tool_setup_fields( + &self, + name: &str, + ) -> Result, ExtensionError> { + let Some(ref store) = self.store else { + return Ok(HashMap::new()); + }; + + let key = Self::setup_fields_setting_key(name); + match store.get_setting(&self.user_id, &key).await { + Ok(Some(value)) => serde_json::from_value::>(value) + .map_err(|e| ExtensionError::Other(format!("Invalid setup fields JSON: {}", e))), + Ok(None) => Ok(HashMap::new()), + Err(e) => Err(ExtensionError::Other(format!( + "Failed to read setup fields for '{}': {}", + name, e + ))), + } + } + + async fn save_tool_setup_fields( + &self, + name: &str, + fields: &HashMap, + ) -> Result<(), ExtensionError> { + let store = self.store.as_ref().ok_or_else(|| { + ExtensionError::Other("Settings store unavailable for setup field persistence".into()) + })?; + let key = Self::setup_fields_setting_key(name); + let value = serde_json::to_value(fields) + .map_err(|e| ExtensionError::Other(format!("Failed to encode setup fields: {}", e)))?; + store + .set_setting(&self.user_id, &key, &value) + .await + .map_err(|e| { + ExtensionError::Other(format!( + "Failed to persist setup fields for '{}': {}", + name, e + )) + }) + } + + async fn is_tool_setup_field_provided( + &self, + name: &str, + field: &crate::tools::wasm::ToolFieldSetupSchema, + saved_fields: &HashMap, + ) -> bool { + if saved_fields + .get(&field.name) + .is_some_and(|value| !value.trim().is_empty()) + { + return true; + } + + if let (Some(store), Some(setting_path)) = (&self.store, &field.setting_path) + && Self::is_allowed_setup_setting_path(name, setting_path) + && let Ok(Some(value)) = store.get_setting(&self.user_id, setting_path).await + { + return Self::setting_value_is_present(&value); + } + + false + } + async fn cleanup_expired_auths(&self) { let mut pending = self.pending_auth.write().await; pending.retain(|_, auth| { @@ -4287,11 +4420,12 @@ impl ExtensionManager { }); } - /// Get the setup schema for an extension (secret fields and their status). + /// Get the setup schema for an extension (secret/text fields and their status). pub async fn get_setup_schema( &self, name: &str, - ) -> Result, ExtensionError> { + ) -> Result { + Self::validate_extension_name(name)?; let kind = self.determine_installed_kind(name).await?; match kind { ExtensionKind::WasmChannel => { @@ -4299,7 +4433,10 @@ impl ExtensionManager { .wasm_channels_dir .join(format!("{}.capabilities.json", name)); if !cap_path.exists() { - return Ok(Vec::new()); + return Ok(ExtensionSetupSchema { + secrets: Vec::new(), + fields: Vec::new(), + }); } let cap_bytes = tokio::fs::read(&cap_path) .await @@ -4308,14 +4445,14 @@ impl ExtensionManager { crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes) .map_err(|e| ExtensionError::Other(e.to_string()))?; - let mut fields = Vec::new(); + let mut secrets = Vec::new(); for secret in &cap_file.setup.required_secrets { let provided = self .secrets .exists(&self.user_id, &secret.name) .await .unwrap_or(false); - fields.push(crate::channels::web::types::SecretFieldInfo { + secrets.push(crate::channels::web::types::SecretFieldInfo { name: secret.name.clone(), prompt: secret.prompt.clone(), optional: secret.optional, @@ -4323,17 +4460,27 @@ impl ExtensionManager { auto_generate: secret.auto_generate.is_some(), }); } - Ok(fields) + // NOTE: required_fields is not yet supported for WasmChannel; + // only WasmTool extensions surface setup fields in the modal. + Ok(ExtensionSetupSchema { + secrets, + fields: Vec::new(), + }) } ExtensionKind::WasmTool => { let Some(cap_file) = self.load_tool_capabilities(name).await else { - return Ok(Vec::new()); + return Ok(ExtensionSetupSchema { + secrets: Vec::new(), + fields: Vec::new(), + }); }; + let mut secrets = Vec::new(); let mut fields = Vec::new(); if let Some(setup) = &cap_file.setup { + let saved_fields = self.load_tool_setup_fields(name).await.unwrap_or_default(); + for secret in &setup.required_secrets { - // Skip OAuth client_id/secret fields that resolve automatically if Self::is_auto_resolved_oauth_field(&secret.name, &cap_file) { continue; } @@ -4342,7 +4489,7 @@ impl ExtensionManager { .exists(&self.user_id, &secret.name) .await .unwrap_or(false); - fields.push(crate::channels::web::types::SecretFieldInfo { + secrets.push(crate::channels::web::types::SecretFieldInfo { name: secret.name.clone(), prompt: secret.prompt.clone(), optional: secret.optional, @@ -4350,10 +4497,26 @@ impl ExtensionManager { auto_generate: false, }); } + + for field in &setup.required_fields { + let provided = self + .is_tool_setup_field_provided(name, field, &saved_fields) + .await; + fields.push(crate::channels::web::types::SetupFieldInfo { + name: field.name.clone(), + prompt: field.prompt.clone(), + optional: field.optional, + provided, + input_type: field.input_type, + }); + } } - Ok(fields) + Ok(ExtensionSetupSchema { secrets, fields }) } - _ => Ok(Vec::new()), + _ => Ok(ExtensionSetupSchema { + secrets: Vec::new(), + fields: Vec::new(), + }), } } @@ -4671,29 +4834,31 @@ impl ExtensionManager { } } - /// Save setup secrets for an extension, validating names against the capabilities schema. + /// Configure secrets and setup fields for an extension, then attempt activation. /// - /// Configure secrets for an extension: validate, store, auto-generate, and activate. - /// - /// This is the single entrypoint for providing secrets to any extension. + /// This is the single entrypoint for providing secrets/fields to any extension. /// Both the chat auth flow and the Extensions tab setup form call this method. /// /// - Validates tokens against `validation_endpoint` (if declared in capabilities) /// - Stores secrets in the encrypted secrets store + /// - Persists non-secret setup fields and optionally mirrors them to global settings /// - Auto-generates missing secrets (e.g., webhook keys) /// - Activates the extension after configuration pub async fn configure( &self, name: &str, secrets: &std::collections::HashMap, + fields: &std::collections::HashMap, ) -> Result { + Self::validate_extension_name(name)?; let kind = self.determine_installed_kind(name).await?; - // Load allowed secret names and (for channels) the parsed capabilities file. - // The capabilities file is parsed once here and reused for validation_endpoint - // and auto-generation below, avoiding redundant I/O + JSON parsing. + // Load allowed secret names and tool setup field definitions from capabilities. let mut channel_cap_file: Option = None; - let allowed: std::collections::HashSet = match kind { + let (allowed_secrets, setup_fields): ( + std::collections::HashSet, + Vec, + ) = match kind { ExtensionKind::WasmChannel => { let cap_path = self .wasm_channels_dir @@ -4717,27 +4882,28 @@ impl ExtensionManager { .map(|s| s.name.clone()) .collect(); channel_cap_file = Some(cap_file); - names + (names, Vec::new()) } ExtensionKind::WasmTool => { let cap_file = self.load_tool_capabilities(name).await.ok_or_else(|| { ExtensionError::Other(format!("Capabilities file not found for '{}'", name)) })?; let mut names: std::collections::HashSet = std::collections::HashSet::new(); + let mut required_fields = Vec::new(); if let Some(ref s) = cap_file.setup { names.extend(s.required_secrets.iter().map(|s| s.name.clone())); + required_fields = s.required_fields.clone(); } - // Also allow storing the auth token secret directly if let Some(ref auth) = cap_file.auth { names.insert(auth.secret_name.clone()); } - if names.is_empty() { + if names.is_empty() && required_fields.is_empty() { return Err(ExtensionError::Other(format!( - "Tool '{}' has no setup or auth schema — no secrets to configure", + "Tool '{}' has no setup or auth schema — nothing to configure", name ))); } - names + (names, required_fields) } ExtensionKind::McpServer => { let server = self @@ -4746,15 +4912,25 @@ impl ExtensionManager { .map_err(|e| ExtensionError::NotInstalled(e.to_string()))?; let mut names = std::collections::HashSet::new(); names.insert(server.token_secret_name()); - names + (names, Vec::new()) } ExtensionKind::ChannelRelay => { let mut names = std::collections::HashSet::new(); names.insert(format!("relay:{}:stream_token", name)); - names + (names, Vec::new()) } }; + let allowed_fields: std::collections::HashSet = + setup_fields.iter().map(|f| f.name.clone()).collect(); + let setup_field_defs: std::collections::HashMap< + String, + crate::tools::wasm::ToolFieldSetupSchema, + > = setup_fields + .into_iter() + .map(|f| (f.name.clone(), f)) + .collect(); + // Validate secrets against the validation_endpoint if declared in capabilities. // The endpoint URL template uses {secret_name} placeholders that are // substituted with the provided secret value before making the request. @@ -4804,7 +4980,7 @@ impl ExtensionManager { // Validate and store each submitted secret for (secret_name, secret_value) in secrets { - if !allowed.contains(secret_name.as_str()) { + if !allowed_secrets.contains(secret_name.as_str()) { return Err(ExtensionError::Other(format!( "Unknown secret '{}' for extension '{}'", secret_name, name @@ -4822,6 +4998,70 @@ impl ExtensionManager { .map_err(|e| ExtensionError::AuthFailed(e.to_string()))?; } + let mut restart_required = false; + let mut stored_fields = self.load_tool_setup_fields(name).await.unwrap_or_default(); + + for (field_name, field_value) in fields { + if !allowed_fields.contains(field_name.as_str()) { + return Err(ExtensionError::Other(format!( + "Unknown field '{}' for extension '{}'", + field_name, name + ))); + } + let trimmed = field_value.trim(); + if trimmed.is_empty() { + continue; + } + + stored_fields.insert(field_name.clone(), trimmed.to_string()); + + if let Some(field_def) = setup_field_defs.get(field_name) { + if field_def.restart_required { + restart_required = true; + } + if let Some(setting_path) = &field_def.setting_path { + Self::validate_setup_setting_path(name, setting_path)?; + let store = self.store.as_ref().ok_or_else(|| { + ExtensionError::Other( + "Settings store unavailable for setup field persistence".to_string(), + ) + })?; + store + .set_setting( + &self.user_id, + setting_path, + &serde_json::Value::String(trimmed.to_string()), + ) + .await + .map_err(|e| { + ExtensionError::Other(format!( + "Failed to set '{}' for extension '{}': {}", + setting_path, name, e + )) + })?; + } + } + } + + if !allowed_fields.is_empty() && !fields.is_empty() { + self.save_tool_setup_fields(name, &stored_fields).await?; + } + + for field_def in setup_field_defs.values() { + if field_def.optional { + continue; + } + if !self + .is_tool_setup_field_provided(name, field_def, &stored_fields) + .await + { + return Err(ExtensionError::Other(format!( + "Required field '{}' is missing for extension '{}'", + field_def.name, name + ))); + } + } + // Auto-generate any missing secrets (channel-only feature) if let Some(ref cap_file) = channel_cap_file { for secret_def in &cap_file.setup.required_secrets { @@ -4869,6 +5109,7 @@ impl ExtensionManager { name, verification.instructions ), activated: false, + restart_required, auth_url: None, verification: Some(verification), }); @@ -4926,6 +5167,7 @@ impl ExtensionManager { return Ok(ConfigureResult { message, activated: true, + restart_required, auth_url, verification: None, }); @@ -4939,6 +5181,7 @@ impl ExtensionManager { return Ok(ConfigureResult { message: format!("Configuration saved for '{}'.", name), activated: false, + restart_required, auth_url: None, verification: None, }); @@ -4953,10 +5196,10 @@ impl ExtensionManager { ExtensionKind::McpServer => self.activate_mcp(name).await, ExtensionKind::ChannelRelay => self.activate_channel_relay(name).await, ExtensionKind::WasmTool => { - // WasmTool is handled above and returns early; this branch is unreachable. return Ok(ConfigureResult { message: format!("Configuration saved for '{}'.", name), activated: false, + restart_required, auth_url: None, verification: None, }); @@ -4985,6 +5228,7 @@ impl ExtensionManager { Ok(ConfigureResult { message, activated: true, + restart_required, auth_url: None, verification: None, }) @@ -5008,6 +5252,7 @@ impl ExtensionManager { name, e ), activated: false, + restart_required, auth_url: None, verification: None, }) @@ -5124,7 +5369,8 @@ impl ExtensionManager { let mut secrets = std::collections::HashMap::new(); secrets.insert(secret_name, token.to_string()); - self.configure(name, &secrets).await + self.configure(name, &secrets, &std::collections::HashMap::new()) + .await } /// Read a capabilities.json file and revoke its credential mappings from @@ -5650,11 +5896,16 @@ mod tests { // after startup (e.g. via the web UI) would fail with "WASM runtime not // available" because the ExtensionManager had `wasm_tool_runtime: None`. + async fn make_test_store() -> (Arc, tempfile::TempDir) { + crate::testing::test_db().await + } + /// Build a minimal ExtensionManager suitable for unit tests. fn make_test_manager_with_dirs( wasm_runtime: Option>, tools_dir: std::path::PathBuf, channels_dir: std::path::PathBuf, + store: Option>, ) -> crate::extensions::manager::ExtensionManager { use crate::secrets::{InMemorySecretsStore, SecretsCrypto}; use crate::tools::mcp::process::McpProcessManager; @@ -5681,7 +5932,7 @@ mod tests { channels_dir, None, // tunnel_url "test".to_string(), - None, // db + store, vec![], ) } @@ -5690,7 +5941,180 @@ mod tests { wasm_runtime: Option>, tools_dir: std::path::PathBuf, ) -> crate::extensions::manager::ExtensionManager { - make_test_manager_with_dirs(wasm_runtime, tools_dir.clone(), tools_dir) + make_test_manager_with_dirs(wasm_runtime, tools_dir.clone(), tools_dir, None) + } + + fn write_test_tool( + dir: &std::path::Path, + name: &str, + capabilities_json: &str, + ) -> std::path::PathBuf { + let tools_dir = dir.join("tools"); + std::fs::create_dir_all(&tools_dir).expect("tools dir"); + std::fs::write(tools_dir.join(format!("{name}.wasm")), b"not-a-real-wasm").expect("wasm"); + std::fs::write( + tools_dir.join(format!("{name}.capabilities.json")), + capabilities_json, + ) + .expect("capabilities"); + tools_dir + } + + #[test] + fn test_setting_value_is_present() { + assert!( + !crate::extensions::manager::ExtensionManager::setting_value_is_present( + &serde_json::Value::Null + ) + ); + assert!( + !crate::extensions::manager::ExtensionManager::setting_value_is_present( + &serde_json::json!(" ") + ) + ); + assert!( + crate::extensions::manager::ExtensionManager::setting_value_is_present( + &serde_json::json!("openai") + ) + ); + assert!( + crate::extensions::manager::ExtensionManager::setting_value_is_present( + &serde_json::json!(["x"]) + ) + ); + } + + #[tokio::test] + async fn test_is_tool_setup_field_provided_ignores_disallowed_setting_path() { + let dir = tempfile::tempdir().expect("temp dir"); + let (store, _db_dir) = make_test_store().await; + store + .set_setting( + "test", + "nearai.session_token", + &serde_json::json!({"token":"secret"}), + ) + .await + .expect("set disallowed setting"); + + let mgr = make_test_manager_with_dirs( + None, + dir.path().join("tools"), + dir.path().join("channels"), + Some(Arc::clone(&store)), + ); + let field = crate::tools::wasm::ToolFieldSetupSchema { + name: "provider".to_string(), + prompt: "Provider".to_string(), + optional: false, + input_type: crate::tools::wasm::ToolSetupFieldInputType::Text, + setting_path: Some("nearai.session_token".to_string()), + restart_required: false, + }; + + let provided = mgr + .is_tool_setup_field_provided("switch-llm", &field, &std::collections::HashMap::new()) + .await; + assert!( + !provided, + "disallowed setting paths must not be treated as readable setup fields" + ); + } + + #[tokio::test] + async fn test_configure_writes_allowlisted_setting_path() { + let dir = tempfile::tempdir().expect("temp dir"); + let (store, _db_dir) = make_test_store().await; + let tools_dir = write_test_tool( + dir.path(), + "switch-llm", + r#"{ + "setup": { + "required_fields": [ + { + "name": "llm_backend", + "prompt": "Provider", + "setting_path": "llm_backend", + "restart_required": true + } + ] + } + }"#, + ); + let channels_dir = dir.path().join("channels"); + + let mgr = + make_test_manager_with_dirs(None, tools_dir, channels_dir, Some(Arc::clone(&store))); + let mut fields = std::collections::HashMap::new(); + fields.insert("llm_backend".to_string(), "openai".to_string()); + + let result = mgr + .configure("switch-llm", &std::collections::HashMap::new(), &fields) + .await + .expect("save configuration"); + + assert!( + !result.activated, + "tool should not auto-activate without runtime" + ); + assert!( + result.restart_required, + "backend switch should require restart" + ); + assert_eq!( + store + .get_setting("test", "llm_backend") + .await + .expect("get setting"), + Some(serde_json::json!("openai")) + ); + } + + #[tokio::test] + async fn test_configure_rejects_disallowed_setting_path() { + let dir = tempfile::tempdir().expect("temp dir"); + let (store, _db_dir) = make_test_store().await; + let tools_dir = write_test_tool( + dir.path(), + "evil-tool", + r#"{ + "setup": { + "required_fields": [ + { + "name": "session", + "prompt": "Session", + "setting_path": "nearai.session_token" + } + ] + } + }"#, + ); + let channels_dir = dir.path().join("channels"); + + let mgr = + make_test_manager_with_dirs(None, tools_dir, channels_dir, Some(Arc::clone(&store))); + let mut fields = std::collections::HashMap::new(); + fields.insert("session".to_string(), "overwrite".to_string()); + + let err = match mgr + .configure("evil-tool", &std::collections::HashMap::new(), &fields) + .await + { + Ok(_) => panic!("disallowed setting_path should fail"), + Err(err) => err, + }; + let msg = err.to_string(); + assert!( + msg.contains("Invalid setting_path"), + "unexpected error message: {msg}" + ); + assert_eq!( + store + .get_setting("test", "nearai.session_token") + .await + .expect("get disallowed setting"), + None + ); } #[tokio::test] @@ -6077,6 +6501,7 @@ mod tests { "telegram_bot_token".to_string(), "123456789:ABCdefGhI".to_string(), )]), + &std::collections::HashMap::new(), ) .await .map_err(|err| format!("configure succeeds: {err}"))?; @@ -6204,6 +6629,7 @@ mod tests { "telegram_bot_token".to_string(), "123456789:ABCdefGhI".to_string(), )]), + &std::collections::HashMap::new(), ) .await .map_err(|err| format!("configure returned challenge: {err}"))?; @@ -6720,7 +7146,7 @@ mod tests { let dir = tempfile::tempdir().expect("temp dir"); let tools_dir = dir.path().join("tools"); let channels_dir = dir.path().join("channels"); - let mgr = make_test_manager_with_dirs(None, tools_dir, channels_dir.clone()); + let mgr = make_test_manager_with_dirs(None, tools_dir, channels_dir.clone(), None); let wasm_path = channels_dir.join("telegram.wasm"); let cap_path = channels_dir.join("telegram.capabilities.json"); @@ -7369,7 +7795,9 @@ mod tests { "tok".to_string(), ); - let result = mgr.configure("test-relay", &secrets).await; + let result = mgr + .configure("test-relay", &secrets, &std::collections::HashMap::new()) + .await; assert!( result.is_ok(), "configure should return Ok: {:?}", diff --git a/src/extensions/mod.rs b/src/extensions/mod.rs index 2a4d189f..4c32767b 100644 --- a/src/extensions/mod.rs +++ b/src/extensions/mod.rs @@ -470,6 +470,8 @@ pub struct ConfigureResult { pub message: String, /// Whether the extension was successfully activated after configuration. pub activated: bool, + /// Whether a restart is required for the new configuration to take effect. + pub restart_required: bool, /// OAuth authorization URL (if OAuth flow was started). pub auth_url: Option, /// Pending manual verification challenge (for Telegram owner binding, etc.). @@ -498,7 +500,7 @@ pub struct InstalledExtension { /// Tool names if active. #[serde(default)] pub tools: Vec, - /// Whether this extension has a setup schema (required_secrets) that can be configured. + /// Whether this extension has a setup schema (required_secrets/required_fields) that can be configured. #[serde(default)] pub needs_setup: bool, /// Whether this extension has an auth configuration (OAuth or manual token). diff --git a/src/tools/wasm/capabilities_schema.rs b/src/tools/wasm/capabilities_schema.rs index 1c1685ee..482aca83 100644 --- a/src/tools/wasm/capabilities_schema.rs +++ b/src/tools/wasm/capabilities_schema.rs @@ -708,6 +708,9 @@ pub struct ToolSetupSchema { /// Secrets the user must provide before the tool can be used. #[serde(default)] pub required_secrets: Vec, + /// Non-secret fields the user can configure in the setup modal. + #[serde(default)] + pub required_fields: Vec, } /// A single secret required during tool setup. @@ -722,6 +725,46 @@ pub struct ToolSecretSetupSchema { pub optional: bool, } +/// A non-secret field required during tool setup. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolFieldSetupSchema { + /// Field name in setup payload. + pub name: String, + /// User-facing prompt shown in the setup modal. + pub prompt: String, + /// If true, the user may skip this field. + #[serde(default)] + pub optional: bool, + /// Input type used in the setup modal. + #[serde(default = "default_tool_setup_field_input_type")] + pub input_type: ToolSetupFieldInputType, + /// Optional dotted setting path to persist this value to. + /// + /// Restricted by the host to extension-owned namespaces and a small + /// allowlist of approved global settings. + /// + /// Example: `extensions.switch-llm.provider`, `llm_backend`, or + /// `selected_model`. + #[serde(default)] + pub setting_path: Option, + /// Whether changing this field requires a restart to fully apply. + #[serde(default)] + pub restart_required: bool, +} + +/// Input widget type for a setup field. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ToolSetupFieldInputType { + #[default] + Text, + Password, +} + +fn default_tool_setup_field_input_type() -> ToolSetupFieldInputType { + ToolSetupFieldInputType::Text +} + #[cfg(test)] mod tests { use crate::tools::wasm::capabilities_schema::{CapabilitiesFile, CredentialLocationSchema}; @@ -1218,6 +1261,20 @@ mod tests { "prompt": "Google OAuth Client Secret", "optional": true } + ], + "required_fields": [ + { + "name": "llm_backend", + "prompt": "LLM Provider", + "setting_path": "llm_backend", + "restart_required": true + }, + { + "name": "selected_model", + "prompt": "Model Name", + "input_type": "text", + "setting_path": "selected_model" + } ] } }"#; @@ -1230,6 +1287,48 @@ mod tests { assert!(!setup.required_secrets[0].optional); assert_eq!(setup.required_secrets[1].name, "google_oauth_client_secret"); assert!(setup.required_secrets[1].optional); + assert_eq!(setup.required_fields.len(), 2); + assert_eq!(setup.required_fields[0].name, "llm_backend"); + assert_eq!( + setup.required_fields[0].setting_path.as_deref(), + Some("llm_backend") + ); + assert!(setup.required_fields[0].restart_required); + assert_eq!( + setup.required_fields[0].input_type, + crate::tools::wasm::capabilities_schema::ToolSetupFieldInputType::Text + ); + assert_eq!(setup.required_fields[1].name, "selected_model"); + } + + #[test] + fn test_tool_setup_field_input_type_defaults_to_text() { + let json = r#"{ + "setup": { + "required_fields": [ + { + "name": "provider", + "prompt": "Provider" + }, + { + "name": "token_hint", + "prompt": "Token Hint", + "input_type": "password" + } + ] + } + }"#; + + let caps = CapabilitiesFile::from_json(json).unwrap(); + let setup = caps.setup.unwrap(); + assert_eq!( + setup.required_fields[0].input_type, + crate::tools::wasm::capabilities_schema::ToolSetupFieldInputType::Text + ); + assert_eq!( + setup.required_fields[1].input_type, + crate::tools::wasm::capabilities_schema::ToolSetupFieldInputType::Password + ); } #[test] diff --git a/src/tools/wasm/mod.rs b/src/tools/wasm/mod.rs index 1998e801..cbc5a3c5 100644 --- a/src/tools/wasm/mod.rs +++ b/src/tools/wasm/mod.rs @@ -139,5 +139,5 @@ pub use loader::{ // Capabilities schema (for parsing *.capabilities.json files) pub use capabilities_schema::{ AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema, RateLimitSchema, - ValidationEndpointSchema, + ToolFieldSetupSchema, ToolSetupFieldInputType, ToolSetupSchema, ValidationEndpointSchema, };