agent_first_http/cli/cmd/
host.rs1use 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 #[arg(long, help_heading = "Listener")]
19 pub listen: String,
20 #[arg(long, default_value = "-", help_heading = "Profile")]
26 pub profile: String,
27 #[arg(long, help_heading = "Display & takeover")]
29 pub display: Option<DisplayArg>,
30 #[arg(
35 long = "takeover-provider",
36 default_value = "off",
37 help_heading = "Display & takeover"
38 )]
39 pub takeover_provider: TakeoverProviderArg,
40 #[arg(
44 long = "takeover-quality-percent",
45 default_value_t = 100,
46 help_heading = "Display & takeover"
47 )]
48 pub takeover_quality_percent: u8,
49 #[arg(long, default_value = "auto", help_heading = "Browser")]
51 pub browser: BrowserArg,
52 #[arg(long, help_heading = "Browser")]
54 pub browser_bin: Option<PathBuf>,
55 #[arg(long = "token-secret", help_heading = "Listener")]
57 pub token: Option<String>,
58 #[arg(long, help_heading = "Diagnostics")]
60 pub no_health: bool,
61 #[arg(long, default_value = "off", help_heading = "Diagnostics")]
63 pub health_public: HealthPublicArg,
64 #[arg(
70 long = "engine-env",
71 value_name = "NAME=VALUE",
72 help_heading = "Browser"
73 )]
74 pub engine_envs: Vec<String>,
75 #[arg(long = "browser-arg", value_name = "FLAG", help_heading = "Browser")]
83 pub browser_args: Vec<String>,
84 #[arg(long = "proxy-url", help_heading = "Browser")]
89 pub proxy: Option<String>,
90 #[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
161fn 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}