Skip to main content

agentmux_srv/
config.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4
5use 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    /// Path to wave data directory (overrides AGENTMUX_DATA_HOME)
13    #[arg(long = "wavedata")]
14    pub wavedata: Option<PathBuf>,
15
16    /// Instance identifier (used for multi-version coexistence)
17    #[arg(long = "instance", default_value = "default")]
18    pub instance: String,
19
20    /// Run without a launcher or desktop host (container, server, CI).
21    /// Read early by `headless::prepare_env`; see
22    /// docs/specs/SPEC_SRV_HEADLESS_MODE_2026_09_26.md.
23    #[arg(long = "headless")]
24    pub headless: bool,
25
26    /// Headless: read the auth key from this file instead of generating one.
27    #[arg(long = "auth-key-file")]
28    pub auth_key_file: Option<PathBuf>,
29
30    /// Headless: fixed loopback port for the web listener (default: OS-chosen).
31    #[arg(long = "web-port")]
32    pub web_port: Option<u16>,
33
34    /// Headless: fixed loopback port for the websocket listener (default: OS-chosen).
35    #[arg(long = "ws-port")]
36    pub ws_port: Option<u16>,
37
38    /// Headless: serve the built frontend (a directory holding index.html)
39    /// at `/`; health stays at `/health`.
40    #[arg(long = "frontend-dir")]
41    pub frontend_dir: Option<PathBuf>,
42
43    /// Headless: also accept this browser origin (e.g. https://app.example.com)
44    /// for CORS and the /ws check, beside loopback. Repeatable.
45    #[arg(long = "allowed-origin")]
46    pub allowed_origins: Vec<String>,
47
48    /// Headless: where srv keeps secrets (API keys, OAuth accounts, cloud
49    /// credentials). Default `file`: owner-only files under the shared dir,
50    /// for machines with no OS keychain.
51    #[arg(long = "secret-store", value_enum)]
52    pub secret_store: Option<SecretStoreKind>,
53
54    #[command(subcommand)]
55    pub command: Option<SrvCommand>,
56}
57
58/// `--secret-store`: see `identity::secret_store`.
59#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
60pub enum SecretStoreKind {
61    /// The OS keychain, as the desktop app uses.
62    Keychain,
63    /// Owner-only files, one per secret.
64    File,
65}
66
67#[derive(Subcommand, Debug)]
68pub enum SrvCommand {
69    /// Run pending data migrations and exit. Invoked by the launcher before
70    /// starting the daemon so the srv always starts with clean migrated state.
71    Migrate {
72        /// Print pending migrations without applying them.
73        #[arg(long)]
74        dry_run: bool,
75        /// List all migrations and their applied/pending status.
76        #[arg(long)]
77        list: bool,
78        /// Check every APPLIED migration post-condition (row counts, marker
79        /// files) instead of running anything. Exit 0 if every check holds or
80        /// is not verifiable, 3 if any is a mismatch or errored (2 is clap usage-error, so a typoed flag never reads as a data finding). This is the
81        /// doctor pass from SPEC_MIGRATION_SYSTEM_HARDENING_2026_08_03 Phase 1:
82        /// db_migrations only records that up() returned Ok, never that it
83        /// wrote what it was meant to.
84        #[arg(long)]
85        verify: bool,
86    },
87}
88
89#[derive(Debug, Clone)]
90pub struct Config {
91    pub auth_key: String,
92    /// A separate, narrowly-scoped credential for LAN peer discovery
93    /// (mDNS TXT record + UDP broadcast responder) — NOT `auth_key`.
94    /// Minted fresh per-launch, internal to srv only (never needs to
95    /// leave this process except via the LAN broadcast itself, unlike
96    /// `auth_key` which the launcher/frontend also need — see
97    /// `LanDiscovery`'s doc comment). Broadcasting the full-access
98    /// `auth_key` to anything that can receive an mDNS multicast packet
99    /// or send a UDP probe used to mean a passive LAN listener got
100    /// standing access to the ENTIRE local `/agentmux/service` surface,
101    /// not just LAN-forwarding — this key is accepted only by the two
102    /// routes LAN peer forwarding actually needs
103    /// (`lan_or_full_auth_middleware` in `server/mod.rs`), so a captured
104    /// value's blast radius shrinks to "can forward jekts to this
105    /// instance and query which agents live here."
106    pub lan_key: String,
107    /// Shared secret proving a `host_ipc.Register` caller is really the
108    /// paired CEF host, not an agent process (agents share `auth_key` too
109    /// — see `host_ipc.rs::handle_register`'s doc comment). `None` when
110    /// nobody set `AGENTMUX_HOST_REG_SECRET` — `handle_register` then
111    /// refuses every registration rather than accepting one unauthenticated,
112    /// since there's nothing to check the caller against. Sourced from the
113    /// launcher (`agentmux-launcher::srv_spawner::SrvSpawnResult::host_reg_secret`,
114    /// minted once and reused across host-only crash-restarts, exactly
115    /// like `auth_key`) or, in host-owned-spawn/dev mode, from the host
116    /// itself (`crates/cef/src/sidecar.rs::spawn_backend`, which
117    /// generates it alongside its own `auth_key`).
118    pub host_reg_secret: Option<String>,
119    /// Empty when unset. Paths stay `PathBuf` (read with `var_os`) so a home
120    /// directory that isn't valid UTF-8 still reaches the stores intact
121    /// (Codex P2 on #3893).
122    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    /// Build config from env vars + CLI args.
134    /// Removes AGENTMUX_AUTH_KEY from the environment after reading (matching Go behavior).
135    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        // Remove from env after read (matching Go authkey.go:50)
144        std::env::remove_var("AGENTMUX_AUTH_KEY");
145
146        // CLI flag wins over env. The launcher sets the canonical
147        // `AGENTMUX_DATA_DIR` and `AGENTMUX_CONFIG_DIR` via
148        // `agentmux_common::DataPaths::to_env_vars`. Pre-unification
149        // names (`AGENTMUX_DATA_HOME`, `AGENTMUX_CONFIG_HOME`) are no
150        // longer set — no fallback (symmetry; partial-rollout isn't a
151        // supported scenario per spec §3.4 "no migration").
152        // An empty `--wavedata ""` counts as absent, as it does for the lock
153        // headless takes (`headless::effective_data_dir`) — otherwise the lock
154        // and the stores would pick different directories (Codex P2 on #3893).
155        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        // is_dev is now derived from AGENTMUX_RUNTIME_MODE (the
165        // canonical env var emitted by the unified DataPaths layer).
166        // Legacy AGENTMUX_DEV is no longer set by the launcher.
167        let is_dev = matches!(
168            agentmux_common::RuntimeMode::from_env(),
169            Some(agentmux_common::RuntimeMode::Dev { .. })
170        );
171
172        // Two v4 UUIDs concatenated for margin over a single UUID's 122 bits
173        // of randomness — same "avoid a rand/getrandom dependency just for
174        // this" reasoning as agent_jekt_keys.rs's random_key_bytes(), but
175        // kept as a plain string here (not raw bytes) since this only ever
176        // travels as a String — an mDNS TXT record value / UDP JSON field /
177        // HTTP header — never binary-serialized or HMAC-keyed.
178        let lan_key = format!("{}{}", uuid::Uuid::new_v4(), uuid::Uuid::new_v4());
179
180        // Optional — only set by a launcher/host that knows about this
181        // credential. Scrub it from the env the same way AGENTMUX_AUTH_KEY
182        // is scrubbed above, so it can't be read back out of this
183        // process's own environment later (e.g. by a shell tool an agent
184        // asks this process to run — not applicable to srv itself, but
185        // matching the auth_key precedent costs nothing).
186        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    // Serialize config tests — they mutate process-global env vars
214    static ENV_LOCK: Mutex<()> = Mutex::new(());
215
216    /// Lock helper that recovers from poisoned mutex. A panic in any
217    /// test would otherwise propagate poison to all later tests via
218    /// `lock().unwrap()` and produce noise unrelated to the actual
219    /// failing test.
220    fn lock() -> std::sync::MutexGuard<'static, ()> {
221        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
222    }
223
224    /// Clear every env var our config reads so each test starts from
225    /// a known state, regardless of leakage from prior tests.
226    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    /// A data or config dir that isn't valid UTF-8 (a Unix home with such a
312    /// name) is kept byte for byte, not dropped: `std::env::var` would call it
313    /// absent, and the stores would open under the default root instead.
314    #[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}