Skip to main content

agentmux_srv/
headless.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Headless mode: srv started directly — a container entrypoint, a server, CI —
5//! with no launcher or desktop host. docs/specs/SPEC_SRV_HEADLESS_MODE_2026_09_26.md.
6//!
7//! Normally the launcher prepares srv's world before spawning it: the data-path
8//! env (`DataPaths::to_env_vars`), a per-launch auth key, and a stdin pipe and
9//! parent process whose end means "shut down". Headless srv does that itself:
10//!
11//! - resolves and exports the data-path env when it isn't already set;
12//! - takes srv's data-dir lock early (the same lock every srv takes), so no two
13//!   servers — headless or launcher-started — share a set of databases;
14//! - takes its auth key from `AGENTMUX_AUTH_KEY`, `--auth-key-file`, or
15//!   generates one and writes it to `<data dir>/srv-auth-key` (0600) — the key
16//!   is never printed, only the file's path;
17//! - keeps secrets in owner-only files under the shared dir instead of the OS
18//!   keychain, which a container usually doesn't have (`--secret-store
19//!   keychain` to use the keychain anyway);
20//! - turns the cloud subscriber off: a server doesn't connect to the AgentMux
21//!   cloud relay on its own;
22//! - is not tied to stdin or a parent process (`main` skips those watchers);
23//!   SIGINT/SIGTERM still shut it down.
24//!
25//! It still binds loopback only (`--web-port` / `--ws-port` fix the ports).
26//! Reaching it from another machine is a reverse proxy's job, not srv's. For a
27//! client behind such a proxy, `--frontend-dir` serves the built frontend and
28//! `--allowed-origin` names the proxy's origin for CORS and the /ws check.
29
30use std::ffi::OsString;
31use std::path::{Path, PathBuf};
32use std::sync::OnceLock;
33
34use crate::config::{CliArgs, SecretStoreKind};
35
36
37/// Set once `prepare_env` succeeded: this process is the headless server.
38static ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
39
40/// `--web-port` / `--ws-port`, held in-process (never in the env, so a
41/// launcher-spawned srv or an agent can't inherit them — ReAgent P1 on #3893).
42static PORTS: OnceLock<(Option<u16>, Option<u16>)> = OnceLock::new();
43
44/// `--frontend-dir` / `--allowed-origin`, held in-process like the ports.
45static WEB: OnceLock<WebOptions> = OnceLock::new();
46
47#[derive(Debug, Default)]
48struct WebOptions {
49    frontend_dir: Option<PathBuf>,
50    /// Normalized by [`normalize_origin`].
51    allowed_origins: Vec<String>,
52}
53
54/// Which startup listener a bind address is for.
55#[derive(Clone, Copy)]
56pub enum Listener {
57    Web,
58    Ws,
59}
60
61/// Is this process running headless (after `prepare_env`)?
62pub fn active() -> bool {
63    ACTIVE.load(std::sync::atomic::Ordering::SeqCst)
64}
65
66/// File name of the generated auth key, beside the databases.
67pub const AUTH_KEY_FILE_NAME: &str = "srv-auth-key";
68
69/// Is this the headless server starting (`--headless` or `AGENTMUX_HEADLESS=1`)?
70/// Never for a subcommand such as `migrate`: it needs none of the headless
71/// setup, and its lock would refuse a `migrate --verify` run beside a running
72/// headless server (ReAgent P1 on #3893).
73pub 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    // Parse with the same CliArgs `load_config` uses. A subcommand, or
81    // --help/--version (which clap reports as an Err), is not the server, so
82    // no setup and no lock (Codex P2 on #3893). Any other argv clap rejects is
83    // left for `load_config` to report.
84    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
95/// Prepare the environment the launcher would normally provide. Runs before
96/// logging is initialized, so problems are returned for `main` to print.
97/// Returns the path the auth key was written to, when it generated one.
98pub fn prepare_env() -> Result<Option<PathBuf>, String> {
99    // The same parser `load_config` uses, over `args_os`, so path flags that
100    // aren't valid UTF-8 arrive intact (Codex P2 on #3893).
101    let cli = <CliArgs as clap::Parser>::try_parse_from(std::env::args_os()).map_err(|e| e.to_string())?;
102    // Checked before anything is written, so a typo leaves no lock or key behind.
103    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    // Every path is now explicit in the env, as the launcher leaves it. Drop the
118    // root override so srv resolves its stores the launcher's way — from the
119    // exported data dir — rather than at the override root, which would put
120    // every version's databases in one place (Codex P1 on #3893).
121    std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
122
123    // Lock the directory the databases actually live in — `--wavedata` when
124    // given (Config makes it the data home), else the resolved data dir — with
125    // the same lock every srv takes (`base::acquire_data_dir_lock`), and before
126    // writing anything there, so a second server can't clobber the first's key
127    // file (Codex P1/P2 on #3893).
128    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    // Secrets: one store per machine, like the keychain, so under the shared
133    // dir rather than any one version's data dir.
134    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
163/// The directory srv will open its databases in: `--wavedata` if given (the
164/// same precedence `Config::from_env_and_args` applies), else `resolved`.
165fn 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
185/// The directory `--frontend-dir` named, when headless and given.
186pub fn frontend_dir() -> Option<&'static Path> {
187    WEB.get().and_then(|w| w.frontend_dir.as_deref())
188}
189
190/// Is `origin` one `--allowed-origin` named? Always false for a srv that
191/// isn't headless.
192pub 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
200/// An origin as browsers send it, via the WHATWG URL parser browsers use:
201/// http or https, a host, an optional port — no path, user, query or
202/// fragment. Serialized the way `Origin` is: lowercase, IDNA hosts in
203/// punycode, canonical IPv4/IPv6, and no default port, so every spelling of
204/// one origin compares equal (Codex P2s on #3900). A trailing `/` is fine.
205fn 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    // `Url` drops an empty `?` / `#`; refuse them too.
218    if !only_origin || origin.contains(['?', '#', '@']) {
219        return Err(SHAPE.into());
220    }
221    Ok(url.origin().ascii_serialization())
222}
223
224/// Read a key from a file the operator provided; surrounding whitespace is ignored.
225fn 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
236/// Generate a fresh key and write it to `<dir>/srv-auth-key`, readable by the
237/// owner only. A new key every start, like a launcher-spawned srv.
238fn 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
256/// The address a startup listener binds: `STARTUP_BIND_ADDR` (loopback,
257/// OS-chosen port) — or, headless only, the same loopback host with the port
258/// `--web-port` / `--ws-port` gave. A non-headless srv always gets the default.
259pub 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    /// A subcommand, --help or --version never runs the headless setup (or
299    /// takes its lock), even with AGENTMUX_HEADLESS=1 set for the whole env.
300    #[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    /// The lock goes where the databases go: --wavedata wins, as in Config.
309    #[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        // An empty --wavedata is a parse error (clap's PathBuf parser), so
317        // prepare_env exits before taking any lock; Config also treats an
318        // empty value as absent (Codex P2 on #3893).
319        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    /// A `--wavedata` that isn't valid UTF-8 is locked byte for byte, the same
326    /// directory Config then opens the stores in.
327    #[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        // An empty port is the default one, as browsers parse it.
369        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    /// A srv that isn't headless never set WEB: no extra origins, no frontend.
415    #[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    /// Only `prepare_env` sets the fixed ports, and only in-process: a srv
457    /// that isn't headless binds the default whatever its env says.
458    #[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        // Headless takes it early, bootstrap again later: fine in one process.
475        acquire_data_dir_lock(dir.path()).unwrap();
476        // Anyone else is refused while it's held.
477        assert!(MuxLock::acquire_at(&dir.path().join(SRV_DATA_LOCK_FILE)).is_err());
478    }
479}