Skip to main content

agent_first_http/cli/cmd/
host.rs

1//! `afhttp host` subcommand. Builds [`HostArgs`] from the resolved invocation
2//! and forwards to [`crate::host::bootstrap::run`].
3
4use std::path::PathBuf;
5
6use crate::host::bootstrap::{
7    BrowserChoice, DisplayMode, HealthPublic, HostArgs, ProfileChoice, Takeover,
8};
9use crate::host::listener::{ListenAddr, parse_listen};
10use crate::shared::error::{Error, ErrorCode};
11
12#[derive(Debug)]
13pub struct Args {
14    pub listen: String,
15    pub profile: String,
16    /// Only the no-takeover shape carries a display choice; a takeover host is
17    /// headful by construction, so the registry does not accept one there.
18    pub display: Option<DisplayMode>,
19    pub takeover: Takeover,
20    pub takeover_quality_percent: u8,
21    pub browser: BrowserChoice,
22    pub browser_bin: Option<PathBuf>,
23    pub token: Option<String>,
24    pub no_health: bool,
25    pub health_public: HealthPublic,
26    pub engine_envs: Vec<String>,
27    pub browser_args: Vec<String>,
28    pub proxy: Option<String>,
29    pub recent_requests_cap: usize,
30}
31
32pub async fn run(args: Args) -> Result<(), Error> {
33    enforce_listen_auth(&args.listen, args.token.as_deref())?;
34    let profile_raw = args.profile.trim();
35    if profile_raw.is_empty() || profile_raw.contains(',') {
36        return Err(Error::new(
37            ErrorCode::InvalidArgument,
38            "--profile: expected one profile name or '-'",
39        ));
40    }
41    let profile = if profile_raw == "-" {
42        ProfileChoice::Ephemeral
43    } else {
44        ProfileChoice::Persistent(profile_raw.to_string())
45    };
46    let takeover_enabled = !matches!(args.takeover, Takeover::Off);
47    let display = match args.takeover {
48        Takeover::On { .. } => DisplayMode::Headful,
49        Takeover::Off => args.display.unwrap_or(DisplayMode::Headless),
50    };
51    let health_enabled: bool = !args.no_health;
52    let mut engine_envs = Vec::with_capacity(args.engine_envs.len());
53    for raw in &args.engine_envs {
54        engine_envs.push(parse_engine_env(raw)?);
55    }
56    let host_args = HostArgs {
57        listen: args.listen,
58        profile,
59        display,
60        takeover: args.takeover,
61        display_quality: args.takeover_quality_percent,
62        browser: args.browser,
63        browser_bin: args.browser_bin,
64        token: args.token,
65        takeover_enabled,
66        health_enabled,
67        health_public: args.health_public,
68        engine_envs,
69        browser_args: args.browser_args,
70        proxy: args.proxy,
71        recent_requests_cap: args.recent_requests_cap,
72    };
73    crate::host::bootstrap::run(host_args).await
74}
75
76/// Refuse to expose a token-less control surface to the network. A TCP listener
77/// on any non-loopback address (`0.0.0.0`, a LAN/mesh IP, …) serves `/cdp` —
78/// full browser and profile control plus arbitrary in-page JS — to anyone who
79/// can reach the port, so a token is mandatory there. Loopback TCP and unix
80/// sockets are reachable only locally, so a token stays optional. (When a token
81/// is set we skip the parse here; the listener validates the address later.)
82fn enforce_listen_auth(listen: &str, token: Option<&str>) -> Result<(), Error> {
83    if token.is_some() {
84        return Ok(());
85    }
86    if let ListenAddr::Tcp(addr) = parse_listen(listen)?
87        && !addr.ip().is_loopback()
88    {
89        return Err(Error::new(
90            ErrorCode::InvalidArgument,
91            format!(
92                "--listen {listen}: refusing to bind a non-loopback address without --token-secret. \
93                     A token-less TCP host exposes full browser and profile control (/cdp) to \
94                     anyone who can reach the port. Pass --token-secret, or bind tcp:127.0.0.1:<port> \
95                     or a unix: socket."
96            ),
97        ));
98    }
99    Ok(())
100}
101
102fn parse_engine_env(raw: &str) -> Result<(String, String), Error> {
103    let (k, v) = raw.split_once('=').ok_or_else(|| {
104        Error::new(
105            ErrorCode::InvalidArgument,
106            format!("--engine-env: expected NAME=VALUE, got {raw:?}"),
107        )
108    })?;
109    if k.is_empty() {
110        return Err(Error::new(
111            ErrorCode::InvalidArgument,
112            "--engine-env: key must not be empty",
113        ));
114    }
115    Ok((k.to_string(), v.to_string()))
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn loopback_tcp_needs_no_token() {
124        enforce_listen_auth("tcp:127.0.0.1:9222", None).unwrap();
125        enforce_listen_auth("tcp:[::1]:9222", None).unwrap();
126    }
127
128    #[test]
129    fn unix_socket_needs_no_token() {
130        enforce_listen_auth("unix:/run/afhttp.sock", None).unwrap();
131    }
132
133    #[test]
134    fn non_loopback_tcp_requires_token() {
135        for spec in ["tcp:0.0.0.0:9222", "tcp:192.168.1.10:9222", "tcp:[::]:9222"] {
136            let err = enforce_listen_auth(spec, None).err().unwrap();
137            assert_eq!(err.error_code, ErrorCode::InvalidArgument, "spec={spec}");
138        }
139    }
140
141    #[test]
142    fn token_allows_any_address() {
143        enforce_listen_auth("tcp:0.0.0.0:9222", Some("secret")).unwrap();
144    }
145}