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