Skip to content
Merged
254 changes: 252 additions & 2 deletions runner/src/config.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pub tls: Option<TlsConfig>,
Comment thread
kirich1409 marked this conversation as resolved.
pub auth: Option<AuthConfig>,
pub mdns: Option<MdnsConfig>,
/// 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<ProfileConfig>,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -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)]
Comment thread
kirich1409 marked this conversation as resolved.
pub allowed_images: Vec<String>,
/// 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
Comment thread
kirich1409 marked this conversation as resolved.
#[serde(default)]
pub allowed_hosts: Vec<String>,
}

/// 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<String, String>,
}

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<String> {
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")
Comment thread
kirich1409 marked this conversation as resolved.
}

impl Default for Config {
fn default() -> Self {
Self {
port: default_port(),
rest_port: default_rest_port(),
data_dir: default_data_dir(),
Comment thread
kirich1409 marked this conversation as resolved.
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());
Comment thread
kirich1409 marked this conversation as resolved.
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
}
}
Loading