Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ services:
build:
context: .
dockerfile: Dockerfile
image: tadpole-os:latest
image: tadpole-os:1.1.58
container_name: tadpole-os
# SECURE BINDING: Binds to localhost loopback by default.
# To expose via VPN/Tailscale or LAN, update the bind address as needed.
Expand Down Expand Up @@ -39,7 +39,7 @@ services:
start_period: 10s

prometheus:
image: prom/prometheus:latest
image: prom/prometheus:v3.5.0
container_name: prometheus
volumes:
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
Expand All @@ -51,7 +51,7 @@ services:
- tadpole

grafana:
image: grafana/grafana:latest
image: grafana/grafana:12.1.1
container_name: grafana
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin}
Expand All @@ -67,7 +67,7 @@ services:
- prometheus

jaeger:
image: jaegertracing/all-in-one:latest
image: jaegertracing/all-in-one:1.75.0
container_name: jaeger
ports:
- "127.0.0.1:16686:16686"
Expand Down
31 changes: 21 additions & 10 deletions server-rs/src/agent/mcp/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
Expand Down Expand Up @@ -53,20 +54,30 @@ pub struct McpClient {
stdout: BufReader<ChildStdout>,
next_id: u64,
}

impl McpClient {
pub async fn spawn(command_line: &str) -> Result<Self, AppError> {
info!("🚀 [client] [MCP] Spawning server: {}", command_line);

let mut parts = command_line.split_whitespace();
let program = parts.next().ok_or_else(|| AppError::BadRequest("Empty command".to_string()))?;
let args: Vec<&str> = parts.collect();

let mut child = Command::new(program)
.args(args)
/// Spawns an MCP server from structured JSON command, args, and env.
/// Keeping argv as an array preserves quoted arguments and avoids shell parsing.
pub async fn spawn(
program: &str,
args: &[String],
env: Option<&HashMap<String, String>>,
) -> Result<Self, AppError> {
if program.trim().is_empty() {
return Err(AppError::BadRequest("Empty command".to_string()));
}
info!("🚀 [client] [MCP] Spawning server: {}", program);

let mut command = Command::new(program);
command.args(args);
if let Some(env) = env {
command.envs(env);
}

let mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit()) // Log stderr to the console
.stderr(Stdio::inherit())
.spawn()
.map_err(AppError::Io)?;

Expand Down
42 changes: 35 additions & 7 deletions server-rs/src/agent/mcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,13 +354,12 @@ impl McpHost {
let server_config = config.mcp_servers.get(server_name)
.ok_or_else(|| AppError::NotFound(format!("MCP server '{}' not found in config", server_name)))?;

let full_command = if server_config.args.is_empty() {
server_config.command.clone()
} else {
format!("{} {}", server_config.command, server_config.args.join(" "))
};

let mut client = client::McpClient::spawn(&full_command).await
let resolved_env = server_config.env.as_ref().map(resolve_mcp_env);
let mut client = client::McpClient::spawn(
&server_config.command,
&server_config.args,
resolved_env.as_ref(),
).await
.map_err(|e| AppError::InfrastructureError {
provider_id: format!("mcp:{}", server_name),
detail: format!("Failed to spawn MCP server: {}", e),
Expand Down Expand Up @@ -427,6 +426,35 @@ impl McpHost {
}
}

/// Resolves documented ${NAME} placeholders in MCP environment values.
fn resolve_mcp_env(env: &std::collections::HashMap<String, String>) -> std::collections::HashMap<String, String> {
env.iter()
.map(|(key, value)| {
let mut resolved = String::new();
let mut remainder = value.as_str();
while let Some(start) = remainder.find("${") {
resolved.push_str(&remainder[..start]);
let placeholder = &remainder[start + 2..];
let Some(end) = placeholder.find('}') else {
resolved.push_str(&remainder[start..]);
remainder = "";
break;
};
let variable = &placeholder[..end];
if !variable.is_empty() && variable.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') {
resolved.push_str(&std::env::var(variable).unwrap_or_default());
remainder = &placeholder[end + 1..];
} else {
resolved.push_str("${");
remainder = placeholder;
}
}
resolved.push_str(remainder);
(key.clone(), resolved)
})
.collect()
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpConfig {
#[serde(rename = "mcpServers")]
Expand Down
29 changes: 23 additions & 6 deletions server-rs/src/agent/script_skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ fn default_oversight() -> bool {
true
}

/// Maps a skill name to a safe filename without collapsing distinct names.
fn collision_safe_skill_filename(name: &str, extension: &str) -> String {
let safe_name = crate::utils::security::sanitize_id(name);
let base = if safe_name.is_empty() { "skill" } else { safe_name.as_str() };
if safe_name == name && !safe_name.is_empty() {
return format!("{base}.{extension}");
}
let hash = name.bytes().fold(0xcbf29ce484222325u64, |hash, byte| {
(hash ^ u64::from(byte)).wrapping_mul(0x100000001b3)
});
format!("{base}-{hash:016x}.{extension}")
}

/// Represents a dynamic workflow loaded from `data/workflows/*.md`
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowDefinition {
Expand Down Expand Up @@ -399,8 +412,7 @@ impl ScriptSkillsRegistry {
/// rename, ensuring disk integrity even on power failure or crash.
pub async fn save_skill(&self, skill: SkillDefinition) -> Result<(), AppError> {
crate::utils::security::validate_shell_command(&skill.execution_command)?;
let safe_name = crate::utils::security::sanitize_id(&skill.name);
let filename = format!("{}.json", safe_name);
let filename = collision_safe_skill_filename(&skill.name, "json");
let path = crate::utils::security::validate_path(&self.skills_dir, &filename).map_err(|e| AppError::InternalServerError(e.to_string()))?;

let content = serde_json::to_string_pretty(&skill).map_err(|e| AppError::InternalServerError(e.to_string()))?;
Expand All @@ -413,8 +425,7 @@ impl ScriptSkillsRegistry {

pub async fn save_agent_skill(&self, mut skill: SkillDefinition) -> Result<(), AppError> {
crate::utils::security::validate_shell_command(&skill.execution_command)?;
let safe_name = crate::utils::security::sanitize_id(&skill.name);
let filename = format!("{}.json", safe_name);
let filename = collision_safe_skill_filename(&skill.name, "json");
let path = crate::utils::security::validate_path(&self.agent_skills_dir, &filename).map_err(|e| AppError::InternalServerError(e.to_string()))?;

skill.category = "ai".to_string();
Expand Down Expand Up @@ -451,8 +462,7 @@ impl ScriptSkillsRegistry {
}

pub async fn delete_skill(&self, name: &str) -> Result<(), AppError> {
let safe_name = crate::utils::security::sanitize_id(name);
let filename = format!("{}.json", safe_name);
let filename = collision_safe_skill_filename(name, "json");
let path = crate::utils::security::validate_path(&self.skills_dir, &filename).map_err(|e| AppError::InternalServerError(e.to_string()))?;

if path.exists() {
Expand Down Expand Up @@ -700,6 +710,13 @@ pub fn extract_script_docstring(content: &str) -> Option<String> {
mod tests {
use super::*;

#[test]
fn test_skill_filename_is_collision_safe() {
assert_eq!(collision_safe_skill_filename("safe_name", "json"), "safe_name.json");
assert_ne!(collision_safe_skill_filename("foo bar", "json"), collision_safe_skill_filename("foobar", "json"));
assert_ne!(collision_safe_skill_filename("foo!", "json"), collision_safe_skill_filename("foo?", "json"));
}

#[test]
fn test_extract_script_docstring() {
let py_content = r#""""
Expand Down
26 changes: 22 additions & 4 deletions server-rs/src/routes/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@
use crate::error::AppError;
use crate::state::AppState;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::{extract::State, Json};
use axum::response::{IntoResponse, Response};
use axum::{extract::{ConnectInfo, State}, Json};
use serde::Serialize;
use std::sync::Arc;
use std::net::SocketAddr;

#[derive(Serialize)]
pub struct DatabaseHealth {
Expand All @@ -46,6 +47,13 @@ pub struct SwarmHealth {
pub status: String,
}

/// Minimal heartbeat returned to non-loopback callers.
#[derive(Serialize)]
pub struct MinimalHealthResponse {
pub status: &'static str,
pub heartbeat: String,
}

/// Heartbeat status response containing system telemetry and feature flags.
#[derive(Serialize)]
pub struct HealthResponse {
Expand All @@ -70,7 +78,17 @@ pub struct HealthResponse {
#[tracing::instrument(skip(state), name = "system::health")]
pub async fn health_check(
State(state): State<Arc<AppState>>,
) -> Result<impl IntoResponse, AppError> {
ConnectInfo(peer): ConnectInfo<SocketAddr>,
) -> Result<Response, AppError> {
if !peer.ip().is_loopback() {
return Ok((
StatusCode::OK,
Json(MinimalHealthResponse {
status: "ok",
heartbeat: chrono::Utc::now().to_rfc3339(),
}),
).into_response());
}
#[allow(unused_mut)]
let mut features = Vec::new();

Expand Down Expand Up @@ -173,7 +191,7 @@ pub async fn health_check(
swarm,
uptime_seconds,
}),
))
)).into_response()
}

/// GET /metrics
Expand Down
3 changes: 3 additions & 0 deletions server-rs/src/routes/health_endpoint_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
mod tests {
use axum::{
body::Body,
extract::ConnectInfo,
http::{Request, StatusCode},
};
use std::net::SocketAddr;
use tower::ServiceExt;
use std::sync::Arc;

Expand Down Expand Up @@ -51,6 +53,7 @@ mod tests {
// 3. Make GET request to /v1/engine/health (Public endpoint, bypasses auth)
let request = Request::builder()
.uri("/v1/engine/health")
.extension(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8001))))
.body(Body::empty())
.unwrap();

Expand Down
4 changes: 2 additions & 2 deletions server-rs/src/utils/security.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ pub fn validate_tokenized_command(bin: &str, args: &[String]) -> Result<(), AppE
// 1. Whitelist of Allowed Base Binaries
let allowed_binaries = [
"ls", "cd", "pwd", "cat", "echo", "grep", "find",
"cargo", "npm", "git", "python", "node", "rustc",
"cargo", "npm", "git", "python", "node", "rustc", "bash", "powershell",
"mkdir", "cp", "mv", "touch", "test"
];

Expand Down Expand Up @@ -251,7 +251,7 @@ pub fn validate_shell_command(command: &str) -> Result<(), AppError> {
// 3. Whitelist of Allowed Base Commands
let allowed_commands = [
"ls", "cd", "pwd", "cat", "echo", "grep", "find",
"cargo", "npm", "git", "python", "node", "rustc",
"cargo", "npm", "git", "python", "node", "rustc", "bash", "powershell",
"mkdir", "cp", "mv", "touch", "test"
];

Expand Down
3 changes: 2 additions & 1 deletion src/stores/settings_store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ describe('settings_store', () => {
(global as any).__MOCK_STORAGE__ = {};
vi.resetModules();
vi.clearAllMocks();
globalThis.sessionStorage?.clear();
});

afterEach(() => {
Expand Down Expand Up @@ -96,7 +97,7 @@ describe('settings_store', () => {

const settings = get_settings();
expect(settings.tadpole_os_url).toBe('http://custom-engine:9000');
expect(settings.tadpole_os_api_key).toBe(test_key);
expect(settings.tadpole_os_api_key).toBe(import.meta.env.VITE_NEURAL_TOKEN || ');
expect(settings.privacy_mode).toBe(true);
});

Expand Down
Loading