Skip to main content

agent_first_http/cli/cmd/
host.rs

1//! `afhttp host` subcommand. Builds [`HostArgs`] from clap and forwards to
2//! [`crate::host::bootstrap::run`].
3
4use std::path::PathBuf;
5
6use clap::Args as ClapArgs;
7
8use crate::host::bootstrap::{
9    BrowserChoice, DisplayMode, HealthPublic, HostArgs, ProfileChoice, Takeover,
10};
11use crate::host::listener::{parse_listen, ListenAddr};
12use crate::shared::error::{Error, ErrorCode};
13
14#[derive(ClapArgs, Debug)]
15pub struct Args {
16    /// Listener address: `tcp:host:port` or `unix:/path/to.sock`.
17    #[arg(long, help_heading = "Listener")]
18    pub listen: String,
19    /// Profile name under $XDG_DATA_HOME/afhttp/profiles, or `-` for an
20    /// ephemeral profile. One host binds exactly one profile.
21    #[arg(long, default_value = "-", help_heading = "Profile")]
22    pub profile: String,
23    /// headless or headful. Omit when --takeover=kasmvnc should imply headful.
24    #[arg(long, help_heading = "Display & takeover")]
25    pub display: Option<String>,
26    /// Human takeover mode (like --render, pick one): none serves no takeover
27    /// panel; screencast serves the CDP screencast panel at /ops (works
28    /// headless, no VNC/X needed); kasmvnc serves a real KasmVNC display at
29    /// /ops/display for hard sites (captcha, IME, flaky CDP input — implies
30    /// headful).
31    #[arg(
32        long,
33        default_value = "screencast",
34        help_heading = "Display & takeover"
35    )]
36    pub takeover: String,
37    /// Display-takeover image quality, 0-100 (default 100 = crispest). Maps to
38    /// KasmVNC's 0-9 quality tiers; lower trades clarity for bandwidth. Only
39    /// applies with `--takeover kasmvnc`. Adjustable live in the panel too.
40    #[arg(
41        long = "display-quality-percent",
42        default_value_t = 100,
43        help_heading = "Display & takeover"
44    )]
45    pub display_quality: u8,
46    /// auto | chromium | chrome | chrome_shell | fingerprint_chromium | edge | brave | lightpanda | camoufox.
47    #[arg(long, default_value = "auto", help_heading = "Browser")]
48    pub browser: String,
49    /// Override browser binary path.
50    #[arg(long, help_heading = "Browser")]
51    pub browser_bin: Option<PathBuf>,
52    /// Bearer token required for clients on TCP listeners.
53    #[arg(long = "token-secret", help_heading = "Listener")]
54    pub token: Option<String>,
55    /// Serve /health and /capabilities.
56    #[arg(long, default_value = "on", help_heading = "Listener")]
57    pub health: String,
58    /// Make /health public with minimal payload.
59    #[arg(long, default_value = "off", help_heading = "Listener")]
60    pub health_public: String,
61    /// Propagate an environment variable into the browser subprocess.
62    /// Repeatable. The host scrubs all other ambient env (`HTTP_PROXY`,
63    /// `XDG_*`, `BROWSER`, locale, etc.) so a browsing environment can
64    /// never silently honor configuration the agent did not request.
65    /// Use the form `K=V`.
66    #[arg(long = "engine-env", value_name = "K=V", help_heading = "Browser")]
67    pub engine_envs: Vec<String>,
68    /// Append a raw flag to the backend subprocess command line.
69    /// Repeatable. Use for backend-specific surfaces the host doesn't
70    /// model first-class — for example
71    /// `--browser-arg --fingerprint-brand=Chrome` to override
72    /// fingerprint-chromium's brand string. Chromium honors last-wins
73    /// for duplicate flags, so an explicit entry overrides any
74    /// default the host applied.
75    #[arg(long = "browser-arg", value_name = "FLAG", help_heading = "Browser")]
76    pub browser_args: Vec<String>,
77    /// Explicit upstream proxy URL. The host never inherits
78    /// `HTTP_PROXY`/`HTTPS_PROXY` from the environment — this is the
79    /// only way to route browser traffic. Example:
80    /// `http://user:pass@proxy.local:8080` or `socks5://10.0.0.5:1080`.
81    #[arg(long = "proxy-url", help_heading = "Browser")]
82    pub proxy: Option<String>,
83    /// Enable /recent-requests with a bounded ring of N entries. 0 = off.
84    #[arg(long, default_value_t = 0, help_heading = "Listener")]
85    pub recent_requests_cap: usize,
86}
87
88pub async fn run(args: Args) -> Result<(), Error> {
89    enforce_listen_auth(&args.listen, args.token.as_deref())?;
90    let profile_raw = args.profile.trim();
91    if profile_raw.is_empty() || profile_raw.contains(',') {
92        return Err(Error::new(
93            ErrorCode::InvalidArgument,
94            "--profile: expected one profile name or '-'",
95        ));
96    }
97    let profile = if profile_raw == "-" {
98        ProfileChoice::Ephemeral
99    } else {
100        ProfileChoice::Persistent(profile_raw.to_string())
101    };
102    // One render-style flag projects onto the two internal knobs: whether the
103    // /ops screencast panel is served (`ops_enabled`) and which display-takeover
104    // backend runs (`Takeover`).
105    let (ops_enabled, takeover) = match args.takeover.as_str() {
106        "none" => (false, Takeover::Off),
107        "screencast" => (true, Takeover::Off),
108        "kasmvnc" => (true, Takeover::KasmVnc),
109        other => {
110            return Err(Error::new(
111                ErrorCode::InvalidArgument,
112                format!("--takeover: unknown mode {other:?}; expected none|screencast|kasmvnc"),
113            ));
114        }
115    };
116    let display_explicit = args.display.is_some();
117    let mut display = match args.display.as_deref().unwrap_or("headless") {
118        "headless" => DisplayMode::Headless,
119        "headful" => DisplayMode::Headful,
120        other => {
121            return Err(Error::new(
122                ErrorCode::InvalidArgument,
123                format!("--display: unknown mode {other:?}; expected headless|headful"),
124            ));
125        }
126    };
127    if matches!(takeover, Takeover::KasmVnc) {
128        if display_explicit && matches!(display, DisplayMode::Headless) {
129            return Err(Error::new(
130                ErrorCode::InvalidArgument,
131                "--takeover kasmvnc requires a headful browser; omit --display or pass --display headful",
132            ));
133        }
134        display = DisplayMode::Headful;
135    }
136    let browser = args
137        .browser
138        .parse::<BrowserChoice>()
139        .map_err(|e| Error::new(ErrorCode::InvalidArgument, format!("--browser: {e}")))?;
140    let health_public = match args.health_public.as_str() {
141        "off" => HealthPublic::Off,
142        "minimal" => HealthPublic::Minimal,
143        other => {
144            return Err(Error::new(
145                ErrorCode::InvalidArgument,
146                format!("--health-public: unknown {other:?}; expected off|minimal"),
147            ));
148        }
149    };
150    let health_enabled = parse_on_off("--health", &args.health)?;
151    let mut engine_envs = Vec::with_capacity(args.engine_envs.len());
152    for raw in &args.engine_envs {
153        engine_envs.push(parse_engine_env(raw)?);
154    }
155    if args.display_quality > 100 {
156        return Err(Error::new(
157            ErrorCode::InvalidArgument,
158            format!(
159                "--display-quality-percent: must be 0-100, got {}",
160                args.display_quality
161            ),
162        ));
163    }
164    let host_args = HostArgs {
165        listen: args.listen,
166        profile,
167        display,
168        takeover,
169        display_quality: args.display_quality,
170        browser,
171        browser_bin: args.browser_bin,
172        token: args.token,
173        ops_enabled,
174        health_enabled,
175        health_public,
176        engine_envs,
177        browser_args: args.browser_args,
178        proxy: args.proxy,
179        recent_requests_cap: args.recent_requests_cap,
180    };
181    crate::host::bootstrap::run(host_args).await
182}
183
184/// Refuse to expose a token-less control surface to the network. A TCP listener
185/// on any non-loopback address (`0.0.0.0`, a LAN/mesh IP, …) serves `/cdp` —
186/// full browser and profile control plus arbitrary in-page JS — to anyone who
187/// can reach the port, so a token is mandatory there. Loopback TCP and unix
188/// sockets are reachable only locally, so a token stays optional. (When a token
189/// is set we skip the parse here; the listener validates the address later.)
190fn enforce_listen_auth(listen: &str, token: Option<&str>) -> Result<(), Error> {
191    if token.is_some() {
192        return Ok(());
193    }
194    if let ListenAddr::Tcp(addr) = parse_listen(listen)? {
195        if !addr.ip().is_loopback() {
196            return Err(Error::new(
197                ErrorCode::InvalidArgument,
198                format!(
199                    "--listen {listen}: refusing to bind a non-loopback address without --token-secret. \
200                     A token-less TCP host exposes full browser and profile control (/cdp) to \
201                     anyone who can reach the port. Pass --token-secret, or bind tcp:127.0.0.1:<port> \
202                     or a unix: socket."
203                ),
204            ));
205        }
206    }
207    Ok(())
208}
209
210fn parse_engine_env(raw: &str) -> Result<(String, String), Error> {
211    let (k, v) = raw.split_once('=').ok_or_else(|| {
212        Error::new(
213            ErrorCode::InvalidArgument,
214            format!("--engine-env: expected K=V, got {raw:?}"),
215        )
216    })?;
217    if k.is_empty() {
218        return Err(Error::new(
219            ErrorCode::InvalidArgument,
220            "--engine-env: key must not be empty",
221        ));
222    }
223    Ok((k.to_string(), v.to_string()))
224}
225
226fn parse_on_off(flag: &str, value: &str) -> Result<bool, Error> {
227    match value {
228        "on" => Ok(true),
229        "off" => Ok(false),
230        other => Err(Error::new(
231            ErrorCode::InvalidArgument,
232            format!("{flag}: expected on|off, got {other:?}"),
233        )),
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn loopback_tcp_needs_no_token() {
243        enforce_listen_auth("tcp:127.0.0.1:9222", None).unwrap();
244        enforce_listen_auth("tcp:[::1]:9222", None).unwrap();
245    }
246
247    #[test]
248    fn unix_socket_needs_no_token() {
249        enforce_listen_auth("unix:/run/afhttp.sock", None).unwrap();
250    }
251
252    #[test]
253    fn non_loopback_tcp_requires_token() {
254        for spec in ["tcp:0.0.0.0:9222", "tcp:192.168.1.10:9222", "tcp:[::]:9222"] {
255            let err = enforce_listen_auth(spec, None).err().unwrap();
256            assert_eq!(err.error_code, ErrorCode::InvalidArgument, "spec={spec}");
257        }
258    }
259
260    #[test]
261    fn token_allows_any_address() {
262        enforce_listen_auth("tcp:0.0.0.0:9222", Some("secret")).unwrap();
263    }
264}