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")]
27 pub profile: String,
28 #[arg(long, help_heading = "Display & takeover")]
30 pub display: Option<DisplayArg>,
31 #[arg(
37 long = "takeover-provider",
38 default_value = "off",
39 help_heading = "Display & takeover"
40 )]
41 pub takeover_provider: TakeoverProviderArg,
42 #[arg(
47 long = "takeover-quality-percent",
48 default_value_t = 100,
49 help_heading = "Display & takeover"
50 )]
51 pub takeover_quality_percent: u8,
52 #[arg(long, default_value = "auto", help_heading = "Browser")]
54 pub browser: BrowserArg,
55 #[arg(long, help_heading = "Browser")]
57 pub browser_bin: Option<PathBuf>,
58 #[arg(long = "token-secret", help_heading = "Listener")]
60 pub token: Option<String>,
61 #[arg(long, help_heading = "Diagnostics")]
63 pub no_health: bool,
64 #[arg(long, default_value = "off", help_heading = "Diagnostics")]
66 pub health_public: HealthPublicArg,
67 #[arg(
73 long = "engine-env",
74 value_name = "NAME=VALUE",
75 help_heading = "Browser"
76 )]
77 pub engine_envs: Vec<String>,
78 #[arg(long = "browser-arg", value_name = "FLAG", help_heading = "Browser")]
85 pub browser_args: Vec<String>,
86 #[arg(long = "proxy-url", help_heading = "Browser")]
92 pub proxy: Option<String>,
93 #[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
164fn 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}