1use std::ffi::OsString;
31use std::path::{Path, PathBuf};
32use std::sync::OnceLock;
33
34use crate::config::{CliArgs, SecretStoreKind};
35
36
37static ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
39
40static PORTS: OnceLock<(Option<u16>, Option<u16>)> = OnceLock::new();
43
44static WEB: OnceLock<WebOptions> = OnceLock::new();
46
47#[derive(Debug, Default)]
48struct WebOptions {
49 frontend_dir: Option<PathBuf>,
50 allowed_origins: Vec<String>,
52}
53
54#[derive(Clone, Copy)]
56pub enum Listener {
57 Web,
58 Ws,
59}
60
61pub fn active() -> bool {
63 ACTIVE.load(std::sync::atomic::Ordering::SeqCst)
64}
65
66pub const AUTH_KEY_FILE_NAME: &str = "srv-auth-key";
68
69pub fn requested() -> bool {
74 let args: Vec<OsString> = std::env::args_os().collect();
75 requested_from(&args, std::env::var("AGENTMUX_HEADLESS").ok().as_deref())
76}
77
78fn requested_from(args: &[OsString], env: Option<&str>) -> bool {
79 let asked = args.iter().any(|a| a == "--headless") || matches!(env, Some("1") | Some("true"));
80 use clap::error::ErrorKind;
85 let not_the_server = match <CliArgs as clap::Parser>::try_parse_from(args) {
86 Ok(a) => a.command.is_some(),
87 Err(e) => matches!(
88 e.kind(),
89 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
90 ),
91 };
92 asked && !not_the_server
93}
94
95pub fn prepare_env() -> Result<Option<PathBuf>, String> {
99 let cli = <CliArgs as clap::Parser>::try_parse_from(std::env::args_os()).map_err(|e| e.to_string())?;
102 let web = web_options(cli.frontend_dir.clone(), &cli.allowed_origins)?;
104
105 let paths = match agentmux_common::DataPaths::from_env() {
106 Some(p) => p,
107 None => {
108 let mode = agentmux_common::RuntimeMode::from_env().unwrap_or(agentmux_common::RuntimeMode::Installed);
109 let p = agentmux_common::DataPaths::resolve(env!("CARGO_PKG_VERSION"), &mode)?;
110 p.ensure_dirs()?;
111 for (k, v) in p.to_env_vars() {
112 std::env::set_var(k, v);
113 }
114 p
115 }
116 };
117 std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
122
123 let data_dir = effective_data_dir(cli.wavedata.as_deref(), &paths.data_dir);
129 std::fs::create_dir_all(&data_dir).map_err(|e| format!("cannot create {}: {e}", data_dir.display()))?;
130 crate::backend::base::acquire_data_dir_lock(&data_dir)?;
131
132 if cli.secret_store.unwrap_or(SecretStoreKind::File) == SecretStoreKind::File {
135 crate::identity::secret_store::use_file_store(paths.shared_dir.join("secrets"))?;
136 }
137
138 let mut generated = None;
139 if std::env::var("AGENTMUX_AUTH_KEY").map(|k| k.is_empty()).unwrap_or(true) {
140 let key_file = cli.auth_key_file.clone().or_else(|| std::env::var_os("AGENTMUX_AUTH_KEY_FILE").map(PathBuf::from));
141 let key = match key_file {
142 Some(file) => read_key_file(&file)?,
143 None => {
144 let (key, path) = write_generated_key(&data_dir)?;
145 generated = Some(path);
146 key
147 }
148 };
149 std::env::set_var("AGENTMUX_AUTH_KEY", key);
150 }
151
152 let _ = PORTS.set((cli.web_port, cli.ws_port));
153 let _ = WEB.set(web);
154
155 if std::env::var_os("AGENTMUX_DISABLE_CLOUD_SUBSCRIBER").is_none() {
156 std::env::set_var("AGENTMUX_DISABLE_CLOUD_SUBSCRIBER", "1");
157 }
158
159 ACTIVE.store(true, std::sync::atomic::Ordering::SeqCst);
160 Ok(generated)
161}
162
163fn effective_data_dir(wavedata: Option<&Path>, resolved: &Path) -> PathBuf {
166 wavedata
167 .filter(|p| !p.as_os_str().is_empty())
168 .unwrap_or(resolved)
169 .to_path_buf()
170}
171
172fn web_options(frontend_dir: Option<PathBuf>, origins: &[String]) -> Result<WebOptions, String> {
173 if let Some(dir) = &frontend_dir {
174 if !dir.join("index.html").is_file() {
175 return Err(format!("--frontend-dir {}: no index.html there", dir.display()));
176 }
177 }
178 let allowed_origins = origins
179 .iter()
180 .map(|o| normalize_origin(o).map_err(|e| format!("--allowed-origin {o:?}: {e}")))
181 .collect::<Result<_, _>>()?;
182 Ok(WebOptions { frontend_dir, allowed_origins })
183}
184
185pub fn frontend_dir() -> Option<&'static Path> {
187 WEB.get().and_then(|w| w.frontend_dir.as_deref())
188}
189
190pub fn is_extra_allowed_origin(origin: &str) -> bool {
193 WEB.get().is_some_and(|w| origin_in(origin, &w.allowed_origins))
194}
195
196fn origin_in(origin: &str, allowed: &[String]) -> bool {
197 normalize_origin(origin).is_ok_and(|o| allowed.contains(&o))
198}
199
200fn normalize_origin(origin: &str) -> Result<String, String> {
206 const SHAPE: &str = "expected scheme://host[:port], with no path, user or query";
207 let url = url::Url::parse(origin.trim()).map_err(|e| format!("{SHAPE} ({e})"))?;
208 if !matches!(url.scheme(), "http" | "https") {
209 return Err("scheme must be http or https".into());
210 }
211 let only_origin = url.host().is_some()
212 && url.username().is_empty()
213 && url.password().is_none()
214 && url.path() == "/"
215 && url.query().is_none()
216 && url.fragment().is_none();
217 if !only_origin || origin.contains(['?', '#', '@']) {
219 return Err(SHAPE.into());
220 }
221 Ok(url.origin().ascii_serialization())
222}
223
224fn read_key_file(path: &Path) -> Result<String, String> {
226 let key = std::fs::read_to_string(path)
227 .map_err(|e| format!("--auth-key-file {}: {e}", path.display()))?
228 .trim()
229 .to_string();
230 if key.is_empty() {
231 return Err(format!("--auth-key-file {}: empty", path.display()));
232 }
233 Ok(key)
234}
235
236fn write_generated_key(dir: &Path) -> Result<(String, PathBuf), String> {
239 let key = format!("{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple());
240 let path = dir.join(AUTH_KEY_FILE_NAME);
241 let _ = std::fs::remove_file(&path);
242 let mut opts = std::fs::OpenOptions::new();
243 opts.write(true).create_new(true);
244 #[cfg(unix)]
245 {
246 use std::os::unix::fs::OpenOptionsExt;
247 opts.mode(0o600);
248 }
249 use std::io::Write;
250 opts.open(&path)
251 .and_then(|mut f| f.write_all(key.as_bytes()))
252 .map_err(|e| format!("cannot write the auth key to {}: {e}", path.display()))?;
253 Ok((key, path))
254}
255
256pub fn startup_bind_addr(listener: Listener) -> String {
260 let fixed = PORTS.get().and_then(|(web, ws)| match listener {
261 Listener::Web => *web,
262 Listener::Ws => *ws,
263 });
264 bind_addr_with(fixed)
265}
266
267fn bind_addr_with(port: Option<u16>) -> String {
268 let default = crate::backend::lan_listeners::STARTUP_BIND_ADDR;
269 match port {
270 Some(port) => {
271 let host = default.rsplit_once(':').map(|(h, _)| h).unwrap_or(default);
272 format!("{host}:{port}")
273 }
274 None => default.to_string(),
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 fn args(v: &[&str]) -> Vec<OsString> {
283 v.iter().map(OsString::from).collect()
284 }
285
286 fn cli(v: &[&str]) -> CliArgs {
287 <CliArgs as clap::Parser>::try_parse_from(args(v)).unwrap()
288 }
289
290 #[test]
291 fn headless_is_requested_by_flag_or_env() {
292 assert!(requested_from(&args(&["srv", "--headless"]), None));
293 assert!(requested_from(&args(&["srv"]), Some("1")));
294 assert!(!requested_from(&args(&["srv", "--wavedata", "x"]), None));
295 assert!(!requested_from(&args(&["srv"]), Some("0")));
296 }
297
298 #[test]
301 fn a_subcommand_or_help_is_never_headless() {
302 assert!(!requested_from(&args(&["srv", "migrate", "--verify"]), Some("1")));
303 assert!(!requested_from(&args(&["srv", "--headless", "migrate", "--list"]), None));
304 assert!(!requested_from(&args(&["srv", "--headless", "--help"]), None));
305 assert!(!requested_from(&args(&["srv", "--help"]), Some("1")));
306 }
307
308 #[test]
310 fn the_lock_follows_wavedata() {
311 let resolved = Path::new("/home/u/.agentmux/channels/stable/versions/1/data");
312 let dir = |v: &[&str]| effective_data_dir(cli(v).wavedata.as_deref(), resolved);
313 assert_eq!(dir(&["srv", "--headless"]), resolved);
314 assert_eq!(dir(&["srv", "--headless", "--wavedata", "/shared"]), Path::new("/shared"));
315 assert_eq!(dir(&["srv", "--wavedata=/shared"]), Path::new("/shared"));
316 assert!(<CliArgs as clap::Parser>::try_parse_from(args(&["srv", "--headless", "--wavedata", ""])).is_err());
320 assert!(<CliArgs as clap::Parser>::try_parse_from(args(&["srv", "--headless", "--wavedata="])).is_err());
321 assert!(requested_from(&args(&["srv", "--headless", "--wavedata", ""]), None));
322 assert_eq!(effective_data_dir(Some(Path::new("")), resolved), resolved);
323 }
324
325 #[cfg(unix)]
328 #[test]
329 fn a_non_utf8_wavedata_is_kept() {
330 use std::ffi::OsStr;
331 use std::os::unix::ffi::OsStrExt;
332 let raw = OsStr::from_bytes(b"/data/caf\xe9");
333 let a = vec![OsString::from("srv"), OsString::from("--headless"), OsString::from("--wavedata"), raw.to_os_string()];
334 assert!(requested_from(&a, None));
335 let parsed = <CliArgs as clap::Parser>::try_parse_from(a).unwrap();
336 assert_eq!(effective_data_dir(parsed.wavedata.as_deref(), Path::new("/x")).as_os_str(), raw);
337 }
338
339 #[test]
340 fn secret_store_flag_parses() {
341 assert_eq!(cli(&["srv", "--headless"]).secret_store, None);
342 assert_eq!(cli(&["srv", "--headless", "--secret-store", "keychain"]).secret_store, Some(SecretStoreKind::Keychain));
343 assert_eq!(cli(&["srv", "--headless", "--secret-store=file"]).secret_store, Some(SecretStoreKind::File));
344 assert!(<CliArgs as clap::Parser>::try_parse_from(args(&["srv", "--secret-store", "vault"])).is_err());
345 }
346
347 #[test]
348 fn flags_parse_in_both_forms() {
349 let c = cli(&["srv", "--headless", "--web-port", "8190", "--ws-port=8191"]);
350 assert_eq!((c.web_port, c.ws_port), (Some(8190), Some(8191)));
351 assert_eq!(c.auth_key_file, None);
352 assert!(<CliArgs as clap::Parser>::try_parse_from(args(&["srv", "--web-port", "x"])).is_err());
353 }
354
355 #[test]
356 fn origins_normalize_the_way_browsers_send_them() {
357 let n = |o: &str| normalize_origin(o);
358 assert_eq!(n("https://App.Example.com").unwrap(), "https://app.example.com");
359 assert_eq!(n("https://app.example.com/").unwrap(), "https://app.example.com");
360 assert_eq!(n("https://app.example.com:443").unwrap(), "https://app.example.com");
361 assert_eq!(n("http://app.example.com:80").unwrap(), "http://app.example.com");
362 assert_eq!(n("https://app.example.com:8443").unwrap(), "https://app.example.com:8443");
363 assert_eq!(n("http://[::1]:8190").unwrap(), "http://[::1]:8190");
364 assert_eq!(n("https://app.example.com:0443").unwrap(), "https://app.example.com");
365 assert_eq!(n("https://app.example.com:08443").unwrap(), "https://app.example.com:8443");
366 assert_eq!(n("http://[0:0:0:0:0:0:0:1]:8190").unwrap(), "http://[::1]:8190");
367 assert_eq!(n("http://127.1:8190").unwrap(), "http://127.0.0.1:8190");
368 assert_eq!(n("https://app.example.com:").unwrap(), "https://app.example.com");
370 assert_eq!(n("https://bücher.example").unwrap(), "https://xn--bcher-kva.example");
371 for bad in [
372 "app.example.com",
373 "ftp://app.example.com",
374 "https://",
375 "https://app.example.com/path",
376 "https://user@app.example.com",
377 "https://app.example.com?x=1",
378 "https://app.example.com?",
379 "https://app.example.com#",
380 "https://user:pw@app.example.com",
381 "https://app.example.com:99999",
382 "http://[evil/x]",
383 "*",
384 "null",
385 ] {
386 assert!(n(bad).is_err(), "{bad} should be rejected");
387 }
388 }
389
390 #[test]
391 fn only_the_named_origins_are_added() {
392 let allowed = vec![normalize_origin("https://app.example.com").unwrap()];
393 assert!(origin_in("https://app.example.com", &allowed));
394 assert!(origin_in("https://APP.example.com:443", &allowed));
395 assert!(!origin_in("http://app.example.com", &allowed));
396 assert!(!origin_in("https://app.example.com.evil.test", &allowed));
397 assert!(!origin_in("https://evil.test", &allowed));
398 assert!(!origin_in("null", &allowed));
399 assert!(!origin_in("https://app.example.com", &[]));
400 }
401
402 #[test]
403 fn frontend_dir_needs_an_index_and_origins_must_parse() {
404 let dir = tempfile::tempdir().unwrap();
405 assert!(web_options(Some(dir.path().to_path_buf()), &[]).is_err());
406 std::fs::write(dir.path().join("index.html"), "<!doctype html>").unwrap();
407 let w = web_options(Some(dir.path().to_path_buf()), &["https://App.example.com/".into()]).unwrap();
408 assert_eq!(w.allowed_origins, vec!["https://app.example.com".to_string()]);
409 assert!(web_options(None, &["https://app.example.com/ui".into()]).is_err());
410 let c = cli(&["srv", "--headless", "--allowed-origin", "https://a.test", "--allowed-origin=https://b.test"]);
411 assert_eq!(c.allowed_origins, vec!["https://a.test", "https://b.test"]);
412 }
413
414 #[test]
416 fn a_non_headless_srv_adds_nothing() {
417 assert!(!is_extra_allowed_origin("https://app.example.com"));
418 assert!(frontend_dir().is_none());
419 }
420
421 #[test]
422 fn generated_key_is_written_owner_only_and_fresh_each_time() {
423 let dir = tempfile::tempdir().unwrap();
424 let (k1, p1) = write_generated_key(dir.path()).unwrap();
425 assert_eq!(std::fs::read_to_string(&p1).unwrap(), k1);
426 assert_eq!(k1.len(), 64);
427 #[cfg(unix)]
428 {
429 use std::os::unix::fs::PermissionsExt;
430 let mode = std::fs::metadata(&p1).unwrap().permissions().mode() & 0o777;
431 assert_eq!(mode, 0o600);
432 }
433 let (k2, _) = write_generated_key(dir.path()).unwrap();
434 assert_ne!(k1, k2);
435 assert_eq!(std::fs::read_to_string(&p1).unwrap(), k2);
436 }
437
438 #[test]
439 fn key_file_is_trimmed_and_must_not_be_empty() {
440 let dir = tempfile::tempdir().unwrap();
441 let p = dir.path().join("k");
442 std::fs::write(&p, " secret-key\n").unwrap();
443 assert_eq!(read_key_file(&p).unwrap(), "secret-key");
444 std::fs::write(&p, "\n").unwrap();
445 assert!(read_key_file(&p).is_err());
446 assert!(read_key_file(&dir.path().join("missing")).is_err());
447 }
448
449 #[test]
450 fn bind_addr_is_the_startup_address_or_its_host_with_a_fixed_port() {
451 let default = crate::backend::lan_listeners::STARTUP_BIND_ADDR;
452 assert_eq!(bind_addr_with(None), default);
453 assert_eq!(bind_addr_with(Some(8190)), "127.0.0.1:8190");
454 }
455
456 #[test]
459 fn a_non_headless_srv_ignores_port_env() {
460 std::env::set_var("AGENTMUX_SRV_WEB_PORT", "8190");
461 assert_eq!(
462 startup_bind_addr(Listener::Web),
463 crate::backend::lan_listeners::STARTUP_BIND_ADDR
464 );
465 std::env::remove_var("AGENTMUX_SRV_WEB_PORT");
466 }
467
468 #[cfg(unix)]
469 #[test]
470 fn the_data_dir_lock_is_reentrant_here_and_exclusive_otherwise() {
471 use crate::backend::base::{acquire_data_dir_lock, MuxLock, SRV_DATA_LOCK_FILE};
472 let dir = tempfile::tempdir().unwrap();
473 acquire_data_dir_lock(dir.path()).unwrap();
474 acquire_data_dir_lock(dir.path()).unwrap();
476 assert!(MuxLock::acquire_at(&dir.path().join(SRV_DATA_LOCK_FILE)).is_err());
478 }
479}