From 6b38f20c89ea86c281607e7e094a5f4c42390709 Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Sat, 11 Apr 2026 00:34:01 +0200 Subject: [PATCH 1/3] feat: integrate Tool Engine for container management and runtime detection - Added Tool Engine panel to the Dashboard for managing container tools. - Implemented runtime detection for Podman and Docker, providing user feedback on installation status. - Enhanced MCP server management to support workspace roots and filesystem paths. - Improved error handling and user notifications during tool installation and uninstallation processes. - Updated Setup Wizard to include container runtime installation as a prerequisite. --- src-tauri/.gitignore | 3 + src-tauri/mcp.example.json | 11 +- src-tauri/src/app.rs | 22 +- src-tauri/src/infrastructure/http_server.rs | 316 +++- src-tauri/src/modules/bot/agent.rs | 26 +- src-tauri/src/modules/mcp/client.rs | 12 +- src-tauri/src/modules/mcp/protocol.rs | 2 - src-tauri/src/modules/mcp/registry.rs | 112 +- src-tauri/src/modules/mcp/service.rs | 268 ++- src-tauri/src/modules/mcp/transport.rs | 23 +- src-tauri/src/modules/mcp/types.rs | 4 + src-tauri/src/modules/mod.rs | 1 + .../container/file-manager/Dockerfile | 8 + .../tool_engine/container/file-manager/build | 7 + .../container/file-manager/package-lock.json | 1608 +++++++++++++++++ .../container/file-manager/package.json | 7 + src-tauri/src/modules/tool_engine/mod.rs | 3 + src-tauri/src/modules/tool_engine/runtime.rs | 122 ++ src-tauri/src/modules/tool_engine/service.rs | 456 +++++ src-tauri/src/modules/tool_engine/tools.json | 79 + src-tauri/src/modules/tool_engine/types.rs | 103 ++ src-tauri/src/shared/state.rs | 8 +- src-tauri/tests/mcp_tools.rs | 10 +- src/modules/bot/components/SetupWizard.tsx | 393 +--- .../bot/components/SetupWizardSteps.tsx | 499 +++++ src/modules/mcp/components/McpServerCard.tsx | 146 +- src/modules/mcp/components/McpToolsPanel.tsx | 10 + src/modules/mcp/index.ts | 2 +- .../toolengine/components/ToolEnginePanel.tsx | 235 +++ src/modules/toolengine/index.ts | 152 ++ src/pages/DashboardPage.tsx | 6 + src/pages/SetupPage.tsx | 1 + src/shared/api/config.ts | 15 + src/shared/mcpEvents.ts | 7 + src/shared/ui/WizardLayout.tsx | 2 +- 35 files changed, 4208 insertions(+), 471 deletions(-) create mode 100644 src-tauri/src/modules/tool_engine/container/file-manager/Dockerfile create mode 100755 src-tauri/src/modules/tool_engine/container/file-manager/build create mode 100644 src-tauri/src/modules/tool_engine/container/file-manager/package-lock.json create mode 100644 src-tauri/src/modules/tool_engine/container/file-manager/package.json create mode 100644 src-tauri/src/modules/tool_engine/mod.rs create mode 100644 src-tauri/src/modules/tool_engine/runtime.rs create mode 100644 src-tauri/src/modules/tool_engine/service.rs create mode 100644 src-tauri/src/modules/tool_engine/tools.json create mode 100644 src-tauri/src/modules/tool_engine/types.rs create mode 100644 src/modules/bot/components/SetupWizardSteps.tsx create mode 100644 src/modules/toolengine/components/ToolEnginePanel.tsx create mode 100644 src/modules/toolengine/index.ts create mode 100644 src/shared/mcpEvents.ts diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index b21bd68..7ace95b 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -5,3 +5,6 @@ # Generated by Tauri # will have schema files for capabilities auto-completion /gen/schemas + +# Local npm install for file-manager image build +src/modules/tool_engine/container/file-manager/node_modules/ diff --git a/src-tauri/mcp.example.json b/src-tauri/mcp.example.json index 52dcec6..0afb307 100644 --- a/src-tauri/mcp.example.json +++ b/src-tauri/mcp.example.json @@ -1,18 +1,9 @@ { + "workspace_roots": ["/absolute/path/to/your/project"], "servers": { "dice": { "type": "native", "id": "dice" - }, - "filesystem": { - "type": "stdio", - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-filesystem", - "/absolute/path/to/allowed/folder" - ], - "env": {} } } } diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index f089a55..303de4c 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -35,23 +35,19 @@ pub fn run() { app.manage(shared_state.clone()); - // Load MCP before any bot work so the first Telegram message never sees an empty registry. + // Connect MCP stdio servers in the background so window + HTTP API are not blocked by + // slow starters (Podman containers, `npx`, etc.). The registry stays empty until connect + // finishes; early Telegram turns simply omit tools until then. let mcp_path = shared_state.mcp_config_path.clone(); let mcp_state = shared_state.clone(); - tauri::async_runtime::block_on(async move { + tauri::async_runtime::spawn(async move { mcp_state - .emit_log("mcp", &format!("loading {}", mcp_path.display())) + .emit_log( + "mcp", + &format!("connecting servers in background ({})", mcp_path.display()), + ) .await; - match mcp_service::load_or_init_config(&mcp_path) { - Ok(cfg) => { - mcp_service::rebuild_registry_into_state(&mcp_state, &cfg).await; - } - Err(e) => { - mcp_state - .emit_log("mcp", &format!("mcp.json error: {e}")) - .await; - } - } + mcp_service::rebuild_registry_into_state(&mcp_state).await; }); // Resume persisted Telegram connection if present. diff --git a/src-tauri/src/infrastructure/http_server.rs b/src-tauri/src/infrastructure/http_server.rs index 268924b..78a11c8 100644 --- a/src-tauri/src/infrastructure/http_server.rs +++ b/src-tauri/src/infrastructure/http_server.rs @@ -2,6 +2,7 @@ use crate::infrastructure::bot_lifecycle; use crate::modules::bot::{repository, service as bot_service}; use crate::modules::mcp::service as mcp_service; use crate::modules::ollama::service as ollama_service; +use crate::modules::tool_engine::{runtime as te_runtime, service as te_service}; use crate::shared::state::{AppState, ConnectionData}; use axum::extract::{Path, State}; use axum::http::StatusCode; @@ -95,6 +96,14 @@ pub async fn start_server(state: AppState) { .route("/v1/mcp/servers", get(handle_mcp_servers_list)) .route("/v1/mcp/servers/{name}", put(handle_mcp_server_upsert)) .route("/v1/mcp/servers/{name}", delete(handle_mcp_server_delete)) + .route("/v1/toolengine/runtime", get(handle_toolengine_runtime)) + .route("/v1/toolengine/catalog", get(handle_toolengine_catalog)) + .route("/v1/toolengine/installed", get(handle_toolengine_installed)) + .route("/v1/toolengine/install", post(handle_toolengine_install)) + .route( + "/v1/toolengine/uninstall", + post(handle_toolengine_uninstall), + ) .layer(cors) .with_state(state.clone()); @@ -352,40 +361,68 @@ async fn handle_mcp_filesystem_put( )); } - let _guard = state.mcp_config_mutex.lock().await; + let rt = te_runtime::detect_runtime().await; - let mut cfg = if state.mcp_config_path.exists() { - mcp_service::read_config(&state.mcp_config_path) - .map_err(|e| (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e })))? - } else { - mcp_service::load_or_init_config(&state.mcp_config_path).map_err(|e| { + let sync_note = { + let _guard = state.mcp_config_mutex.lock().await; + + let mut cfg = if state.mcp_config_path.exists() { + mcp_service::read_config(&state.mcp_config_path) + .map_err(|e| (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e })))? + } else { + mcp_service::load_or_init_config(&state.mcp_config_path).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e }), + ) + })? + }; + + mcp_service::set_filesystem_allowed_paths(&mut cfg, &paths); + + let mut note = None::; + if let Some(ref r) = rt { + if let Err(e) = + te_service::sync_workspace_mounted_tools_if_installed(&mut cfg, &paths, r) + { + note = Some(e); + } + } + + mcp_service::save_config(&state.mcp_config_path, &cfg).map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e }), ) - })? + })?; + + note }; - mcp_service::set_filesystem_allowed_paths(&mut cfg, &paths); - mcp_service::save_config(&state.mcp_config_path, &cfg).map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { error: e }), - ) - })?; + if let Some(msg) = sync_note { + state + .emit_log( + "toolengine", + &format!("file-manager entry not updated: {msg}"), + ) + .await; + } state .emit_log( "mcp", &format!( - "filesystem allowed paths ({}) updated → {}", + "workspace_roots ({}) updated → {}", paths.len(), state.mcp_config_path.display() ), ) .await; - mcp_service::rebuild_registry_into_state(&state, &cfg).await; + let bg = state.clone(); + tokio::spawn(async move { + mcp_service::rebuild_registry_into_state(&bg).await; + }); Ok((StatusCode::OK, Json(serde_json::json!({ "ok": true })))) } @@ -465,27 +502,33 @@ async fn handle_mcp_server_upsert( } } - let _guard = state.mcp_config_mutex.lock().await; - let mut cfg = mcp_service::load_or_init_config(&state.mcp_config_path).map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { error: e }), - ) - })?; + { + let _guard = state.mcp_config_mutex.lock().await; + let mut cfg = mcp_service::load_or_init_config(&state.mcp_config_path).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e }), + ) + })?; - cfg.servers.insert(name.clone(), entry); + cfg.servers.insert(name.clone(), entry); - mcp_service::save_config(&state.mcp_config_path, &cfg).map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { error: e }), - ) - })?; + mcp_service::save_config(&state.mcp_config_path, &cfg).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e }), + ) + })?; + } state .emit_log("mcp", &format!("server '{name}' saved")) .await; - mcp_service::rebuild_registry_into_state(&state, &cfg).await; + + let bg = state.clone(); + tokio::spawn(async move { + mcp_service::rebuild_registry_into_state(&bg).await; + }); Ok((StatusCode::OK, Json(serde_json::json!({ "ok": true })))) } @@ -494,35 +537,208 @@ async fn handle_mcp_server_delete( State(state): State, Path(name): Path, ) -> Result<(StatusCode, Json), (StatusCode, Json)> { - let _guard = state.mcp_config_mutex.lock().await; + { + let _guard = state.mcp_config_mutex.lock().await; - let mut cfg = mcp_service::load_or_init_config(&state.mcp_config_path).map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { error: e }), - ) - })?; + let mut cfg = mcp_service::load_or_init_config(&state.mcp_config_path).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e }), + ) + })?; - if cfg.servers.remove(&name).is_none() { - return Err(( - StatusCode::NOT_FOUND, - Json(ErrorResponse { - error: format!("server '{name}' not found"), - }), - )); + if cfg.servers.remove(&name).is_none() { + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("server '{name}' not found"), + }), + )); + } + + mcp_service::save_config(&state.mcp_config_path, &cfg).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e }), + ) + })?; + } + + state + .emit_log("mcp", &format!("server '{name}' removed")) + .await; + + let bg = state.clone(); + tokio::spawn(async move { + mcp_service::rebuild_registry_into_state(&bg).await; + }); + + Ok((StatusCode::OK, Json(serde_json::json!({ "ok": true })))) +} + +// ── Tool Engine ───────────────────────────────────────────────────── + +async fn handle_toolengine_runtime(State(_state): State) -> Json { + match te_runtime::detect_runtime().await { + Some(info) => Json(serde_json::json!({ + "available": true, + "kind": info.kind, + "version": info.version, + "rootless": info.rootless, + })), + None => Json(serde_json::json!({ "available": false })), } +} - mcp_service::save_config(&state.mcp_config_path, &cfg).map_err(|e| { +async fn handle_toolengine_catalog( + State(state): State, +) -> Result, (StatusCode, Json)> { + let catalog = te_service::load_catalog().map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e }), ) })?; - state - .emit_log("mcp", &format!("server '{name}' removed")) - .await; - mcp_service::rebuild_registry_into_state(&state, &cfg).await; + let installed_ids = te_service::installed_tool_ids(&state.mcp_config_path); + + let tools: Vec = catalog + .tools + .iter() + .map(|t| { + let commands: Vec = t + .commands + .iter() + .map(|c| { + serde_json::json!({ + "name": c.name, + "description": c.description, + }) + }) + .collect(); + serde_json::json!({ + "id": t.id, + "name": t.name, + "version": t.version, + "description": t.description, + "installed": installed_ids.contains(&t.id), + "commands": commands, + }) + }) + .collect(); + + Ok(Json(serde_json::json!({ "tools": tools }))) +} + +async fn handle_toolengine_installed(State(state): State) -> Json { + let installed = te_service::installed_tool_ids(&state.mcp_config_path); + Json(serde_json::json!({ "installed": installed })) +} + +#[derive(Deserialize)] +struct ToolEngineActionBody { + tool_id: String, +} + +async fn handle_toolengine_install( + State(state): State, + Json(body): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + let tool_id = body.tool_id; + let runtime = match te_runtime::detect_runtime().await { + Some(rt) => rt, + None => { + let msg = "no container runtime found (install Podman or Docker)"; + state.emit_log("toolengine", &format!("error: {msg}")).await; + return Err(( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse { error: msg.into() }), + )); + } + }; + + { + let _guard = state.tool_engine_mutex.lock().await; + + state + .emit_log("toolengine", &format!("installing {tool_id}…")) + .await; + + let host_paths = { + let _cfg_guard = state.mcp_config_mutex.lock().await; + match mcp_service::read_config(&state.mcp_config_path) { + Ok(cfg) => mcp_service::filesystem_allowed_paths(&cfg), + Err(_) => Vec::new(), + } + }; + + if let Err(e) = + te_service::install_tool(&tool_id, &runtime, &state.mcp_config_path, &host_paths).await + { + state + .emit_log("toolengine", &format!("install failed: {e}")) + .await; + return Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))); + } + + state + .emit_log("toolengine", &format!("{tool_id} installed")) + .await; + } + + // Respond immediately; MCP reconnect can take minutes (Podman / npx) and must not block the UI. + let bg = state.clone(); + tokio::spawn(async move { + mcp_service::rebuild_registry_into_state(&bg).await; + }); + + Ok((StatusCode::OK, Json(serde_json::json!({ "ok": true })))) +} + +async fn handle_toolengine_uninstall( + State(state): State, + Json(body): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + let tool_id = body.tool_id; + let runtime = match te_runtime::detect_runtime().await { + Some(rt) => rt, + None => { + let msg = "no container runtime found"; + state.emit_log("toolengine", &format!("error: {msg}")).await; + return Err(( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse { error: msg.into() }), + )); + } + }; + + { + let _guard = state.tool_engine_mutex.lock().await; + + state + .emit_log("toolengine", &format!("uninstalling {tool_id}…")) + .await; + + if let Err(e) = te_service::uninstall_tool(&tool_id, &runtime, &state.mcp_config_path).await + { + state + .emit_log("toolengine", &format!("uninstall failed: {e}")) + .await; + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e }), + )); + } + + state + .emit_log("toolengine", &format!("{tool_id} uninstalled")) + .await; + } + + let bg = state.clone(); + tokio::spawn(async move { + mcp_service::rebuild_registry_into_state(&bg).await; + }); Ok((StatusCode::OK, Json(serde_json::json!({ "ok": true })))) } diff --git a/src-tauri/src/modules/bot/agent.rs b/src-tauri/src/modules/bot/agent.rs index c855e7c..ea51f1a 100644 --- a/src-tauri/src/modules/bot/agent.rs +++ b/src-tauri/src/modules/bot/agent.rs @@ -1,4 +1,5 @@ use crate::modules::ollama::service as ollama; +use crate::modules::tool_engine::service::workspace_app_bind_pairs; use crate::shared::state::AppState; use serde_json::json; use std::time::{Duration, Instant}; @@ -51,15 +52,24 @@ pub async fn run_turn(state: &AppState, user_message: &str) -> Result>() + .join("\n"); + let roots_note = if paths.is_empty() { + "No shared folders are configured yet — the container only allows **`/tmp`** for MCP file tools. \ + To read a project like `pengine`, add its folder in Dashboard → MCP Tools (File Manager) first; \ + then use **`/app//README.md`** (folder-name = last path segment)." } else { - let listing = paths.join(", "); - format!( - "\nFile tools operate on these directories: {listing}\n\ - Always use absolute paths rooted in one of those directories." - ) - } + "Use the **`/app/...`** paths below only — not host paths like /Users/…, and not **`/mcp/...`** (that is the server working directory, not a file root)." + }; + format!( + "\nFile Manager runs in a container. Allowed file roots are **`/tmp`** plus **`/app/`** for each folder you add in MCP Tools.\n\ + {roots_note}\n\ + Relative paths in tools are resolved under **`/app/`** (e.g. **`pengine/README.md`** → **`/app/pengine/README.md`**).\n\ +{host_lines}\n" + ) }; let system = if has_tools { diff --git a/src-tauri/src/modules/mcp/client.rs b/src-tauri/src/modules/mcp/client.rs index 74256b4..e2b20e0 100644 --- a/src-tauri/src/modules/mcp/client.rs +++ b/src-tauri/src/modules/mcp/client.rs @@ -2,6 +2,10 @@ use super::transport::StdioTransport; use super::types::ToolDef; use serde_json::{json, Value}; use std::collections::HashMap; +use std::time::Duration; + +/// `podman run` + `npx -y` inside the container can exceed a minute on cold cache / slow networks. +const MCP_CONNECT_CALL_TIMEOUT: Duration = Duration::from_secs(300); pub struct McpClient { pub server_name: String, @@ -24,10 +28,14 @@ impl McpClient { "capabilities": {}, "clientInfo": { "name": "pengine", "version": "0.1.0" }, }); - transport.call("initialize", Some(init_params)).await?; + transport + .call_with_timeout("initialize", Some(init_params), MCP_CONNECT_CALL_TIMEOUT) + .await?; let _ = transport.notify("notifications/initialized", None).await; - let result = transport.call("tools/list", None).await?; + let result = transport + .call_with_timeout("tools/list", None, MCP_CONNECT_CALL_TIMEOUT) + .await?; let mut tools = parse_tools(&server_name, &result); if direct_return { diff --git a/src-tauri/src/modules/mcp/protocol.rs b/src-tauri/src/modules/mcp/protocol.rs index 4f78322..18cd325 100644 --- a/src-tauri/src/modules/mcp/protocol.rs +++ b/src-tauri/src/modules/mcp/protocol.rs @@ -1,5 +1,3 @@ -//! Minimal JSON-RPC 2.0 for MCP over stdio. - use serde::{Deserialize, Serialize}; use serde_json::Value; diff --git a/src-tauri/src/modules/mcp/registry.rs b/src-tauri/src/modules/mcp/registry.rs index 19c4ff4..add0e84 100644 --- a/src-tauri/src/modules/mcp/registry.rs +++ b/src-tauri/src/modules/mcp/registry.rs @@ -4,6 +4,76 @@ use super::types::ToolDef; use serde_json::{json, Value}; use std::sync::Arc; +/// Normalize File Manager paths: absolute container paths pass through; relative `pengine/README.md` → `/app/pengine/README.md`. +fn rewrite_file_manager_path(s: &str) -> String { + let t = s.trim(); + if t.is_empty() { + return t.to_string(); + } + // Models often confuse the image WORKDIR (`/mcp`) with an allowed root — it is not. + if let Some(rest) = t.strip_prefix("/mcp/") { + return format!("/app/{rest}"); + } + if t == "/mcp" { + return "/tmp".to_string(); + } + if t.starts_with("/opt/mcp-filesystem") { + return t.to_string(); + } + if t.starts_with('/') { + return t.to_string(); + } + if t.contains(':') || t.starts_with("\\\\") { + return t.to_string(); + } + if t.contains("..") { + return t.to_string(); + } + let u = t.replace('\\', "/"); + format!("/app/{u}") +} + +fn normalize_file_manager_tool_args(v: Value) -> Value { + match v { + Value::Object(mut map) => { + let keys: Vec = map.keys().cloned().collect(); + for k in keys { + let Some(val) = map.remove(&k) else { + continue; + }; + let val = match k.as_str() { + "path" => match val { + Value::String(s) => Value::String(rewrite_file_manager_path(&s)), + other => normalize_file_manager_tool_args(other), + }, + "paths" => match val { + Value::Array(arr) => Value::Array( + arr.into_iter() + .map(|item| match item { + Value::String(s) => { + Value::String(rewrite_file_manager_path(&s)) + } + other => normalize_file_manager_tool_args(other), + }) + .collect(), + ), + other => normalize_file_manager_tool_args(other), + }, + _ => normalize_file_manager_tool_args(val), + }; + map.insert(k, val); + } + Value::Object(map) + } + Value::Array(arr) => Value::Array( + arr.into_iter() + .map(normalize_file_manager_tool_args) + .collect(), + ), + other => other, + } +} + #[derive(Clone)] pub enum Provider { Native(Arc), @@ -88,6 +158,12 @@ impl ToolRegistry { pub async fn call_tool(&self, name: &str, args: Value) -> Result<(String, bool), String> { let (provider, tool, direct) = self.resolve_tool(name)?; + let args = match &provider { + Provider::Mcp(c) if c.server_name == "te_pengine-file-manager" => { + normalize_file_manager_tool_args(args) + } + _ => args, + }; let text = provider.call_tool(&tool, args).await?; Ok((text, direct)) } @@ -153,6 +229,10 @@ const REDUNDANT_TOOLS: &[&str] = &[ "list_allowed_directories", ]; +fn ollama_tool_description(t: &ToolDef) -> String { + t.description.clone().unwrap_or_default() +} + fn build_ollama_tools(providers: &[Provider]) -> Value { let arr: Vec = providers .iter() @@ -163,7 +243,7 @@ fn build_ollama_tools(providers: &[Provider]) -> Value { "type": "function", "function": { "name": t.name, - "description": t.description.clone().unwrap_or_default(), + "description": ollama_tool_description(t), "parameters": t.input_schema, } }) @@ -171,3 +251,33 @@ fn build_ollama_tools(providers: &[Provider]) -> Value { .collect(); Value::Array(arr) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rewrite_container_and_relative() { + assert_eq!( + rewrite_file_manager_path("/app/pengine/README.md"), + "/app/pengine/README.md" + ); + assert_eq!( + rewrite_file_manager_path("/mcp/pengine/readme.md"), + "/app/pengine/readme.md" + ); + assert_eq!(rewrite_file_manager_path("/mcp"), "/tmp"); + assert_eq!( + rewrite_file_manager_path("pengine/README.md"), + "/app/pengine/README.md" + ); + assert_eq!(rewrite_file_manager_path("README.md"), "/app/README.md"); + } + + #[test] + fn normalize_paths_in_arguments() { + let raw = json!({ "path": "pengine/readme.md" }); + let out = normalize_file_manager_tool_args(raw); + assert_eq!(out["path"], "/app/pengine/readme.md"); + } +} diff --git a/src-tauri/src/modules/mcp/service.rs b/src-tauri/src/modules/mcp/service.rs index a156fe0..2edf4fe 100644 --- a/src-tauri/src/modules/mcp/service.rs +++ b/src-tauri/src/modules/mcp/service.rs @@ -9,7 +9,6 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; const FILESYSTEM_SERVER_KEY: &str = "filesystem"; -const FILESYSTEM_PKG: &str = "@modelcontextprotocol/server-filesystem"; /// Prefer project `mcp.json` under `src-tauri/` (or crate-root `mcp.json`) by walking up from /// [`std::env::current_exe`], so resolution does not depend on process CWD. Falls back to @@ -42,11 +41,15 @@ pub fn resolve_mcp_config_path(store_path: &Path) -> (PathBuf, &'static str) { pub fn read_config(path: &Path) -> Result { let raw = std::fs::read_to_string(path).map_err(|e| format!("read mcp.json: {e}"))?; - serde_json::from_str(&raw).map_err(|e| { + let mut cfg: McpConfig = serde_json::from_str(&raw).map_err(|e| { format!( "parse mcp.json: {e} — every server entry needs a \"type\" field (\"native\" or \"stdio\")" ) - }) + })?; + if migrate_legacy_npx_filesystem(&mut cfg) { + save_config(path, &cfg)?; + } + Ok(cfg) } pub fn save_config(path: &Path, cfg: &McpConfig) -> Result<(), String> { @@ -58,27 +61,55 @@ pub fn save_config(path: &Path, cfg: &McpConfig) -> Result<(), String> { std::fs::write(path, pretty).map_err(|e| format!("write mcp.json: {e}")) } -/// All allowed folders for the official MCP filesystem stdio server (paths after the package arg). +/// Host paths shared with the File Manager container (and previously the legacy npx filesystem MCP). pub fn filesystem_allowed_paths(cfg: &McpConfig) -> Vec { + if !cfg.workspace_roots.is_empty() { + return cfg.workspace_roots.clone(); + } let Some(ServerEntry::Stdio { args, .. }) = cfg.servers.get(FILESYSTEM_SERVER_KEY) else { return Vec::new(); }; let Some(pkg_idx) = args.iter().position(|a| a.contains("server-filesystem")) else { return Vec::new(); }; - args[pkg_idx + 1..].to_vec() + args[pkg_idx + 1..] + .iter() + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect() } pub fn set_filesystem_allowed_paths(cfg: &mut McpConfig, paths: &[String]) { - let mut args = vec!["-y".into(), FILESYSTEM_PKG.into()]; - args.extend(paths.iter().map(|p| p.trim().to_string())); - let entry = ServerEntry::Stdio { - command: "npx".into(), - args, - env: Default::default(), - direct_return: true, + cfg.workspace_roots = paths + .iter() + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect(); + cfg.servers.remove(FILESYSTEM_SERVER_KEY); +} + +/// Drop legacy `npx @modelcontextprotocol/server-filesystem` server; keep paths in `workspace_roots`. +fn migrate_legacy_npx_filesystem(cfg: &mut McpConfig) -> bool { + let Some(ServerEntry::Stdio { command, args, .. }) = cfg.servers.get(FILESYSTEM_SERVER_KEY) + else { + return false; }; - cfg.servers.insert(FILESYSTEM_SERVER_KEY.into(), entry); + if command != "npx" { + return false; + } + let Some(pkg_idx) = args.iter().position(|a| a.contains("server-filesystem")) else { + return false; + }; + let legacy: Vec = args[pkg_idx + 1..] + .iter() + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect(); + if cfg.workspace_roots.is_empty() && !legacy.is_empty() { + cfg.workspace_roots = legacy; + } + cfg.servers.remove(FILESYSTEM_SERVER_KEY); + true } fn default_config_value() -> serde_json::Value { @@ -108,64 +139,140 @@ pub fn load_or_init_config(path: &Path) -> Result { serde_json::from_value(default).map_err(|e| e.to_string()) } -/// Connect every server in order (stable `BTreeMap` keys). Returns registry + status lines. -pub async fn build_registry(cfg: &McpConfig) -> (ToolRegistry, Vec) { +/// Connect one server from config (native or stdio). Shared by tests and incremental rebuilds. +pub async fn connect_one_server( + server_key: &str, + entry: &ServerEntry, +) -> (Option, String) { + match entry { + ServerEntry::Native { id } => match native::native_for(server_key, id) { + Ok(p) => { + let n = p.tools.len(); + let msg = format!( + "{server_key} native ({n} tool{})", + if n == 1 { "" } else { "s" } + ); + (Some(Provider::Native(Arc::new(p))), msg) + } + Err(e) => (None, format!("{server_key} native failed: {e}")), + }, + ServerEntry::Stdio { + command, + args, + env, + direct_return, + } => match McpClient::connect( + server_key.to_string(), + command.clone(), + args.clone(), + env.clone(), + *direct_return, + ) + .await + { + Ok(client) => { + let n = client.tools.len(); + let dr = if *direct_return { " direct_return" } else { "" }; + let msg = format!( + "{server_key} stdio ({n} tool{}{})", + if n == 1 { "" } else { "s" }, + dr + ); + (Some(Provider::Mcp(Arc::new(client))), msg) + } + Err(e) => (None, format!("{server_key} stdio failed: {e}")), + }, + } +} + +/// Build MCP providers only (native + stdio). Used by tests and by [`build_registry`]. +pub async fn build_mcp_providers(cfg: &McpConfig) -> (Vec, Vec) { let mut providers = Vec::new(); let mut status = Vec::new(); for (server_key, entry) in &cfg.servers { - match entry { - ServerEntry::Native { id } => match native::native_for(server_key, id) { - Ok(p) => { - let n = p.tools.len(); - providers.push(Provider::Native(Arc::new(p))); - status.push(format!( - "{server_key} native ({n} tool{})", - if n == 1 { "" } else { "s" } - )); - } - Err(e) => status.push(format!("{server_key} native failed: {e}")), - }, - ServerEntry::Stdio { - command, - args, - env, - direct_return, - } => match McpClient::connect( - server_key.clone(), - command.clone(), - args.clone(), - env.clone(), - *direct_return, - ) - .await - { - Ok(client) => { - let n = client.tools.len(); - let dr = if *direct_return { " direct_return" } else { "" }; - providers.push(Provider::Mcp(Arc::new(client))); - status.push(format!( - "{server_key} stdio ({n} tool{}{dr})", - if n == 1 { "" } else { "s" } - )); - } - Err(e) => status.push(format!("{server_key} stdio failed: {e}")), - }, + let (prov, line) = connect_one_server(server_key, entry).await; + status.push(line); + if let Some(p) = prov { + providers.push(p); } } + (providers, status) +} + +/// Build full registry from MCP config (native + stdio providers). +pub async fn build_registry(cfg: &McpConfig) -> (ToolRegistry, Vec) { + let (providers, status) = build_mcp_providers(cfg).await; (ToolRegistry::new(providers), status) } -/// Replace in-memory tools after a config change (writes should use [`save_config`] first). -pub async fn rebuild_registry_into_state(state: &crate::shared::state::AppState, cfg: &McpConfig) { - *state.cached_filesystem_paths.write().await = filesystem_allowed_paths(cfg); - let (registry, status) = build_registry(cfg).await; - for line in status { +/// Reload `mcp.json` from disk and replace the in-memory tool registry. +/// +/// Call only after the file on disk is up to date. Holds `mcp_rebuild_mutex` for the full connect +/// phase; uses `mcp_config_mutex` only while reading the file so HTTP config reads are not blocked +/// by slow stdio servers (Podman, npx, …). +/// +/// Before connecting, refreshes every installed Tool Engine entry with `mount_workspace` so `podman run` +/// argv matches `workspace_roots` (empty → placeholder root `/tmp` in the image). Saves `mcp.json` when +/// sync succeeds. +pub async fn rebuild_registry_into_state(state: &crate::shared::state::AppState) { + let _rebuild = state.mcp_rebuild_mutex.lock().await; + let cfg = { + let _cfg_guard = state.mcp_config_mutex.lock().await; + let mut cfg = match load_or_init_config(&state.mcp_config_path) { + Ok(c) => c, + Err(e) => { + drop(_cfg_guard); + state.emit_log("mcp", &format!("mcp.json error: {e}")).await; + return; + } + }; + + let paths = filesystem_allowed_paths(&cfg); + if let Some(rt) = crate::modules::tool_engine::runtime::detect_runtime().await { + match crate::modules::tool_engine::service::sync_workspace_mounted_tools_if_installed( + &mut cfg, &paths, &rt, + ) { + Ok(changed) => { + if changed { + if let Err(e) = save_config(&state.mcp_config_path, &cfg) { + state + .emit_log( + "mcp", + &format!("mcp.json not saved after workspace sync: {e}"), + ) + .await; + } + } + } + Err(e) => { + state + .emit_log("toolengine", &format!("workspace mount sync skipped: {e}")) + .await; + } + } + } + + cfg + }; + + *state.cached_filesystem_paths.write().await = filesystem_allowed_paths(&cfg); + + // Publish the registry after each server so native tools (e.g. dice) are usable while slow + // stdio servers (e.g. Podman-backed Tool Engine) are still connecting. + let mut providers = Vec::new(); + for (server_key, entry) in &cfg.servers { + let (prov, line) = connect_one_server(server_key, entry).await; state.emit_log("mcp", &line).await; + if let Some(p) = prov { + providers.push(p); + } + let registry = ToolRegistry::new(providers.clone()); + *state.mcp.write().await = registry; } - let n = registry.tool_names().len(); - *state.mcp.write().await = registry; + + let n = state.mcp.read().await.tool_names().len(); state .emit_log( "mcp", @@ -173,3 +280,46 @@ pub async fn rebuild_registry_into_state(state: &crate::shared::state::AppState, ) .await; } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn temp_json(name: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + let n = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + p.push(format!("pengine-mcp-svc-{name}-{n}.json")); + p + } + + #[test] + fn read_config_migrates_npx_filesystem_to_workspace_roots() { + let path = temp_json("migrate"); + std::fs::write( + &path, + r#"{"servers":{"filesystem":{"type":"stdio","command":"npx","args":["-y","@modelcontextprotocol/server-filesystem","/host/proj"],"env":{},"direct_return":true},"dice":{"type":"native","id":"dice"}}}"#, + ) + .unwrap(); + let cfg = read_config(&path).expect("read"); + assert_eq!(cfg.workspace_roots, vec!["/host/proj"]); + assert!(!cfg.servers.contains_key("filesystem")); + let round = read_config(&path).expect("read again"); + assert_eq!(round.workspace_roots, vec!["/host/proj"]); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn set_filesystem_paths_writes_workspace_roots_not_npx_server() { + let mut cfg: McpConfig = serde_json::from_value(serde_json::json!({ + "servers": { "dice": { "type": "native", "id": "dice" } } + })) + .unwrap(); + set_filesystem_allowed_paths(&mut cfg, &["/a".into(), "/b".into()]); + assert_eq!(cfg.workspace_roots, vec!["/a", "/b"]); + assert!(!cfg.servers.contains_key("filesystem")); + } +} diff --git a/src-tauri/src/modules/mcp/transport.rs b/src-tauri/src/modules/mcp/transport.rs index 2df1f7b..da829d2 100644 --- a/src-tauri/src/modules/mcp/transport.rs +++ b/src-tauri/src/modules/mcp/transport.rs @@ -1,11 +1,10 @@ -//! Line-delimited JSON over stdin/stdout of a child process. - use super::protocol::{JsonRpcRequest, JsonRpcResponse}; use serde_json::Value; use std::collections::HashMap; use std::process::Stdio; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; +use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, ChildStdin, Command}; use tokio::sync::{oneshot, Mutex}; @@ -93,7 +92,22 @@ impl StdioTransport { }) } + /// Default for ongoing `tools/call` traffic (container cold start is already paid at connect). + pub fn default_call_timeout() -> Duration { + Duration::from_secs(120) + } + pub async fn call(&self, method: &str, params: Option) -> Result { + self.call_with_timeout(method, params, Self::default_call_timeout()) + .await + } + + pub async fn call_with_timeout( + &self, + method: &str, + params: Option, + timeout: Duration, + ) -> Result { let id = self.next_id.fetch_add(1, Ordering::Relaxed); let req = JsonRpcRequest::new(id, method, params); let mut payload = serde_json::to_vec(&req).map_err(|e| format!("encode request: {e}"))?; @@ -111,10 +125,11 @@ impl StdioTransport { stdin.flush().await.map_err(|e| format!("flush: {e}"))?; } - let resp = match tokio::time::timeout(std::time::Duration::from_secs(30), rx).await { + let secs = timeout.as_secs().max(1); + let resp = match tokio::time::timeout(timeout, rx).await { Err(_) => { self.pending.lock().await.remove(&id); - return Err("mcp call timed out".to_string()); + return Err(format!("mcp call `{method}` timed out after {secs}s",)); } Ok(rx_result) => match rx_result { Err(_) => { diff --git a/src-tauri/src/modules/mcp/types.rs b/src-tauri/src/modules/mcp/types.rs index 66a96f8..819172c 100644 --- a/src-tauri/src/modules/mcp/types.rs +++ b/src-tauri/src/modules/mcp/types.rs @@ -4,6 +4,10 @@ use std::collections::{BTreeMap, HashMap}; /// Root config: `src-tauri/mcp.json` in dev or `mcp.json` next to app data (`connection.json`). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct McpConfig { + /// Host folders shared with the File Manager container (`/app/`). Replaces legacy + /// `npx @modelcontextprotocol/server-filesystem` entries under `servers.filesystem`. + #[serde(default)] + pub workspace_roots: Vec, #[serde(default)] pub servers: BTreeMap, } diff --git a/src-tauri/src/modules/mod.rs b/src-tauri/src/modules/mod.rs index 4a4ea12..0493163 100644 --- a/src-tauri/src/modules/mod.rs +++ b/src-tauri/src/modules/mod.rs @@ -1,3 +1,4 @@ pub mod bot; pub mod mcp; pub mod ollama; +pub mod tool_engine; diff --git a/src-tauri/src/modules/tool_engine/container/file-manager/Dockerfile b/src-tauri/src/modules/tool_engine/container/file-manager/Dockerfile new file mode 100644 index 0000000..2c7e244 --- /dev/null +++ b/src-tauri/src/modules/tool_engine/container/file-manager/Dockerfile @@ -0,0 +1,8 @@ +FROM node:22-alpine +WORKDIR /mcp +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev \ + && npm cache clean --force \ + && rm -rf /root/.npm +ENV NODE_ENV=production +ENTRYPOINT ["node", "/mcp/node_modules/@modelcontextprotocol/server-filesystem/dist/index.js"] diff --git a/src-tauri/src/modules/tool_engine/container/file-manager/build b/src-tauri/src/modules/tool_engine/container/file-manager/build new file mode 100755 index 0000000..326015f --- /dev/null +++ b/src-tauri/src/modules/tool_engine/container/file-manager/build @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +set -e +cd "$(dirname "$0")" +if command -v podman >/dev/null 2>&1; then + exec podman build -t file-manager:0.1.0 . +fi +exec docker build -t file-manager:0.1.0 . diff --git a/src-tauri/src/modules/tool_engine/container/file-manager/package-lock.json b/src-tauri/src/modules/tool_engine/container/file-manager/package-lock.json new file mode 100644 index 0000000..e098034 --- /dev/null +++ b/src-tauri/src/modules/tool_engine/container/file-manager/package-lock.json @@ -0,0 +1,1608 @@ +{ + "name": "pengine-file-manager-image", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pengine-file-manager-image", + "dependencies": { + "@modelcontextprotocol/server-filesystem": "2026.1.14" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.13", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", + "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/server-filesystem": { + "version": "2026.1.14", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-filesystem/-/server-filesystem-2026.1.14.tgz", + "integrity": "sha512-bGAfu3fWRVeF10NxvPhFBDlRen6ExSx6YkKJzoVgQMNrbdVVV4okfGGQ3KBRu9ygXYfw5/N9ermHAJXA0uys+g==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.2", + "diff": "^5.1.0", + "glob": "^10.5.0", + "minimatch": "^10.0.1", + "zod-to-json-schema": "^3.23.5" + }, + "bin": { + "mcp-server-filesystem": "dist/index.js" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/diff": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.12", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", + "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/src-tauri/src/modules/tool_engine/container/file-manager/package.json b/src-tauri/src/modules/tool_engine/container/file-manager/package.json new file mode 100644 index 0000000..a468524 --- /dev/null +++ b/src-tauri/src/modules/tool_engine/container/file-manager/package.json @@ -0,0 +1,7 @@ +{ + "name": "pengine-file-manager-image", + "private": true, + "dependencies": { + "@modelcontextprotocol/server-filesystem": "2026.1.14" + } +} diff --git a/src-tauri/src/modules/tool_engine/mod.rs b/src-tauri/src/modules/tool_engine/mod.rs new file mode 100644 index 0000000..7a46c22 --- /dev/null +++ b/src-tauri/src/modules/tool_engine/mod.rs @@ -0,0 +1,3 @@ +pub mod runtime; +pub mod service; +pub mod types; diff --git a/src-tauri/src/modules/tool_engine/runtime.rs b/src-tauri/src/modules/tool_engine/runtime.rs new file mode 100644 index 0000000..c1bc1ff --- /dev/null +++ b/src-tauri/src/modules/tool_engine/runtime.rs @@ -0,0 +1,122 @@ +use super::types::RuntimeKind; +use serde::Serialize; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize)] +pub struct RuntimeInfo { + pub kind: RuntimeKind, + pub binary: String, + pub version: String, + pub rootless: bool, +} + +/// Detect a container runtime. Prefers Podman (rootless by default), falls back to Docker. +/// +/// GUI apps on macOS often inherit a minimal `PATH` (no Homebrew), so we probe well-known +/// install locations in addition to the bare executable name. +pub async fn detect_runtime() -> Option { + if let Some(info) = try_runtime("podman", RuntimeKind::Podman).await { + return Some(info); + } + try_runtime("docker", RuntimeKind::Docker).await +} + +fn push_candidate(out: &mut Vec, p: PathBuf) { + if p.as_os_str().is_empty() { + return; + } + if !out.iter().any(|x| x == &p) { + out.push(p); + } +} + +/// Ordered list of paths to try for `podman` / `docker`. +fn runtime_binary_candidates(name: &str) -> Vec { + let mut out = Vec::new(); + push_candidate(&mut out, PathBuf::from(name)); + + if let Ok(path_var) = std::env::var("PATH") { + let sep = if cfg!(windows) { ';' } else { ':' }; + for dir in path_var.split(sep) { + if dir.is_empty() { + continue; + } + push_candidate(&mut out, Path::new(dir).join(name)); + } + } + + #[cfg(target_os = "macos")] + { + push_candidate(&mut out, PathBuf::from(format!("/opt/homebrew/bin/{name}"))); + push_candidate(&mut out, PathBuf::from(format!("/usr/local/bin/{name}"))); + push_candidate(&mut out, PathBuf::from(format!("/opt/podman/bin/{name}"))); + } + + #[cfg(target_os = "linux")] + { + push_candidate(&mut out, PathBuf::from(format!("/usr/bin/{name}"))); + push_candidate(&mut out, PathBuf::from(format!("/bin/{name}"))); + } + + if let Ok(home) = std::env::var("HOME") { + push_candidate(&mut out, Path::new(&home).join(".local/bin").join(name)); + } + + out +} + +async fn try_runtime(binary_name: &str, kind: RuntimeKind) -> Option { + for path in runtime_binary_candidates(binary_name) { + if let Some(info) = try_runtime_at(&path, kind).await { + return Some(info); + } + } + None +} + +async fn try_runtime_at(path: &Path, kind: RuntimeKind) -> Option { + let output = tokio::process::Command::new(path) + .args(["version", "--format", "{{.Client.Version}}"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .output() + .await + .ok()?; + + if !output.status.success() { + return None; + } + + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if version.is_empty() { + return None; + } + + let binary = path.to_string_lossy().into_owned(); + + let rootless = match kind { + RuntimeKind::Podman => true, + RuntimeKind::Docker => check_docker_rootless(path).await, + }; + + Some(RuntimeInfo { + kind, + binary, + version, + rootless, + }) +} + +async fn check_docker_rootless(binary: &Path) -> bool { + let output = tokio::process::Command::new(binary) + .args(["info", "--format", "{{.SecurityOptions}}"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .output() + .await + .ok(); + + output + .map(|o| String::from_utf8_lossy(&o.stdout).contains("rootless")) + .unwrap_or(false) +} diff --git a/src-tauri/src/modules/tool_engine/service.rs b/src-tauri/src/modules/tool_engine/service.rs new file mode 100644 index 0000000..ee7df34 --- /dev/null +++ b/src-tauri/src/modules/tool_engine/service.rs @@ -0,0 +1,456 @@ +use super::runtime::RuntimeInfo; +use super::types::{ToolCatalog, ToolEntry}; +use crate::modules::mcp::service as mcp_service; +use crate::modules::mcp::types::{McpConfig, ServerEntry}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +const EMBEDDED_CATALOG: &str = include_str!("tools.json"); + +/// Server key prefix for tool-engine entries in `mcp.json`. +const TE_PREFIX: &str = "te_"; + +/// Sole MCP root when no shared folders are set yet (standard path in Linux images; no extra image dirs). +pub const EMPTY_WORKSPACE_CONTAINER_ROOT: &str = "/tmp"; + +pub fn load_catalog() -> Result { + serde_json::from_str(EMBEDDED_CATALOG).map_err(|e| format!("parse embedded tools.json: {e}")) +} + +/// Derive the `mcp.json` server key for a tool ID (e.g. `pengine/file-manager` -> `te_pengine-file-manager`). +fn server_key(tool_id: &str) -> String { + format!("{TE_PREFIX}{}", tool_id.replace('/', "-")) +} + +fn sanitize_mount_label(name: &str) -> String { + let s: String = name + .chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect(); + if s.is_empty() || s.chars().all(|c| c == '_') { + "folder".into() + } else { + s + } +} + +/// Each host folder → `/app/` (basename from the path; duplicates become `name_1`, `name_2`, …). +/// Same order as the MCP allow-list. Used for bind mounts and MCP root argv. +pub fn workspace_app_bind_pairs(host_paths: &[String]) -> Vec<(String, String)> { + let mut seen: HashSet = HashSet::new(); + let mut out = Vec::with_capacity(host_paths.len()); + for h in host_paths { + let base = Path::new(h.trim()) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("folder"); + let label = sanitize_mount_label(base); + let mut key = label.clone(); + let mut n = 0u32; + while seen.contains(&key) { + n += 1; + key = format!("{label}_{n}"); + } + seen.insert(key.clone()); + out.push((h.clone(), format!("/app/{key}"))); + } + out +} + +/// `-v=host:container:ro|rw` fragments only (no `run`, `--rm`, etc.). +pub fn workspace_volume_bind_args(host_paths: &[String], mount_read_only: bool) -> Vec { + let suffix = if mount_read_only { "ro" } else { "rw" }; + workspace_app_bind_pairs(host_paths) + .into_iter() + .map(|(host, cpath)| format!("-v={host}:{cpath}:{suffix}")) + .collect() +} + +/// Container roots passed to `server-filesystem` (same order as the allow-list). +pub fn workspace_container_roots(host_paths: &[String]) -> Vec { + workspace_app_bind_pairs(host_paths) + .into_iter() + .map(|(_, cpath)| cpath) + .collect() +} + +/// Full `podman|docker run …` argv (excluding the runtime binary) for a catalog tool entry. +pub fn podman_run_argv_for_tool( + entry: &ToolEntry, + host_paths: &[String], +) -> Result, String> { + if entry.append_workspace_roots && !entry.mount_workspace { + return Err("catalog: append_workspace_roots requires mount_workspace".into()); + } + + let mut args: Vec = vec![ + "run".into(), + "--rm".into(), + "-i".into(), + "--network=none".into(), + format!("--cpus={}", entry.limits.cpus), + format!("--memory={}", entry.limits.memory), + ]; + + if entry.container_read_only_rootfs { + args.push("--read-only".into()); + } + + if entry.mount_workspace && !host_paths.is_empty() { + args.extend(workspace_volume_bind_args( + host_paths, + entry.mount_read_only, + )); + } + + args.push(entry.image.clone()); + args.extend(entry.mcp_server_cmd.iter().cloned()); + + if entry.append_workspace_roots { + if host_paths.is_empty() { + args.push(EMPTY_WORKSPACE_CONTAINER_ROOT.to_string()); + } else { + args.extend(workspace_container_roots(host_paths)); + } + } + + Ok(args) +} + +/// Backwards-compatible name for the file-manager layout (uses catalog flags on the entry). +#[inline] +pub fn podman_args_for_file_manager( + entry: &ToolEntry, + host_paths: &[String], +) -> Result, String> { + podman_run_argv_for_tool(entry, host_paths) +} + +/// Check whether a tool is installed by looking for its server key in `mcp.json`. +pub fn is_installed(tool_id: &str, mcp_config_path: &Path) -> bool { + let key = server_key(tool_id); + mcp_config_path + .exists() + .then(|| mcp_service::read_config(mcp_config_path).ok()) + .flatten() + .map(|cfg| cfg.servers.contains_key(&key)) + .unwrap_or(false) +} + +/// Return all tool IDs that are currently installed (have a `te_` entry in `mcp.json`). +async fn image_present(runtime: &RuntimeInfo, image: &str) -> bool { + tokio::process::Command::new(&runtime.binary) + .args(["image", "inspect", image]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Resolve Dockerfile directory: env override, else path relative to the `src-tauri` crate. +fn resolve_build_context_dir(rel: &str) -> PathBuf { + if let Ok(p) = std::env::var("PENGINE_FILE_MANAGER_BUILD_CTX") { + PathBuf::from(p) + } else { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(rel) + } +} + +/// Pull from registry, or use a local image, or build from `build_context` when configured. +async fn ensure_tool_image(runtime: &RuntimeInfo, entry: &ToolEntry) -> Result<(), String> { + if image_present(runtime, &entry.image).await { + return Ok(()); + } + + let pull_output = tokio::process::Command::new(&runtime.binary) + .args(["pull", &entry.image]) + .output() + .await + .map_err(|e| format!("failed to pull image: {e}"))?; + + if pull_output.status.success() { + return Ok(()); + } + + if image_present(runtime, &entry.image).await { + return Ok(()); + } + + let Some(rel) = entry + .build_context + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + else { + let stderr = String::from_utf8_lossy(&pull_output.stderr); + return Err(format!( + "image `{}` not available — {}. Install from a Pengine source tree (auto-build), or run ./build in src-tauri/src/modules/tool_engine/container/file-manager/, or publish the image to a registry.", + entry.image, + stderr.trim() + )); + }; + + let ctx = resolve_build_context_dir(rel); + if !ctx.join("Dockerfile").is_file() { + let stderr = String::from_utf8_lossy(&pull_output.stderr); + return Err(format!( + "image `{}` missing and no Dockerfile at {} (pull: {}). Set PENGINE_FILE_MANAGER_BUILD_CTX or build the image manually.", + entry.image, + ctx.display(), + stderr.trim() + )); + } + + let build_fut = tokio::process::Command::new(&runtime.binary) + .current_dir(&ctx) + .arg("build") + .arg("-t") + .arg(&entry.image) + .arg("-f") + .arg("Dockerfile") + .arg(".") + .output(); + + let build_output = tokio::time::timeout(Duration::from_secs(900), build_fut) + .await + .map_err(|_| "container image build timed out after 15 minutes".to_string())? + .map_err(|e| format!("container build failed to start: {e}"))?; + + if !build_output.status.success() { + let mut msg = String::from_utf8_lossy(&build_output.stderr).to_string(); + if msg.trim().is_empty() { + msg = String::from_utf8_lossy(&build_output.stdout).to_string(); + } + const MAX: usize = 6000; + let tail = if msg.len() > MAX { + format!("…{}", &msg[msg.len() - MAX..]) + } else { + msg + }; + return Err(format!( + "building `{}` failed: {}", + entry.image, + tail.trim() + )); + } + + if !image_present(runtime, &entry.image).await { + return Err(format!( + "build finished but `{}` is not visible to `{}`", + entry.image, runtime.binary + )); + } + + Ok(()) +} + +pub fn installed_tool_ids(mcp_config_path: &Path) -> Vec { + let cfg = match mcp_config_path + .exists() + .then(|| mcp_service::read_config(mcp_config_path).ok()) + .flatten() + { + Some(c) => c, + None => return Vec::new(), + }; + + cfg.servers + .keys() + .filter_map(|k| k.strip_prefix(TE_PREFIX)) + .map(|s| s.replacen('-', "/", 1)) + .collect() +} + +/// Pull a whitelisted container image and register it as an MCP stdio server in `mcp.json`. +pub async fn install_tool( + tool_id: &str, + runtime: &RuntimeInfo, + mcp_config_path: &Path, + host_paths: &[String], +) -> Result<(), String> { + let catalog = load_catalog()?; + let entry = catalog + .tools + .iter() + .find(|t| t.id == tool_id) + .ok_or_else(|| format!("tool '{tool_id}' not in catalog (whitelist)"))?; + + ensure_tool_image(runtime, entry).await?; + + // Verify digest (skip if catalog entry has no pinned digest). + if !entry.digest.is_empty() { + let inspect_output = tokio::process::Command::new(&runtime.binary) + .args(["image", "inspect", "--format", "{{.Digest}}", &entry.image]) + .output() + .await + .map_err(|e| format!("failed to inspect image: {e}"))?; + + if inspect_output.status.success() { + let actual = String::from_utf8_lossy(&inspect_output.stdout) + .trim() + .to_string(); + if !actual.is_empty() && actual != entry.digest { + let _ = tokio::process::Command::new(&runtime.binary) + .args(["rmi", &entry.image]) + .output() + .await; + return Err(format!( + "digest mismatch: expected {}, got {actual}", + entry.digest + )); + } + } + } + + let args = podman_run_argv_for_tool(entry, host_paths)?; + + let server_entry = ServerEntry::Stdio { + command: runtime.binary.clone(), + args, + env: HashMap::new(), + direct_return: entry.direct_return, + }; + + // Write to mcp.json. + let mut cfg = mcp_service::load_or_init_config(mcp_config_path)?; + cfg.servers.insert(server_key(tool_id), server_entry); + mcp_service::save_config(mcp_config_path, &cfg)?; + + Ok(()) +} + +/// Rewrite every **installed** catalog tool that uses `mount_workspace` so argv matches `host_paths` +/// (empty list → in-image stub root only). Returns whether `mcp.json` should be saved. +pub fn sync_workspace_mounted_tools_if_installed( + cfg: &mut McpConfig, + host_paths: &[String], + runtime: &RuntimeInfo, +) -> Result { + let catalog = load_catalog()?; + let mut changed = false; + for entry in catalog.tools.iter().filter(|t| t.mount_workspace) { + let key = server_key(&entry.id); + if !cfg.servers.contains_key(&key) { + continue; + } + + let args = podman_run_argv_for_tool(entry, host_paths)?; + let same = matches!( + cfg.servers.get(&key), + Some(ServerEntry::Stdio { + args: cur, + command: cmd, + .. + }) if cur == &args && cmd == &runtime.binary + ); + if same { + continue; + } + + let (direct_return, env) = match cfg.servers.get(&key) { + Some(ServerEntry::Stdio { + direct_return, env, .. + }) => (*direct_return, env.clone()), + _ => (entry.direct_return, HashMap::new()), + }; + + cfg.servers.insert( + key, + ServerEntry::Stdio { + command: runtime.binary.clone(), + args, + env, + direct_return, + }, + ); + changed = true; + } + Ok(changed) +} + +/// Remove an MCP stdio server entry from `mcp.json` and remove the container image. +pub async fn uninstall_tool( + tool_id: &str, + runtime: &RuntimeInfo, + mcp_config_path: &Path, +) -> Result<(), String> { + // Remove from mcp.json. + let key = server_key(tool_id); + if mcp_config_path.exists() { + let mut cfg = mcp_service::read_config(mcp_config_path)?; + cfg.servers.remove(&key); + mcp_service::save_config(mcp_config_path, &cfg)?; + } + + // Remove the container image. + let catalog = load_catalog()?; + if let Some(entry) = catalog.tools.iter().find(|t| t.id == tool_id) { + let _ = tokio::process::Command::new(&runtime.binary) + .args(["rmi", &entry.image]) + .output() + .await; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspace_app_layout() { + let hosts = vec!["/Users/x/pengine".into(), "/opt/other".into()]; + assert_eq!( + workspace_app_bind_pairs(&hosts), + vec![ + ("/Users/x/pengine".into(), "/app/pengine".into()), + ("/opt/other".into(), "/app/other".into()), + ] + ); + let binds = workspace_volume_bind_args(&hosts, true); + assert_eq!(binds[0], "-v=/Users/x/pengine:/app/pengine:ro"); + assert_eq!(binds[1], "-v=/opt/other:/app/other:ro"); + assert_eq!( + workspace_container_roots(&hosts), + vec!["/app/pengine".to_string(), "/app/other".to_string()] + ); + } + + #[test] + fn duplicate_basenames_get_suffix() { + let hosts = vec!["/a/foo".into(), "/b/foo".into()]; + let pairs = workspace_app_bind_pairs(&hosts); + assert_eq!(pairs[0].1, "/app/foo"); + assert_eq!(pairs[1].1, "/app/foo_1"); + } + + #[test] + fn podman_argv_empty_paths_uses_tmp_root() { + let catalog = load_catalog().unwrap(); + let entry = catalog + .tools + .iter() + .find(|t| t.id == "pengine/file-manager") + .unwrap(); + let argv = podman_run_argv_for_tool(entry, &[]).unwrap(); + assert!( + !argv.iter().any(|a| a.starts_with("-v=")), + "no bind mounts until folders are set" + ); + assert_eq!( + argv.last().map(String::as_str), + Some(EMPTY_WORKSPACE_CONTAINER_ROOT) + ); + } +} diff --git a/src-tauri/src/modules/tool_engine/tools.json b/src-tauri/src/modules/tool_engine/tools.json new file mode 100644 index 0000000..de4602d --- /dev/null +++ b/src-tauri/src/modules/tool_engine/tools.json @@ -0,0 +1,79 @@ +{ + "version": 1, + "tools": [ + { + "id": "pengine/file-manager", + "name": "File Manager", + "version": "0.1.0", + "description": "Filesystem MCP in a container. Add folders in MCP Tools; each mounts at /app/. Install works before any folder is set.", + "image": "file-manager:0.1.0", + "digest": "", + "build_context": "src/modules/tool_engine/container/file-manager", + "mcp_server_cmd": [], + "container_read_only_rootfs": false, + "mount_read_only": false, + "mount_workspace": true, + "append_workspace_roots": true, + "commands": [ + { + "name": "read_text_file", + "description": "Read a file as UTF-8 text; optional head/tail line limits" + }, + { + "name": "read_media_file", + "description": "Read image or audio as base64 with MIME type" + }, + { + "name": "read_multiple_files", + "description": "Read several files in one call" + }, + { + "name": "write_file", + "description": "Create or overwrite a file" + }, + { + "name": "edit_file", + "description": "Pattern-based selective edits with optional dry run" + }, + { + "name": "create_directory", + "description": "Create a directory (and parents)" + }, + { + "name": "list_directory", + "description": "List entries with [FILE]/[DIR] prefixes" + }, + { + "name": "list_directory_with_sizes", + "description": "List directory with sizes and optional sort" + }, + { + "name": "move_file", + "description": "Move or rename a file or directory" + }, + { + "name": "search_files", + "description": "Recursive glob search under a path" + }, + { + "name": "directory_tree", + "description": "Recursive JSON tree of directory contents" + }, + { + "name": "get_file_info", + "description": "Metadata: size, times, type, permissions" + }, + { + "name": "list_allowed_directories", + "description": "List MCP roots currently allowed" + } + ], + "limits": { + "cpus": "0.5", + "memory": "256m", + "timeout_secs": 30 + }, + "direct_return": true + } + ] +} diff --git a/src-tauri/src/modules/tool_engine/types.rs b/src-tauri/src/modules/tool_engine/types.rs new file mode 100644 index 0000000..d0d68a8 --- /dev/null +++ b/src-tauri/src/modules/tool_engine/types.rs @@ -0,0 +1,103 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeKind { + Podman, + Docker, +} + +fn default_true() -> bool { + true +} + +/// Catalog command line shown in the Tool Engine UI (mirrors the MCP server’s tool list). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CatalogCommand { + pub name: String, + pub description: String, +} + +/// One entry in the tool catalog (`tools.json`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolEntry { + /// Unique tool identifier, e.g. "pengine/file-manager". + pub id: String, + pub name: String, + pub version: String, + pub description: String, + /// Full OCI image reference, e.g. "file-manager:0.1.0". + pub image: String, + /// Expected image digest for verification after pull (empty = skip). + #[serde(default)] + pub digest: String, + /// Relative to the `src-tauri` crate root: if pull fails and the image is missing, run + /// `podman|docker build -t -f Dockerfile .` in this directory (first install from dev tree). + #[serde(default)] + pub build_context: Option, + /// Extra argv after the image (before auto-appended root paths). Often empty when the image ENTRYPOINT runs MCP. + #[serde(default)] + pub mcp_server_cmd: Vec, + /// When true, add `--read-only` to the container run (rootfs). + #[serde(default = "default_true")] + pub container_read_only_rootfs: bool, + /// When true, use `:ro` on volume binds. + #[serde(default = "default_true")] + pub mount_read_only: bool, + /// When true, `podman|docker run` bind-mounts each allow-list folder under `/app/`. + #[serde(default)] + pub mount_workspace: bool, + /// When true, append allowed container roots after `image` + `mcp_server_cmd` (for MCP servers + /// like `@modelcontextprotocol/server-filesystem` that take roots as argv). Requires `mount_workspace`. + #[serde(default)] + pub append_workspace_roots: bool, + /// Tool names for the dashboard (same surface as `@modelcontextprotocol/server-filesystem`). + #[serde(default)] + pub commands: Vec, + /// Resource limits applied to the container. + #[serde(default)] + pub limits: ResourceLimits, + /// When true, tool results go directly to the user without model summarisation. + #[serde(default)] + pub direct_return: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourceLimits { + /// CPU quota, e.g. "0.5". + #[serde(default = "default_cpus")] + pub cpus: String, + /// Memory limit, e.g. "256m". + #[serde(default = "default_memory")] + pub memory: String, + /// Kill container after this many seconds. + #[serde(default = "default_timeout")] + pub timeout_secs: u64, +} + +fn default_cpus() -> String { + "1.0".into() +} +fn default_memory() -> String { + "256m".into() +} +fn default_timeout() -> u64 { + 30 +} + +impl Default for ResourceLimits { + fn default() -> Self { + Self { + cpus: default_cpus(), + memory: default_memory(), + timeout_secs: default_timeout(), + } + } +} + +/// Root of the embedded `tools.json` catalog. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCatalog { + pub version: u32, + pub tools: Vec, +} diff --git a/src-tauri/src/shared/state.rs b/src-tauri/src/shared/state.rs index 850b605..d069e06 100644 --- a/src-tauri/src/shared/state.rs +++ b/src-tauri/src/shared/state.rs @@ -28,16 +28,16 @@ pub struct AppState { pub bot_running: Arc>, pub log_tx: Arc>>>, pub store_path: PathBuf, - /// Resolved `mcp.json` path (project `src-tauri/mcp.json` when present, else app data dir). pub mcp_config_path: PathBuf, - /// `"project"` or `"app_data"` — for dashboard copy only. pub mcp_config_source: String, pub app_handle: Arc>>, pub mcp: Arc>, pub mcp_config_mutex: Arc>, + /// Ensures only one MCP registry rebuild (stdio connects) runs at a time. + pub mcp_rebuild_mutex: Arc>, pub preferred_ollama_model: Arc>>, - /// Allowed filesystem paths from `mcp.json` (updated with MCP rebuild); avoids disk read per agent turn. pub cached_filesystem_paths: Arc>>, + pub tool_engine_mutex: Arc>, } impl AppState { @@ -54,8 +54,10 @@ impl AppState { app_handle: Arc::new(Mutex::new(None)), mcp: Arc::new(RwLock::new(ToolRegistry::default())), mcp_config_mutex: Arc::new(Mutex::new(())), + mcp_rebuild_mutex: Arc::new(Mutex::new(())), preferred_ollama_model: Arc::new(RwLock::new(None)), cached_filesystem_paths: Arc::new(RwLock::new(Vec::new())), + tool_engine_mutex: Arc::new(Mutex::new(())), } } diff --git a/src-tauri/tests/mcp_tools.rs b/src-tauri/tests/mcp_tools.rs index 9955b90..7838ad0 100644 --- a/src-tauri/tests/mcp_tools.rs +++ b/src-tauri/tests/mcp_tools.rs @@ -1,5 +1,6 @@ //! Integration tests for MCP tooling. +use pengine_lib::modules::mcp::registry::ToolRegistry; use pengine_lib::modules::mcp::{native, service}; use serde_json::json; use std::path::PathBuf; @@ -61,7 +62,8 @@ async fn mcp_json_loads_native_dice() { let cfg = service::load_or_init_config(&path).expect("load_or_init"); assert!(cfg.servers.contains_key("dice")); - let (reg, status) = service::build_registry(&cfg).await; + let (providers, status) = service::build_mcp_providers(&cfg).await; + let reg = ToolRegistry::new(providers); assert!(status .iter() .any(|s| s.contains("dice") && s.contains("native"))); @@ -73,7 +75,8 @@ async fn mcp_json_loads_native_dice() { async fn native_dice_callable_through_registry_from_config() { let path = temp_mcp_path("registry"); let cfg = service::load_or_init_config(&path).expect("load_or_init"); - let (reg, _) = service::build_registry(&cfg).await; + let (providers, _) = service::build_mcp_providers(&cfg).await; + let reg = ToolRegistry::new(providers); let (text, direct) = reg .call_tool("roll_dice", json!({"sides": 6})) .await @@ -96,7 +99,8 @@ fn native_server_key_rename_in_config() { .enable_all() .build() .unwrap(); - let (reg, _) = rt.block_on(service::build_registry(&cfg)); + let (providers, _) = rt.block_on(service::build_mcp_providers(&cfg)); + let reg = ToolRegistry::new(providers); assert_eq!(reg.all_tools()[0].server_name, "mydice"); let _ = std::fs::remove_file(path); } diff --git a/src/modules/bot/components/SetupWizard.tsx b/src/modules/bot/components/SetupWizard.tsx index 934fb17..782c771 100644 --- a/src/modules/bot/components/SetupWizard.tsx +++ b/src/modules/bot/components/SetupWizard.tsx @@ -1,10 +1,16 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { OLLAMA_API_BASE } from "../../../shared/api/config"; import { fetchOllamaModel } from "../../ollama/api"; +import { fetchRuntimeStatus, type RuntimeStatus } from "../../toolengine"; import { getPengineHealth, PENGINE, postConnect } from "../api"; import { useAppSessionStore } from "../store/appSessionStore"; -import { StyledQrCode } from "../../../shared/ui/StyledQrCode"; import { WizardLayout } from "../../../shared/ui/WizardLayout"; +import { + WizardStepConnect, + WizardStepContainerRuntime, + WizardStepCreateBot, + WizardStepOllama, + WizardStepPengineLocal, +} from "./SetupWizardSteps"; export const SETUP_STEPS = [ { @@ -17,6 +23,11 @@ export const SETUP_STEPS = [ summary: "Install Ollama on this machine so Pengine can run models locally.", duration: "~2 min", }, + { + title: "Install Podman", + summary: "Install a container runtime so Pengine can run tools in isolated sandboxes.", + duration: "~2 min", + }, { title: "Pengine local", summary: "Install and start the Pengine runtime on this computer.", @@ -58,6 +69,8 @@ export function SetupWizard({ onStepChange, onCompleteSetup }: SetupWizardProps) const [ollamaChecking, setOllamaChecking] = useState(false); const [ollamaModel, setOllamaModel] = useState(null); const [ollamaReachable, setOllamaReachable] = useState(null); + const [runtimeChecking, setRuntimeChecking] = useState(false); + const [runtimeStatus, setRuntimeStatus] = useState(null); const [pengineReachable, setPengineReachable] = useState(null); const [pengineChecking, setPengineChecking] = useState(false); const [connectStatus, setConnectStatus] = useState<"idle" | "connecting" | "connected" | "error">( @@ -84,12 +97,15 @@ export function SetupWizard({ onStepChange, onCompleteSetup }: SetupWizardProps) }, [botUsername, verifiedBot]); const canContinueStep = useMemo(() => { - if (step === 0) return status === "valid"; - if (step === 1) return !!ollamaModel; - if (step === 2) return pengineReachable === true; - if (step === 3) return connectStatus === "connected"; - return false; - }, [step, status, ollamaModel, pengineReachable, connectStatus]); + const gates: Record = { + 0: status === "valid", + 1: !!ollamaModel, + 2: runtimeStatus?.available === true, + 3: pengineReachable === true, + 4: connectStatus === "connected", + }; + return gates[step] ?? false; + }, [step, status, ollamaModel, runtimeStatus, pengineReachable, connectStatus]); const canGoNext = step < stepTitles.length - 1 && canContinueStep; @@ -116,6 +132,23 @@ export function SetupWizard({ onStepChange, onCompleteSetup }: SetupWizardProps) } }, [step, checkOllama]); + const checkRuntime = useCallback(async () => { + setRuntimeChecking(true); + setRuntimeStatus(null); + try { + const rt = await fetchRuntimeStatus(5000); + setRuntimeStatus(rt ?? { available: false }); + } finally { + setRuntimeChecking(false); + } + }, []); + + useEffect(() => { + if (step === 2) { + checkRuntime(); + } + }, [step, checkRuntime]); + const checkPengineHealth = useCallback(async () => { setPengineChecking(true); try { @@ -126,7 +159,7 @@ export function SetupWizard({ onStepChange, onCompleteSetup }: SetupWizardProps) }, []); useEffect(() => { - if (step === 2) { + if (step === 3) { checkPengineHealth(); } }, [step, checkPengineHealth]); @@ -178,313 +211,53 @@ export function SetupWizard({ onStepChange, onCompleteSetup }: SetupWizardProps) canGoNext={canGoNext} > {step === 0 && ( -
-
-
-

Step 1

-

Create your Telegram bot

-

- Open BotFather, create a new bot, then paste the token here. -

-
- - Open BotFather - -
- - setBotToken(event.target.value)} - placeholder="1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ-abc123..." - /> -
- -

{tokenStatusMessage(status)}

-
-
-
-
-

Why

-

- The token encodes your bot ID. Pengine - uses that ID to pair with your bot automatically. -

-
-
+ )} - {step === 1 && ( -
-
-

Step 2

-

Install Ollama

-

- Ollama runs AI models on your machine. Install it and pull a model before continuing. -

-
-              {`curl -fsSL https://ollama.com/install.sh | sh
-ollama pull qwen3:8b`}
-            
-

- Recommended: qwen3:8b — good balance of speed - and tool-calling support. -

- - {ollamaChecking && ( -

Detecting Ollama…

- )} - - {ollamaReachable === true && ollamaModel && ( -
-

- Ollama detected — active model: -

-

{ollamaModel}

-
- )} - - {ollamaReachable === true && !ollamaModel && ( -

- Ollama is running but no model is pulled yet. Run{" "} - ollama pull qwen3:8b first. -

- )} - - {ollamaReachable === false && ( -
-

- Could not reach Ollama at {OLLAMA_API_BASE}. Make sure it's installed and running. -

- -
- )} - - {ollamaModel && ( -

Ready to continue.

- )} -
-
-

- Ollama status -

-
    -
  • - Connection:{" "} - - {ollamaReachable - ? "reachable" - : ollamaReachable === false - ? "not reachable" - : "checking…"} - -
  • -
  • - Active model:{" "} - - {ollamaModel ?? "none detected"} - -
  • -
-
-
+ )} - {step === 2 && ( -
-
-

Step 3

-

Start Pengine locally

-

- The Pengine desktop app must be running on this machine. It hosts the bot service on - localhost so messages keep flowing even after you close this browser tab. -

-
-

- Checking {PENGINE.health}… -

-
- {pengineChecking &&

Checking…

} - {pengineReachable === true && ( -

- Pengine is running on localhost. -

- )} - {pengineReachable === false && ( -
-

- Could not reach Pengine. Start the desktop app and retry. -

- -
- )} -
-
-

- What happens next -

-

- The next step hands off your bot token to the local Pengine process. The bot will - start polling Telegram automatically. -

-
-
+ )} - {step === 3 && ( -
-
-

Step 4

-

Connect bot to Pengine

-

- Send your bot token to the local Pengine service. It will verify the token with - Telegram and start listening for messages. -

-
-

- Bot ID:{" "} - {botId ?? "— paste token in step 1"} -

-
- - {connectStatus === "idle" && ( - - )} - {connectStatus === "connecting" && ( -

- Verifying token with Telegram… -

- )} - {connectStatus === "error" && ( -
-

{connectError}

- -
- )} - {connectStatus === "connected" && verifiedBot && ( -
-

- Connected as @{verifiedBot.bot_username} (ID: {verifiedBot.bot_id}) -

-
- - setBotUsername(event.target.value)} - placeholder="@YourPengineBot" - /> -
-
- -
-

- Scan to open your bot in Telegram -

-
- )} - -
- -
-
-
-
-

- Direct link -

- - {telegramBotUrl} - -
-
-
-

{status === "valid" ? "✓" : "○"} Bot token saved

-

{ollamaModel ? "✓" : "○"} Ollama ready

-

{pengineReachable ? "✓" : "○"} Pengine running

-

{connectStatus === "connected" ? "✓" : "○"} Bot connected

-
- {connectStatus === "connected" && ( - - )} -
-
-
+ + )} + {step === 4 && ( + )} ); diff --git a/src/modules/bot/components/SetupWizardSteps.tsx b/src/modules/bot/components/SetupWizardSteps.tsx new file mode 100644 index 0000000..e256856 --- /dev/null +++ b/src/modules/bot/components/SetupWizardSteps.tsx @@ -0,0 +1,499 @@ +import { OLLAMA_API_BASE } from "../../../shared/api/config"; +import { PENGINE } from "../api"; +import { StyledQrCode } from "../../../shared/ui/StyledQrCode"; +import type { RuntimeStatus } from "../../toolengine"; + +type TokenStatus = "idle" | "valid" | "typing"; + +export function WizardStepCreateBot(props: { + botToken: string; + onBotTokenChange: (value: string) => void; + status: TokenStatus; + tokenStatusMessage: (status: TokenStatus) => string; +}) { + const { botToken, onBotTokenChange, status, tokenStatusMessage } = props; + return ( +
+
+
+

Step 1

+

Create your Telegram bot

+

+ Open BotFather, create a new bot, then paste the token here. +

+
+ + Open BotFather + +
+ + onBotTokenChange(event.target.value)} + placeholder="1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ-abc123..." + /> +
+ +

{tokenStatusMessage(status)}

+
+
+
+
+

Why

+

+ The token encodes your bot ID. Pengine uses + that ID to pair with your bot automatically. +

+
+
+ ); +} + +export function WizardStepOllama(props: { + ollamaChecking: boolean; + ollamaReachable: boolean | null; + ollamaModel: string | null; + onRetry: () => void; +}) { + const { ollamaChecking, ollamaReachable, ollamaModel, onRetry } = props; + return ( +
+
+

Step 2

+

Install Ollama

+

+ Ollama runs AI models on your machine. Install it and pull a model before continuing. +

+
+          {`curl -fsSL https://ollama.com/install.sh | sh
+ollama pull qwen3:8b`}
+        
+

+ Recommended: qwen3:8b — good balance of speed and + tool-calling support. +

+ + {ollamaChecking && ( +

Detecting Ollama…

+ )} + + {ollamaReachable === true && ollamaModel && ( +
+

Ollama detected — active model:

+

{ollamaModel}

+
+ )} + + {ollamaReachable === true && !ollamaModel && ( +

+ Ollama is running but no model is pulled yet. Run{" "} + ollama pull qwen3:8b first. +

+ )} + + {ollamaReachable === false && ( +
+

+ Could not reach Ollama at {OLLAMA_API_BASE}. Make sure it's installed and + running. +

+ +
+ )} + + {ollamaModel && ( +

Ready to continue.

+ )} +
+
+

+ Ollama status +

+
    +
  • + Connection:{" "} + + {ollamaReachable + ? "reachable" + : ollamaReachable === false + ? "not reachable" + : "checking…"} + +
  • +
  • + Active model:{" "} + + {ollamaModel ?? "none detected"} + +
  • +
+
+
+ ); +} + +export function WizardStepContainerRuntime(props: { + runtimeChecking: boolean; + runtimeStatus: RuntimeStatus | null; + onRetry: () => void; +}) { + const { runtimeChecking, runtimeStatus, onRetry } = props; + return ( +
+
+

Step 3

+

Install a container runtime

+

+ Pengine uses Podman (preferred) or Docker to run tools inside isolated, rootless + containers. Install one of them before continuing. +

+ +
+
+

+ Option A — Podman (recommended) +

+
+              {`# macOS
+brew install podman
+podman machine init
+podman machine start
+
+# Linux (Debian/Ubuntu)
+sudo apt install podman`}
+            
+
+ +
+

+ Option B — Docker +

+
+              {`# macOS / Linux
+# Install Docker Desktop from https://docker.com/get-started
+# or use the convenience script:
+curl -fsSL https://get.docker.com | sh`}
+            
+
+
+ +

+ Podman is preferred because it runs{" "} + rootless by default — no daemon, no elevated + privileges. +

+ + {runtimeChecking && ( +

Detecting container runtime…

+ )} + + {runtimeStatus?.available && ( +
+

Container runtime detected:

+

+ {runtimeStatus.kind} {runtimeStatus.version} + {runtimeStatus.rootless ? " (rootless)" : ""} +

+
+ )} + + {runtimeStatus && !runtimeStatus.available && ( +
+

+ No container runtime found. Install Podman or Docker and make sure it's running. +

+ +
+ )} + + {runtimeStatus?.available && ( +

Ready to continue.

+ )} +
+ +
+

+ Runtime status +

+
    +
  • + Engine:{" "} + + {runtimeStatus?.available + ? runtimeStatus.kind + : runtimeChecking + ? "checking…" + : "not detected"} + +
  • +
  • + Version:{" "} + + {runtimeStatus?.version ?? "—"} + +
  • +
  • + Rootless:{" "} + + {runtimeStatus?.available ? (runtimeStatus.rootless ? "yes" : "no") : "—"} + +
  • +
+ +
+

+ Why containers? +

+

+ The Tool Engine runs each tool inside an isolated container with no network access, + read-only filesystem, and strict resource limits. This keeps your system safe even when + the AI agent executes external tools. +

+
+
+
+ ); +} + +export function WizardStepPengineLocal(props: { + pengineChecking: boolean; + pengineReachable: boolean | null; + onRetry: () => void; +}) { + const { pengineChecking, pengineReachable, onRetry } = props; + return ( +
+
+

Step 4

+

Start Pengine locally

+

+ The Pengine desktop app must be running on this machine. It hosts the bot service on + localhost so messages keep flowing even after you close this browser tab. +

+
+

+ Checking {PENGINE.health}… +

+
+ {pengineChecking &&

Checking…

} + {pengineReachable === true && ( +

+ Pengine is running on localhost. +

+ )} + {pengineReachable === false && ( +
+

+ Could not reach Pengine. Start the desktop app and retry. +

+ +
+ )} +
+
+

+ What happens next +

+

+ The next step hands off your bot token to the local Pengine process. The bot will start + polling Telegram automatically. +

+
+
+ ); +} + +export function WizardStepConnect(props: { + botId: string | null; + status: TokenStatus; + ollamaModel: string | null; + runtimeStatus: RuntimeStatus | null; + pengineReachable: boolean | null; + connectStatus: "idle" | "connecting" | "connected" | "error"; + connectError: string; + verifiedBot: { bot_id: string; bot_username: string } | null; + botUsername: string; + onBotUsernameChange: (value: string) => void; + telegramBotUrl: string; + onConnect: () => void; + onCopyUri: () => void; + copiedUri: boolean; + onCompleteSetup?: () => void; +}) { + const { + botId, + status, + ollamaModel, + runtimeStatus, + pengineReachable, + connectStatus, + connectError, + verifiedBot, + botUsername, + onBotUsernameChange, + telegramBotUrl, + onConnect, + onCopyUri, + copiedUri, + onCompleteSetup, + } = props; + + return ( +
+
+

Step 5

+

Connect bot to Pengine

+

+ Send your bot token to the local Pengine service. It will verify the token with Telegram + and start listening for messages. +

+
+

+ Bot ID: {botId ?? "— paste token in step 1"} +

+
+ + {connectStatus === "idle" && ( + + )} + {connectStatus === "connecting" && ( +

Verifying token with Telegram…

+ )} + {connectStatus === "error" && ( +
+

{connectError}

+ +
+ )} + {connectStatus === "connected" && verifiedBot && ( +
+

+ Connected as @{verifiedBot.bot_username} (ID: {verifiedBot.bot_id}) +

+
+ + onBotUsernameChange(event.target.value)} + placeholder="@YourPengineBot" + /> +
+
+ +
+

+ Scan to open your bot in Telegram +

+
+ )} + +
+ +
+
+
+
+

Direct link

+ + {telegramBotUrl} + +
+
+
+

{status === "valid" ? "✓" : "○"} Bot token saved

+

{ollamaModel ? "✓" : "○"} Ollama ready

+

{runtimeStatus?.available ? "✓" : "○"} Container runtime

+

{pengineReachable ? "✓" : "○"} Pengine running

+

{connectStatus === "connected" ? "✓" : "○"} Bot connected

+
+ {connectStatus === "connected" && ( + + )} +
+
+
+ ); +} diff --git a/src/modules/mcp/components/McpServerCard.tsx b/src/modules/mcp/components/McpServerCard.tsx index c3f1813..6d6c8c5 100644 --- a/src/modules/mcp/components/McpServerCard.tsx +++ b/src/modules/mcp/components/McpServerCard.tsx @@ -1,5 +1,11 @@ -import { useState } from "react"; -import type { McpTool, ServerEntry, ServerEntryStdio } from ".."; +import { useEffect, useState } from "react"; +import { + fetchMcpConfig, + putMcpFilesystemPaths, + type McpTool, + type ServerEntry, + type ServerEntryStdio, +} from ".."; type Props = { name: string; @@ -10,6 +16,8 @@ type Props = { onSave: (name: string, entry: ServerEntry) => Promise; onDelete: (name: string) => Promise; onEditStart: (name: string | null) => void; + /** After filesystem paths apply (te_ File Manager), refresh server list from API. */ + onReloadServers?: () => Promise; }; /** Detect filesystem MCP package in live args textarea (one token per line). */ @@ -21,6 +29,27 @@ function argsTextLooksLikeFilesystem(argsText: string): boolean { .some((a) => a.includes("server-filesystem")); } +/** Mirrors server-side mounts: each folder → `/app/` (duplicates get `_1`, `_2`, …). */ +function appMountPathsForFolders(paths: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const p of paths) { + const parts = p.replace(/\\/g, "/").split("/").filter(Boolean); + const raw = parts.length > 0 ? parts[parts.length - 1]! : "folder"; + const safe = raw.replace(/[^a-zA-Z0-9_-]/g, "_"); + const label = /^_+$/.test(safe) || safe === "" ? "folder" : safe; + let key = label; + let n = 0; + while (seen.has(key)) { + n += 1; + key = `${label}_${n}`; + } + seen.add(key); + out.push(`/app/${key}`); + } + return out; +} + export function McpServerCard({ name, entry, @@ -30,6 +59,7 @@ export function McpServerCard({ onSave, onDelete, onEditStart, + onReloadServers, }: Props) { const isNative = entry.type === "native"; const isEditing = editingName === name; @@ -65,6 +95,7 @@ export function McpServerCard({ busy={busy} onSave={(updated) => onSave(name, updated)} onCancel={() => onEditStart(null)} + onReloadServers={onReloadServers} /> ); @@ -187,12 +218,14 @@ function InlineEditForm({ busy, onSave, onCancel, + onReloadServers, }: { name: string; entry: ServerEntryStdio; busy: boolean; onSave: (entry: ServerEntry) => Promise; onCancel: () => void; + onReloadServers?: () => Promise; }) { const [command, setCommand] = useState(entry.command); const [argsText, setArgsText] = useState(entry.args.join("\n")); @@ -204,6 +237,21 @@ function InlineEditForm({ const [directReturn, setDirectReturn] = useState(entry.direct_return); const [pickFolderError, setPickFolderError] = useState(null); + const isTeFileManager = name === "te_pengine-file-manager"; + const [tePaths, setTePaths] = useState([]); + const teAppMounts = isTeFileManager ? appMountPathsForFolders(tePaths) : []; + const [tePickError, setTePickError] = useState(null); + const [teApplyError, setTeApplyError] = useState(null); + const [teApplyBusy, setTeApplyBusy] = useState(false); + + useEffect(() => { + if (!isTeFileManager) return; + void (async () => { + const cfg = await fetchMcpConfig(5000); + if (cfg) setTePaths([...cfg.filesystem_allowed_paths]); + })(); + }, [isTeFileManager, name]); + const isFs = argsTextLooksLikeFilesystem(argsText); // ── Filesystem folder helpers (read/write the args textarea) ────── @@ -259,6 +307,50 @@ function InlineEditForm({ } }; + const addTePath = (p: string) => { + const t = p.trim(); + if (!t || tePaths.includes(t)) return; + setTePaths((prev) => [...prev, t]); + }; + + const removeTePath = (p: string) => { + setTePaths((prev) => prev.filter((x) => x !== p)); + }; + + const pickTeFolder = async () => { + setTePickError(null); + try { + const { invoke } = await import("@tauri-apps/api/core"); + try { + const picked = await invoke("pick_mcp_filesystem_folder"); + if (picked) addTePath(picked); + } catch (invokeErr) { + setTePickError( + invokeErr instanceof Error ? invokeErr.message : "Could not open folder picker", + ); + } + } catch { + // Web / non-Tauri + } + }; + + const applyTeFolders = async () => { + setTeApplyError(null); + if (tePaths.length === 0) { + setTeApplyError("Add at least one folder."); + return; + } + setTeApplyBusy(true); + const ok = await putMcpFilesystemPaths(tePaths, 60_000); + setTeApplyBusy(false); + if (!ok) { + setTeApplyError("Could not save — is the Pengine API running?"); + return; + } + await onReloadServers?.(); + onCancel(); + }; + // ── Submit ──────────────────────────────────────────────────────── const handleSubmit = async () => { @@ -300,7 +392,53 @@ function InlineEditForm({
- {/* Filesystem folder helper */} + {isTeFileManager && ( +
+

+ Shared folders (File Manager container mounts) +

+

+ After File Manager is installed, add paths here (or install it first from Tool Engine + with an empty list). Each folder mounts as{" "} + /app/<name>. Apply updates{" "} + workspace_roots in{" "} + mcp.json and closes the editor. +

+ {tePaths.length > 0 && ( +
    + {tePaths.map((p, i) => ( +
  • + {teAppMounts[i] ?? ""} + + {p} +
  • + ))} +
+ )} + void pickTeFolder()} + /> + {teApplyError && ( +

+ {teApplyError} +

+ )} + +
+ )} + + {/* Filesystem folder helper (npx server-filesystem) */} {isFs && ( { + const onRegistryChanged = () => { + void reload(); + }; + window.addEventListener(PENGINE_MCP_REGISTRY_CHANGED, onRegistryChanged); + return () => window.removeEventListener(PENGINE_MCP_REGISTRY_CHANGED, onRegistryChanged); + }, [reload]); + // ── Server CRUD handlers ─────────────────────────────────────────── const handleSaveServer = async (name: string, entry: ServerEntry): Promise => { @@ -227,6 +236,7 @@ export function McpToolsPanel() { }} onDelete={handleDeleteServer} onEditStart={setEditingName} + onReloadServers={reload} /> ))}
diff --git a/src/modules/mcp/index.ts b/src/modules/mcp/index.ts index 035c4b6..606f721 100644 --- a/src/modules/mcp/index.ts +++ b/src/modules/mcp/index.ts @@ -55,7 +55,7 @@ export async function fetchMcpConfig(timeoutMs = 3000): Promise { const { signal, cleanup } = makeTimeoutSignal(timeoutMs); try { diff --git a/src/modules/toolengine/components/ToolEnginePanel.tsx b/src/modules/toolengine/components/ToolEnginePanel.tsx new file mode 100644 index 0000000..7fd1d3d --- /dev/null +++ b/src/modules/toolengine/components/ToolEnginePanel.tsx @@ -0,0 +1,235 @@ +import * as Accordion from "@radix-ui/react-accordion"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { notifyMcpRegistryChanged } from "../../../shared/mcpEvents"; +import { + fetchRuntimeStatus, + fetchToolCatalog, + installTool, + uninstallTool, + type CatalogTool, + type RuntimeStatus, +} from ".."; + +export function ToolEnginePanel() { + const [runtime, setRuntime] = useState(null); + const [catalog, setCatalog] = useState(null); + const [loading, setLoading] = useState(true); + const [catalogError, setCatalogError] = useState(null); + const [actionError, setActionError] = useState(null); + const [busyTool, setBusyTool] = useState(null); + const [notice, setNotice] = useState(null); + + const cancelledRef = useRef(false); + const seqRef = useRef(0); + + const loadData = useCallback(async () => { + const id = ++seqRef.current; + const [rt, cat] = await Promise.all([fetchRuntimeStatus(), fetchToolCatalog()]); + if (cancelledRef.current || id !== seqRef.current) return; + setLoading(false); + if (rt !== null) setRuntime(rt); + if (cat !== null) { + setCatalog(cat); + setCatalogError(null); + } else { + setCatalogError("Could not load tool catalog"); + } + }, []); + + useEffect(() => { + cancelledRef.current = false; + void loadData(); + return () => { + cancelledRef.current = true; + }; + }, [loadData]); + + const handleInstall = async (toolId: string) => { + setBusyTool(toolId); + setNotice(null); + setActionError(null); + try { + const result = await installTool(toolId); + if (cancelledRef.current) return; + if (result.ok) { + setNotice(`"${toolId}" installed`); + notifyMcpRegistryChanged(); + } else { + setActionError(result.error ?? "Install failed"); + } + await loadData(); + } finally { + if (!cancelledRef.current) setBusyTool(null); + } + }; + + const handleUninstall = async (toolId: string) => { + setBusyTool(toolId); + setNotice(null); + setActionError(null); + try { + const result = await uninstallTool(toolId); + if (cancelledRef.current) return; + if (result.ok) { + setNotice(`"${toolId}" uninstalled`); + notifyMcpRegistryChanged(); + } else { + setActionError(result.error ?? "Uninstall failed"); + } + await loadData(); + } finally { + if (!cancelledRef.current) setBusyTool(null); + } + }; + + return ( +
+

Tool Engine

+ + {/* Runtime status */} +
+ +

+ {runtime?.available + ? `${runtime.kind} ${runtime.version}${runtime.rootless ? " (rootless)" : ""}` + : runtime === null && loading + ? "Detecting container runtime…" + : "No container runtime found — install Podman or Docker"} +

+
+ + {notice && ( +

+ {notice} +

+ )} + + {actionError && ( +

+ {actionError} +

+ )} + + {catalogError && ( +

+ {catalogError} +

+ )} + + {/* Catalog */} + {loading && catalog === null && ( +

+ Loading… +

+ )} + + {catalog !== null && catalog.length === 0 && ( +

No tools in catalog.

+ )} + + {catalog !== null && catalog.length > 0 && ( +
+ {catalog.map((tool) => ( +
+
+
+

+ {tool.name} +

+

+ v{tool.version} — {tool.commands.length} command + {tool.commands.length === 1 ? "" : "s"} +

+

+ {tool.description} +

+
+ + +
+ + {/* MCP tools exposed by the container image (collapsible, same pattern as MCP Tools) */} + {tool.commands.length > 0 && ( + + + + +
+

+ Docker command list +

+

+ {tool.commands.length} MCP tool + {tool.commands.length === 1 ? "" : "s"} +

+
+ + + + +
+
+ +
    + {tool.commands.map((cmd) => ( +
  • +

    {cmd.name}

    + {cmd.description ? ( +

    + {cmd.description} +

    + ) : null} +
  • + ))} +
+
+
+
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/src/modules/toolengine/index.ts b/src/modules/toolengine/index.ts new file mode 100644 index 0000000..a5e2bb2 --- /dev/null +++ b/src/modules/toolengine/index.ts @@ -0,0 +1,152 @@ +import { fetchErrorMessage, PENGINE_API_BASE } from "../../shared/api/config"; + +export type RuntimeStatus = { + available: boolean; + kind?: "podman" | "docker"; + version?: string; + rootless?: boolean; +}; + +export type CatalogToolCommand = { + name: string; + description: string; +}; + +export type CatalogTool = { + id: string; + name: string; + version: string; + description: string; + installed: boolean; + commands: CatalogToolCommand[]; +}; + +function makeTimeoutSignal(timeoutMs: number): { signal: AbortSignal; cleanup: () => void } { + if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") { + return { signal: AbortSignal.timeout(timeoutMs), cleanup: () => {} }; + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + return { + signal: controller.signal, + cleanup: () => clearTimeout(timer), + }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Tauri starts the loopback API in a spawned task; the webview may load first. Brief retries avoid a false "offline" flash. */ +async function fetchOkWithRetry( + url: string, + init: RequestInit | undefined, + timeoutMs: number, + attempts = 6, + delayMs = 250, +): Promise { + for (let i = 0; i < attempts; i++) { + const { signal, cleanup } = makeTimeoutSignal(timeoutMs); + try { + const resp = await fetch(url, { ...init, signal }); + cleanup(); + if (resp.ok) return resp; + } catch { + cleanup(); + } + if (i + 1 < attempts) await sleep(delayMs); + } + return null; +} + +/** GET `/v1/toolengine/runtime` — container runtime detection status. */ +export async function fetchRuntimeStatus(timeoutMs = 3000): Promise { + const resp = await fetchOkWithRetry( + `${PENGINE_API_BASE}/v1/toolengine/runtime`, + undefined, + timeoutMs, + ); + if (!resp) return null; + try { + return (await resp.json()) as RuntimeStatus; + } catch { + return null; + } +} + +/** GET `/v1/toolengine/catalog` — full tool catalog with installed flags. */ +export async function fetchToolCatalog(timeoutMs = 5000): Promise { + const resp = await fetchOkWithRetry( + `${PENGINE_API_BASE}/v1/toolengine/catalog`, + undefined, + timeoutMs, + ); + if (!resp) return null; + try { + const body = (await resp.json()) as { tools: CatalogTool[] }; + return body.tools; + } catch { + return null; + } +} + +/** POST `/v1/toolengine/install` — pull + verify a whitelisted container image. */ +export async function installTool( + toolId: string, + /** Large image pulls on slow links can exceed a few minutes. */ + timeoutMs = 900_000, +): Promise<{ ok: boolean; error?: string }> { + const { signal, cleanup } = makeTimeoutSignal(timeoutMs); + try { + const resp = await fetch(`${PENGINE_API_BASE}/v1/toolengine/install`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tool_id: toolId }), + signal, + }); + if (resp.ok) return { ok: true }; + const raw = await resp.text(); + let message = `Request failed (HTTP ${resp.status})`; + try { + const body = JSON.parse(raw) as { error?: string }; + message = body.error ?? raw.trim(); + } catch { + message = raw.trim() || message; + } + return { ok: false, error: message }; + } catch (e) { + return { ok: false, error: fetchErrorMessage(e) }; + } finally { + cleanup(); + } +} + +/** POST `/v1/toolengine/uninstall` — remove a container image. */ +export async function uninstallTool( + toolId: string, + timeoutMs = 120_000, +): Promise<{ ok: boolean; error?: string }> { + const { signal, cleanup } = makeTimeoutSignal(timeoutMs); + try { + const resp = await fetch(`${PENGINE_API_BASE}/v1/toolengine/uninstall`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tool_id: toolId }), + signal, + }); + if (resp.ok) return { ok: true }; + const raw = await resp.text(); + let message = `Request failed (HTTP ${resp.status})`; + try { + const body = JSON.parse(raw) as { error?: string }; + message = body.error ?? raw.trim(); + } catch { + message = raw.trim() || message; + } + return { ok: false, error: message }; + } catch (e) { + return { ok: false, error: fetchErrorMessage(e) }; + } finally { + cleanup(); + } +} diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index d090bb2..74b8a99 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -5,6 +5,7 @@ import { TerminalPreview } from "../modules/bot/components/TerminalPreview"; import { useAppSessionStore } from "../modules/bot/store/appSessionStore"; import { McpToolsPanel } from "../modules/mcp/components/McpToolsPanel"; import { fetchOllamaModels, setPreferredOllamaModel } from "../modules/ollama/api"; +import { ToolEnginePanel } from "../modules/toolengine/components/ToolEnginePanel"; import { TopMenu } from "../shared/ui/TopMenu"; type ServiceInfo = { @@ -211,6 +212,11 @@ export function DashboardPage() {
+ + {/* ── Tool Engine (container tools) ───────────────────────── */} +
+ +
); diff --git a/src/pages/SetupPage.tsx b/src/pages/SetupPage.tsx index 6cd1f1d..ef41a04 100644 --- a/src/pages/SetupPage.tsx +++ b/src/pages/SetupPage.tsx @@ -8,6 +8,7 @@ const requirements = [ "Telegram account", "Bot token from BotFather", "Ollama installed on this machine", + "Podman or Docker installed", "Pengine desktop app installed", ]; diff --git a/src/shared/api/config.ts b/src/shared/api/config.ts index 4306c5f..29b043c 100644 --- a/src/shared/api/config.ts +++ b/src/shared/api/config.ts @@ -3,3 +3,18 @@ export const PENGINE_API_BASE = "http://127.0.0.1:21516"; /** Default Ollama HTTP API (same host as typical desktop install). */ export const OLLAMA_API_BASE = "http://localhost:11434"; + +/** Browsers often report timeouts as AbortError / “Fetch is aborted”. */ +export function fetchErrorMessage(e: unknown): string { + if (e instanceof DOMException && e.name === "AbortError") { + return "Request timed out — the app may still be working (e.g. reconnecting MCP or pulling an image). Wait and refresh, or check the in-app log."; + } + if (e instanceof Error) { + const m = e.message.toLowerCase(); + if (m.includes("abort")) { + return "Request timed out — the app may still be working (e.g. reconnecting MCP or pulling an image). Wait and refresh, or check the in-app log."; + } + return e.message; + } + return "Request failed"; +} diff --git a/src/shared/mcpEvents.ts b/src/shared/mcpEvents.ts new file mode 100644 index 0000000..a41e45a --- /dev/null +++ b/src/shared/mcpEvents.ts @@ -0,0 +1,7 @@ +/** Dispatched when MCP registry (e.g. mcp.json) may have changed from outside the MCP panel. */ +export const PENGINE_MCP_REGISTRY_CHANGED = "pengine:mcp-registry-changed"; + +export function notifyMcpRegistryChanged(): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new Event(PENGINE_MCP_REGISTRY_CHANGED)); +} diff --git a/src/shared/ui/WizardLayout.tsx b/src/shared/ui/WizardLayout.tsx index 0c5db86..28fa9fb 100644 --- a/src/shared/ui/WizardLayout.tsx +++ b/src/shared/ui/WizardLayout.tsx @@ -63,7 +63,7 @@ export function WizardLayout({ -
    +
      {stepTitles.map((title, index) => (
    1. Container runtime detected:

      - {runtimeStatus.kind} {runtimeStatus.version} + {runtimeStatus.kind ?? "unknown"} {runtimeStatus.version ?? ""} {runtimeStatus.rootless ? " (rootless)" : ""}

      @@ -260,7 +272,7 @@ curl -fsSL https://get.docker.com | sh`} Engine:{" "} {runtimeStatus?.available - ? runtimeStatus.kind + ? (runtimeStatus.kind ?? "unknown") : runtimeChecking ? "checking…" : "not detected"} @@ -269,7 +281,7 @@ curl -fsSL https://get.docker.com | sh`}
    2. Version:{" "} - {runtimeStatus?.version ?? "—"} + {runtimeStatus?.version?.trim() || "—"}
    3. @@ -360,7 +372,6 @@ export function WizardStepConnect(props: { verifiedBot: { bot_id: string; bot_username: string } | null; botUsername: string; onBotUsernameChange: (value: string) => void; - telegramBotUrl: string; onConnect: () => void; onCopyUri: () => void; copiedUri: boolean; @@ -377,13 +388,19 @@ export function WizardStepConnect(props: { verifiedBot, botUsername, onBotUsernameChange, - telegramBotUrl, onConnect, onCopyUri, copiedUri, onCompleteSetup, } = props; + const telegramBotUrl = useMemo(() => { + const fromInput = botUsername.replace(/^@+/, "").trim(); + const fromVerified = verifiedBot?.bot_username.replace(/^@+/, "").trim() ?? ""; + const name = fromInput || fromVerified; + return name ? `https://t.me/${name}` : "https://t.me/botfather"; + }, [botUsername, verifiedBot]); + return (
      diff --git a/src/modules/mcp/components/McpServerCard.tsx b/src/modules/mcp/components/McpServerCard.tsx index 6d6c8c5..35fd862 100644 --- a/src/modules/mcp/components/McpServerCard.tsx +++ b/src/modules/mcp/components/McpServerCard.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { workspaceAppContainerMountPaths } from "../../../shared/workspaceMounts"; import { fetchMcpConfig, putMcpFilesystemPaths, @@ -29,27 +30,6 @@ function argsTextLooksLikeFilesystem(argsText: string): boolean { .some((a) => a.includes("server-filesystem")); } -/** Mirrors server-side mounts: each folder → `/app/` (duplicates get `_1`, `_2`, …). */ -function appMountPathsForFolders(paths: string[]): string[] { - const seen = new Set(); - const out: string[] = []; - for (const p of paths) { - const parts = p.replace(/\\/g, "/").split("/").filter(Boolean); - const raw = parts.length > 0 ? parts[parts.length - 1]! : "folder"; - const safe = raw.replace(/[^a-zA-Z0-9_-]/g, "_"); - const label = /^_+$/.test(safe) || safe === "" ? "folder" : safe; - let key = label; - let n = 0; - while (seen.has(key)) { - n += 1; - key = `${label}_${n}`; - } - seen.add(key); - out.push(`/app/${key}`); - } - return out; -} - export function McpServerCard({ name, entry, @@ -239,7 +219,7 @@ function InlineEditForm({ const isTeFileManager = name === "te_pengine-file-manager"; const [tePaths, setTePaths] = useState([]); - const teAppMounts = isTeFileManager ? appMountPathsForFolders(tePaths) : []; + const teAppMounts = isTeFileManager ? workspaceAppContainerMountPaths(tePaths) : []; const [tePickError, setTePickError] = useState(null); const [teApplyError, setTeApplyError] = useState(null); const [teApplyBusy, setTeApplyBusy] = useState(false); @@ -336,10 +316,6 @@ function InlineEditForm({ const applyTeFolders = async () => { setTeApplyError(null); - if (tePaths.length === 0) { - setTeApplyError("Add at least one folder."); - return; - } setTeApplyBusy(true); const ok = await putMcpFilesystemPaths(tePaths, 60_000); setTeApplyBusy(false); @@ -429,7 +405,7 @@ function InlineEditForm({ )}
      -
        +
          {stepTitles.map((title, index) => (
        1. diff --git a/src/modules/toolengine/components/ToolEnginePanel.tsx b/src/modules/toolengine/components/ToolEnginePanel.tsx index 063bb37..06afc83 100644 --- a/src/modules/toolengine/components/ToolEnginePanel.tsx +++ b/src/modules/toolengine/components/ToolEnginePanel.tsx @@ -18,6 +18,7 @@ export function ToolEnginePanel() { const [runtimeError, setRuntimeError] = useState(null); const [actionError, setActionError] = useState(null); const [busyTool, setBusyTool] = useState(null); + const [busyKind, setBusyKind] = useState<"install" | "uninstall" | null>(null); const [notice, setNotice] = useState(null); const cancelledRef = useRef(false); @@ -53,6 +54,7 @@ export function ToolEnginePanel() { const handleInstall = async (toolId: string) => { setBusyTool(toolId); + setBusyKind("install"); setNotice(null); setActionError(null); try { @@ -66,12 +68,16 @@ export function ToolEnginePanel() { } await loadData(); } finally { - if (!cancelledRef.current) setBusyTool(null); + if (!cancelledRef.current) { + setBusyTool(null); + setBusyKind(null); + } } }; const handleUninstall = async (toolId: string) => { setBusyTool(toolId); + setBusyKind("uninstall"); setNotice(null); setActionError(null); try { @@ -85,7 +91,10 @@ export function ToolEnginePanel() { } await loadData(); } finally { - if (!cancelledRef.current) setBusyTool(null); + if (!cancelledRef.current) { + setBusyTool(null); + setBusyKind(null); + } } }; @@ -141,6 +150,38 @@ export function ToolEnginePanel() {

          )} + {busyTool && busyKind && ( +
          +
          +

          + {busyKind === "install" ? "Pulling image" : "Removing image"} +

          +

          + {busyTool} +

          +
          +
          +
          +
          +
          + )} + {/* Catalog */} {loading && catalog === null && (

          @@ -210,7 +251,7 @@ export function ToolEnginePanel() {

          - Docker command list + Container commands

          {tool.commands.length} MCP tool