From d237ecf161845e0b430bbfb8a169a7f5e7ec63e5 Mon Sep 17 00:00:00 2001 From: jaberjaber23 Date: Sat, 7 Mar 2026 04:22:16 +0300 Subject: [PATCH] community batch --- Cargo.lock | 28 ++-- crates/openfang-api/src/middleware.rs | 4 + crates/openfang-api/src/routes.rs | 90 +++++++++++ crates/openfang-api/src/server.rs | 2 +- crates/openfang-api/static/css/components.css | 42 ++++- crates/openfang-api/static/index_body.html | 9 ++ crates/openfang-api/static/js/pages/chat.js | 24 ++- crates/openfang-kernel/src/kernel.rs | 14 +- crates/openfang-runtime/src/drivers/mod.rs | 8 +- crates/openfang-runtime/src/model_catalog.rs | 48 +++++- crates/openfang-runtime/src/tool_runner.rs | 35 +++++ crates/openfang-skills/src/clawhub.rs | 30 +++- crates/openfang-types/src/agent.rs | 3 +- crates/openfang-types/src/model_catalog.rs | 1 + crates/openfang-types/src/serde_compat.rs | 143 ++++++++++++++++++ scripts/install.sh | 30 +++- 16 files changed, 466 insertions(+), 45 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cfbf154b..0e58a826 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3875,7 +3875,7 @@ dependencies = [ [[package]] name = "openfang-api" -version = "0.3.24" +version = "0.3.25" dependencies = [ "async-trait", "axum", @@ -3912,7 +3912,7 @@ dependencies = [ [[package]] name = "openfang-channels" -version = "0.3.24" +version = "0.3.25" dependencies = [ "async-trait", "axum", @@ -3944,7 +3944,7 @@ dependencies = [ [[package]] name = "openfang-cli" -version = "0.3.24" +version = "0.3.25" dependencies = [ "clap", "clap_complete", @@ -3971,7 +3971,7 @@ dependencies = [ [[package]] name = "openfang-desktop" -version = "0.3.24" +version = "0.3.25" dependencies = [ "axum", "open", @@ -3997,7 +3997,7 @@ dependencies = [ [[package]] name = "openfang-extensions" -version = "0.3.24" +version = "0.3.25" dependencies = [ "aes-gcm", "argon2", @@ -4025,7 +4025,7 @@ dependencies = [ [[package]] name = "openfang-hands" -version = "0.3.24" +version = "0.3.25" dependencies = [ "chrono", "dashmap", @@ -4042,7 +4042,7 @@ dependencies = [ [[package]] name = "openfang-kernel" -version = "0.3.24" +version = "0.3.25" dependencies = [ "async-trait", "chrono", @@ -4078,7 +4078,7 @@ dependencies = [ [[package]] name = "openfang-memory" -version = "0.3.24" +version = "0.3.25" dependencies = [ "async-trait", "chrono", @@ -4097,7 +4097,7 @@ dependencies = [ [[package]] name = "openfang-migrate" -version = "0.3.24" +version = "0.3.25" dependencies = [ "chrono", "dirs 6.0.0", @@ -4116,7 +4116,7 @@ dependencies = [ [[package]] name = "openfang-runtime" -version = "0.3.24" +version = "0.3.25" dependencies = [ "anyhow", "async-trait", @@ -4148,7 +4148,7 @@ dependencies = [ [[package]] name = "openfang-skills" -version = "0.3.24" +version = "0.3.25" dependencies = [ "chrono", "hex", @@ -4171,7 +4171,7 @@ dependencies = [ [[package]] name = "openfang-types" -version = "0.3.24" +version = "0.3.25" dependencies = [ "async-trait", "chrono", @@ -4190,7 +4190,7 @@ dependencies = [ [[package]] name = "openfang-wire" -version = "0.3.24" +version = "0.3.25" dependencies = [ "async-trait", "chrono", @@ -8818,7 +8818,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xtask" -version = "0.3.24" +version = "0.3.25" [[package]] name = "yoke" diff --git a/crates/openfang-api/src/middleware.rs b/crates/openfang-api/src/middleware.rs index b2785da9..be7e720b 100644 --- a/crates/openfang-api/src/middleware.rs +++ b/crates/openfang-api/src/middleware.rs @@ -196,6 +196,10 @@ pub async fn security_headers(request: Request, next: Next) -> Response>, + Path(id): Path, + Json(body): Json, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ); + } + }; + + if state.kernel.registry.get(agent_id).is_none() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ); + } + + // Apply partial updates using dedicated registry methods + if let Some(name) = body.get("name").and_then(|v| v.as_str()) { + if let Err(e) = state + .kernel + .registry + .update_name(agent_id, name.to_string()) + { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": format!("{e}")})), + ); + } + } + if let Some(desc) = body.get("description").and_then(|v| v.as_str()) { + if let Err(e) = state + .kernel + .registry + .update_description(agent_id, desc.to_string()) + { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": format!("{e}")})), + ); + } + } + if let Some(model) = body.get("model").and_then(|v| v.as_str()) { + if let Err(e) = state.kernel.set_agent_model(agent_id, model) { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": format!("{e}")})), + ); + } + } + if let Some(system_prompt) = body.get("system_prompt").and_then(|v| v.as_str()) { + if let Err(e) = state + .kernel + .registry + .update_system_prompt(agent_id, system_prompt.to_string()) + { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": format!("{e}")})), + ); + } + } + + // Persist updated entry to SQLite + if let Some(entry) = state.kernel.registry.get(agent_id) { + let _ = state.kernel.memory.save_agent(&entry); + ( + StatusCode::OK, + Json(serde_json::json!({"status": "ok", "agent_id": entry.id.to_string(), "name": entry.name})), + ) + } else { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Agent vanished during update"})), + ) + } +} + // --------------------------------------------------------------------------- // Migration endpoint // --------------------------------------------------------------------------- @@ -6113,6 +6197,12 @@ pub async fn clear_agent_history( ) } }; + if state.kernel.registry.get(agent_id).is_none() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ); + } match state.kernel.clear_agent_history(agent_id) { Ok(()) => ( StatusCode::OK, diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index 11393184..a87b2519 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -126,7 +126,7 @@ pub async fn build_router( ) .route( "/api/agents/{id}", - axum::routing::get(routes::get_agent).delete(routes::kill_agent), + axum::routing::get(routes::get_agent).delete(routes::kill_agent).patch(routes::patch_agent), ) .route( "/api/agents/{id}/mode", diff --git a/crates/openfang-api/static/css/components.css b/crates/openfang-api/static/css/components.css index 4d03b633..755a28c7 100644 --- a/crates/openfang-api/static/css/components.css +++ b/crates/openfang-api/static/css/components.css @@ -69,6 +69,32 @@ gap: 16px; } +/* Card-based flex containers for agent chips and similar inline layouts */ +.card-flex { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +/* Nested list indentation inside cards, detail panels, and modals */ +.card ul, .card ol, +.detail-grid ul, .detail-grid ol, +.modal ul, .modal ol, +.info-card ul, .info-card ol { + padding-left: 18px; + margin: 4px 0; +} +.card ul ul, .card ol ol, +.modal ul ul, .modal ol ol { + padding-left: 16px; + margin: 2px 0; +} +.card li, .modal li, .info-card li { + margin-bottom: 2px; + font-size: 12px; + line-height: 1.5; +} + /* Glow effect on card hover */ .card-glow { overflow: hidden; @@ -90,13 +116,17 @@ display: inline-flex; align-items: center; gap: 4px; - padding: 2px 8px; + padding: 3px 8px; border-radius: 20px; font-size: 10px; font-weight: 600; letter-spacing: 0.5px; text-transform: uppercase; + white-space: nowrap; + line-height: 1.2; + vertical-align: middle; } +.badge + .badge { margin-left: 4px; } .badge-running { background: rgba(74,222,128,0.12); color: var(--success); } .badge-suspended { background: rgba(245,158,11,0.12); color: var(--warning); } @@ -110,7 +140,7 @@ .badge-error { background: rgba(239,68,68,0.12); color: var(--error); } .badge-muted { background: rgba(148,163,184,0.12); color: var(--text-dim); } .badge-info { background: rgba(59,130,246,0.12); color: var(--info); } -.badge-dim { background: rgba(148,163,184,0.08); color: var(--text-dim); font-size: 0.65rem; } +.badge-dim { background: rgba(148,163,184,0.08); color: var(--text-dim); font-size: 0.65rem; padding: 2px 6px; } .text-danger { color: var(--error); } /* Tables */ @@ -949,6 +979,14 @@ mark.search-highlight { padding: 8px 12px; border-bottom: 1px solid var(--border); } +.model-switcher-search select { + max-width: 100px; + flex-shrink: 0; +} +.model-switcher-search select:focus { + outline: none; + border-color: var(--accent); +} .model-switcher-search input { flex: 1; background: none; diff --git a/crates/openfang-api/static/index_body.html b/crates/openfang-api/static/index_body.html index 6aeae4b8..4ed1f69d 100644 --- a/crates/openfang-api/static/index_body.html +++ b/crates/openfang-api/static/index_body.html @@ -769,6 +769,12 @@
@@ -841,6 +847,9 @@
+ diff --git a/crates/openfang-api/static/js/pages/chat.js b/crates/openfang-api/static/js/pages/chat.js index f1c96d45..cd9fc57a 100644 --- a/crates/openfang-api/static/js/pages/chat.js +++ b/crates/openfang-api/static/js/pages/chat.js @@ -37,6 +37,7 @@ function chatPage() { // Model switcher dropdown showModelSwitcher: false, modelSwitcherFilter: '', + modelSwitcherProviderFilter: '', modelSwitcherIdx: 0, modelSwitching: false, _modelCache: null, @@ -99,14 +100,25 @@ function chatPage() { return short.length > 24 ? short.substring(0, 22) + '\u2026' : short; }, + get switcherProviders() { + var seen = {}; + (this._modelCache || []).forEach(function(m) { seen[m.provider] = true; }); + return Object.keys(seen).sort(); + }, + get filteredSwitcherModels() { var models = this._modelCache || []; - if (!this.modelSwitcherFilter) return models; - var f = this.modelSwitcherFilter.toLowerCase(); + var provFilter = this.modelSwitcherProviderFilter; + var textFilter = this.modelSwitcherFilter ? this.modelSwitcherFilter.toLowerCase() : ''; + if (!provFilter && !textFilter) return models; return models.filter(function(m) { - return m.id.toLowerCase().indexOf(f) !== -1 || - (m.display_name || '').toLowerCase().indexOf(f) !== -1 || - m.provider.toLowerCase().indexOf(f) !== -1; + if (provFilter && m.provider !== provFilter) return false; + if (textFilter) { + return m.id.toLowerCase().indexOf(textFilter) !== -1 || + (m.display_name || '').toLowerCase().indexOf(textFilter) !== -1 || + m.provider.toLowerCase().indexOf(textFilter) !== -1; + } + return true; }); }, @@ -220,6 +232,7 @@ function chatPage() { var now = Date.now(); if (this._modelCache && (now - this._modelCacheTime) < 300000) { this.modelSwitcherFilter = ''; + this.modelSwitcherProviderFilter = ''; this.modelSwitcherIdx = 0; this.showModelSwitcher = true; this.$nextTick(function() { @@ -234,6 +247,7 @@ function chatPage() { self._modelCacheTime = Date.now(); self.modelPickerList = models; self.modelSwitcherFilter = ''; + self.modelSwitcherProviderFilter = ''; self.modelSwitcherIdx = 0; self.showModelSwitcher = true; self.$nextTick(function() { diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 266e4aa0..88e0baf4 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -572,7 +572,12 @@ impl OpenFangKernel { } } - // Add fallback providers to the chain + // Add fallback providers to the chain (with model names for cross-provider fallback) + let mut model_chain: Vec<(Arc, String)> = Vec::new(); + // Primary driver uses empty model name (uses the request's model field as-is) + for d in &driver_chain { + model_chain.push((d.clone(), String::new())); + } for fb in &config.fallback_providers { let fb_config = DriverConfig { provider: fb.provider.clone(), @@ -593,7 +598,8 @@ impl OpenFangKernel { model = %fb.model, "Fallback provider configured" ); - driver_chain.push(d); + driver_chain.push(d.clone()); + model_chain.push((d, fb.model.clone())); } Err(e) => { warn!( @@ -607,8 +613,8 @@ impl OpenFangKernel { // Use the chain, or create a stub driver if everything failed let driver: Arc = if driver_chain.len() > 1 { - Arc::new(openfang_runtime::drivers::fallback::FallbackDriver::new( - driver_chain, + Arc::new(openfang_runtime::drivers::fallback::FallbackDriver::with_models( + model_chain, )) } else if let Some(single) = driver_chain.into_iter().next() { single diff --git a/crates/openfang-runtime/src/drivers/mod.rs b/crates/openfang-runtime/src/drivers/mod.rs index 5a69938e..da3cf9ad 100644 --- a/crates/openfang-runtime/src/drivers/mod.rs +++ b/crates/openfang-runtime/src/drivers/mod.rs @@ -14,7 +14,8 @@ pub mod openai; use crate::llm_driver::{DriverConfig, LlmDriver, LlmError}; use openfang_types::model_catalog::{ AI21_BASE_URL, ANTHROPIC_BASE_URL, CEREBRAS_BASE_URL, COHERE_BASE_URL, DEEPSEEK_BASE_URL, - FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, LMSTUDIO_BASE_URL, + FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, LEMONADE_BASE_URL, + LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL, REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL, @@ -89,6 +90,11 @@ fn provider_defaults(provider: &str) -> Option { api_key_env: "LMSTUDIO_API_KEY", key_required: false, }), + "lemonade" => Some(ProviderDefaults { + base_url: LEMONADE_BASE_URL, + api_key_env: "LEMONADE_API_KEY", + key_required: false, + }), "perplexity" => Some(ProviderDefaults { base_url: PERPLEXITY_BASE_URL, api_key_env: "PERPLEXITY_API_KEY", diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs index 63c7cdfd..3dfacb6a 100644 --- a/crates/openfang-runtime/src/model_catalog.rs +++ b/crates/openfang-runtime/src/model_catalog.rs @@ -8,7 +8,8 @@ use openfang_types::model_catalog::{ BEDROCK_BASE_URL, CEREBRAS_BASE_URL, COHERE_BASE_URL, DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, GEMINI_BASE_URL, GITHUB_COPILOT_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL, - OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL, + LEMONADE_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, + QIANFAN_BASE_URL, QWEN_BASE_URL, REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL, @@ -499,6 +500,15 @@ fn builtin_providers() -> Vec { auth_status: AuthStatus::NotRequired, model_count: 0, }, + ProviderInfo { + id: "lemonade".into(), + display_name: "Lemonade".into(), + api_key_env: "LEMONADE_API_KEY".into(), + base_url: LEMONADE_BASE_URL.into(), + key_required: false, + auth_status: AuthStatus::NotRequired, + model_count: 0, + }, // ── New providers (8) ────────────────────────────────────── ProviderInfo { id: "perplexity".into(), @@ -768,7 +778,7 @@ fn builtin_aliases() -> HashMap { ("qwen", "qwen-plus"), ("glm", "glm-5-20250605"), ("ernie", "ernie-4.5-8k"), - ("kimi", "moonshot-v1-128k"), + ("kimi", "kimi-k2-0711"), ("minimax", "MiniMax-M2.5"), ("minimax-m2.5", "MiniMax-M2.5"), ("minimax-m2.1", "MiniMax-M2.1"), @@ -2843,7 +2853,7 @@ fn builtin_models() -> Vec { aliases: vec!["codegeex".into()], }, // ══════════════════════════════════════════════════════════════ - // Moonshot / Kimi (3) + // Moonshot / Kimi (5) // ══════════════════════════════════════════════════════════════ ModelCatalogEntry { id: "moonshot-v1-128k".into(), @@ -2857,7 +2867,7 @@ fn builtin_models() -> Vec { supports_tools: true, supports_vision: false, supports_streaming: true, - aliases: vec!["kimi".into()], + aliases: vec![], }, ModelCatalogEntry { id: "moonshot-v1-32k".into(), @@ -2887,6 +2897,34 @@ fn builtin_models() -> Vec { supports_streaming: true, aliases: vec![], }, + ModelCatalogEntry { + id: "kimi-k2-0711".into(), + display_name: "Kimi K2".into(), + provider: "moonshot".into(), + tier: ModelTier::Frontier, + context_window: 131_072, + max_output_tokens: 16_384, + input_cost_per_m: 2.00, + output_cost_per_m: 8.00, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec!["kimi-k2".into()], + }, + ModelCatalogEntry { + id: "kimi-k2.5-0711".into(), + display_name: "Kimi K2.5".into(), + provider: "moonshot".into(), + tier: ModelTier::Frontier, + context_window: 131_072, + max_output_tokens: 16_384, + input_cost_per_m: 2.00, + output_cost_per_m: 8.00, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec!["kimi-k2.5".into()], + }, // ══════════════════════════════════════════════════════════════ // Baidu Qianfan / ERNIE (3) // ══════════════════════════════════════════════════════════════ @@ -3243,7 +3281,7 @@ mod tests { #[test] fn test_catalog_has_providers() { let catalog = ModelCatalog::new(); - assert_eq!(catalog.list_providers().len(), 35); + assert_eq!(catalog.list_providers().len(), 36); } #[test] diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index 7f3c5397..f37db523 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -296,6 +296,9 @@ pub async fn execute_tool( // Location tool "location_get" => tool_location_get().await, + // System time tool + "system_time" => Ok(tool_system_time()), + // Cron scheduling tools "cron_create" => tool_cron_create(input, kernel, caller_agent_id).await, "cron_list" => tool_cron_list(kernel, caller_agent_id).await, @@ -1177,6 +1180,16 @@ pub fn builtin_tool_definitions() -> Vec { "properties": {} }), }, + // --- System time tool --- + ToolDefinition { + name: "system_time".to_string(), + description: "Get the current date, time, and timezone. Returns ISO 8601 timestamp, Unix epoch seconds, and timezone info.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, // --- Canvas / A2UI tool --- ToolDefinition { name: "canvas_present".to_string(), @@ -2537,6 +2550,27 @@ async fn tool_location_get() -> Result { serde_json::to_string_pretty(&result).map_err(|e| format!("Serialize error: {e}")) } +// --------------------------------------------------------------------------- +// System time tool +// --------------------------------------------------------------------------- + +/// Return current date, time, timezone, and Unix epoch. +fn tool_system_time() -> String { + let now_utc = chrono::Utc::now(); + let now_local = chrono::Local::now(); + let result = serde_json::json!({ + "utc": now_utc.to_rfc3339(), + "local": now_local.to_rfc3339(), + "unix_epoch": now_utc.timestamp(), + "timezone": now_local.format("%Z").to_string(), + "utc_offset": now_local.format("%:z").to_string(), + "date": now_local.format("%Y-%m-%d").to_string(), + "time": now_local.format("%H:%M:%S").to_string(), + "day_of_week": now_local.format("%A").to_string(), + }); + serde_json::to_string_pretty(&result).unwrap_or_else(|_| now_utc.to_rfc3339()) +} + // --------------------------------------------------------------------------- // Media understanding tools // --------------------------------------------------------------------------- @@ -3114,6 +3148,7 @@ mod tests { assert!(names.contains(&"schedule_delete")); assert!(names.contains(&"image_analyze")); assert!(names.contains(&"location_get")); + assert!(names.contains(&"system_time")); // 6 browser tools assert!(names.contains(&"browser_navigate")); assert!(names.contains(&"browser_click")); diff --git a/crates/openfang-skills/src/clawhub.rs b/crates/openfang-skills/src/clawhub.rs index 6d1f4d1b..3f9129cb 100644 --- a/crates/openfang-skills/src/clawhub.rs +++ b/crates/openfang-skills/src/clawhub.rs @@ -593,14 +593,25 @@ impl ClawHubClient { } } -/// Minimal URL-encoding for query parameters. +/// RFC 3986 percent-encoding for query parameters. +/// Unreserved characters pass through, space becomes `+`, everything else is `%XX`. fn urlencoded(s: &str) -> String { - s.replace(' ', "+") - .replace('&', "%26") - .replace('=', "%3D") - .replace('?', "%3F") - .replace('#', "%23") - .replace('/', "%2F") + const HEX_UPPER: &[u8; 16] = b"0123456789ABCDEF"; + let mut result = String::with_capacity(s.len() * 3); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + result.push(b as char); + } + b' ' => result.push('+'), + _ => { + result.push('%'); + result.push(HEX_UPPER[(b >> 4) as usize] as char); + result.push(HEX_UPPER[(b & 0xf) as usize] as char); + } + } + } + result } /// Check if a binary is available on PATH. @@ -786,6 +797,11 @@ mod tests { assert_eq!(urlencoded("hello world"), "hello+world"); assert_eq!(urlencoded("a&b=c"), "a%26b%3Dc"); assert_eq!(urlencoded("path/to#frag"), "path%2Fto%23frag"); + // Previously missed characters + assert_eq!(urlencoded("100%"), "100%25"); + assert_eq!(urlencoded("a+b"), "a%2Bb"); + // Unreserved chars pass through + assert_eq!(urlencoded("hello-world_2.0~test"), "hello-world_2.0~test"); } #[test] diff --git a/crates/openfang-types/src/agent.rs b/crates/openfang-types/src/agent.rs index 600fcead..12f63f16 100644 --- a/crates/openfang-types/src/agent.rs +++ b/crates/openfang-types/src/agent.rs @@ -474,7 +474,8 @@ pub struct AgentManifest { #[serde(default = "default_true")] pub generate_identity_files: bool, /// Per-agent exec policy override. If None, uses global exec_policy. - #[serde(default)] + /// Accepts string shorthand ("allow", "deny", "full", "allowlist") or full table. + #[serde(default, deserialize_with = "crate::serde_compat::exec_policy_lenient")] pub exec_policy: Option, /// Tool allowlist — only these tools are available (empty = all tools). #[serde(default, deserialize_with = "crate::serde_compat::vec_lenient")] diff --git a/crates/openfang-types/src/model_catalog.rs b/crates/openfang-types/src/model_catalog.rs index 3e98e76b..92019518 100644 --- a/crates/openfang-types/src/model_catalog.rs +++ b/crates/openfang-types/src/model_catalog.rs @@ -20,6 +20,7 @@ pub const FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1"; pub const OLLAMA_BASE_URL: &str = "http://localhost:11434/v1"; pub const VLLM_BASE_URL: &str = "http://localhost:8000/v1"; pub const LMSTUDIO_BASE_URL: &str = "http://localhost:1234/v1"; +pub const LEMONADE_BASE_URL: &str = "http://localhost:8888/api/v1"; pub const PERPLEXITY_BASE_URL: &str = "https://api.perplexity.ai"; pub const COHERE_BASE_URL: &str = "https://api.cohere.com/v2"; pub const AI21_BASE_URL: &str = "https://api.ai21.com/studio/v1"; diff --git a/crates/openfang-types/src/serde_compat.rs b/crates/openfang-types/src/serde_compat.rs index e1a50a03..b3b2fb77 100644 --- a/crates/openfang-types/src/serde_compat.rs +++ b/crates/openfang-types/src/serde_compat.rs @@ -157,6 +157,70 @@ where deserializer.deserialize_any(MapLenientVisitor(PhantomData)) } +/// Deserialize an `Option` leniently: accepts either a string +/// shorthand (e.g., `"allow"`, `"deny"`, `"full"`, `"allowlist"`) which maps +/// to `ExecPolicy { mode: , ..Default::default() }`, or the full +/// struct/table form. Returns `None` for null/missing. +pub fn exec_policy_lenient<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct ExecPolicyVisitor; + + impl<'de> Visitor<'de> for ExecPolicyVisitor { + type Value = Option; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str( + "a string shorthand (\"allow\", \"deny\", \"full\", \"allowlist\") or an ExecPolicy table", + ) + } + + fn visit_str(self, v: &str) -> Result { + let mode = match v.to_lowercase().as_str() { + "deny" | "none" | "disabled" => crate::config::ExecSecurityMode::Deny, + "allowlist" | "restricted" => crate::config::ExecSecurityMode::Allowlist, + "full" | "allow" | "all" | "unrestricted" => crate::config::ExecSecurityMode::Full, + other => { + return Err(de::Error::unknown_variant( + other, + &[ + "deny", "none", "disabled", "allowlist", "restricted", "full", + "allow", "all", "unrestricted", + ], + )); + } + }; + Ok(Some(crate::config::ExecPolicy { + mode, + ..Default::default() + })) + } + + fn visit_map(self, map: A) -> Result + where + A: MapAccess<'de>, + { + let policy = crate::config::ExecPolicy::deserialize( + de::value::MapAccessDeserializer::new(map), + )?; + Ok(Some(policy)) + } + + fn visit_none(self) -> Result { + Ok(None) + } + + fn visit_unit(self) -> Result { + Ok(None) + } + } + + deserializer.deserialize_any(ExecPolicyVisitor) +} + #[cfg(test)] mod tests { use super::*; @@ -303,4 +367,83 @@ mod tests { assert!(new.fallback_models.is_empty()); assert!(new.skills.is_empty()); } + + // --- exec_policy_lenient tests --- + + #[derive(Debug, Deserialize)] + struct TestExecPolicy { + #[serde(default, deserialize_with = "exec_policy_lenient")] + exec_policy: Option, + } + + #[test] + fn exec_policy_string_allow() { + let toml_str = r#"exec_policy = "allow""#; + let parsed: TestExecPolicy = toml::from_str(toml_str).unwrap(); + let policy = parsed.exec_policy.unwrap(); + assert_eq!(policy.mode, crate::config::ExecSecurityMode::Full); + // Should have default safe_bins, timeout, etc. + assert!(!policy.safe_bins.is_empty()); + assert_eq!(policy.timeout_secs, 30); + } + + #[test] + fn exec_policy_string_deny() { + let toml_str = r#"exec_policy = "deny""#; + let parsed: TestExecPolicy = toml::from_str(toml_str).unwrap(); + let policy = parsed.exec_policy.unwrap(); + assert_eq!(policy.mode, crate::config::ExecSecurityMode::Deny); + } + + #[test] + fn exec_policy_string_full() { + let toml_str = r#"exec_policy = "full""#; + let parsed: TestExecPolicy = toml::from_str(toml_str).unwrap(); + let policy = parsed.exec_policy.unwrap(); + assert_eq!(policy.mode, crate::config::ExecSecurityMode::Full); + } + + #[test] + fn exec_policy_string_allowlist() { + let toml_str = r#"exec_policy = "allowlist""#; + let parsed: TestExecPolicy = toml::from_str(toml_str).unwrap(); + let policy = parsed.exec_policy.unwrap(); + assert_eq!(policy.mode, crate::config::ExecSecurityMode::Allowlist); + } + + #[test] + fn exec_policy_table_form() { + let toml_str = r#" +[exec_policy] +mode = "full" +timeout_secs = 60 +"#; + let parsed: TestExecPolicy = toml::from_str(toml_str).unwrap(); + let policy = parsed.exec_policy.unwrap(); + assert_eq!(policy.mode, crate::config::ExecSecurityMode::Full); + assert_eq!(policy.timeout_secs, 60); + } + + #[test] + fn exec_policy_missing_is_none() { + let toml_str = r#"other_field = true"#; + // Use a struct with an extra ignored field + #[derive(Debug, Deserialize)] + struct Wrapper { + #[serde(default, deserialize_with = "exec_policy_lenient")] + exec_policy: Option, + #[allow(dead_code)] + #[serde(default)] + other_field: bool, + } + let parsed: Wrapper = toml::from_str(toml_str).unwrap(); + assert!(parsed.exec_policy.is_none()); + } + + #[test] + fn exec_policy_string_invalid_errors() { + let toml_str = r#"exec_policy = "banana""#; + let result = toml::from_str::(toml_str); + assert!(result.is_err()); + } } diff --git a/scripts/install.sh b/scripts/install.sh index 65c53b35..7e645bf5 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -115,19 +115,39 @@ install() { codesign --force --sign - "$INSTALL_DIR/openfang" 2>/dev/null || true fi - # Add to PATH + # Add to PATH — detect the user's login shell + USER_SHELL="${SHELL:-}" + # Fallback: check /etc/passwd if $SHELL is unset (e.g. minimal containers) + if [ -z "$USER_SHELL" ] && command -v getent &>/dev/null; then + USER_SHELL=$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7) + fi + if [ -z "$USER_SHELL" ] && [ -f /etc/passwd ]; then + USER_SHELL=$(grep "^$(id -un):" /etc/passwd 2>/dev/null | cut -d: -f7) + fi + SHELL_RC="" - case "${SHELL:-}" in - */zsh) SHELL_RC="$HOME/.zshrc" ;; + case "$USER_SHELL" in + */zsh) SHELL_RC="$HOME/.zshrc" ;; */bash) SHELL_RC="$HOME/.bashrc" ;; */fish) SHELL_RC="$HOME/.config/fish/config.fish" ;; esac + # Also check for config files if shell detection failed + if [ -z "$SHELL_RC" ]; then + if [ -f "$HOME/.config/fish/config.fish" ]; then + SHELL_RC="$HOME/.config/fish/config.fish" + USER_SHELL="/usr/bin/fish" + elif [ -f "$HOME/.zshrc" ]; then + SHELL_RC="$HOME/.zshrc" + elif [ -f "$HOME/.bashrc" ]; then + SHELL_RC="$HOME/.bashrc" + fi + fi if [ -n "$SHELL_RC" ] && ! grep -q "openfang" "$SHELL_RC" 2>/dev/null; then - case "${SHELL:-}" in + case "$USER_SHELL" in */fish) mkdir -p "$(dirname "$SHELL_RC")" - echo "set -gx PATH \"$INSTALL_DIR\" \$PATH" >> "$SHELL_RC" + echo "fish_add_path \"$INSTALL_DIR\"" >> "$SHELL_RC" ;; *) echo "export PATH=\"$INSTALL_DIR:\$PATH\"" >> "$SHELL_RC"