agent_first_http/cli/cmd/
host.rs1use 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 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 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
88fn 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
114fn parse_engine_env(raw: &str) -> Result<(String, String), Error> {
115 let (k, v) = raw.split_once('=').ok_or_else(|| {
116 Error::new(
117 ErrorCode::InvalidArgument,
118 format!("--engine-env: expected NAME=VALUE, got {raw:?}"),
119 )
120 })?;
121 if k.is_empty() {
122 return Err(Error::new(
123 ErrorCode::InvalidArgument,
124 "--engine-env: key must not be empty",
125 ));
126 }
127 Ok((k.to_string(), v.to_string()))
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn loopback_tcp_needs_no_token() {
136 enforce_listen_auth("tcp:127.0.0.1:9222", None).unwrap();
137 enforce_listen_auth("tcp:[::1]:9222", None).unwrap();
138 }
139
140 #[test]
141 fn unix_socket_needs_no_token() {
142 enforce_listen_auth("unix:/run/afhttp.sock", None).unwrap();
143 }
144
145 #[test]
146 fn non_loopback_tcp_requires_token() {
147 for spec in ["tcp:0.0.0.0:9222", "tcp:192.168.1.10:9222", "tcp:[::]:9222"] {
148 let err = enforce_listen_auth(spec, None).err().unwrap();
149 assert_eq!(err.error_code, ErrorCode::InvalidArgument, "spec={spec}");
150 }
151 }
152
153 #[test]
154 fn token_allows_any_address() {
155 enforce_listen_auth("tcp:0.0.0.0:9222", Some("secret")).unwrap();
156 }
157}