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, DisplayProvider, 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 display 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/screencast
28    /// (works headless, no VNC/X needed); display serves a real-display
29    /// takeover at /ops/display through a display provider (currently KasmVNC)
30    /// for hard sites (captcha, IME, flaky CDP input — implies headful).
31    #[arg(
32        long,
33        default_value = "screencast",
34        help_heading = "Display & takeover"
35    )]
36    pub takeover: String,
37    /// Display provider for `--takeover display`. Currently only `kasmvnc` is
38    /// supported and it is the default when display takeover is selected.
39    #[arg(long = "display-provider", help_heading = "Display & takeover")]
40    pub display_provider: Option<String>,
41    /// Display-provider image quality hint, 0-100 (default 100 = crispest).
42    /// Current KasmVNC provider maps this to 0-9 quality tiers; lower trades
43    /// clarity for bandwidth. Adjustable live in the display panel too.
44    #[arg(
45        long = "display-quality-percent",
46        default_value_t = 100,
47        help_heading = "Display & takeover"
48    )]
49    pub display_quality: u8,
50    /// auto | chromium | chrome | chrome_shell | fingerprint_chromium | edge | brave | lightpanda | camoufox.
51    #[arg(long, default_value = "auto", help_heading = "Browser")]
52    pub browser: String,
53    /// Override browser binary path.
54    #[arg(long, help_heading = "Browser")]
55    pub browser_bin: Option<PathBuf>,
56    /// Bearer token required for clients on TCP listeners.
57    #[arg(long = "token-secret", help_heading = "Listener")]
58    pub token: Option<String>,
59    /// Serve /health and /capabilities.
60    #[arg(long, default_value = "on", help_heading = "Listener")]
61    pub health: String,
62    /// Make /health public with minimal payload.
63    #[arg(long, default_value = "off", help_heading = "Listener")]
64    pub health_public: String,
65    /// Propagate an environment variable into the browser subprocess.
66    /// Repeatable. The host scrubs all other ambient env (`HTTP_PROXY`,
67    /// `XDG_*`, `BROWSER`, locale, etc.) so a browsing environment can
68    /// never silently honor configuration the agent did not request.
69    /// Use the form `K=V`.
70    #[arg(long = "engine-env", value_name = "K=V", help_heading = "Browser")]
71    pub engine_envs: Vec<String>,
72    /// Append a raw flag to the backend subprocess command line.
73    /// Repeatable. Use for backend-specific surfaces the host doesn't
74    /// model first-class — for example
75    /// `--browser-arg --fingerprint-brand=Chrome` to override
76    /// fingerprint-chromium's brand string. Chromium honors last-wins
77    /// for duplicate flags, so an explicit entry overrides any
78    /// default the host applied.
79    #[arg(long = "browser-arg", value_name = "FLAG", help_heading = "Browser")]
80    pub browser_args: Vec<String>,
81    /// Explicit upstream proxy URL. The host never inherits
82    /// `HTTP_PROXY`/`HTTPS_PROXY` from the environment — this is the
83    /// only way to route browser traffic. Example:
84    /// `http://user:pass@proxy.local:8080` or `socks5://10.0.0.5:1080`.
85    #[arg(long = "proxy-url", help_heading = "Browser")]
86    pub proxy: Option<String>,
87    /// Enable /recent-requests with a bounded ring of N entries. 0 = off.
88    #[arg(long, default_value_t = 0, help_heading = "Listener")]
89    pub recent_requests_cap: usize,
90}
91
92pub async fn run(args: Args) -> Result<(), Error> {
93    enforce_listen_auth(&args.listen, args.token.as_deref())?;
94    let profile_raw = args.profile.trim();
95    if profile_raw.is_empty() || profile_raw.contains(',') {
96        return Err(Error::new(
97            ErrorCode::InvalidArgument,
98            "--profile: expected one profile name or '-'",
99        ));
100    }
101    let profile = if profile_raw == "-" {
102        ProfileChoice::Ephemeral
103    } else {
104        ProfileChoice::Persistent(profile_raw.to_string())
105    };
106    let takeover = parse_takeover(&args.takeover, args.display_provider.as_deref())?;
107    let ops_enabled = !matches!(takeover, Takeover::Off);
108    let display_explicit = args.display.is_some();
109    let mut display = match args.display.as_deref().unwrap_or("headless") {
110        "headless" => DisplayMode::Headless,
111        "headful" => DisplayMode::Headful,
112        other => {
113            return Err(Error::new(
114                ErrorCode::InvalidArgument,
115                format!("--display: unknown mode {other:?}; expected headless|headful"),
116            ));
117        }
118    };
119    if matches!(takeover, Takeover::Display { .. }) {
120        if display_explicit && matches!(display, DisplayMode::Headless) {
121            return Err(Error::new(
122                ErrorCode::InvalidArgument,
123                "--takeover display requires a headful browser; omit --display or pass --display headful",
124            ));
125        }
126        display = DisplayMode::Headful;
127    }
128    let browser = args
129        .browser
130        .parse::<BrowserChoice>()
131        .map_err(|e| Error::new(ErrorCode::InvalidArgument, format!("--browser: {e}")))?;
132    let health_public = match args.health_public.as_str() {
133        "off" => HealthPublic::Off,
134        "minimal" => HealthPublic::Minimal,
135        other => {
136            return Err(Error::new(
137                ErrorCode::InvalidArgument,
138                format!("--health-public: unknown {other:?}; expected off|minimal"),
139            ));
140        }
141    };
142    let health_enabled = parse_on_off("--health", &args.health)?;
143    let mut engine_envs = Vec::with_capacity(args.engine_envs.len());
144    for raw in &args.engine_envs {
145        engine_envs.push(parse_engine_env(raw)?);
146    }
147    if args.display_quality > 100 {
148        return Err(Error::new(
149            ErrorCode::InvalidArgument,
150            format!(
151                "--display-quality-percent: must be 0-100, got {}",
152                args.display_quality
153            ),
154        ));
155    }
156    let host_args = HostArgs {
157        listen: args.listen,
158        profile,
159        display,
160        takeover,
161        display_quality: args.display_quality,
162        browser,
163        browser_bin: args.browser_bin,
164        token: args.token,
165        ops_enabled,
166        health_enabled,
167        health_public,
168        engine_envs,
169        browser_args: args.browser_args,
170        proxy: args.proxy,
171        recent_requests_cap: args.recent_requests_cap,
172    };
173    crate::host::bootstrap::run(host_args).await
174}
175
176/// Refuse to expose a token-less control surface to the network. A TCP listener
177/// on any non-loopback address (`0.0.0.0`, a LAN/mesh IP, …) serves `/cdp` —
178/// full browser and profile control plus arbitrary in-page JS — to anyone who
179/// can reach the port, so a token is mandatory there. Loopback TCP and unix
180/// sockets are reachable only locally, so a token stays optional. (When a token
181/// is set we skip the parse here; the listener validates the address later.)
182fn enforce_listen_auth(listen: &str, token: Option<&str>) -> Result<(), Error> {
183    if token.is_some() {
184        return Ok(());
185    }
186    if let ListenAddr::Tcp(addr) = parse_listen(listen)? {
187        if !addr.ip().is_loopback() {
188            return Err(Error::new(
189                ErrorCode::InvalidArgument,
190                format!(
191                    "--listen {listen}: refusing to bind a non-loopback address without --token-secret. \
192                     A token-less TCP host exposes full browser and profile control (/cdp) to \
193                     anyone who can reach the port. Pass --token-secret, or bind tcp:127.0.0.1:<port> \
194                     or a unix: socket."
195                ),
196            ));
197        }
198    }
199    Ok(())
200}
201
202fn parse_engine_env(raw: &str) -> Result<(String, String), Error> {
203    let (k, v) = raw.split_once('=').ok_or_else(|| {
204        Error::new(
205            ErrorCode::InvalidArgument,
206            format!("--engine-env: expected K=V, got {raw:?}"),
207        )
208    })?;
209    if k.is_empty() {
210        return Err(Error::new(
211            ErrorCode::InvalidArgument,
212            "--engine-env: key must not be empty",
213        ));
214    }
215    Ok((k.to_string(), v.to_string()))
216}
217
218fn parse_on_off(flag: &str, value: &str) -> Result<bool, Error> {
219    match value {
220        "on" => Ok(true),
221        "off" => Ok(false),
222        other => Err(Error::new(
223            ErrorCode::InvalidArgument,
224            format!("{flag}: expected on|off, got {other:?}"),
225        )),
226    }
227}
228
229fn parse_takeover(raw: &str, display_provider: Option<&str>) -> Result<Takeover, Error> {
230    match raw {
231        "none" => {
232            reject_display_provider_without_display(display_provider)?;
233            Ok(Takeover::Off)
234        }
235        "screencast" => {
236            reject_display_provider_without_display(display_provider)?;
237            Ok(Takeover::Screencast)
238        }
239        "display" => {
240            let provider = match display_provider {
241                Some(raw) => raw.parse::<DisplayProvider>().map_err(|e| {
242                    Error::new(ErrorCode::InvalidArgument, format!("--display-provider: {e}"))
243                })?,
244                None => DisplayProvider::KasmVnc,
245            };
246            Ok(Takeover::Display { provider })
247        }
248        "kasmvnc" => Err(Error::new(
249            ErrorCode::InvalidArgument,
250            "--takeover: mode \"kasmvnc\" was removed; use `--takeover display --display-provider kasmvnc`",
251        )),
252        other => Err(Error::new(
253            ErrorCode::InvalidArgument,
254            format!("--takeover: unknown mode {other:?}; expected none|screencast|display"),
255        )),
256    }
257}
258
259fn reject_display_provider_without_display(display_provider: Option<&str>) -> Result<(), Error> {
260    if display_provider.is_some() {
261        return Err(Error::new(
262            ErrorCode::InvalidArgument,
263            "--display-provider is only valid with `--takeover display`",
264        ));
265    }
266    Ok(())
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn loopback_tcp_needs_no_token() {
275        enforce_listen_auth("tcp:127.0.0.1:9222", None).unwrap();
276        enforce_listen_auth("tcp:[::1]:9222", None).unwrap();
277    }
278
279    #[test]
280    fn unix_socket_needs_no_token() {
281        enforce_listen_auth("unix:/run/afhttp.sock", None).unwrap();
282    }
283
284    #[test]
285    fn non_loopback_tcp_requires_token() {
286        for spec in ["tcp:0.0.0.0:9222", "tcp:192.168.1.10:9222", "tcp:[::]:9222"] {
287            let err = enforce_listen_auth(spec, None).err().unwrap();
288            assert_eq!(err.error_code, ErrorCode::InvalidArgument, "spec={spec}");
289        }
290    }
291
292    #[test]
293    fn token_allows_any_address() {
294        enforce_listen_auth("tcp:0.0.0.0:9222", Some("secret")).unwrap();
295    }
296
297    #[test]
298    fn takeover_modes_parse_new_display_provider_contract() {
299        assert_eq!(parse_takeover("none", None).unwrap(), Takeover::Off);
300        assert_eq!(
301            parse_takeover("screencast", None).unwrap(),
302            Takeover::Screencast
303        );
304        assert_eq!(
305            parse_takeover("display", None).unwrap(),
306            Takeover::Display {
307                provider: DisplayProvider::KasmVnc
308            }
309        );
310        assert_eq!(
311            parse_takeover("display", Some("kasmvnc")).unwrap(),
312            Takeover::Display {
313                provider: DisplayProvider::KasmVnc
314            }
315        );
316    }
317
318    #[test]
319    fn takeover_modes_reject_legacy_and_misplaced_provider() {
320        let legacy = parse_takeover("kasmvnc", None).err().unwrap();
321        assert_eq!(legacy.error_code, ErrorCode::InvalidArgument);
322        assert!(legacy
323            .detail
324            .contains("--takeover display --display-provider kasmvnc"));
325
326        let misplaced = parse_takeover("screencast", Some("kasmvnc")).err().unwrap();
327        assert_eq!(misplaced.error_code, ErrorCode::InvalidArgument);
328
329        let unknown_provider = parse_takeover("display", Some("unknown")).err().unwrap();
330        assert_eq!(unknown_provider.error_code, ErrorCode::InvalidArgument);
331        assert!(unknown_provider.detail.contains("expected kasmvnc"));
332    }
333}