diff --git a/e2e/setup-dashboard.spec.ts b/e2e/setup-dashboard.spec.ts index 0427562..9eca713 100644 --- a/e2e/setup-dashboard.spec.ts +++ b/e2e/setup-dashboard.spec.ts @@ -120,6 +120,22 @@ async function mockApis(page: import("@playwright/test").Page) { } }); + await page.route( + (url) => url.href.startsWith(`${PENGINE_API_BASE}/v1/toolengine/runtime`), + async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + available: true, + kind: "podman", + version: "5.0.0", + rootless: true, + }), + }); + }, + ); + await page.route( (url) => url.href.startsWith(`${PENGINE_API_BASE}/v1/mcp/servers`), async (route) => { @@ -179,14 +195,22 @@ test.describe("setup to dashboard flow", () => { await expect(page.getByText("Ready to continue.")).toBeVisible(); await page.getByRole("button", { name: "Continue" }).click(); - // Step 3: Pengine local (health check auto-passes via mock) + // Step 3: Container runtime (Podman/Docker — mocked as available) + await expect( + page.getByRole("heading", { name: "Install a container runtime", exact: true }), + ).toBeVisible(); + await expect(page.getByText("Container runtime detected:")).toBeVisible(); + await expect(page.getByText("Ready to continue.")).toBeVisible(); + await page.getByRole("button", { name: "Continue" }).click(); + + // Step 4: Pengine local (health check auto-passes via mock) await expect( page.getByRole("heading", { name: "Start Pengine locally", exact: true }), ).toBeVisible(); await expect(page.getByText("Pengine is running on localhost.")).toBeVisible(); await page.getByRole("button", { name: "Continue" }).click(); - // Step 4: Connect + // Step 5: Connect await expect( page.getByRole("heading", { name: "Connect bot to Pengine", exact: true }), ).toBeVisible(); 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..8cd7e6f 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -35,22 +35,25 @@ 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; - } + if let Err(e) = mcp_service::rebuild_registry_into_state(&mcp_state).await { + mcp_state + .emit_log( + "mcp", + &format!("ERROR: MCP registry rebuild failed on startup: {e}"), + ) + .await; } }); diff --git a/src-tauri/src/infrastructure/http_server.rs b/src-tauri/src/infrastructure/http_server.rs index 268924b..c7123fc 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()); @@ -343,49 +352,69 @@ async fn handle_mcp_filesystem_put( .map(|p| p.trim().to_string()) .filter(|p| !p.is_empty()) .collect(); - if paths.is_empty() { - return Err(( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: "at least one path is required".into(), - }), - )); - } - let _guard = state.mcp_config_mutex.lock().await; + 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| { + 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 Err(e) = te_service::sync_workspace_mounted_tools_if_installed(&mut cfg, &paths) { + 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 { + if let Err(e) = mcp_service::rebuild_registry_into_state(&bg).await { + bg.emit_log( + "mcp", + &format!("ERROR: MCP registry rebuild failed after workspace_roots update: {e}"), + ) + .await; + } + }); Ok((StatusCode::OK, Json(serde_json::json!({ "ok": true })))) } @@ -465,27 +494,39 @@ 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 { + if let Err(e) = mcp_service::rebuild_registry_into_state(&bg).await { + bg.emit_log( + "mcp", + &format!("ERROR: MCP registry rebuild failed after server save: {e}"), + ) + .await; + } + }); Ok((StatusCode::OK, Json(serde_json::json!({ "ok": true })))) } @@ -494,35 +535,229 @@ 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| { + 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 { + if let Err(e) = mcp_service::rebuild_registry_into_state(&bg).await { + bg.emit_log( + "mcp", + &format!("ERROR: MCP registry rebuild failed after server delete: {e}"), + ) + .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 })), + } +} + +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 }), ) })?; - if cfg.servers.remove(&name).is_none() { - return Err(( - StatusCode::NOT_FOUND, - Json(ErrorResponse { - error: format!("server '{name}' not found"), - }), - )); + 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; + + if let Err(e) = te_service::install_tool( + &tool_id, + &runtime, + &state.mcp_config_path, + &state.mcp_config_mutex, + ) + .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; } - mcp_service::save_config(&state.mcp_config_path, &cfg).map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { error: e }), + // Respond immediately; MCP reconnect can take minutes (Podman / npx) and must not block the UI. + let bg = state.clone(); + tokio::spawn(async move { + if let Err(e) = mcp_service::rebuild_registry_into_state(&bg).await { + bg.emit_log( + "mcp", + &format!("ERROR: MCP registry rebuild failed after tool install: {e}"), + ) + .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, + &state.mcp_config_mutex, ) - })?; + .await + { + state + .emit_log("toolengine", &format!("uninstall failed: {e}")) + .await; + return Err(( + 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; + state + .emit_log("toolengine", &format!("{tool_id} uninstalled")) + .await; + } + + let bg = state.clone(); + tokio::spawn(async move { + if let Err(e) = mcp_service::rebuild_registry_into_state(&bg).await { + bg.emit_log( + "mcp", + &format!("ERROR: MCP registry rebuild failed after tool uninstall: {e}"), + ) + .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..e5102bc 100644 --- a/src-tauri/src/modules/mcp/registry.rs +++ b/src-tauri/src/modules/mcp/registry.rs @@ -4,6 +4,99 @@ 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" { + log::warn!( + "rewrite_file_manager_path: `/mcp` is the server working directory, not an MCP file root; leaving path unchanged" + ); + return "/mcp".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(); + } + resolve_relative_under_app(t) +} + +/// Resolve a relative path under `/app` with `..` handling; escaping above the root → `/app`. +fn resolve_relative_under_app(raw: &str) -> String { + let u = raw.replace('\\', "/"); + let mut stack: Vec<&str> = Vec::new(); + let mut escaped = false; + for seg in u.split('/').filter(|s| !s.is_empty() && *s != ".") { + if seg == ".." { + if stack.pop().is_none() { + escaped = true; + } + } else { + stack.push(seg); + } + } + if escaped { + return "/app".to_string(); + } + if stack.is_empty() { + "/app".to_string() + } else { + format!("/app/{}", stack.join("/")) + } +} + +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 +181,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)) } @@ -171,3 +270,41 @@ 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"), "/mcp"); + 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 relative_path_traversal_collapses_to_app_root() { + assert_eq!( + rewrite_file_manager_path("pengine/../../etc/passwd"), + "/app" + ); + } + + #[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..0bce43d 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,42 @@ 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 folders shared with the File Manager container. After [`migrate_legacy_npx_filesystem`] +/// runs (in [`read_config`]), this is exactly `cfg.workspace_roots`. pub fn filesystem_allowed_paths(cfg: &McpConfig) -> Vec { - 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() + cfg.workspace_roots.clone() } 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 = sanitize_path_list(paths); +} + +/// Trim each path and drop empties — used by both the public setter and the legacy migration. +fn sanitize_path_list(paths: &[String]) -> Vec { + paths + .iter() + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect() +} + +/// 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; + } + if let Some(pkg_idx) = args.iter().position(|a| a.contains("server-filesystem")) { + let legacy = sanitize_path_list(&args[pkg_idx + 1..]); + 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,68 +126,184 @@ 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}")), + }, + } +} + +/// Connect every server in `cfg` and return the providers + per-server status lines. +/// Used by tests and as a one-shot rebuild path; the live runtime uses +/// [`rebuild_registry_into_state`] which publishes incrementally. +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); } } - (ToolRegistry::new(providers), status) + (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, +) -> Result<(), String> { + 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); + let msg = format!("mcp.json error: {e}"); + state.emit_log("mcp", &msg).await; + return Err(msg); + } + }; + + let paths = filesystem_allowed_paths(&cfg); + match crate::modules::tool_engine::service::sync_workspace_mounted_tools_if_installed( + &mut cfg, &paths, + ) { + 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 *successful* connect so native tools (e.g. dice) are usable + // while slow stdio servers (Podman-backed Tool Engine, npx, …) are still connecting. Failed + // connects only emit a log line — no need to rebuild the registry. + 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; + let Some(p) = prov else { continue }; + providers.push(p); + *state.mcp.write().await = ToolRegistry::new(providers.clone()); } - let n = registry.tool_names().len(); - *state.mcp.write().await = registry; + + let n = state.mcp.read().await.tool_names().len(); state .emit_log( "mcp", &format!("ready ({n} tool{})", if n == 1 { "" } else { "s" }), ) .await; + Ok(()) +} + +#[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..ccb42a2 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}"))?; @@ -104,17 +118,21 @@ impl StdioTransport { { let mut stdin = self.stdin.lock().await; - stdin - .write_all(&payload) - .await - .map_err(|e| format!("write stdin: {e}"))?; - stdin.flush().await.map_err(|e| format!("flush: {e}"))?; + if let Err(e) = stdin.write_all(&payload).await { + self.pending.lock().await.remove(&id); + return Err(format!("write stdin: {e}")); + } + if let Err(e) = stdin.flush().await { + self.pending.lock().await.remove(&id); + return Err(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..fd0cd72 --- /dev/null +++ b/src-tauri/src/modules/tool_engine/container/file-manager/Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-alpine +RUN addgroup -S mcp && adduser -S -G mcp -H mcp +WORKDIR /mcp +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev \ + && npm cache clean --force \ + && rm -rf /root/.npm \ + && chown -R mcp:mcp /mcp +USER mcp +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..0cdf059 --- /dev/null +++ b/src-tauri/src/modules/tool_engine/container/file-manager/build @@ -0,0 +1,11 @@ +#!/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 +if command -v docker >/dev/null 2>&1; then + exec docker build -t file-manager:0.1.0 . +fi +echo "No container runtime found: install Podman or Docker" >&2 +exit 1 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..265db08 --- /dev/null +++ b/src-tauri/src/modules/tool_engine/service.rs @@ -0,0 +1,435 @@ +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 +} + +/// 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()); + } + + // Compute the host→container layout once and reuse it for both bind mounts and root args. + let bind_pairs = if entry.mount_workspace { + workspace_app_bind_pairs(host_paths) + } else { + Vec::new() + }; + + if entry.mount_workspace && !bind_pairs.is_empty() { + let suffix = if entry.mount_read_only { "ro" } else { "rw" }; + args.extend( + bind_pairs + .iter() + .map(|(host, cpath)| format!("-v={host}:{cpath}:{suffix}")), + ); + } + + args.push(entry.image.clone()); + args.extend(entry.mcp_server_cmd.iter().cloned()); + + if entry.append_workspace_roots { + if bind_pairs.is_empty() { + args.push(EMPTY_WORKSPACE_CONTAINER_ROOT.to_string()); + } else { + args.extend(bind_pairs.into_iter().map(|(_, cpath)| cpath)); + } + } + + Ok(args) +} + +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, + mcp_cfg_lock: &tokio::sync::Mutex<()>, +) -> 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 _cfg_guard = mcp_cfg_lock.lock().await; + let mut cfg = mcp_service::load_or_init_config(mcp_config_path)?; + let host_paths = mcp_service::filesystem_allowed_paths(&cfg); + 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, + }; + + 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], +) -> 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); + let Some(ServerEntry::Stdio { + command, + args, + env, + direct_return, + }) = cfg.servers.get(&key) + else { + continue; + }; + + let new_args = podman_run_argv_for_tool(entry, host_paths)?; + if args == &new_args { + continue; + } + + let new_entry = ServerEntry::Stdio { + command: command.clone(), + args: new_args, + env: env.clone(), + direct_return: *direct_return, + }; + cfg.servers.insert(key, new_entry); + 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, + mcp_cfg_lock: &tokio::sync::Mutex<()>, +) -> Result<(), String> { + // Remove from mcp.json. + let key = server_key(tool_id); + if mcp_config_path.exists() { + let _cfg_guard = mcp_cfg_lock.lock().await; + 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()), + ] + ); + } + + #[test] + fn podman_argv_with_paths_emits_ro_binds_and_roots() { + let catalog = load_catalog().unwrap(); + let entry = catalog + .tools + .iter() + .find(|t| t.id == "pengine/file-manager") + .unwrap(); + let hosts = vec!["/Users/x/pengine".into(), "/opt/other".into()]; + let argv = podman_run_argv_for_tool(entry, &hosts).unwrap(); + let suffix = if entry.mount_read_only { "ro" } else { "rw" }; + assert!(argv + .iter() + .any(|a| a == &format!("-v=/Users/x/pengine:/app/pengine:{suffix}"))); + assert!(argv + .iter() + .any(|a| a == &format!("-v=/opt/other:/app/other:{suffix}"))); + // Roots are appended after the image + mcp_server_cmd. + assert_eq!( + &argv[argv.len() - 2..], + &["/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/index.css b/src/index.css index 3793e3e..8b67825 100644 --- a/src/index.css +++ b/src/index.css @@ -100,3 +100,50 @@ a { .status-pulse { animation: pulse-ring 2.1s ease-in-out infinite; } + +/* Tool Engine — indeterminate pull/remove (no byte-level progress from API) */ +@keyframes install-sheen { + 0% { + transform: translateX(-45%); + } + 100% { + transform: translateX(245%); + } +} + +.install-progress-track { + position: relative; + height: 2px; + overflow: hidden; + border-radius: 9999px; + background: rgba(255, 255, 255, 0.06); +} + +.install-progress-sheen { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 38%; + border-radius: 9999px; + background: linear-gradient( + 90deg, + transparent, + rgba(0, 212, 170, 0.15), + rgba(0, 212, 170, 0.85), + rgba(0, 212, 170, 0.15), + transparent + ); + animation: install-sheen 1.85s ease-in-out infinite; +} + +.install-progress-sheen--remove { + background: linear-gradient( + 90deg, + transparent, + rgba(255, 95, 109, 0.12), + rgba(255, 95, 109, 0.65), + rgba(255, 95, 109, 0.12), + transparent + ); +} diff --git a/src/modules/bot/components/SetupWizard.tsx b/src/modules/bot/components/SetupWizard.tsx index 934fb17..119e0e3 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 a container runtime", + summary: "Install Podman (preferred) or Docker 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">( @@ -77,19 +90,16 @@ export function SetupWizard({ onStepChange, onCompleteSetup }: SetupWizardProps) const stepTitles = SETUP_STEPS.map((item) => item.title); const botId = useMemo(() => parseBotIdFromToken(botToken), [botToken]); - const telegramBotUrl = useMemo(() => { - const name = verifiedBot?.bot_username || botUsername.replace(/^@+/, "").trim(); - if (name) return `https://t.me/${name}`; - return "https://t.me/botfather"; - }, [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 +126,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 +153,7 @@ export function SetupWizard({ onStepChange, onCompleteSetup }: SetupWizardProps) }, []); useEffect(() => { - if (step === 2) { + if (step === 3) { checkPengineHealth(); } }, [step, checkPengineHealth]); @@ -178,313 +205,52 @@ 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..1de94ff --- /dev/null +++ b/src/modules/bot/components/SetupWizardSteps.tsx @@ -0,0 +1,516 @@ +import { useMemo, useState } from "react"; +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; + const [showToken, setShowToken] = useState(false); + 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 && ollamaReachable === true && ( +

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 ?? "unknown"} {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 ?? "unknown") + : runtimeChecking + ? "checking…" + : "not detected"} + +
  • +
  • + Version:{" "} + + {runtimeStatus?.version?.trim() || "—"} + +
  • +
  • + 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; + onConnect: () => void; + onCopyUri: () => void; + copiedUri: boolean; + onCompleteSetup?: () => void; +}) { + const { + botId, + status, + ollamaModel, + runtimeStatus, + pengineReachable, + connectStatus, + connectError, + verifiedBot, + botUsername, + onBotUsernameChange, + 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 ( +
+
+

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" && onCompleteSetup && ( + + )} +
+
+
+ ); +} diff --git a/src/modules/mcp/components/McpServerCard.tsx b/src/modules/mcp/components/McpServerCard.tsx index c3f1813..35fd862 100644 --- a/src/modules/mcp/components/McpServerCard.tsx +++ b/src/modules/mcp/components/McpServerCard.tsx @@ -1,5 +1,12 @@ -import { useState } from "react"; -import type { McpTool, ServerEntry, ServerEntryStdio } from ".."; +import { useEffect, useState } from "react"; +import { workspaceAppContainerMountPaths } from "../../../shared/workspaceMounts"; +import { + fetchMcpConfig, + putMcpFilesystemPaths, + type McpTool, + type ServerEntry, + type ServerEntryStdio, +} from ".."; type Props = { name: string; @@ -10,6 +17,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). */ @@ -30,6 +39,7 @@ export function McpServerCard({ onSave, onDelete, onEditStart, + onReloadServers, }: Props) { const isNative = entry.type === "native"; const isEditing = editingName === name; @@ -65,6 +75,7 @@ export function McpServerCard({ busy={busy} onSave={(updated) => onSave(name, updated)} onCancel={() => onEditStart(null)} + onReloadServers={onReloadServers} /> ); @@ -187,12 +198,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 +217,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 ? workspaceAppContainerMountPaths(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 +287,46 @@ 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); + 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 +368,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..06afc83 --- /dev/null +++ b/src/modules/toolengine/components/ToolEnginePanel.tsx @@ -0,0 +1,289 @@ +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 [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); + 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); + setRuntimeError(null); + } else { + setRuntime(null); + setRuntimeError("Could not load runtime status"); + } + 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); + setBusyKind("install"); + 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); + setBusyKind(null); + } + } + }; + + const handleUninstall = async (toolId: string) => { + setBusyTool(toolId); + setBusyKind("uninstall"); + 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); + setBusyKind(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} +

+ )} + + {runtimeError && ( +

+ {runtimeError} +

+ )} + + {catalogError && ( +

+ {catalogError} +

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

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

+

+ {busyTool} +

+
+
+
+
+
+ )} + + {/* 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 && ( + + + + +
+

+ Container commands +

+

+ {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..30d2be1 100644 --- a/src/shared/ui/WizardLayout.tsx +++ b/src/shared/ui/WizardLayout.tsx @@ -63,7 +63,10 @@ export function WizardLayout({ -
    +
      {stepTitles.map((title, index) => (