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