diff --git a/Cargo.lock b/Cargo.lock index db81d97590ef..6ce7b815db4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -832,7 +832,7 @@ dependencies = [ "sha1", "sync_wrapper 1.0.2", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.26.2", "tower 0.5.2", "tower-layer", "tower-service", @@ -1992,7 +1992,16 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", ] [[package]] @@ -2003,10 +2012,22 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.4.6", "windows-sys 0.48.0", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.60.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -2633,7 +2654,7 @@ dependencies = [ "criterion", "ctor", "dashmap", - "dirs", + "dirs 5.0.1", "dotenvy", "etcetera", "fs2", @@ -2853,7 +2874,9 @@ dependencies = [ "futures", "goose", "goose-mcp", + "hex", "http 1.2.0", + "rand 0.9.2", "reqwest 0.12.12", "rmcp 0.9.0", "schemars", @@ -2861,16 +2884,19 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_yaml", + "socket2 0.6.1", "tempfile", "thiserror 1.0.69", "tokio", "tokio-stream", + "tokio-tungstenite 0.28.0", "tokio-util", "tower 0.5.2", "tower-http", "tracing", "tracing-appender", "tracing-subscriber", + "url", "utoipa", "uuid", "winreg 0.55.0", @@ -3248,7 +3274,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.0", + "socket2 0.6.1", "tokio", "tower-service", "tracing", @@ -4174,6 +4200,23 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk-context" version = "0.1.1" @@ -5234,9 +5277,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -5382,6 +5425,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 2.0.12", +] + [[package]] name = "ref-cast" version = "1.0.24" @@ -6165,9 +6219,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", @@ -6201,7 +6255,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" dependencies = [ - "dirs", + "dirs 6.0.0", ] [[package]] @@ -6295,12 +6349,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -7047,6 +7101,16 @@ dependencies = [ "syn 2.0.99", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.24.1" @@ -7087,7 +7151,21 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite", + "tungstenite 0.26.2", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite 0.28.0", ] [[package]] @@ -7446,7 +7524,25 @@ dependencies = [ "http 1.2.0", "httparse", "log", - "rand 0.9.1", + "rand 0.9.2", + "sha1", + "thiserror 2.0.12", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http 1.2.0", + "httparse", + "log", + "native-tls", + "rand 0.9.2", "sha1", "thiserror 2.0.12", "utf-8", @@ -8222,6 +8318,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + [[package]] name = "windows-targets" version = "0.42.2" diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml index a616de6489e6..b81d53ddfc36 100644 --- a/crates/goose-server/Cargo.toml +++ b/crates/goose-server/Cargo.toml @@ -39,7 +39,11 @@ reqwest = { version = "0.12.9", features = ["json", "rustls-tls", "blocking", "m tokio-util = "0.7.15" uuid = { version = "1.11", features = ["v4"] } serde_path_to_error = "0.1.20" -winreg = { version = "0.55.0", optional = true } +tokio-tungstenite = { version = "0.28.0", features = ["native-tls"] } +url = "2.5.7" +rand = "0.9.2" +hex = "0.4.3" +socket2 = "0.6.1" [target.'cfg(windows)'.dependencies] winreg = { version = "0.55.0" } diff --git a/crates/goose-server/src/commands/agent.rs b/crates/goose-server/src/commands/agent.rs index 666534395d20..950640987916 100644 --- a/crates/goose-server/src/commands/agent.rs +++ b/crates/goose-server/src/commands/agent.rs @@ -49,7 +49,7 @@ pub async fn run() -> Result<()> { .allow_methods(Any) .allow_headers(Any); - let app = crate::routes::configure(app_state, secret_key.clone()) + let app = crate::routes::configure(app_state.clone(), secret_key.clone()) .layer(middleware::from_fn_with_state( secret_key.clone(), check_token, @@ -59,6 +59,11 @@ pub async fn run() -> Result<()> { let listener = tokio::net::TcpListener::bind(settings.socket_addr()).await?; info!("listening on {}", listener.local_addr()?); + let tunnel_manager = app_state.tunnel_manager.clone(); + tokio::spawn(async move { + tunnel_manager.check_auto_start().await; + }); + axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await?; diff --git a/crates/goose-server/src/lib.rs b/crates/goose-server/src/lib.rs index b945da8ce595..aa5ae94f4114 100644 --- a/crates/goose-server/src/lib.rs +++ b/crates/goose-server/src/lib.rs @@ -1,7 +1,10 @@ pub mod auth; +pub mod configuration; +pub mod error; pub mod openapi; pub mod routes; pub mod state; +pub mod tunnel; // Re-export commonly used items pub use openapi::*; diff --git a/crates/goose-server/src/main.rs b/crates/goose-server/src/main.rs index 03e04fcde79f..ef4b6bc5d4a8 100644 --- a/crates/goose-server/src/main.rs +++ b/crates/goose-server/src/main.rs @@ -5,6 +5,7 @@ mod logging; mod openapi; mod routes; mod state; +mod tunnel; use clap::{Parser, Subcommand}; use goose::config::paths::Paths; diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 8870be0a73f8..edd101ecb6fc 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -391,6 +391,9 @@ derive_utoipa!(Icon as IconSchema); super::routes::recipe::parse_recipe, super::routes::setup::start_openrouter_setup, super::routes::setup::start_tetrate_setup, + super::routes::tunnel::start_tunnel, + super::routes::tunnel::stop_tunnel, + super::routes::tunnel::get_tunnel_status, ), components(schemas( super::routes::config_management::UpsertConfigQuery, @@ -513,6 +516,8 @@ derive_utoipa!(Icon as IconSchema); super::routes::agent::AddExtensionRequest, super::routes::agent::RemoveExtensionRequest, super::routes::setup::SetupResponse, + super::tunnel::TunnelInfo, + super::tunnel::TunnelState, )) )] pub struct ApiDoc; diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index db2071451d8b..9c11afee3595 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -10,6 +10,7 @@ pub mod schedule; pub mod session; pub mod setup; pub mod status; +pub mod tunnel; pub mod utils; use std::sync::Arc; @@ -28,5 +29,6 @@ pub fn configure(state: Arc, secret_key: String) -> Rout .merge(session::routes(state.clone())) .merge(schedule::routes(state.clone())) .merge(setup::routes(state.clone())) + .merge(tunnel::routes(state.clone())) .merge(mcp_ui_proxy::routes(secret_key)) } diff --git a/crates/goose-server/src/routes/tunnel.rs b/crates/goose-server/src/routes/tunnel.rs new file mode 100644 index 000000000000..d84aff4afe53 --- /dev/null +++ b/crates/goose-server/src/routes/tunnel.rs @@ -0,0 +1,78 @@ +use crate::state::AppState; +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use serde::Serialize; +use std::sync::Arc; +use utoipa::ToSchema; + +#[derive(Debug, Serialize, ToSchema)] +pub struct ErrorResponse { + pub error: String, +} + +/// Start the tunnel +#[utoipa::path( + post, + path = "/tunnel/start", + responses( + (status = 200, description = "Tunnel started successfully", body = TunnelInfo), + (status = 400, description = "Bad request", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse) + ) +)] +#[axum::debug_handler] +pub async fn start_tunnel(State(state): State>) -> Response { + match state.tunnel_manager.start().await { + Ok(info) => (StatusCode::OK, Json(info)).into_response(), + Err(e) => { + tracing::error!("Failed to start tunnel: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: e.to_string(), + }), + ) + .into_response() + } + } +} + +/// Stop the tunnel +#[utoipa::path( + post, + path = "/tunnel/stop", + responses( + (status = 200, description = "Tunnel stopped successfully"), + (status = 500, description = "Internal server error", body = ErrorResponse) + ) +)] +pub async fn stop_tunnel(State(state): State>) -> Response { + state.tunnel_manager.stop(true).await; + StatusCode::OK.into_response() +} + +/// Get tunnel info +#[utoipa::path( + get, + path = "/tunnel/status", + responses( + (status = 200, description = "Tunnel info", body = TunnelInfo) + ) +)] +pub async fn get_tunnel_status(State(state): State>) -> Response { + let info = state.tunnel_manager.get_info().await; + (StatusCode::OK, Json(info)).into_response() +} + +pub fn routes(state: Arc) -> Router { + Router::new() + .route("/tunnel/start", post(start_tunnel)) + .route("/tunnel/stop", post(stop_tunnel)) + .route("/tunnel/status", get(get_tunnel_status)) + .with_state(state) +} diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index 2a976dcb3d2f..4a9c582e39e2 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -6,6 +6,9 @@ use std::path::PathBuf; use std::sync::atomic::AtomicUsize; use std::sync::Arc; use tokio::sync::Mutex; + +use crate::tunnel::TunnelManager; + #[derive(Clone)] pub struct AppState { pub(crate) agent_manager: Arc, @@ -13,16 +16,20 @@ pub struct AppState { pub session_counter: Arc, /// Tracks sessions that have already emitted recipe telemetry to prevent double counting. recipe_session_tracker: Arc>>, + pub tunnel_manager: Arc, } impl AppState { pub async fn new() -> anyhow::Result> { let agent_manager = AgentManager::instance().await?; + let tunnel_manager = Arc::new(TunnelManager::new()); + Ok(Arc::new(Self { agent_manager, recipe_file_hash_map: Arc::new(Mutex::new(HashMap::new())), session_counter: Arc::new(AtomicUsize::new(0)), recipe_session_tracker: Arc::new(Mutex::new(HashSet::new())), + tunnel_manager, })) } diff --git a/crates/goose-server/src/tunnel/lapstone.rs b/crates/goose-server/src/tunnel/lapstone.rs new file mode 100644 index 000000000000..8449abc42611 --- /dev/null +++ b/crates/goose-server/src/tunnel/lapstone.rs @@ -0,0 +1,607 @@ +use super::TunnelInfo; +use anyhow::{Context, Result}; +use futures::{SinkExt, StreamExt}; +use reqwest; +use serde::{Deserialize, Serialize}; +use socket2::{SockRef, TcpKeepalive}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{mpsc, RwLock}; +use tokio::task::JoinHandle; +use tokio_tungstenite::{connect_async, tungstenite::Message}; +use tracing::{error, info, warn}; +use url::Url; + +/// Constant-time comparison using hash to prevent timing attacks +fn secure_compare(a: &str, b: &str) -> bool { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher_a = DefaultHasher::new(); + a.hash(&mut hasher_a); + let hash_a = hasher_a.finish(); + + let mut hasher_b = DefaultHasher::new(); + b.hash(&mut hasher_b); + let hash_b = hasher_b.finish(); + + hash_a == hash_b +} + +const WORKER_URL: &str = "https://cloudflare-tunnel-proxy.michael-neale.workers.dev"; +const IDLE_TIMEOUT_SECS: u64 = 300; +const CONNECTION_TIMEOUT_SECS: u64 = 30; +const MAX_WS_SIZE: usize = 900_000; + +fn get_worker_url() -> String { + std::env::var("GOOSE_TUNNEL_WORKER_URL") + .ok() + .unwrap_or_else(|| WORKER_URL.to_string()) +} + +type WebSocketSender = Arc< + RwLock< + Option< + futures::stream::SplitSink< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + Message, + >, + >, + >, +>; + +#[derive(Debug, Serialize, Deserialize)] +struct TunnelMessage { + #[serde(rename = "requestId")] + request_id: String, + method: String, + path: String, + #[serde(skip_serializing_if = "Option::is_none")] + headers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + body: Option, +} + +#[derive(Debug, Serialize)] +struct TunnelResponse { + #[serde(rename = "requestId")] + request_id: String, + status: u16, + #[serde(skip_serializing_if = "Option::is_none")] + headers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + body: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "chunkIndex")] + chunk_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "totalChunks")] + total_chunks: Option, + #[serde(rename = "isChunked")] + is_chunked: bool, + #[serde(rename = "isStreaming")] + is_streaming: bool, + #[serde(rename = "isFirstChunk")] + is_first_chunk: bool, + #[serde(rename = "isLastChunk")] + is_last_chunk: bool, +} + +fn validate_and_build_request( + client: &reqwest::Client, + url: &str, + message: &TunnelMessage, + tunnel_secret: &str, + server_secret: &str, +) -> Result { + let incoming_secret = message + .headers + .as_ref() + .and_then(|h| { + h.iter() + .find(|(k, _)| k.eq_ignore_ascii_case("x-secret-key")) + .map(|(_, v)| v) + }) + .ok_or_else(|| anyhow::anyhow!("Missing tunnel secret header"))?; + + if !secure_compare(incoming_secret, tunnel_secret) { + anyhow::bail!("Invalid tunnel secret"); + } + + let mut request_builder = match message.method.as_str() { + "GET" => client.get(url), + "POST" => client.post(url), + "PUT" => client.put(url), + "DELETE" => client.delete(url), + "PATCH" => client.patch(url), + _ => client.get(url), + }; + + if let Some(headers) = &message.headers { + for (key, value) in headers { + if key.eq_ignore_ascii_case("x-secret-key") { + continue; + } + request_builder = request_builder.header(key, value); + } + } + + request_builder = request_builder.header("X-Secret-Key", server_secret); + + if let Some(body) = &message.body { + if message.method != "GET" && message.method != "HEAD" { + request_builder = request_builder.body(body.clone()); + } + } + + Ok(request_builder) +} + +async fn handle_streaming_response( + response: reqwest::Response, + status: u16, + headers_map: HashMap, + request_id: String, + message_path: String, + ws_tx: WebSocketSender, +) -> Result<()> { + info!("← {} {} [{}] (streaming)", status, message_path, request_id); + + let mut stream = response.bytes_stream(); + let mut chunk_index = 0; + let mut is_first_chunk = true; + + while let Some(chunk_result) = stream.next().await { + match chunk_result { + Ok(chunk) => { + let chunk_str = String::from_utf8_lossy(&chunk).to_string(); + let tunnel_response = TunnelResponse { + request_id: request_id.clone(), + status, + headers: if is_first_chunk { + Some(headers_map.clone()) + } else { + None + }, + body: Some(chunk_str), + error: None, + chunk_index: Some(chunk_index), + total_chunks: None, + is_chunked: false, + is_streaming: true, + is_first_chunk, + is_last_chunk: false, + }; + send_response(ws_tx.clone(), tunnel_response).await?; + chunk_index += 1; + is_first_chunk = false; + } + Err(e) => { + error!("Error reading stream chunk: {}", e); + break; + } + } + } + + let tunnel_response = TunnelResponse { + request_id: request_id.clone(), + status, + headers: None, + body: Some(String::new()), + error: None, + chunk_index: Some(chunk_index), + total_chunks: None, + is_chunked: false, + is_streaming: true, + is_first_chunk: false, + is_last_chunk: true, + }; + send_response(ws_tx, tunnel_response).await?; + info!( + "← {} {} [{}] (complete, {} chunks)", + status, message_path, request_id, chunk_index + ); + Ok(()) +} + +async fn handle_chunked_response( + body: String, + status: u16, + headers_map: HashMap, + request_id: String, + message_path: String, + ws_tx: WebSocketSender, +) -> Result<()> { + let total_chunks = body.len().div_ceil(MAX_WS_SIZE); + info!( + "← {} {} [{}] ({} bytes, {} chunks)", + status, + message_path, + request_id, + body.len(), + total_chunks + ); + + for (i, chunk) in body.as_bytes().chunks(MAX_WS_SIZE).enumerate() { + let chunk_str = String::from_utf8_lossy(chunk).to_string(); + let tunnel_response = TunnelResponse { + request_id: request_id.clone(), + status, + headers: if i == 0 { + Some(headers_map.clone()) + } else { + None + }, + body: Some(chunk_str), + error: None, + chunk_index: Some(i), + total_chunks: Some(total_chunks), + is_chunked: true, + is_streaming: false, + is_first_chunk: false, + is_last_chunk: false, + }; + send_response(ws_tx.clone(), tunnel_response).await?; + } + Ok(()) +} + +async fn handle_request( + message: TunnelMessage, + port: u16, + ws_tx: WebSocketSender, + tunnel_secret: String, + server_secret: String, +) -> Result<()> { + let request_id = message.request_id.clone(); + + let client = reqwest::Client::new(); + let url = format!("http://127.0.0.1:{}{}", port, message.path); + + let request_builder = + match validate_and_build_request(&client, &url, &message, &tunnel_secret, &server_secret) { + Ok(builder) => builder, + Err(e) => { + error!("✗ Authentication error [{}]: {}", request_id, e); + let error_response = TunnelResponse { + request_id, + status: 401, + headers: None, + body: None, + error: Some(e.to_string()), + chunk_index: None, + total_chunks: None, + is_chunked: false, + is_streaming: false, + is_first_chunk: false, + is_last_chunk: false, + }; + send_response(ws_tx, error_response).await?; + return Ok(()); + } + }; + + let response = match request_builder.send().await { + Ok(resp) => resp, + Err(e) => { + error!("✗ Request error [{}]: {}", request_id, e); + let error_response = TunnelResponse { + request_id, + status: 500, + headers: None, + body: None, + error: Some(e.to_string()), + chunk_index: None, + total_chunks: None, + is_chunked: false, + is_streaming: false, + is_first_chunk: false, + is_last_chunk: false, + }; + send_response(ws_tx, error_response).await?; + return Ok(()); + } + }; + + let status = response.status().as_u16(); + // Normalize header names to lowercase per RFC 7230 (HTTP headers are case-insensitive) + let headers_map: HashMap = response + .headers() + .iter() + .map(|(k, v)| { + ( + k.as_str().to_lowercase(), + v.to_str().unwrap_or("").to_string(), + ) + }) + .collect(); + + let is_streaming = headers_map + .get("content-type") + .map(|ct| ct.contains("text/event-stream")) + .unwrap_or(false); + + if is_streaming { + handle_streaming_response( + response, + status, + headers_map, + request_id, + message.path, + ws_tx, + ) + .await?; + } else { + let body = response.text().await.unwrap_or_default(); + + if body.len() > MAX_WS_SIZE { + handle_chunked_response(body, status, headers_map, request_id, message.path, ws_tx) + .await?; + } else { + let tunnel_response = TunnelResponse { + request_id: request_id.clone(), + status, + headers: Some(headers_map), + body: Some(body), + error: None, + chunk_index: None, + total_chunks: None, + is_chunked: false, + is_streaming: false, + is_first_chunk: false, + is_last_chunk: false, + }; + send_response(ws_tx, tunnel_response).await?; + } + } + + Ok(()) +} + +async fn send_response(ws_tx: WebSocketSender, response: TunnelResponse) -> Result<()> { + let json = serde_json::to_string(&response)?; + if let Some(tx) = ws_tx.write().await.as_mut() { + tx.send(Message::Text(json.into())) + .await + .context("Failed to send response")?; + } + Ok(()) +} + +fn configure_tcp_keepalive( + stream: &tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, +) { + let tcp_stream = stream.get_ref().get_ref(); + let socket_ref = SockRef::from(tcp_stream); + + let keepalive = TcpKeepalive::new() + .with_time(Duration::from_secs(30)) + .with_interval(Duration::from_secs(30)); + + if let Err(e) = socket_ref.set_tcp_keepalive(&keepalive) { + warn!("Failed to set TCP keep-alive: {}", e); + } else { + info!("✓ TCP keep-alive enabled (30s interval)"); + } +} + +async fn handle_websocket_messages( + mut read: futures::stream::SplitStream< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + >, + ws_tx: WebSocketSender, + port: u16, + tunnel_secret: String, + server_secret: String, + last_activity: Arc>, + active_tasks: Arc>>>, +) { + while let Some(msg) = read.next().await { + match msg { + Ok(Message::Text(text)) => { + *last_activity.write().await = Instant::now(); + + match serde_json::from_str::(&text) { + Ok(tunnel_msg) => { + let ws_tx_clone = ws_tx.clone(); + let tunnel_secret_clone = tunnel_secret.clone(); + let server_secret_clone = server_secret.clone(); + let task = tokio::spawn(async move { + if let Err(e) = handle_request( + tunnel_msg, + port, + ws_tx_clone, + tunnel_secret_clone, + server_secret_clone, + ) + .await + { + error!("Error handling request: {}", e); + } + }); + { + let mut tasks = active_tasks.write().await; + tasks.retain(|t| !t.is_finished()); + tasks.push(task); + } + } + Err(e) => { + error!("Error parsing tunnel message: {}", e); + } + } + } + Ok(Message::Close(_)) => { + info!("✗ Connection closed by server"); + break; + } + Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => { + *last_activity.write().await = Instant::now(); + } + Err(e) => { + error!("✗ WebSocket error: {}", e); + break; + } + _ => {} + } + } +} + +async fn cleanup_connection( + ws_tx: WebSocketSender, + active_tasks: Arc>>>, +) { + if let Some(mut tx) = ws_tx.write().await.take() { + let _ = tx.close().await; + } + + let tasks = active_tasks.write().await.drain(..).collect::>(); + info!("Aborting {} active request tasks", tasks.len()); + for task in tasks { + task.abort(); + } +} + +async fn run_single_connection( + port: u16, + agent_id: String, + tunnel_secret: String, + server_secret: String, + restart_tx: mpsc::Sender<()>, +) { + let worker_url = get_worker_url(); + let ws_url = worker_url + .replace("https://", "wss://") + .replace("http://", "ws://"); + + let url = format!("{}/connect?agent_id={}", ws_url, agent_id); + + info!("Connecting to {}...", url); + + let ws_stream = match tokio::time::timeout( + Duration::from_secs(CONNECTION_TIMEOUT_SECS), + connect_async(url.clone()), + ) + .await + { + Ok(Ok((stream, _))) => { + configure_tcp_keepalive(&stream); + stream + } + Ok(Err(e)) => { + error!("✗ WebSocket connection error: {}", e); + let _ = restart_tx.send(()).await; + return; + } + Err(_) => { + error!( + "✗ WebSocket connection timeout after {}s", + CONNECTION_TIMEOUT_SECS + ); + let _ = restart_tx.send(()).await; + return; + } + }; + + info!("✓ Connected as agent: {}", agent_id); + info!("✓ Proxying to: http://127.0.0.1:{}", port); + let public_url = format!("{}/tunnel/{}", worker_url, agent_id); + info!("✓ Public URL: {}", public_url); + + let (write, read) = ws_stream.split(); + let ws_tx: WebSocketSender = Arc::new(RwLock::new(Some(write))); + let last_activity = Arc::new(RwLock::new(Instant::now())); + let active_tasks: Arc>>> = Arc::new(RwLock::new(Vec::new())); + + let last_activity_clone = last_activity.clone(); + let idle_task = async move { + loop { + tokio::time::sleep(Duration::from_secs(60)).await; + let elapsed = last_activity_clone.read().await.elapsed(); + if elapsed > Duration::from_secs(IDLE_TIMEOUT_SECS) { + warn!( + "No activity for {} minutes, forcing reconnect", + IDLE_TIMEOUT_SECS / 60 + ); + break; + } + } + }; + + tokio::select! { + _ = idle_task => { + info!("✗ Idle timeout triggered"); + } + _ = handle_websocket_messages( + read, + ws_tx.clone(), + port, + tunnel_secret.clone(), + server_secret.clone(), + last_activity, + active_tasks.clone() + ) => { + info!("✗ Connection ended"); + } + } + + cleanup_connection(ws_tx, active_tasks).await; + + let _ = restart_tx.send(()).await; +} + +pub async fn start( + port: u16, + tunnel_secret: String, + server_secret: String, + agent_id: String, + handle: Arc>>>, + restart_tx: mpsc::Sender<()>, +) -> Result { + let worker_url = get_worker_url(); + + let agent_id_clone = agent_id.clone(); + let tunnel_secret_clone = tunnel_secret.clone(); + let server_secret_clone = server_secret; + + let task = tokio::spawn(async move { + run_single_connection( + port, + agent_id_clone, + tunnel_secret_clone, + server_secret_clone, + restart_tx, + ) + .await; + }); + + *handle.write().await = Some(task); + + let public_url = format!("{}/tunnel/{}", worker_url, agent_id); + let hostname = Url::parse(&worker_url)? + .host_str() + .unwrap_or("") + .to_string(); + + Ok(TunnelInfo { + state: super::TunnelState::Running, + url: public_url, + hostname, + secret: tunnel_secret, + }) +} + +pub async fn stop(handle: Arc>>>) { + if let Some(task) = handle.write().await.take() { + task.abort(); + info!("Lapstone tunnel stopped"); + } +} diff --git a/crates/goose-server/src/tunnel/lapstone_test.rs b/crates/goose-server/src/tunnel/lapstone_test.rs new file mode 100644 index 000000000000..c3a87d7948a6 --- /dev/null +++ b/crates/goose-server/src/tunnel/lapstone_test.rs @@ -0,0 +1,170 @@ +//! Integration tests for the Lapstone HTTP tunnel +//! +//! These tests verify the full tunnel flow: +//! 1. Start a local HTTP server +//! 2. Start the tunnel (connects to real Cloudflare worker via WebSocket) +//! 3. Make requests to the public HTTPS URL +//! 4. Verify they proxy through to the local server + +use super::lapstone; +use axum::{ + extract::Request, + response::Json, + routing::{get, post}, + Router, +}; +use serde_json::{json, Value}; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::sync::{mpsc, RwLock}; + +const TEST_TUNNEL_SECRET: &str = "test-tunnel-secret-12345"; +const TEST_SERVER_SECRET: &str = "test-server-secret-67890"; + +async fn find_available_port() -> u16 { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("Failed to bind to port 0"); + let addr = listener.local_addr().expect("Failed to get local address"); + addr.port() +} + +async fn health_handler() -> Json { + Json(json!({ + "status": "ok", + "message": "Test server is running" + })) +} + +async fn echo_handler(req: Request) -> Json { + let headers: Vec<(String, String)> = req + .headers() + .iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) + .collect(); + + let body = axum::body::to_bytes(req.into_body(), usize::MAX) + .await + .unwrap_or_default(); + let body_str = String::from_utf8_lossy(&body).to_string(); + + Json(json!({ + "headers": headers, + "body": body_str + })) +} + +fn create_test_server() -> Router { + Router::new() + .route("/health", get(health_handler)) + .route("/echo", post(echo_handler)) +} + +async fn start_test_http_server(port: u16) -> tokio::task::JoinHandle<()> { + let app = create_test_server(); + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + + tokio::spawn(async move { + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); + }) +} + +#[tokio::test] +async fn test_tunnel_end_to_end() { + let port = find_available_port().await; + let server_handle = start_test_http_server(port).await; + tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + + let handle = Arc::new(RwLock::new(None)); + let (restart_tx, _restart_rx) = mpsc::channel(1); + + let tunnel_secret = TEST_TUNNEL_SECRET.to_string(); + let server_secret = TEST_SERVER_SECRET.to_string(); + let agent_id = super::generate_agent_id(); + + let tunnel_info = lapstone::start( + port, + tunnel_secret.clone(), + server_secret.clone(), + agent_id.clone(), + handle.clone(), + restart_tx, + ) + .await + .expect("Failed to start tunnel"); + + let public_url = &tunnel_info.url; + println!("Tunnel public URL: {}", public_url); + + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + let client = reqwest::Client::new(); + let response = client + .get(format!("{}/health", public_url)) + .header("X-Secret-Key", &tunnel_secret) + .send() + .await + .expect("Failed to make request to public URL"); + + assert!( + response.status().is_success(), + "Response status: {}", + response.status() + ); + let body: Value = response.json().await.expect("Failed to parse JSON"); + assert_eq!(body["status"], "ok"); + assert_eq!(body["message"], "Test server is running"); + + lapstone::stop(handle).await; + server_handle.abort(); +} + +#[tokio::test] +async fn test_tunnel_post_request() { + let port = find_available_port().await; + let server_handle = start_test_http_server(port).await; + tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + + let handle = Arc::new(RwLock::new(None)); + let (restart_tx, _restart_rx) = mpsc::channel(1); + + let tunnel_secret = TEST_TUNNEL_SECRET.to_string(); + let server_secret = TEST_SERVER_SECRET.to_string(); + let agent_id = super::generate_agent_id(); + + let tunnel_info = lapstone::start( + port, + tunnel_secret.clone(), + server_secret.clone(), + agent_id.clone(), + handle.clone(), + restart_tx, + ) + .await + .expect("Failed to start tunnel"); + + let public_url = &tunnel_info.url; + + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + let client = reqwest::Client::new(); + let test_body = json!({"test": "data", "number": 42}); + let response = client + .post(format!("{}/echo", public_url)) + .header("X-Secret-Key", &tunnel_secret) + .header("Content-Type", "application/json") + .json(&test_body) + .send() + .await + .expect("Failed to make POST request"); + + assert!(response.status().is_success()); + let body: Value = response.json().await.expect("Failed to parse JSON"); + assert!(body["body"].as_str().unwrap().contains("test")); + assert!(body["body"].as_str().unwrap().contains("data")); + assert!(body["body"].as_str().unwrap().contains("42")); + + lapstone::stop(handle).await; + server_handle.abort(); +} diff --git a/crates/goose-server/src/tunnel/mod.rs b/crates/goose-server/src/tunnel/mod.rs new file mode 100644 index 000000000000..d87cc5db9ec9 --- /dev/null +++ b/crates/goose-server/src/tunnel/mod.rs @@ -0,0 +1,275 @@ +pub mod lapstone; + +#[cfg(test)] +mod lapstone_test; + +use crate::configuration::Settings; +use goose::config::Config; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::{mpsc, RwLock}; +use utoipa::ToSchema; + +fn get_server_port() -> anyhow::Result { + let settings = Settings::new()?; + Ok(settings.port) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum TunnelState { + #[default] + Idle, + Starting, + Running, + Error, + Disabled, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct TunnelInfo { + pub state: TunnelState, + pub url: String, + pub hostname: String, + pub secret: String, +} + +pub struct TunnelManager { + state: Arc>, + info: Arc>>, + lapstone_handle: Arc>>>, + restart_tx: Arc>>>, + watchdog_handle: Arc>>>, +} + +impl Default for TunnelManager { + fn default() -> Self { + Self::new() + } +} + +impl TunnelManager { + pub fn new() -> Self { + TunnelManager { + state: Arc::new(RwLock::new(TunnelState::Idle)), + info: Arc::new(RwLock::new(None)), + lapstone_handle: Arc::new(RwLock::new(None)), + restart_tx: Arc::new(RwLock::new(None)), + watchdog_handle: Arc::new(RwLock::new(None)), + } + } + + fn get_auto_start() -> bool { + Config::global() + .get_param("tunnel_auto_start") + .unwrap_or(false) + } + + fn get_secret() -> Option { + Config::global().get_secret("tunnel_secret").ok() + } + + fn get_agent_id() -> Option { + Config::global().get_secret("tunnel_agent_id").ok() + } + + pub async fn check_auto_start(&self) { + let auto_start = Self::get_auto_start(); + let state = self.state.read().await.clone(); + + if auto_start && state == TunnelState::Idle { + tracing::info!("Auto-starting tunnel"); + match self.start().await { + Ok(info) => { + tracing::info!("Tunnel auto-started successfully: {:?}", info.url); + } + Err(e) => { + tracing::error!("Failed to auto-start tunnel: {}", e); + } + } + } + } + + fn is_tunnel_disabled() -> bool { + if let Ok(val) = std::env::var("GOOSE_TUNNEL") { + let val = val.to_lowercase(); + val == "no" || val == "none" + } else { + false + } + } + + pub async fn get_info(&self) -> TunnelInfo { + if Self::is_tunnel_disabled() { + return TunnelInfo { + state: TunnelState::Disabled, + url: String::new(), + hostname: String::new(), + secret: String::new(), + }; + } + + let state = self.state.read().await.clone(); + let info = self.info.read().await.clone(); + + match info { + Some(mut tunnel_info) => { + tunnel_info.state = state; + tunnel_info + } + None => TunnelInfo { + state, + url: String::new(), + hostname: String::new(), + secret: String::new(), + }, + } + } + + pub fn set_auto_start(auto_start: bool) -> anyhow::Result<()> { + Config::global() + .set_param("tunnel_auto_start", auto_start) + .map_err(|e| anyhow::anyhow!("Failed to save tunnel config: {}", e)) + } + + pub fn set_secret(secret: &str) -> anyhow::Result<()> { + Config::global() + .set_secret("tunnel_secret", &secret.to_string()) + .map_err(|e| anyhow::anyhow!("Failed to save tunnel secret: {}", e)) + } + + pub fn set_agent_id(agent_id: &str) -> anyhow::Result<()> { + Config::global() + .set_secret("tunnel_agent_id", &agent_id.to_string()) + .map_err(|e| anyhow::anyhow!("Failed to save tunnel agent_id: {}", e)) + } + + async fn start_tunnel_internal(&self) -> anyhow::Result<(TunnelInfo, mpsc::Receiver<()>)> { + let server_port = get_server_port()?; + let tunnel_secret = Self::get_secret().unwrap_or_else(generate_secret); + let server_secret = + std::env::var("GOOSE_SERVER__SECRET_KEY").unwrap_or_else(|_| "test".to_string()); + let agent_id = Self::get_agent_id().unwrap_or_else(generate_agent_id); + + Self::set_secret(&tunnel_secret)?; + Self::set_agent_id(&agent_id)?; + + let (restart_tx, restart_rx) = mpsc::channel::<()>(1); + *self.restart_tx.write().await = Some(restart_tx.clone()); + + let result = lapstone::start( + server_port, + tunnel_secret, + server_secret, + agent_id, + self.lapstone_handle.clone(), + restart_tx, + ) + .await; + + match result { + Ok(info) => Ok((info, restart_rx)), + Err(e) => Err(e), + } + } + + pub async fn start(&self) -> anyhow::Result { + if Self::is_tunnel_disabled() { + anyhow::bail!("Tunnel is disabled via GOOSE_TUNNEL environment variable"); + } + + let mut state = self.state.write().await; + if *state != TunnelState::Idle { + anyhow::bail!("Tunnel is already running or starting"); + } + *state = TunnelState::Starting; + drop(state); + + match self.start_tunnel_internal().await { + Ok((info, mut restart_rx)) => { + *self.state.write().await = TunnelState::Running; + *self.info.write().await = Some(info.clone()); + let _ = Self::set_auto_start(true); + + let state = self.state.clone(); + let lapstone_handle = self.lapstone_handle.clone(); + let watchdog_handle_arc = self.watchdog_handle.clone(); + let manager = Arc::new(self.clone_for_watchdog()); + + let watchdog = tokio::spawn(async move { + while restart_rx.recv().await.is_some() { + let auto_start = Self::get_auto_start(); + if !auto_start { + tracing::info!("Tunnel connection lost but auto_start is disabled"); + break; + } + + tracing::warn!("Tunnel connection lost, initiating restart..."); + lapstone::stop(lapstone_handle.clone()).await; + *state.write().await = TunnelState::Idle; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + *state.write().await = TunnelState::Starting; + + match manager.start_tunnel_internal().await { + Ok((_, new_restart_rx)) => { + *state.write().await = TunnelState::Running; + tracing::info!("Tunnel restarted successfully"); + restart_rx = new_restart_rx; + } + Err(e) => { + tracing::error!("Failed to restart tunnel: {}", e); + *state.write().await = TunnelState::Error; + break; + } + } + } + }); + + *watchdog_handle_arc.write().await = Some(watchdog); + + Ok(info) + } + Err(e) => { + *self.state.write().await = TunnelState::Error; + Err(e) + } + } + } + + fn clone_for_watchdog(&self) -> Self { + TunnelManager { + state: self.state.clone(), + info: self.info.clone(), + lapstone_handle: self.lapstone_handle.clone(), + restart_tx: self.restart_tx.clone(), + watchdog_handle: self.watchdog_handle.clone(), + } + } + + pub async fn stop(&self, clear_auto_start: bool) { + if let Some(handle) = self.watchdog_handle.write().await.take() { + handle.abort(); + } + + *self.restart_tx.write().await = None; + + lapstone::stop(self.lapstone_handle.clone()).await; + + *self.state.write().await = TunnelState::Idle; + *self.info.write().await = None; + + if clear_auto_start { + let _ = Self::set_auto_start(false); + } + } +} + +fn generate_secret() -> String { + let bytes: [u8; 32] = rand::random(); + hex::encode(bytes) +} + +pub(super) fn generate_agent_id() -> String { + let bytes: [u8; 32] = rand::random(); + hex::encode(bytes) +} diff --git a/ui/desktop/.gitignore b/ui/desktop/.gitignore index c3482b5af576..2e53ae74cbc0 100644 --- a/ui/desktop/.gitignore +++ b/ui/desktop/.gitignore @@ -9,3 +9,5 @@ src/bin/goose-npm/ /test-results/ /src/bin/temporal-service src/bin/temporal.db +# Signing credentials +.env.signing diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 1507b4019a12..a7bc0d15ecbd 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -2263,6 +2263,92 @@ } } } + }, + "/tunnel/start": { + "post": { + "tags": [ + "super::routes::tunnel" + ], + "summary": "Start the tunnel", + "operationId": "start_tunnel", + "responses": { + "200": { + "description": "Tunnel started successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TunnelInfo" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/tunnel/status": { + "get": { + "tags": [ + "super::routes::tunnel" + ], + "summary": "Get tunnel info", + "operationId": "get_tunnel_status", + "responses": { + "200": { + "description": "Tunnel info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TunnelInfo" + } + } + } + } + } + } + }, + "/tunnel/stop": { + "post": { + "tags": [ + "super::routes::tunnel" + ], + "summary": "Stop the tunnel", + "operationId": "stop_tunnel", + "responses": { + "200": { + "description": "Tunnel stopped successfully" + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } } }, "components": { @@ -5133,6 +5219,39 @@ } } }, + "TunnelInfo": { + "type": "object", + "required": [ + "state", + "url", + "hostname", + "secret" + ], + "properties": { + "hostname": { + "type": "string" + }, + "secret": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/TunnelState" + }, + "url": { + "type": "string" + } + } + }, + "TunnelState": { + "type": "string", + "enum": [ + "idle", + "starting", + "running", + "error", + "disabled" + ] + }, "UpdateCustomProviderRequest": { "type": "object", "required": [ diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index fad7aec48d83..97f647581e5d 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -41,6 +41,7 @@ "katex": "^0.16.25", "lodash": "^4.17.21", "lucide-react": "^0.546.0", + "qrcode.react": "^4.2.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-icons": "^5.5.0", @@ -125,9 +126,9 @@ "license": "MIT" }, "node_modules/@ai-sdk/gateway": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.0.tgz", - "integrity": "sha512-Gj0PuawK7NkZuyYgO/h5kDK/l6hFOjhLdTq3/Lli1FTl47iGmwhH1IZQpAL3Z09BeFYWakcwUmn02ovIm2wy9g==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.1.tgz", + "integrity": "sha512-vPVIbnP35ZnayS937XLo85vynR85fpBQWHCdUweq7apzqFOTU2YkUd4V3msebEHbQ2Zro60ZShDDy9SMiyWTqA==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "2.0.0", @@ -271,9 +272,9 @@ } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.2.tgz", - "integrity": "sha512-ccKogJI+0aiDhOahdjANIc9SDixSud1gbwdVrhn7kMopAtLXqsz9MKmQQtIl6Y5aC2IYq+j4dz/oedL2AVMmVQ==", + "version": "6.7.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.3.tgz", + "integrity": "sha512-kiGFeY+Hxf5KbPpjRLf+ffWbkos1aGo8MBfd91oxS3O57RgU3XhZrt/6UzoVF9VMpWbC3v87SRc9jxGrc9qHtQ==", "dev": true, "license": "MIT", "dependencies": { @@ -316,9 +317,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", - "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", "dev": true, "license": "MIT", "engines": { @@ -326,22 +327,22 @@ } }, "node_modules/@babel/core": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", - "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", + "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.4", + "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.4", - "@babel/types": "^7.28.4", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -368,13 +369,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -470,9 +471,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -503,12 +504,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", - "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.4" + "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" @@ -573,17 +574,17 @@ } }, "node_modules/@babel/traverse": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", - "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", + "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.4", + "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4", + "@babel/types": "^7.28.5", "debug": "^4.3.1" }, "engines": { @@ -591,13 +592,13 @@ } }, "node_modules/@babel/types": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", - "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -2271,9 +2272,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -3038,9 +3039,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz", - "integrity": "sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA==", + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.2.tgz", + "integrity": "sha512-6rqTdFt67AAAzln3NOKsXRmv5ZzPkgbfaebKBqUbts7vK1GZudqnrun5a8d3M/h955cam9RHZ6Jb4Y1XhnmFPg==", "license": "MIT", "dependencies": { "ajv": "^6.12.6", @@ -5131,9 +5132,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.38", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", - "integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==", + "version": "1.0.0-beta.43", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz", + "integrity": "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==", "dev": true, "license": "MIT" }, @@ -5488,49 +5489,49 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.15.tgz", - "integrity": "sha512-HF4+7QxATZWY3Jr8OlZrBSXmwT3Watj0OogeDvdUY/ByXJHQ+LBtqA2brDb3sBxYslIFx6UP94BJ4X6a4L9Bmw==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz", + "integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", - "jiti": "^2.6.0", + "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.19", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.15" + "tailwindcss": "4.1.16" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.15.tgz", - "integrity": "sha512-krhX+UOOgnsUuks2SR7hFafXmLQrKxB4YyRTERuCE59JlYL+FawgaAlSkOYmDRJdf1Q+IFNDMl9iRnBW7QBDfQ==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz", + "integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==", "dev": true, "license": "MIT", "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.15", - "@tailwindcss/oxide-darwin-arm64": "4.1.15", - "@tailwindcss/oxide-darwin-x64": "4.1.15", - "@tailwindcss/oxide-freebsd-x64": "4.1.15", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.15", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.15", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.15", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.15", - "@tailwindcss/oxide-linux-x64-musl": "4.1.15", - "@tailwindcss/oxide-wasm32-wasi": "4.1.15", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.15", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.15" + "@tailwindcss/oxide-android-arm64": "4.1.16", + "@tailwindcss/oxide-darwin-arm64": "4.1.16", + "@tailwindcss/oxide-darwin-x64": "4.1.16", + "@tailwindcss/oxide-freebsd-x64": "4.1.16", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.16", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.16", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.16", + "@tailwindcss/oxide-linux-x64-musl": "4.1.16", + "@tailwindcss/oxide-wasm32-wasi": "4.1.16", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.16", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.16" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.15.tgz", - "integrity": "sha512-TkUkUgAw8At4cBjCeVCRMc/guVLKOU1D+sBPrHt5uVcGhlbVKxrCaCW9OKUIBv1oWkjh4GbunD/u/Mf0ql6kEA==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz", + "integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==", "cpu": [ "arm64" ], @@ -5545,9 +5546,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.15.tgz", - "integrity": "sha512-xt5XEJpn2piMSfvd1UFN6jrWXyaKCwikP4Pidcf+yfHTSzSpYhG3dcMktjNkQO3JiLCp+0bG0HoWGvz97K162w==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz", + "integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==", "cpu": [ "arm64" ], @@ -5562,9 +5563,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.15.tgz", - "integrity": "sha512-TnWaxP6Bx2CojZEXAV2M01Yl13nYPpp0EtGpUrY+LMciKfIXiLL2r/SiSRpagE5Fp2gX+rflp/Os1VJDAyqymg==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz", + "integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==", "cpu": [ "x64" ], @@ -5579,9 +5580,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.15.tgz", - "integrity": "sha512-quISQDWqiB6Cqhjc3iWptXVZHNVENsWoI77L1qgGEHNIdLDLFnw3/AfY7DidAiiCIkGX/MjIdB3bbBZR/G2aJg==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz", + "integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==", "cpu": [ "x64" ], @@ -5596,9 +5597,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.15.tgz", - "integrity": "sha512-ObG76+vPlab65xzVUQbExmDU9FIeYLQ5k2LrQdR2Ud6hboR+ZobXpDoKEYXf/uOezOfIYmy2Ta3w0ejkTg9yxg==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz", + "integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==", "cpu": [ "arm" ], @@ -5613,9 +5614,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.15.tgz", - "integrity": "sha512-4WbBacRmk43pkb8/xts3wnOZMDKsPFyEH/oisCm2q3aLZND25ufvJKcDUpAu0cS+CBOL05dYa8D4U5OWECuH/Q==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz", + "integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==", "cpu": [ "arm64" ], @@ -5630,9 +5631,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.15.tgz", - "integrity": "sha512-AbvmEiteEj1nf42nE8skdHv73NoR+EwXVSgPY6l39X12Ex8pzOwwfi3Kc8GAmjsnsaDEbk+aj9NyL3UeyHcTLg==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz", + "integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==", "cpu": [ "arm64" ], @@ -5647,9 +5648,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.15.tgz", - "integrity": "sha512-+rzMVlvVgrXtFiS+ES78yWgKqpThgV19ISKD58Ck+YO5pO5KjyxLt7AWKsWMbY0R9yBDC82w6QVGz837AKQcHg==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz", + "integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==", "cpu": [ "x64" ], @@ -5664,9 +5665,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.15.tgz", - "integrity": "sha512-fPdEy7a8eQN9qOIK3Em9D3TO1z41JScJn8yxl/76mp4sAXFDfV4YXxsiptJcOwy6bGR+70ZSwFIZhTXzQeqwQg==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz", + "integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==", "cpu": [ "x64" ], @@ -5681,9 +5682,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.15.tgz", - "integrity": "sha512-sJ4yd6iXXdlgIMfIBXuVGp/NvmviEoMVWMOAGxtxhzLPp9LOj5k0pMEMZdjeMCl4C6Up+RM8T3Zgk+BMQ0bGcQ==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz", + "integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -5711,9 +5712,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.15.tgz", - "integrity": "sha512-sJGE5faXnNQ1iXeqmRin7Ds/ru2fgCiaQZQQz3ZGIDtvbkeV85rAZ0QJFMDg0FrqsffZG96H1U9AQlNBRLsHVg==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz", + "integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==", "cpu": [ "arm64" ], @@ -5728,9 +5729,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.15.tgz", - "integrity": "sha512-NLeHE7jUV6HcFKS504bpOohyi01zPXi2PXmjFfkzTph8xRxDdxkRsXm/xDO5uV5K3brrE1cCwbUYmFUSHR3u1w==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz", + "integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==", "cpu": [ "x64" ], @@ -5758,15 +5759,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.15.tgz", - "integrity": "sha512-B6s60MZRTUil+xKoZoGe6i0Iar5VuW+pmcGlda2FX+guDuQ1G1sjiIy1W0frneVpeL/ZjZ4KEgWZHNrIm++2qA==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.16.tgz", + "integrity": "sha512-bbguNBcDxsRmi9nnlWJxhfDWamY3lmcyACHcdO1crxfzuLpOhHLLtEIN/nCbbAtj5rchUgQD17QVAKi1f7IsKg==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.1.15", - "@tailwindcss/oxide": "4.1.15", - "tailwindcss": "4.1.15" + "@tailwindcss/node": "4.1.16", + "@tailwindcss/oxide": "4.1.16", + "tailwindcss": "4.1.16" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" @@ -5786,12 +5787,13 @@ } }, "node_modules/@tanstack/form-core": { - "version": "1.24.3", - "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.24.3.tgz", - "integrity": "sha512-e+HzSD49NWr4aIqJWtPPzmi+/phBJAP3nSPN8dvxwmJWqAxuB/cH138EcmCFf3+oA7j3BXvwvTY0I+8UweGPjQ==", + "version": "1.24.4", + "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.24.4.tgz", + "integrity": "sha512-+eIR7DiDamit1zvTVgaHxuIRA02YFgJaXMUGxsLRJoBpUjGl/g/nhUocQoNkRyfXqOlh8OCMTanjwDprWSRq6w==", "license": "MIT", "dependencies": { - "@tanstack/devtools-event-client": "^0.3.2", + "@tanstack/devtools-event-client": "^0.3.3", + "@tanstack/pacer": "^0.15.3", "@tanstack/store": "^0.7.7" }, "funding": { @@ -5799,13 +5801,30 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/pacer": { + "version": "0.15.4", + "resolved": "https://registry.npmjs.org/@tanstack/pacer/-/pacer-0.15.4.tgz", + "integrity": "sha512-vGY+CWsFZeac3dELgB6UZ4c7OacwsLb8hvL2gLS6hTgy8Fl0Bm/aLokHaeDIP+q9F9HUZTnp360z9uv78eg8pg==", + "license": "MIT", + "dependencies": { + "@tanstack/devtools-event-client": "^0.3.2", + "@tanstack/store": "^0.7.5" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/react-form": { - "version": "1.23.7", - "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.23.7.tgz", - "integrity": "sha512-p/j9Gi2+s135sOjj48RjM+6xZQr1FVpliQlETLYBEGmmmxWHgYYs2b62mTDSnuv7AqtuZhpQ+t0CRFVfbQLsFA==", + "version": "1.23.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.23.8.tgz", + "integrity": "sha512-ivfkiOHAI3aIWkCY4FnPWVAL6SkQWGWNVjtwIZpaoJE4ulukZWZ1KB8TQKs8f4STl+egjTsMHrWJuf2fv3Xh1w==", "license": "MIT", "dependencies": { - "@tanstack/form-core": "1.24.3", + "@tanstack/form-core": "1.24.4", "@tanstack/react-store": "^0.7.7", "decode-formdata": "^0.9.0", "devalue": "^5.3.2" @@ -6131,9 +6150,9 @@ } }, "node_modules/@types/express": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", - "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.4.tgz", + "integrity": "sha512-g64dbryHk7loCIrsa0R3shBnEu5p6LPJ09bu9NG58+jz+cRUjFrc3Bz0kNQ7j9bXeCsrRDvNET1G54P/GJkAyA==", "dev": true, "license": "MIT", "dependencies": { @@ -6372,9 +6391,9 @@ } }, "node_modules/@types/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz", - "integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6382,9 +6401,9 @@ } }, "node_modules/@types/serve-static": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz", - "integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==", + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", "dev": true, "license": "MIT", "dependencies": { @@ -6394,9 +6413,9 @@ } }, "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.5", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", - "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", "dev": true, "license": "MIT", "dependencies": { @@ -6705,18 +6724,18 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz", - "integrity": "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.0.tgz", + "integrity": "sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.28.4", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.38", + "@rolldown/pluginutils": "1.0.0-beta.43", "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" + "react-refresh": "^0.18.0" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -7184,12 +7203,12 @@ } }, "node_modules/ai": { - "version": "5.0.76", - "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.76.tgz", - "integrity": "sha512-ZCxi1vrpyCUnDbtYrO/W8GLvyacV9689f00yshTIQ3mFFphbD7eIv40a2AOZBv3GGRA7SSRYIDnr56wcS/gyQg==", + "version": "5.0.80", + "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.80.tgz", + "integrity": "sha512-g1o6pjxm1eTtyh295dRhsg0gvZaHFlSo2oruWrK2rIR7KafWEhNB2A2/aJ9hyPT9AMI8JnQJyto1Tl9DMqwc9w==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/gateway": "2.0.0", + "@ai-sdk/gateway": "2.0.1", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.12", "@opentelemetry/api": "1.9.0" @@ -7488,9 +7507,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.7.tgz", - "integrity": "sha512-kr1Hy6YRZBkGQSb6puP+D6FQ59Cx4m0siYhAxygMCAgadiWQ6oxAxQXHOMvJx67SJ63jRoVIIg5eXzUbbct1ww==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz", + "integrity": "sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7644,9 +7663,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.19.tgz", - "integrity": "sha512-zoKGUdu6vb2jd3YOq0nnhEDQVbPcHhco3UImJrv5dSkvxTc2pl2WjOPsjZXDwPDSl5eghIMuY3R6J9NDKF3KcQ==", + "version": "2.8.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz", + "integrity": "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -7736,9 +7755,9 @@ } }, "node_modules/browserslist": { - "version": "4.26.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", - "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "version": "4.27.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", + "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", "dev": true, "funding": [ { @@ -7757,11 +7776,11 @@ "license": "MIT", "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.8.9", - "caniuse-lite": "^1.0.30001746", - "electron-to-chromium": "^1.5.227", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.8.19", + "caniuse-lite": "^1.0.30001751", + "electron-to-chromium": "^1.5.238", + "node-releases": "^2.0.26", + "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" @@ -8610,9 +8629,9 @@ } }, "node_modules/cronstrue": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-3.3.0.tgz", - "integrity": "sha512-iwJytzJph1hosXC09zY8F5ACDJKerr0h3/2mOxg9+5uuFObYlgK0m35uUPk4GCvhHc2abK7NfnR9oMqY0qZFAg==", + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-3.9.0.tgz", + "integrity": "sha512-T3S35zmD0Ai2B4ko6+mEM+k9C6tipe2nB9RLiGT6QL2Wn0Vsn2cCZAC8Oeuf4CaE00GZWVdpYitbpWCNlIWqdA==", "license": "MIT", "bin": { "cronstrue": "bin/cli.js" @@ -9044,9 +9063,9 @@ "license": "MIT" }, "node_modules/devalue": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.1.tgz", - "integrity": "sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.2.tgz", + "integrity": "sha512-MwPZTKEPK2k8Qgfmqrd48ZKVvzSQjgW0lXLxiIBA8dQjtf/6mw6pggHNLcyDKyf+fI6eXxlQwPsfaCMTU5U+Bw==", "license": "MIT" }, "node_modules/devlop": { @@ -9143,9 +9162,9 @@ "license": "MIT" }, "node_modules/electron": { - "version": "38.3.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-38.3.0.tgz", - "integrity": "sha512-Wij4AzX4SAV0X/ktq+NrWrp5piTCSS8F6YWh1KAcG+QRtNzyns9XLKERP68nFHIwfprhxF2YCN2uj7nx9DaeJw==", + "version": "38.4.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-38.4.0.tgz", + "integrity": "sha512-9CsXKbGf2qpofVe2pQYSgom2E//zLDJO2rGLLbxgy9tkdTOs7000Gte+d/PUtzLjI/DS95jDK0ojYAeqjLvpYg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -9650,9 +9669,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.237", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz", - "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==", + "version": "1.5.240", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.240.tgz", + "integrity": "sha512-OBwbZjWgrCOH+g6uJsA2/7Twpas2OlepS9uvByJjR2datRDuKGYeD+nP8lBBks2qnB7bGJNHDUx7c/YLaT3QMQ==", "dev": true, "license": "ISC" }, @@ -13174,9 +13193,9 @@ } }, "node_modules/knip": { - "version": "5.66.2", - "resolved": "https://registry.npmjs.org/knip/-/knip-5.66.2.tgz", - "integrity": "sha512-5wvsdc17C5bMxjuGfN9KVS/tW5KIvzP1RClfpTMdLYm8IXIsfWsiHlFkTvZIca9skwoVDyTyXmbRq4w1Poim+A==", + "version": "5.66.3", + "resolved": "https://registry.npmjs.org/knip/-/knip-5.66.3.tgz", + "integrity": "sha512-BEe9ZCI8fm4TJzehnrCt+L/Faqu6qfMH6VrwSfck+lCGotQzf0jh5dVXysPWjWqMpdUSr6+MpMu9JW/G6wiAcQ==", "dev": true, "funding": [ { @@ -13536,14 +13555,14 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "16.2.5", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.5.tgz", - "integrity": "sha512-o36wH3OX0jRWqDw5dOa8a8x6GXTKaLM+LvhRaucZxez0IxA+KNDUCiyjBfNgsMNmchwSX6urLSL7wShcUqAang==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.6.tgz", + "integrity": "sha512-s1gphtDbV4bmW1eylXpVMk2u7is7YsrLl8hzrtvC70h4ByhcMLZFY01Fx05ZUDNuv1H8HO4E+e2zgejV1jVwNw==", "dev": true, "license": "MIT", "dependencies": { "commander": "^14.0.1", - "listr2": "^9.0.4", + "listr2": "^9.0.5", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", @@ -13619,9 +13638,9 @@ } }, "node_modules/lint-staged/node_modules/cli-truncate": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.0.tgz", - "integrity": "sha512-7JDGG+4Zp0CsknDCedl0DYdaeOhc46QNpXi3NLQblkZpXXgA6LncLDUUyvrjSvZeF3VRQa+KiMGomazQrC1V8g==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", + "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", "dev": true, "license": "MIT", "dependencies": { @@ -13636,9 +13655,9 @@ } }, "node_modules/lint-staged/node_modules/commander": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.1.tgz", - "integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", "dev": true, "license": "MIT", "engines": { @@ -14205,9 +14224,9 @@ } }, "node_modules/magic-string": { - "version": "0.30.19", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", - "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -16861,6 +16880,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -17115,9 +17143,9 @@ } }, "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", "dev": true, "license": "MIT", "engines": { @@ -19060,9 +19088,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.15.tgz", - "integrity": "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz", + "integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==", "license": "MIT", "peer": true }, @@ -19904,9 +19932,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", "dev": true, "funding": [ { @@ -20106,9 +20134,9 @@ } }, "node_modules/vite": { - "version": "7.1.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", - "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", + "version": "7.1.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", + "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", "peer": true, diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 34857eae72c9..a7105296165b 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -71,6 +71,7 @@ "katex": "^0.16.25", "lodash": "^4.17.21", "lucide-react": "^0.546.0", + "qrcode.react": "^4.2.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-icons": "^5.5.0", diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 6f0e8186f1be..c81373db530c 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; +import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -604,3 +604,33 @@ export const status = (options?: Options(options?: Options) => { + return (options?.client ?? client).post({ + url: '/tunnel/start', + ...options + }); +}; + +/** + * Get tunnel info + */ +export const getTunnelStatus = (options?: Options) => { + return (options?.client ?? client).get({ + url: '/tunnel/status', + ...options + }); +}; + +/** + * Stop the tunnel + */ +export const stopTunnel = (options?: Options) => { + return (options?.client ?? client).post({ + url: '/tunnel/stop', + ...options + }); +}; diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 901e0c35890d..00f29ea9a58f 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -921,6 +921,15 @@ export type ToolResponse = { }; }; +export type TunnelInfo = { + hostname: string; + secret: string; + state: TunnelState; + url: string; +}; + +export type TunnelState = 'idle' | 'starting' | 'running' | 'error' | 'disabled'; + export type UpdateCustomProviderRequest = { api_key: string; api_url: string; @@ -2728,3 +2737,71 @@ export type StatusResponses = { }; export type StatusResponse = StatusResponses[keyof StatusResponses]; + +export type StartTunnelData = { + body?: never; + path?: never; + query?: never; + url: '/tunnel/start'; +}; + +export type StartTunnelErrors = { + /** + * Bad request + */ + 400: ErrorResponse; + /** + * Internal server error + */ + 500: ErrorResponse; +}; + +export type StartTunnelError = StartTunnelErrors[keyof StartTunnelErrors]; + +export type StartTunnelResponses = { + /** + * Tunnel started successfully + */ + 200: TunnelInfo; +}; + +export type StartTunnelResponse = StartTunnelResponses[keyof StartTunnelResponses]; + +export type GetTunnelStatusData = { + body?: never; + path?: never; + query?: never; + url: '/tunnel/status'; +}; + +export type GetTunnelStatusResponses = { + /** + * Tunnel info + */ + 200: TunnelInfo; +}; + +export type GetTunnelStatusResponse = GetTunnelStatusResponses[keyof GetTunnelStatusResponses]; + +export type StopTunnelData = { + body?: never; + path?: never; + query?: never; + url: '/tunnel/stop'; +}; + +export type StopTunnelErrors = { + /** + * Internal server error + */ + 500: ErrorResponse; +}; + +export type StopTunnelError = StopTunnelErrors[keyof StopTunnelErrors]; + +export type StopTunnelResponses = { + /** + * Tunnel stopped successfully + */ + 200: unknown; +}; diff --git a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx index b9e11b5a5909..f5564521d80c 100644 --- a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx +++ b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx @@ -4,6 +4,7 @@ import { Button } from '../../ui/button'; import { Settings, RefreshCw, ExternalLink } from 'lucide-react'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog'; import UpdateSection from './UpdateSection'; +import TunnelSection from '../tunnel/TunnelSection'; import { COST_TRACKING_ENABLED, UPDATES_ENABLED } from '../../../updates'; import { getApiUrl } from '../../../config'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; @@ -394,6 +395,8 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti + + Help & feedback diff --git a/ui/desktop/src/components/settings/tunnel/TunnelSection.tsx b/ui/desktop/src/components/settings/tunnel/TunnelSection.tsx new file mode 100644 index 000000000000..abf5fedde845 --- /dev/null +++ b/ui/desktop/src/components/settings/tunnel/TunnelSection.tsx @@ -0,0 +1,335 @@ +import { useState, useEffect } from 'react'; +import { Button } from '../../ui/button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../ui/dialog'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; +import { QRCodeSVG } from 'qrcode.react'; +import { + Loader2, + Copy, + Check, + ChevronDown, + ChevronUp, + Info, + ExternalLink, + QrCode, +} from 'lucide-react'; +import { errorMessage } from '../../../utils/conversionUtils'; +import { startTunnel, stopTunnel, getTunnelStatus } from '../../../api/sdk.gen'; +import type { TunnelInfo } from '../../../api/types.gen'; + +const STATUS_MESSAGES = { + idle: 'Tunnel is not running', + starting: 'Starting tunnel...', + running: 'Tunnel is active', + error: 'Tunnel encountered an error', + disabled: 'Tunnel is disabled', +} as const; + +const IOS_APP_STORE_URL = 'https://apps.apple.com/us/app/goose-ai/id6752889295'; + +export default function TunnelSection() { + const [tunnelInfo, setTunnelInfo] = useState({ + state: 'idle', + url: '', + hostname: '', + secret: '', + }); + const [showQRModal, setShowQRModal] = useState(false); + const [showAppStoreQRModal, setShowAppStoreQRModal] = useState(false); + const [error, setError] = useState(null); + const [copiedUrl, setCopiedUrl] = useState(false); + const [copiedSecret, setCopiedSecret] = useState(false); + const [showDetails, setShowDetails] = useState(false); + + useEffect(() => { + const loadTunnelInfo = async () => { + try { + const { data } = await getTunnelStatus(); + if (data) { + setTunnelInfo(data); + } + } catch (err) { + const errorMsg = errorMessage(err, 'Failed to load tunnel status'); + setError(errorMsg); + setTunnelInfo({ state: 'error', url: '', hostname: '', secret: '' }); + } + }; + + loadTunnelInfo(); + }, []); + + const handleToggleTunnel = async () => { + if (tunnelInfo.state === 'running') { + try { + await stopTunnel(); + setTunnelInfo({ state: 'idle', url: '', hostname: '', secret: '' }); + setShowQRModal(false); + } catch (err) { + setError(errorMessage(err, 'Failed to stop tunnel')); + try { + const { data } = await getTunnelStatus(); + if (data) { + setTunnelInfo(data); + } + } catch (statusErr) { + console.error('Failed to fetch tunnel status after stop error:', statusErr); + } + } + } else { + setError(null); + setTunnelInfo({ state: 'starting', url: '', hostname: '', secret: '' }); + + try { + const { data } = await startTunnel(); + if (data) { + setTunnelInfo(data); + setShowQRModal(true); + } + } catch (err) { + const errorMsg = errorMessage(err, 'Failed to start tunnel'); + setError(errorMsg); + setTunnelInfo({ state: 'error', url: '', hostname: '', secret: '' }); + } + } + }; + + const copyToClipboard = async (text: string, type: 'url' | 'secret') => { + try { + await navigator.clipboard.writeText(text); + if (type === 'url') { + setCopiedUrl(true); + setTimeout(() => setCopiedUrl(false), 2000); + } else { + setCopiedSecret(true); + setTimeout(() => setCopiedSecret(false), 2000); + } + } catch (err) { + console.error('Failed to copy to clipboard:', err); + } + }; + + const getQRCodeData = () => { + if (tunnelInfo.state !== 'running') return ''; + + const configJson = JSON.stringify({ + url: tunnelInfo.url, + secret: tunnelInfo.secret, + }); + const urlEncodedConfig = encodeURIComponent(configJson); + return `goosechat://configure?data=${urlEncodedConfig}`; + }; + + if (tunnelInfo.state === 'disabled') { + return null; + } + + return ( + <> + + + Remote Access + +
+ +
+ Preview feature: Enable remote access to goose from mobile devices + using secure tunneling.{' '} + + Get the iOS app + + + {' or '} + +
+
+
+
+ + {error && ( +
+ {error} +
+ )} + +
+
+

Tunnel Status

+

+ {STATUS_MESSAGES[tunnelInfo.state]} +

+
+
+ {tunnelInfo.state === 'starting' ? ( + + ) : tunnelInfo.state === 'running' ? ( + <> + + + + ) : ( + + )} +
+
+ + {tunnelInfo.state === 'running' && ( +
+

+ URL: {tunnelInfo.url} +

+
+ )} +
+
+ + + + + Remote Access Connection + + + {tunnelInfo.state === 'running' && ( +
+
+
+ +
+
+ +
+ Scan this QR code with the goose mobile app. Do not share this code with anyone else + as it is for your personal access. +
+ +
+ + + {showDetails && ( +
+
+

Tunnel URL

+
+ + {tunnelInfo.url} + + +
+
+ +
+

Secret Key

+
+ + {tunnelInfo.secret} + + +
+
+
+ )} +
+
+ )} + + + + + +
+
+ + + + + Download goose iOS App + + +
+
+
+ +
+
+ +
+ Scan this QR code with your iPhone camera to install the goose mobile app from the App + Store +
+ + +
+ + + + +
+
+ + ); +} diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index bf01f3848d78..f994e817cabd 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -1788,7 +1788,6 @@ async function appMain() { app.dock?.hide(); } - // Parse command line arguments const { dirPath } = parseArgs(); await createNewWindow(app, dirPath); diff --git a/ui/desktop/vite.renderer.config.mts b/ui/desktop/vite.renderer.config.mts index 66464c10f97b..9a3c558b7c90 100644 --- a/ui/desktop/vite.renderer.config.mts +++ b/ui/desktop/vite.renderer.config.mts @@ -6,6 +6,7 @@ export default defineConfig({ define: { // This replaces process.env.ALPHA with a literal at build time 'process.env.ALPHA': JSON.stringify(process.env.ALPHA === 'true'), + 'process.env.GOOSE_TUNNEL': JSON.stringify(process.env.GOOSE_TUNNEL !== 'no' && process.env.GOOSE_TUNNEL !== 'none'), }, plugins: [tailwindcss()],