diff --git a/runner/src/config.rs b/runner/src/config.rs index 1bff4e6..4a5d5b9 100644 --- a/runner/src/config.rs +++ b/runner/src/config.rs @@ -1,20 +1,42 @@ //! Runner configuration loaded from config.toml use serde::Deserialize; +use std::collections::HashMap; +use std::path::PathBuf; #[derive(Debug, Deserialize)] pub struct Config { #[serde(default = "default_port")] pub port: u16, + /// REST API port — consumed by Wave-2 REST server (T-7b). + #[serde(default = "default_rest_port")] + #[allow(dead_code)] + pub rest_port: u16, + /// Data directory — consumed by Wave-2 SQLite store (T-3). + #[serde(default = "default_data_dir")] + #[allow(dead_code)] + pub data_dir: PathBuf, #[serde(default = "default_max_sessions")] pub max_sessions: usize, #[serde(default = "default_scrollback_lines")] pub scrollback_lines: usize, - #[serde(default)] + #[serde(default = "default_shell_allowlist")] pub shell_allowlist: Vec, pub tls: Option, pub auth: Option, pub mdns: Option, + /// Docker config — consumed by Wave-2 DockerOrchestrator (T-4). + #[serde(default)] + #[allow(dead_code)] + pub docker: DockerConfig, + /// Git config — consumed by Wave-2 ProjectManager (T-5). + #[serde(default)] + #[allow(dead_code)] + pub git: GitConfig, + /// Session profiles — consumed by Wave-3 session management (T-8c). + #[serde(default)] + #[allow(dead_code)] + pub profiles: Vec, } #[derive(Debug, Deserialize)] @@ -46,26 +68,254 @@ pub struct AuthConfig { pub require_mtls: bool, } +/// Docker configuration for container execution +#[derive(Debug, Deserialize, Clone)] +#[allow(dead_code)] // Fields consumed by Wave-2 DockerOrchestrator (T-4) +pub struct DockerConfig { + /// Path to Docker socket (default: /var/run/docker.sock) + #[serde(default = "default_docker_socket")] + pub socket_path: String, + /// List of allowed image references; empty list means all images allowed + #[serde(default)] + pub allowed_images: Vec, + /// Timeout in seconds for exec operations (default: 30) + #[serde(default = "default_exec_timeout")] + pub exec_timeout_secs: u64, +} + +/// Git configuration for clone operations and security +#[derive(Debug, Deserialize, Clone)] +#[allow(dead_code)] // Fields consumed by Wave-2 ProjectManager (T-5) +pub struct GitConfig { + /// Timeout in seconds for clone operations (default: 300) + #[serde(default = "default_clone_timeout")] + pub clone_timeout_secs: u64, + /// Whitelist of allowed hosts for SSRF protection; empty list means all hosts allowed + #[serde(default)] + pub allowed_hosts: Vec, +} + +/// Profile configuration for predefined terminal session templates +#[derive(Debug, Deserialize, Clone)] +#[allow(dead_code)] // Fields consumed by Wave-3 session management (T-8c) +pub struct ProfileConfig { + /// Profile name (e.g., "claude-default", "ubuntu-latest") + pub name: String, + /// Docker image reference + pub image: String, + /// Environment variable overrides (key-value pairs) + #[serde(default)] + pub env_overrides: HashMap, +} + fn default_port() -> u16 { 50051 } + +fn default_rest_port() -> u16 { + 8080 +} + fn default_max_sessions() -> usize { 50 } + fn default_scrollback_lines() -> usize { 10_000 } +fn default_docker_socket() -> String { + "/var/run/docker.sock".to_string() +} + +fn default_exec_timeout() -> u64 { + 30 +} + +fn default_clone_timeout() -> u64 { + 300 +} + +fn default_shell_allowlist() -> Vec { + vec!["/bin/bash".into(), "/bin/zsh".into(), "/bin/sh".into()] +} + +fn default_data_dir() -> PathBuf { + dirs::data_local_dir() + .unwrap_or_else(std::env::temp_dir) + .join("relay") +} + impl Default for Config { fn default() -> Self { Self { port: default_port(), + rest_port: default_rest_port(), + data_dir: default_data_dir(), max_sessions: default_max_sessions(), scrollback_lines: default_scrollback_lines(), - shell_allowlist: vec!["/bin/bash".into(), "/bin/zsh".into(), "/bin/sh".into()], + shell_allowlist: default_shell_allowlist(), tls: None, auth: None, mdns: None, + docker: DockerConfig::default(), + git: GitConfig::default(), + profiles: Vec::new(), + } + } +} + +impl Default for DockerConfig { + fn default() -> Self { + Self { + socket_path: default_docker_socket(), + allowed_images: Vec::new(), + exec_timeout_secs: default_exec_timeout(), + } + } +} + +impl Default for GitConfig { + fn default() -> Self { + Self { + clone_timeout_secs: default_clone_timeout(), + allowed_hosts: Vec::new(), } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_empty_toml() { + let toml_str = ""; + let config: Config = toml::from_str(toml_str).expect("should parse empty config"); + assert_eq!(config.port, 50051); + assert_eq!(config.rest_port, 8080); + assert_eq!(config.max_sessions, 50); + assert!(config.tls.is_none()); + assert!(config.auth.is_none()); + assert!(config.mdns.is_none()); + assert_eq!(config.shell_allowlist, ["/bin/bash", "/bin/zsh", "/bin/sh"]); + } + + #[test] + fn test_docker_config_defaults() { + let toml_str = "[docker]"; + let config: Config = toml::from_str(toml_str).expect("should parse docker section"); + assert_eq!(config.docker.socket_path, "/var/run/docker.sock"); + assert!(config.docker.allowed_images.is_empty()); + assert_eq!(config.docker.exec_timeout_secs, 30); + } + + #[test] + fn test_docker_config_with_allowed_images() { + let toml_str = r#" +[docker] +socket_path = "/var/run/docker.sock" +allowed_images = ["ubuntu:24.04", "alpine:latest"] +exec_timeout_secs = 60 +"#; + let config: Config = toml::from_str(toml_str).expect("should parse docker config"); + assert_eq!(config.docker.socket_path, "/var/run/docker.sock"); + assert_eq!(config.docker.allowed_images.len(), 2); + assert_eq!(config.docker.allowed_images[0], "ubuntu:24.04"); + assert_eq!(config.docker.allowed_images[1], "alpine:latest"); + assert_eq!(config.docker.exec_timeout_secs, 60); + } + + #[test] + fn test_git_config_defaults() { + let toml_str = "[git]"; + let config: Config = toml::from_str(toml_str).expect("should parse git section"); + assert_eq!(config.git.clone_timeout_secs, 300); + assert!(config.git.allowed_hosts.is_empty()); + } + + #[test] + fn test_git_config_with_allowed_hosts() { + let toml_str = r#" +[git] +clone_timeout_secs = 600 +allowed_hosts = ["github.com", "gitlab.com"] +"#; + let config: Config = toml::from_str(toml_str).expect("should parse git config"); + assert_eq!(config.git.clone_timeout_secs, 600); + assert_eq!(config.git.allowed_hosts.len(), 2); + assert_eq!(config.git.allowed_hosts[0], "github.com"); + assert_eq!(config.git.allowed_hosts[1], "gitlab.com"); + } + + #[test] + fn test_profiles_config() { + let toml_str = r#" +[[profiles]] +name = "claude-default" +image = "ubuntu:24.04" +env_overrides = { "NODE_ENV" = "production", "DEBUG" = "false" } + +[[profiles]] +name = "minimal" +image = "alpine:latest" +"#; + let config: Config = toml::from_str(toml_str).expect("should parse profiles"); + assert_eq!(config.profiles.len(), 2); + + assert_eq!(config.profiles[0].name, "claude-default"); + assert_eq!(config.profiles[0].image, "ubuntu:24.04"); + assert_eq!(config.profiles[0].env_overrides.len(), 2); + assert_eq!( + config.profiles[0].env_overrides.get("NODE_ENV"), + Some(&"production".to_string()) + ); + assert_eq!( + config.profiles[0].env_overrides.get("DEBUG"), + Some(&"false".to_string()) + ); + + assert_eq!(config.profiles[1].name, "minimal"); + assert_eq!(config.profiles[1].image, "alpine:latest"); + assert!(config.profiles[1].env_overrides.is_empty()); + } + + #[test] + fn test_rest_port_and_data_dir() { + let toml_str = r#" +rest_port = 9000 +data_dir = "/tmp/relay-data" +"#; + let config: Config = toml::from_str(toml_str).expect("should parse top-level fields"); + assert_eq!(config.rest_port, 9000); + assert_eq!(config.data_dir, PathBuf::from("/tmp/relay-data")); + } + + #[test] + fn test_allowed_images_empty_means_all_allowed() { + let toml_str = r" +[docker] +allowed_images = [] +"; + let config: Config = toml::from_str(toml_str).expect("should parse empty allowed_images"); + assert!( + config.docker.allowed_images.is_empty(), + "empty list means all images allowed" + ); + } + + #[test] + fn test_backward_compat_minimal_config() { + let toml_str = r" +port = 50051 +max_sessions = 50 +"; + let config: Config = toml::from_str(toml_str).expect("should parse minimal config"); + assert_eq!(config.port, 50051); + assert_eq!(config.max_sessions, 50); + assert_eq!(config.rest_port, 8080); // default + assert_eq!(config.docker.socket_path, "/var/run/docker.sock"); // default + assert_eq!(config.git.clone_timeout_secs, 300); // default + assert!(config.profiles.is_empty()); // default + } +}