1use std::path::PathBuf;
6
7use clap::{Parser, Subcommand};
8
9#[derive(Parser, Debug, Default)]
10#[command(name = "agentmux-srv", about = "AgentMux Rust backend server")]
11pub struct CliArgs {
12 #[arg(long = "wavedata")]
14 pub wavedata: Option<PathBuf>,
15
16 #[arg(long = "instance", default_value = "default")]
18 pub instance: String,
19
20 #[arg(long = "headless")]
24 pub headless: bool,
25
26 #[arg(long = "auth-key-file")]
28 pub auth_key_file: Option<PathBuf>,
29
30 #[arg(long = "web-port")]
32 pub web_port: Option<u16>,
33
34 #[arg(long = "ws-port")]
36 pub ws_port: Option<u16>,
37
38 #[arg(long = "frontend-dir")]
41 pub frontend_dir: Option<PathBuf>,
42
43 #[arg(long = "allowed-origin")]
46 pub allowed_origins: Vec<String>,
47
48 #[arg(long = "secret-store", value_enum)]
52 pub secret_store: Option<SecretStoreKind>,
53
54 #[command(subcommand)]
55 pub command: Option<SrvCommand>,
56}
57
58#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
60pub enum SecretStoreKind {
61 Keychain,
63 File,
65}
66
67#[derive(Subcommand, Debug)]
68pub enum SrvCommand {
69 Migrate {
72 #[arg(long)]
74 dry_run: bool,
75 #[arg(long)]
77 list: bool,
78 #[arg(long)]
85 verify: bool,
86 },
87}
88
89#[derive(Debug, Clone)]
90pub struct Config {
91 pub auth_key: String,
92 pub lan_key: String,
107 pub host_reg_secret: Option<String>,
119 pub data_home: PathBuf,
123 pub config_home: PathBuf,
124 pub app_path: String,
125 #[allow(dead_code)]
126 pub is_dev: bool,
127 pub version: &'static str,
128 pub build_time: &'static str,
129 pub instance_id: String,
130}
131
132impl Config {
133 pub fn from_env_and_args(args: &CliArgs) -> Result<Self, String> {
136 let auth_key = std::env::var("AGENTMUX_AUTH_KEY")
137 .map_err(|_| "AGENTMUX_AUTH_KEY environment variable is required".to_string())?;
138
139 if auth_key.is_empty() {
140 return Err("AGENTMUX_AUTH_KEY must not be empty".to_string());
141 }
142
143 std::env::remove_var("AGENTMUX_AUTH_KEY");
145
146 let data_home = args
156 .wavedata
157 .clone()
158 .filter(|p| !p.as_os_str().is_empty())
159 .or_else(|| std::env::var_os("AGENTMUX_DATA_DIR").map(PathBuf::from))
160 .unwrap_or_default();
161
162 let config_home = std::env::var_os("AGENTMUX_CONFIG_DIR").map(PathBuf::from).unwrap_or_default();
163 let app_path = std::env::var("AGENTMUX_APP_PATH").unwrap_or_default();
164 let is_dev = matches!(
168 agentmux_common::RuntimeMode::from_env(),
169 Some(agentmux_common::RuntimeMode::Dev { .. })
170 );
171
172 let lan_key = format!("{}{}", uuid::Uuid::new_v4(), uuid::Uuid::new_v4());
179
180 let host_reg_secret = std::env::var("AGENTMUX_HOST_REG_SECRET")
187 .ok()
188 .filter(|s| !s.is_empty());
189 if host_reg_secret.is_some() {
190 std::env::remove_var("AGENTMUX_HOST_REG_SECRET");
191 }
192
193 Ok(Config {
194 auth_key,
195 lan_key,
196 host_reg_secret,
197 data_home,
198 config_home,
199 app_path,
200 is_dev,
201 version: env!("CARGO_PKG_VERSION"),
202 build_time: option_env!("BUILD_TIME").unwrap_or("dev"),
203 instance_id: args.instance.clone(),
204 })
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use std::sync::Mutex;
212
213 static ENV_LOCK: Mutex<()> = Mutex::new(());
215
216 fn lock() -> std::sync::MutexGuard<'static, ()> {
221 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
222 }
223
224 fn clear_env() {
227 for k in [
228 "AGENTMUX_AUTH_KEY",
229 "AGENTMUX_DATA_DIR",
230 "AGENTMUX_DATA_HOME",
231 "AGENTMUX_CONFIG_DIR",
232 "AGENTMUX_CONFIG_HOME",
233 "AGENTMUX_APP_PATH",
234 "AGENTMUX_RUNTIME_MODE",
235 "AGENTMUX_DEV",
236 "AGENTMUX_HOST_REG_SECRET",
237 ] {
238 std::env::remove_var(k);
239 }
240 }
241
242 #[test]
243 fn missing_auth_key_errors() {
244 let _lock = lock();
245 clear_env();
246 let args = CliArgs { wavedata: None, instance: "default".to_string(), command: None, ..Default::default() };
247 let result = Config::from_env_and_args(&args);
248 assert!(result.is_err());
249 assert!(result.unwrap_err().contains("AGENTMUX_AUTH_KEY"));
250 }
251
252 #[test]
253 fn empty_auth_key_errors() {
254 let _lock = lock();
255 clear_env();
256 std::env::set_var("AGENTMUX_AUTH_KEY", "");
257 let args = CliArgs { wavedata: None, instance: "default".to_string(), command: None, ..Default::default() };
258 let result = Config::from_env_and_args(&args);
259 assert!(result.is_err());
260 clear_env();
261 }
262
263 #[test]
264 fn cli_wavedata_overrides_env() {
265 let _lock = lock();
266 clear_env();
267 std::env::set_var("AGENTMUX_AUTH_KEY", "test-key-12345");
268 std::env::set_var("AGENTMUX_DATA_DIR", "/from/env");
269 let args = CliArgs {
270 wavedata: Some("/from/cli".into()),
271 instance: "default".to_string(),
272 command: None,
273 ..Default::default()
274 };
275 let config = Config::from_env_and_args(&args).unwrap();
276 assert_eq!(config.data_home, PathBuf::from("/from/cli"));
277 assert!(std::env::var("AGENTMUX_AUTH_KEY").is_err());
278 clear_env();
279 }
280
281 #[test]
282 fn empty_cli_wavedata_counts_as_absent() {
283 let _lock = lock();
284 clear_env();
285 std::env::set_var("AGENTMUX_AUTH_KEY", "test-key-empty-wavedata");
286 std::env::set_var("AGENTMUX_DATA_DIR", "/from/env");
287 let args = CliArgs { wavedata: Some(PathBuf::new()), ..Default::default() };
288 let config = Config::from_env_and_args(&args).unwrap();
289 assert_eq!(config.data_home, PathBuf::from("/from/env"));
290 clear_env();
291 }
292
293 #[test]
294 fn env_var_parsing() {
295 let _lock = lock();
296 clear_env();
297 std::env::set_var("AGENTMUX_AUTH_KEY", "test-key-67890");
298 std::env::set_var("AGENTMUX_DATA_DIR", "/data");
299 std::env::set_var("AGENTMUX_CONFIG_DIR", "/config");
300 std::env::set_var("AGENTMUX_APP_PATH", "/app");
301 std::env::set_var("AGENTMUX_RUNTIME_MODE", "dev:main");
302 let args = CliArgs { wavedata: None, instance: "default".to_string(), command: None, ..Default::default() };
303 let config = Config::from_env_and_args(&args).unwrap();
304 assert_eq!(config.data_home, PathBuf::from("/data"));
305 assert_eq!(config.config_home, PathBuf::from("/config"));
306 assert_eq!(config.app_path, "/app");
307 assert!(config.is_dev);
308 clear_env();
309 }
310
311 #[cfg(unix)]
315 #[test]
316 fn non_utf8_dirs_are_kept() {
317 use std::ffi::OsStr;
318 use std::os::unix::ffi::OsStrExt;
319 let _lock = lock();
320 clear_env();
321 let data = OsStr::from_bytes(b"/home/caf\xe9/data");
322 let conf = OsStr::from_bytes(b"/home/caf\xe9/config");
323 std::env::set_var("AGENTMUX_AUTH_KEY", "test-key-non-utf8");
324 std::env::set_var("AGENTMUX_DATA_DIR", data);
325 std::env::set_var("AGENTMUX_CONFIG_DIR", conf);
326 let config = Config::from_env_and_args(&CliArgs::default()).unwrap();
327 assert_eq!(config.data_home.as_os_str(), data);
328 assert_eq!(config.config_home.as_os_str(), conf);
329 clear_env();
330 }
331
332 #[test]
333 fn host_reg_secret_defaults_to_none_when_unset() {
334 let _lock = lock();
335 clear_env();
336 std::env::set_var("AGENTMUX_AUTH_KEY", "test-key-host-reg-1");
337 let args = CliArgs { wavedata: None, instance: "default".to_string(), command: None, ..Default::default() };
338 let config = Config::from_env_and_args(&args).unwrap();
339 assert_eq!(config.host_reg_secret, None);
340 clear_env();
341 }
342
343 #[test]
344 fn host_reg_secret_is_read_and_scrubbed_from_env() {
345 let _lock = lock();
346 clear_env();
347 std::env::set_var("AGENTMUX_AUTH_KEY", "test-key-host-reg-2");
348 std::env::set_var("AGENTMUX_HOST_REG_SECRET", "the-shared-secret");
349 let args = CliArgs { wavedata: None, instance: "default".to_string(), command: None, ..Default::default() };
350 let config = Config::from_env_and_args(&args).unwrap();
351 assert_eq!(config.host_reg_secret, Some("the-shared-secret".to_string()));
352 assert!(std::env::var("AGENTMUX_HOST_REG_SECRET").is_err());
353 clear_env();
354 }
355
356 #[test]
357 fn lan_key_is_generated_nonempty_and_distinct_from_auth_key() {
358 let _lock = lock();
359 clear_env();
360 std::env::set_var("AGENTMUX_AUTH_KEY", "test-key-67890");
361 let args = CliArgs { wavedata: None, instance: "default".to_string(), command: None, ..Default::default() };
362 let config = Config::from_env_and_args(&args).unwrap();
363 assert!(!config.lan_key.is_empty());
364 assert_ne!(
365 config.lan_key, config.auth_key,
366 "the LAN-broadcast credential must never equal the full-access auth_key"
367 );
368 clear_env();
369 }
370
371 #[test]
372 fn lan_key_is_freshly_generated_per_call_not_a_fixed_constant() {
373 let _lock = lock();
374 clear_env();
375 std::env::set_var("AGENTMUX_AUTH_KEY", "test-key-67890");
376 let args = CliArgs { wavedata: None, instance: "default".to_string(), command: None, ..Default::default() };
377 let first = Config::from_env_and_args(&args).unwrap().lan_key;
378 std::env::set_var("AGENTMUX_AUTH_KEY", "test-key-67890");
379 let second = Config::from_env_and_args(&args).unwrap().lan_key;
380 assert_ne!(first, second, "each process launch must mint its own lan_key");
381 clear_env();
382 }
383}