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::cli::cmd::argenums::{BrowserArg, DisplayArg, HealthPublicArg, TakeoverProviderArg};
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(ClapArgs, Debug)]
16pub struct Args {
17    /// Listener address: `tcp:host:port` or `unix:/path/to.sock`.
18    #[arg(long, help_heading = "Listener")]
19    pub listen: String,
20    /// Initial profile name, or `-` for an ephemeral profile.
21    ///
22    /// Persistent profiles are stored under
23    /// $XDG_DATA_HOME/afhttp/profiles/<backend>/<name>. A host serves one
24    /// active profile at a time but can switch at runtime when a client passes
25    /// `?profile=` on the `/cdp` connection (the browser is relaunched).
26    #[arg(long, default_value = "-", help_heading = "Profile")]
27    pub profile: String,
28    /// Display mode. Omit when `--takeover-provider` should imply headful.
29    #[arg(long, help_heading = "Display & takeover")]
30    pub display: Option<DisplayArg>,
31    /// Real-display takeover provider: off or kasmvnc.
32    ///
33    /// `off` serves no takeover surface; `kasmvnc` serves a real-display
34    /// takeover at /takeover/panel for captcha, IME, or flaky CDP input and
35    /// implies headful mode.
36    #[arg(
37        long = "takeover-provider",
38        default_value = "off",
39        help_heading = "Display & takeover"
40    )]
41    pub takeover_provider: TakeoverProviderArg,
42    /// Takeover image quality hint, from 0 to 100 percent.
43    ///
44    /// The default 100 is crispest. KasmVNC maps this to 0-9 quality tiers;
45    /// lower values trade clarity for bandwidth and can be adjusted live.
46    #[arg(
47        long = "takeover-quality-percent",
48        default_value_t = 100,
49        help_heading = "Display & takeover"
50    )]
51    pub takeover_quality_percent: u8,
52    /// Browser backend.
53    #[arg(long, default_value = "auto", help_heading = "Browser")]
54    pub browser: BrowserArg,
55    /// Override browser binary path.
56    #[arg(long, help_heading = "Browser")]
57    pub browser_bin: Option<PathBuf>,
58    /// Bearer token required for clients on TCP listeners.
59    #[arg(long = "token-secret", help_heading = "Listener")]
60    pub token: Option<String>,
61    /// Disable serving /health and /capabilities (served by default).
62    #[arg(long, help_heading = "Diagnostics")]
63    pub no_health: bool,
64    /// Make /health public with minimal payload.
65    #[arg(long, default_value = "off", help_heading = "Diagnostics")]
66    pub health_public: HealthPublicArg,
67    /// Set a browser subprocess environment variable (repeatable).
68    ///
69    /// The host scrubs all other ambient variables (`HTTP_PROXY`, `XDG_*`,
70    /// `BROWSER`, locale, etc.) so browsing never silently honors unrequested
71    /// configuration. Use `NAME=VALUE`.
72    #[arg(
73        long = "engine-env",
74        value_name = "NAME=VALUE",
75        help_heading = "Browser"
76    )]
77    pub engine_envs: Vec<String>,
78    /// Append a raw browser backend flag (repeatable).
79    ///
80    /// Use for backend-specific surfaces not modeled first-class. For example,
81    /// `--browser-arg --fingerprint-brand=Chrome` overrides
82    /// fingerprint-chromium's brand string. Chromium uses the last duplicate
83    /// flag, so an explicit entry overrides a host default.
84    #[arg(long = "browser-arg", value_name = "FLAG", help_heading = "Browser")]
85    pub browser_args: Vec<String>,
86    /// Explicit upstream browser proxy URL.
87    ///
88    /// The host never inherits ambient `HTTP_PROXY`/`HTTPS_PROXY`; this is the
89    /// only proxy route. For example, `http://user:pass@proxy.local:8080` or
90    /// `socks5://10.0.0.5:1080`.
91    #[arg(long = "proxy-url", help_heading = "Browser")]
92    pub proxy: Option<String>,
93    /// Enable /recent-requests with a bounded ring of N entries. 0 = off.
94    #[arg(long, default_value_t = 0, help_heading = "Diagnostics")]
95    pub recent_requests_cap: usize,
96}
97
98pub async fn run(args: Args) -> Result<(), Error> {
99    enforce_listen_auth(&args.listen, args.token.as_deref())?;
100    let profile_raw = args.profile.trim();
101    if profile_raw.is_empty() || profile_raw.contains(',') {
102        return Err(Error::new(
103            ErrorCode::InvalidArgument,
104            "--profile: expected one profile name or '-'",
105        ));
106    }
107    let profile = if profile_raw == "-" {
108        ProfileChoice::Ephemeral
109    } else {
110        ProfileChoice::Persistent(profile_raw.to_string())
111    };
112    let takeover: Takeover = args.takeover_provider.into();
113    let takeover_enabled = !matches!(takeover, Takeover::Off);
114    let display_explicit = args.display.is_some();
115    let mut display = args
116        .display
117        .map(DisplayMode::from)
118        .unwrap_or(DisplayMode::Headless);
119    if matches!(takeover, Takeover::On { .. }) {
120        if display_explicit && matches!(display, DisplayMode::Headless) {
121            return Err(Error::new(
122                ErrorCode::InvalidArgument,
123                "--takeover-provider <provider> requires a headful browser; omit --display or pass --display headful",
124            ));
125        }
126        display = DisplayMode::Headful;
127    }
128    let browser: BrowserChoice = args.browser.into();
129    let health_public: HealthPublic = args.health_public.into();
130    let health_enabled: bool = !args.no_health;
131    let mut engine_envs = Vec::with_capacity(args.engine_envs.len());
132    for raw in &args.engine_envs {
133        engine_envs.push(parse_engine_env(raw)?);
134    }
135    if args.takeover_quality_percent > 100 {
136        return Err(Error::new(
137            ErrorCode::InvalidArgument,
138            format!(
139                "--takeover-quality-percent: must be 0-100, got {}",
140                args.takeover_quality_percent
141            ),
142        ));
143    }
144    let host_args = HostArgs {
145        listen: args.listen,
146        profile,
147        display,
148        takeover,
149        display_quality: args.takeover_quality_percent,
150        browser,
151        browser_bin: args.browser_bin,
152        token: args.token,
153        takeover_enabled,
154        health_enabled,
155        health_public,
156        engine_envs,
157        browser_args: args.browser_args,
158        proxy: args.proxy,
159        recent_requests_cap: args.recent_requests_cap,
160    };
161    crate::host::bootstrap::run(host_args).await
162}
163
164/// Refuse to expose a token-less control surface to the network. A TCP listener
165/// on any non-loopback address (`0.0.0.0`, a LAN/mesh IP, …) serves `/cdp` —
166/// full browser and profile control plus arbitrary in-page JS — to anyone who
167/// can reach the port, so a token is mandatory there. Loopback TCP and unix
168/// sockets are reachable only locally, so a token stays optional. (When a token
169/// is set we skip the parse here; the listener validates the address later.)
170fn enforce_listen_auth(listen: &str, token: Option<&str>) -> Result<(), Error> {
171    if token.is_some() {
172        return Ok(());
173    }
174    if let ListenAddr::Tcp(addr) = parse_listen(listen)?
175        && !addr.ip().is_loopback()
176    {
177        return Err(Error::new(
178            ErrorCode::InvalidArgument,
179            format!(
180                "--listen {listen}: refusing to bind a non-loopback address without --token-secret. \
181                     A token-less TCP host exposes full browser and profile control (/cdp) to \
182                     anyone who can reach the port. Pass --token-secret, or bind tcp:127.0.0.1:<port> \
183                     or a unix: socket."
184            ),
185        ));
186    }
187    Ok(())
188}
189
190fn parse_engine_env(raw: &str) -> Result<(String, String), Error> {
191    let (k, v) = raw.split_once('=').ok_or_else(|| {
192        Error::new(
193            ErrorCode::InvalidArgument,
194            format!("--engine-env: expected NAME=VALUE, got {raw:?}"),
195        )
196    })?;
197    if k.is_empty() {
198        return Err(Error::new(
199            ErrorCode::InvalidArgument,
200            "--engine-env: key must not be empty",
201        ));
202    }
203    Ok((k.to_string(), v.to_string()))
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn loopback_tcp_needs_no_token() {
212        enforce_listen_auth("tcp:127.0.0.1:9222", None).unwrap();
213        enforce_listen_auth("tcp:[::1]:9222", None).unwrap();
214    }
215
216    #[test]
217    fn unix_socket_needs_no_token() {
218        enforce_listen_auth("unix:/run/afhttp.sock", None).unwrap();
219    }
220
221    #[test]
222    fn non_loopback_tcp_requires_token() {
223        for spec in ["tcp:0.0.0.0:9222", "tcp:192.168.1.10:9222", "tcp:[::]:9222"] {
224            let err = enforce_listen_auth(spec, None).err().unwrap();
225            assert_eq!(err.error_code, ErrorCode::InvalidArgument, "spec={spec}");
226        }
227    }
228
229    #[test]
230    fn token_allows_any_address() {
231        enforce_listen_auth("tcp:0.0.0.0:9222", Some("secret")).unwrap();
232    }
233}