agent_first_http/cli/cmd/
host.rs1use std::path::PathBuf;
5
6use clap::Args as ClapArgs;
7
8use crate::host::bootstrap::{
9 BrowserChoice, DisplayMode, 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(
41 long = "display-quality-percent",
42 default_value_t = 100,
43 help_heading = "Display & takeover"
44 )]
45 pub display_quality: u8,
46 #[arg(long, default_value = "auto", help_heading = "Browser")]
48 pub browser: String,
49 #[arg(long, help_heading = "Browser")]
51 pub browser_bin: Option<PathBuf>,
52 #[arg(long = "token-secret", help_heading = "Listener")]
54 pub token: Option<String>,
55 #[arg(long, default_value = "on", help_heading = "Listener")]
57 pub health: String,
58 #[arg(long, default_value = "off", help_heading = "Listener")]
60 pub health_public: String,
61 #[arg(long = "engine-env", value_name = "K=V", help_heading = "Browser")]
67 pub engine_envs: Vec<String>,
68 #[arg(long = "browser-arg", value_name = "FLAG", help_heading = "Browser")]
76 pub browser_args: Vec<String>,
77 #[arg(long = "proxy-url", help_heading = "Browser")]
82 pub proxy: Option<String>,
83 #[arg(long, default_value_t = 0, help_heading = "Listener")]
85 pub recent_requests_cap: usize,
86}
87
88pub async fn run(args: Args) -> Result<(), Error> {
89 enforce_listen_auth(&args.listen, args.token.as_deref())?;
90 let profile_raw = args.profile.trim();
91 if profile_raw.is_empty() || profile_raw.contains(',') {
92 return Err(Error::new(
93 ErrorCode::InvalidArgument,
94 "--profile: expected one profile name or '-'",
95 ));
96 }
97 let profile = if profile_raw == "-" {
98 ProfileChoice::Ephemeral
99 } else {
100 ProfileChoice::Persistent(profile_raw.to_string())
101 };
102 let (ops_enabled, takeover) = match args.takeover.as_str() {
106 "none" => (false, Takeover::Off),
107 "screencast" => (true, Takeover::Off),
108 "kasmvnc" => (true, Takeover::KasmVnc),
109 other => {
110 return Err(Error::new(
111 ErrorCode::InvalidArgument,
112 format!("--takeover: unknown mode {other:?}; expected none|screencast|kasmvnc"),
113 ));
114 }
115 };
116 let display_explicit = args.display.is_some();
117 let mut display = match args.display.as_deref().unwrap_or("headless") {
118 "headless" => DisplayMode::Headless,
119 "headful" => DisplayMode::Headful,
120 other => {
121 return Err(Error::new(
122 ErrorCode::InvalidArgument,
123 format!("--display: unknown mode {other:?}; expected headless|headful"),
124 ));
125 }
126 };
127 if matches!(takeover, Takeover::KasmVnc) {
128 if display_explicit && matches!(display, DisplayMode::Headless) {
129 return Err(Error::new(
130 ErrorCode::InvalidArgument,
131 "--takeover kasmvnc requires a headful browser; omit --display or pass --display headful",
132 ));
133 }
134 display = DisplayMode::Headful;
135 }
136 let browser = args
137 .browser
138 .parse::<BrowserChoice>()
139 .map_err(|e| Error::new(ErrorCode::InvalidArgument, format!("--browser: {e}")))?;
140 let health_public = match args.health_public.as_str() {
141 "off" => HealthPublic::Off,
142 "minimal" => HealthPublic::Minimal,
143 other => {
144 return Err(Error::new(
145 ErrorCode::InvalidArgument,
146 format!("--health-public: unknown {other:?}; expected off|minimal"),
147 ));
148 }
149 };
150 let health_enabled = parse_on_off("--health", &args.health)?;
151 let mut engine_envs = Vec::with_capacity(args.engine_envs.len());
152 for raw in &args.engine_envs {
153 engine_envs.push(parse_engine_env(raw)?);
154 }
155 if args.display_quality > 100 {
156 return Err(Error::new(
157 ErrorCode::InvalidArgument,
158 format!(
159 "--display-quality-percent: must be 0-100, got {}",
160 args.display_quality
161 ),
162 ));
163 }
164 let host_args = HostArgs {
165 listen: args.listen,
166 profile,
167 display,
168 takeover,
169 display_quality: args.display_quality,
170 browser,
171 browser_bin: args.browser_bin,
172 token: args.token,
173 ops_enabled,
174 health_enabled,
175 health_public,
176 engine_envs,
177 browser_args: args.browser_args,
178 proxy: args.proxy,
179 recent_requests_cap: args.recent_requests_cap,
180 };
181 crate::host::bootstrap::run(host_args).await
182}
183
184fn enforce_listen_auth(listen: &str, token: Option<&str>) -> Result<(), Error> {
191 if token.is_some() {
192 return Ok(());
193 }
194 if let ListenAddr::Tcp(addr) = parse_listen(listen)? {
195 if !addr.ip().is_loopback() {
196 return Err(Error::new(
197 ErrorCode::InvalidArgument,
198 format!(
199 "--listen {listen}: refusing to bind a non-loopback address without --token-secret. \
200 A token-less TCP host exposes full browser and profile control (/cdp) to \
201 anyone who can reach the port. Pass --token-secret, or bind tcp:127.0.0.1:<port> \
202 or a unix: socket."
203 ),
204 ));
205 }
206 }
207 Ok(())
208}
209
210fn parse_engine_env(raw: &str) -> Result<(String, String), Error> {
211 let (k, v) = raw.split_once('=').ok_or_else(|| {
212 Error::new(
213 ErrorCode::InvalidArgument,
214 format!("--engine-env: expected K=V, got {raw:?}"),
215 )
216 })?;
217 if k.is_empty() {
218 return Err(Error::new(
219 ErrorCode::InvalidArgument,
220 "--engine-env: key must not be empty",
221 ));
222 }
223 Ok((k.to_string(), v.to_string()))
224}
225
226fn parse_on_off(flag: &str, value: &str) -> Result<bool, Error> {
227 match value {
228 "on" => Ok(true),
229 "off" => Ok(false),
230 other => Err(Error::new(
231 ErrorCode::InvalidArgument,
232 format!("{flag}: expected on|off, got {other:?}"),
233 )),
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn loopback_tcp_needs_no_token() {
243 enforce_listen_auth("tcp:127.0.0.1:9222", None).unwrap();
244 enforce_listen_auth("tcp:[::1]:9222", None).unwrap();
245 }
246
247 #[test]
248 fn unix_socket_needs_no_token() {
249 enforce_listen_auth("unix:/run/afhttp.sock", None).unwrap();
250 }
251
252 #[test]
253 fn non_loopback_tcp_requires_token() {
254 for spec in ["tcp:0.0.0.0:9222", "tcp:192.168.1.10:9222", "tcp:[::]:9222"] {
255 let err = enforce_listen_auth(spec, None).err().unwrap();
256 assert_eq!(err.error_code, ErrorCode::InvalidArgument, "spec={spec}");
257 }
258 }
259
260 #[test]
261 fn token_allows_any_address() {
262 enforce_listen_auth("tcp:0.0.0.0:9222", Some("secret")).unwrap();
263 }
264}