agent_first_http/cli/cmd/
host.rs1use 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 #[arg(long, help_heading = "Listener")]
18 pub listen: String,
19 #[arg(long, default_value = "-", help_heading = "Profile")]
22 pub profile: String,
23 #[arg(long, help_heading = "Display & takeover")]
25 pub display: Option<String>,
26 #[arg(
32 long,
33 default_value = "screencast",
34 help_heading = "Display & takeover"
35 )]
36 pub takeover: String,
37 #[arg(long = "display-provider", help_heading = "Display & takeover")]
40 pub display_provider: Option<String>,
41 #[arg(
45 long = "display-quality-percent",
46 default_value_t = 100,
47 help_heading = "Display & takeover"
48 )]
49 pub display_quality: u8,
50 #[arg(long, default_value = "auto", help_heading = "Browser")]
52 pub browser: String,
53 #[arg(long, help_heading = "Browser")]
55 pub browser_bin: Option<PathBuf>,
56 #[arg(long = "token-secret", help_heading = "Listener")]
58 pub token: Option<String>,
59 #[arg(long, default_value = "on", help_heading = "Listener")]
61 pub health: String,
62 #[arg(long, default_value = "off", help_heading = "Listener")]
64 pub health_public: String,
65 #[arg(long = "engine-env", value_name = "K=V", help_heading = "Browser")]
71 pub engine_envs: Vec<String>,
72 #[arg(long = "browser-arg", value_name = "FLAG", help_heading = "Browser")]
80 pub browser_args: Vec<String>,
81 #[arg(long = "proxy-url", help_heading = "Browser")]
86 pub proxy: Option<String>,
87 #[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
176fn 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}