From d80fdc7328c8c5355cd6c6a1781d65ae5d49f4a5 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Mon, 13 Apr 2026 16:24:34 +0300 Subject: [PATCH 1/6] T-2: Add DockerConfig, GitConfig, ProfileConfig, and data_dir to config Add new configuration sections to support Docker container execution, Git clone operations, and profile-based session templates: - [docker] section with socket_path, allowed_images (SSRF whitelist), and exec_timeout_secs - [git] section with clone_timeout_secs and allowed_hosts (SSRF whitelist) - [[profiles]] array for predefined terminal session templates with environment variable overrides - Top-level rest_port (default: 8080) for REST API - Top-level data_dir (default: ~/.local/share/relay) for data storage All new fields have #[serde(default)] for backward compatibility. Empty allowed_images/allowed_hosts lists mean all images/hosts are permitted. Includes comprehensive unit tests covering deserialization, defaults, and backward compatibility with existing configs. Closes #16 --- runner/src/config.rs | 236 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) diff --git a/runner/src/config.rs b/runner/src/config.rs index 1bff4e6..34568ee 100644 --- a/runner/src/config.rs +++ b/runner/src/config.rs @@ -1,11 +1,18 @@ //! Runner configuration loaded from config.toml use serde::Deserialize; +use std::collections::HashMap; +use std::path::PathBuf; #[derive(Debug, Deserialize)] +#[allow(dead_code)] pub struct Config { #[serde(default = "default_port")] pub port: u16, + #[serde(default = "default_rest_port")] + pub rest_port: u16, + #[serde(default = "default_data_dir")] + pub data_dir: PathBuf, #[serde(default = "default_max_sessions")] pub max_sessions: usize, #[serde(default = "default_scrollback_lines")] @@ -15,6 +22,12 @@ pub struct Config { pub tls: Option, pub auth: Option, pub mdns: Option, + #[serde(default)] + pub docker: DockerConfig, + #[serde(default)] + pub git: GitConfig, + #[serde(default)] + pub profiles: Vec, } #[derive(Debug, Deserialize)] @@ -46,26 +59,249 @@ pub struct AuthConfig { pub require_mtls: bool, } +/// Docker configuration for container execution +#[derive(Debug, Deserialize, Clone)] +#[allow(dead_code)] +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)] +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)] +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_data_dir() -> PathBuf { + dirs::data_local_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .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()], 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()); + } + + #[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 + } +} From ca1b5599ae0dec925ac315656d7d6cf09352e28e Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Mon, 13 Apr 2026 16:40:11 +0300 Subject: [PATCH 2/6] Fix clippy::needless_raw_string_hashes in config tests --- runner/src/config.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/runner/src/config.rs b/runner/src/config.rs index 34568ee..7555b2f 100644 --- a/runner/src/config.rs +++ b/runner/src/config.rs @@ -279,10 +279,10 @@ data_dir = "/tmp/relay-data" #[test] fn test_allowed_images_empty_means_all_allowed() { - let toml_str = r#" + 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(), @@ -292,10 +292,10 @@ allowed_images = [] #[test] fn test_backward_compat_minimal_config() { - let toml_str = r#" + 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); From 7e78037177a91a473ad7ad02c757bc81a541d720 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Mon, 13 Apr 2026 16:50:01 +0300 Subject: [PATCH 3/6] Address Copilot review: fix shell_allowlist default, data_dir fallback, remove dead_code --- runner/src/config.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/runner/src/config.rs b/runner/src/config.rs index 7555b2f..9ce77a6 100644 --- a/runner/src/config.rs +++ b/runner/src/config.rs @@ -5,7 +5,6 @@ use std::collections::HashMap; use std::path::PathBuf; #[derive(Debug, Deserialize)] -#[allow(dead_code)] pub struct Config { #[serde(default = "default_port")] pub port: u16, @@ -17,7 +16,7 @@ pub struct Config { 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, @@ -61,7 +60,6 @@ pub struct AuthConfig { /// Docker configuration for container execution #[derive(Debug, Deserialize, Clone)] -#[allow(dead_code)] pub struct DockerConfig { /// Path to Docker socket (default: /var/run/docker.sock) #[serde(default = "default_docker_socket")] @@ -76,7 +74,6 @@ pub struct DockerConfig { /// Git configuration for clone operations and security #[derive(Debug, Deserialize, Clone)] -#[allow(dead_code)] pub struct GitConfig { /// Timeout in seconds for clone operations (default: 300) #[serde(default = "default_clone_timeout")] @@ -88,7 +85,6 @@ pub struct GitConfig { /// Profile configuration for predefined terminal session templates #[derive(Debug, Deserialize, Clone)] -#[allow(dead_code)] pub struct ProfileConfig { /// Profile name (e.g., "claude-default", "ubuntu-latest") pub name: String, @@ -127,9 +123,13 @@ 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(|| PathBuf::from(".")) + .unwrap_or_else(std::env::temp_dir) .join("relay") } From 1ade214215fdca62520fc84225aed2e63226a1d4 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Mon, 13 Apr 2026 16:52:45 +0300 Subject: [PATCH 4/6] Narrow dead_code allow to new structs only, document Wave dependency --- runner/src/config.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/runner/src/config.rs b/runner/src/config.rs index 9ce77a6..4655469 100644 --- a/runner/src/config.rs +++ b/runner/src/config.rs @@ -60,6 +60,7 @@ pub struct AuthConfig { /// 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")] @@ -74,6 +75,7 @@ pub struct DockerConfig { /// 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")] @@ -85,6 +87,7 @@ pub struct GitConfig { /// 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, From e55fcf5895499c96ff2ab45c710c514659221bf0 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Mon, 13 Apr 2026 16:56:59 +0300 Subject: [PATCH 5/6] Add field-level dead_code allow for new Config fields (Wave 2/3 consumers) --- runner/src/config.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/runner/src/config.rs b/runner/src/config.rs index 4655469..67d1097 100644 --- a/runner/src/config.rs +++ b/runner/src/config.rs @@ -8,9 +8,13 @@ use std::path::PathBuf; 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, @@ -21,11 +25,17 @@ pub struct Config { 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, } From 941a4b7809f4029921f396a2ac7a0873ab1d0057 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Mon, 13 Apr 2026 17:02:50 +0300 Subject: [PATCH 6/6] Use default_shell_allowlist() in Default impl; add shell_allowlist assertion to test --- runner/src/config.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/runner/src/config.rs b/runner/src/config.rs index 67d1097..4a5d5b9 100644 --- a/runner/src/config.rs +++ b/runner/src/config.rs @@ -154,7 +154,7 @@ impl Default for Config { 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, @@ -198,6 +198,7 @@ mod tests { 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]