1use std::path::{Path, PathBuf};
11use std::process::Command;
12
13use agent_first_data::value_source::SecretString;
14use serde::Serialize;
15
16use crate::cli::output;
17use crate::host::bootstrap::Takeover;
18use crate::sdk::capabilities::BackendFamily;
19use crate::shared::error::{Error, ErrorCode};
20
21const DOCKERFILE: &str = include_str!("../../../container/docker/Dockerfile");
26const INSTALL_BACKENDS: &str = include_str!("../../../container/docker/install-backends.sh");
27const ENTRYPOINT: &str = include_str!("../../../container/docker/entrypoint.sh");
28
29const VERSION: &str = env!("CARGO_PKG_VERSION");
31const MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR");
34pub(crate) const DEFAULT_CONTAINER_NAME: &str = "afhttp-host";
36pub(crate) const DEFAULT_CONTAINER_PORT: u16 = 9222;
37const IMAGE_REPO: &str = "afhttp-host";
38const HOST_TOKEN_PATH: &str = "/data/afhttp/host-token";
43
44#[derive(Debug)]
45pub struct Args {
46 pub sub: ContainerSub,
47}
48
49#[derive(Debug)]
50pub enum ContainerSub {
51 Install(InstallArgs),
52 Uninstall(UninstallArgs),
53 Status(StatusArgs),
54 Logs(LogsArgs),
55}
56
57#[derive(Debug)]
59pub struct CommonArgs {
60 pub runtime: Option<Runtime>,
61 pub name: String,
62}
63
64#[derive(Debug)]
65pub struct InstallArgs {
66 pub common: CommonArgs,
67 pub port: u16,
68 pub profile: Option<String>,
69 pub shm_size: Option<String>,
70 pub takeover_provider: Takeover,
71 pub with: Vec<String>,
72 pub rebuild: bool,
75 pub from_source: bool,
76 pub context: Option<String>,
78 pub host_args: Vec<String>,
79 pub reveal_token_secret: bool,
80}
81
82#[derive(Debug)]
83pub struct UninstallArgs {
84 pub common: CommonArgs,
85 pub purge: bool,
86}
87
88#[derive(Debug)]
89pub struct StatusArgs {
90 pub common: CommonArgs,
91 pub port: u16,
92 pub reveal_token_secret: bool,
93}
94
95#[derive(Debug)]
96pub struct LogsArgs {
97 pub common: CommonArgs,
98 pub follow: bool,
99 pub raw: bool,
100}
101
102#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub enum Runtime {
107 Docker,
108 Podman,
109 Apple,
111}
112
113impl Runtime {
114 fn bin(self) -> &'static str {
116 match self {
117 Runtime::Docker => "docker",
118 Runtime::Podman => "podman",
119 Runtime::Apple => "container",
120 }
121 }
122
123 fn label(self) -> &'static str {
125 match self {
126 Runtime::Docker => "docker",
127 Runtime::Podman => "podman",
128 Runtime::Apple => "apple",
129 }
130 }
131}
132
133pub async fn run(args: Args) -> Result<(), Error> {
134 match args.sub {
135 ContainerSub::Install(a) => install(a).await,
136 ContainerSub::Uninstall(a) => uninstall(a),
137 ContainerSub::Status(a) => status(a).await,
138 ContainerSub::Logs(a) => logs(a),
139 }
140}
141
142#[derive(Clone, Debug, Serialize)]
145pub(crate) struct InstallResult {
146 pub(crate) runtime: &'static str,
147 pub(crate) image: String,
148 pub(crate) container: String,
149 pub(crate) endpoint: String,
150 pub(crate) profile: String,
151 pub(crate) token_available: bool,
152 pub(crate) token_source: &'static str,
153 #[serde(skip_serializing_if = "Option::is_none")]
154 pub(crate) token_secret: Option<String>,
155 pub(crate) client_command: String,
156 #[serde(skip_serializing_if = "Option::is_none")]
157 pub(crate) log_file: Option<PathBuf>,
158 pub(crate) backends: Vec<String>,
159 pub(crate) takeover_ready: bool,
160}
161
162async fn install(mut args: InstallArgs) -> Result<(), Error> {
163 let result = install_result(&mut args).await?;
164 if args.reveal_token_secret {
165 output::emit_unredacted("container_install", &result)
166 } else {
167 output::emit("container_install", &result)
168 }
169}
170
171async fn install_result(args: &mut InstallArgs) -> Result<InstallResult, Error> {
172 apply_hard_site_defaults(args);
173 let backends = resolve_backends(&args.with);
174 validate_install_args(args, &backends)?;
175 let runtime = resolve_runtime(args.common.runtime)?;
176 let image = image_tag();
177 let profile = effective_profile(args);
178 let shm_size = effective_shm_size(args);
179 let log_file = container_operation_log_file(&args.common.name)?;
180
181 start_daemon(runtime);
182
183 if args.from_source {
186 let ctx = resolve_source_context(args.context.as_deref())?;
187 let build = build_args(
188 &image,
189 runtime,
190 BuildSource::FromSource { ctx: &ctx },
191 &backends,
192 );
193 exec_to_log(runtime.bin(), &build, &log_file)?;
194 } else if args.rebuild || !image_exists(runtime, &image) {
195 let ctx = write_build_context()?;
196 let target = target_triple(runtime, std::env::consts::ARCH);
197 let build = build_args(
198 &image,
199 runtime,
200 BuildSource::Embedded { ctx: &ctx, target },
201 &backends,
202 );
203 exec_to_log(runtime.bin(), &build, &log_file)
204 .map_err(|_| build_failed_error(target, &log_file))?;
205 }
206 validate_container_image_host_args(runtime, &image, &args.host_args)?;
207
208 let _ = capture(runtime.bin(), &["stop".into(), args.common.name.clone()]);
211 let _ = capture(runtime.bin(), &["rm".into(), args.common.name.clone()]);
212
213 let run = run_args(
214 &args.common.name,
215 &image,
216 args.port,
217 &profile,
218 &shm_size,
219 &args.host_args,
220 );
221 exec_to_log(runtime.bin(), &run, &log_file)?;
222
223 let token = read_token(runtime, &args.common.name).await?;
224 let endpoint = endpoint_url(args.port);
225 wait_for_container_health(runtime, &args.common.name, args.port, &token).await?;
226 let takeover_ready = install_takeover_provider(args).is_some();
227 if takeover_ready {
228 validate_running_hard_site(&endpoint, &token).await?;
229 }
230 Ok(InstallResult {
231 runtime: runtime.label(),
232 image,
233 container: args.common.name.clone(),
234 endpoint,
235 profile,
236 client_command: client_command(&args.common.name, args.port),
237 token_available: true,
238 token_source: "container_volume",
239 token_secret: args.reveal_token_secret.then_some(token),
240 log_file: Some(log_file),
241 backends: backends.iter().map(|b| b.name.to_string()).collect(),
242 takeover_ready,
243 })
244}
245
246#[derive(Serialize)]
249struct UninstallResult {
250 runtime: &'static str,
251 container: String,
252 removed: bool,
253 image_removed: bool,
254 purged: bool,
255}
256
257fn uninstall(args: UninstallArgs) -> Result<(), Error> {
258 let runtime = resolve_runtime(args.common.runtime)?;
259 let _ = capture(runtime.bin(), &["stop".into(), args.common.name.clone()]);
260 let removed = capture(runtime.bin(), &["rm".into(), args.common.name.clone()])
261 .map(|o| o.status.success())
262 .unwrap_or(false);
263
264 let mut image_removed = false;
265 if args.purge {
266 let image = image_tag();
267 image_removed = capture(runtime.bin(), &["rmi".into(), image])
268 .map(|o| o.status.success())
269 .unwrap_or(false);
270 if let Ok(ctx) = cache_context_dir() {
271 let _ = std::fs::remove_dir_all(&ctx);
272 }
273 }
274
275 output::emit(
276 "container_uninstall",
277 &UninstallResult {
278 runtime: runtime.label(),
279 container: args.common.name,
280 removed,
281 image_removed,
282 purged: args.purge,
283 },
284 )
285}
286
287#[derive(Debug, Serialize)]
290struct StatusResult {
291 runtime: &'static str,
292 container: String,
293 running: bool,
294 endpoint: String,
295 driver_version: &'static str,
296 #[serde(skip_serializing_if = "Option::is_none")]
297 host_version: Option<String>,
298 #[serde(skip_serializing_if = "Option::is_none")]
299 version_match: Option<bool>,
300 #[serde(skip_serializing_if = "Option::is_none")]
301 profile_kind: Option<String>,
302 #[serde(skip_serializing_if = "Option::is_none")]
303 profile: Option<String>,
304 #[serde(skip_serializing_if = "Option::is_none")]
305 profile_backend: Option<String>,
306 #[serde(skip_serializing_if = "Option::is_none")]
307 backend: Option<BackendFamily>,
308 #[serde(skip_serializing_if = "Option::is_none")]
309 provider: Option<String>,
310 #[serde(skip_serializing_if = "Option::is_none")]
311 takeover_ready: Option<bool>,
312 token_available: bool,
313 #[serde(skip_serializing_if = "Option::is_none")]
314 token_source: Option<&'static str>,
315 #[serde(skip_serializing_if = "Option::is_none")]
316 token_secret: Option<String>,
317 #[serde(skip_serializing_if = "Option::is_none")]
318 client_command: Option<String>,
319 #[serde(skip_serializing_if = "Option::is_none")]
320 exit_code: Option<i64>,
321 #[serde(skip_serializing_if = "Option::is_none")]
322 log_summary: Option<String>,
323 #[serde(skip_serializing_if = "Vec::is_empty")]
324 warnings: Vec<String>,
325}
326
327async fn status(args: StatusArgs) -> Result<(), Error> {
328 let runtime = resolve_runtime(args.common.runtime)?;
329 let state = inspect_container_state(runtime, &args.common.name);
330 let running = state
331 .as_ref()
332 .map(|s| s.running)
333 .unwrap_or_else(|| container_running(runtime, &args.common.name));
334 let endpoint = endpoint_url(args.port);
335 let mut warnings = Vec::new();
336
337 let token = if running {
338 match read_token(runtime, &args.common.name).await {
339 Ok(token) => Some(token),
340 Err(e) => {
341 warnings.push(format!("could not read token: {}", e.detail));
342 None
343 }
344 }
345 } else {
346 None
347 };
348 let client_command = token
349 .as_ref()
350 .map(|_| client_command(&args.common.name, args.port));
351 let mut host_version = None;
352 let mut version_match = None;
353 let mut profile_kind = None;
354 let mut profile = None;
355 let mut profile_backend = None;
356 let mut backend = None;
357 let mut provider = None;
358 let mut takeover_ready = None;
359 if running && let Some(token) = token.as_deref() {
360 let client = crate::sdk::Client::connect(&endpoint)?.with_token(token.to_string());
361 match client.health().await {
362 Ok(health) => {
363 let version_warning = host_version_warning(&args.common.name, &health.version);
364 if let Some(warning) = version_warning {
365 warnings.push(warning);
366 }
367 version_match = Some(health.version == VERSION);
368 host_version = Some(health.version);
369 if let Some(snapshot) = health.profile {
370 profile_kind = Some(snapshot.kind);
371 profile = snapshot.name;
372 }
373 }
374 Err(e) => warnings.push(format!("could not read /health: {}", e.detail)),
375 }
376 match client.capabilities().await {
377 Ok(caps) => {
378 profile_backend = profile_kind.as_ref().map(|_| caps.backend.family.clone());
379 provider = caps.takeover.provider.clone();
380 takeover_ready = Some(is_hard_site_capabilities(&caps));
381 backend = Some(caps.backend);
382 }
383 Err(e) => warnings.push(format!("could not read /capabilities: {}", e.detail)),
384 }
385 }
386 let (exit_code, log_summary) = if running {
387 (None, None)
388 } else {
389 let exit_code = state.as_ref().and_then(|s| s.exit_code);
390 let logs = container_logs_summary(runtime, &args.common.name);
391 let logs = (!logs.is_empty()).then_some(logs);
392 (exit_code, logs)
393 };
394
395 let result = StatusResult {
396 runtime: runtime.label(),
397 container: args.common.name,
398 running,
399 endpoint,
400 driver_version: VERSION,
401 host_version,
402 version_match,
403 profile_kind,
404 profile,
405 profile_backend,
406 backend,
407 provider,
408 takeover_ready,
409 token_available: token.is_some(),
410 token_source: token.as_ref().map(|_| "container_volume"),
411 token_secret: args.reveal_token_secret.then_some(token).flatten(),
412 client_command,
413 exit_code,
414 log_summary,
415 warnings,
416 };
417 if args.reveal_token_secret {
418 output::emit_unredacted("container_status", &result)
419 } else {
420 output::emit("container_status", &result)
421 }
422}
423
424pub(crate) fn validate_hard_site_capabilities(
429 caps: &crate::sdk::capabilities::CapabilitiesResponse,
430) -> Result<(), Error> {
431 if takeover_backend(&caps.backend.family).is_none() {
432 return Err(hard_site_host_error(format!(
433 "takeover host requires backend.family=<{}>; host reported {}",
434 takeover_browser_list(),
435 caps.backend.family
436 )));
437 }
438 if !caps.takeover.supported {
439 return Err(hard_site_host_error(
440 "takeover host requires takeover.supported=true".to_string(),
441 ));
442 }
443 if caps.takeover.provider.as_deref() != Some("kasmvnc") {
444 return Err(hard_site_host_error(format!(
445 "takeover host requires takeover.provider=kasmvnc; host reported {:?}",
446 caps.takeover.provider
447 )));
448 }
449 Ok(())
450}
451
452fn hard_site_host_error(detail: String) -> Error {
453 Error::new(
454 ErrorCode::BackendUnsupported,
455 format!("{detail}. Build a takeover-ready host with `afhttp container install`."),
456 )
457}
458
459#[derive(Serialize)]
462struct LogsResult {
463 runtime: &'static str,
464 container: String,
465 log_file: PathBuf,
466 bytes: u64,
467 truncated: bool,
468 tail_lines: Vec<String>,
469}
470
471fn logs(args: LogsArgs) -> Result<(), Error> {
477 let runtime = resolve_runtime(args.common.runtime)?;
478 let container = args.common.name;
479 let mut argv: Vec<String> = vec!["logs".into()];
480 if args.follow {
481 argv.push("-f".into());
482 }
483 argv.push(container.clone());
484 if args.raw {
485 return exec_inherit(runtime.bin(), &argv);
486 }
487 let log_file = container_operation_log_file(&container)?;
488 exec_to_log_without_header(runtime.bin(), &argv, &log_file)?;
489 const TAIL: usize = 80;
490 let (tail_lines, truncated) = tail_lines_from_file(&log_file, TAIL)?;
491 let bytes = std::fs::metadata(&log_file).map(|m| m.len()).map_err(|e| {
492 Error::new(
493 ErrorCode::IoError,
494 format!("stat container log file {}: {e}", log_file.display()),
495 )
496 })?;
497 output::emit(
498 "container_logs",
499 &LogsResult {
500 runtime: runtime.label(),
501 container,
502 log_file,
503 bytes,
504 truncated,
505 tail_lines,
506 },
507 )
508}
509
510fn resolve_runtime(explicit: Option<Runtime>) -> Result<Runtime, Error> {
513 if let Some(r) = explicit {
514 return Ok(r);
515 }
516 if let Some(v) = std::env::var_os("AFHTTP_CONTAINER_RUNTIME") {
517 return runtime_from_str(v.to_string_lossy().trim());
518 }
519 if on_path("docker") {
520 Ok(Runtime::Docker)
521 } else if on_path("podman") {
522 Ok(Runtime::Podman)
523 } else if on_path("container") {
524 Ok(Runtime::Apple)
525 } else {
526 Err(Error::new(
527 ErrorCode::InvalidArgument,
528 "no container runtime found: install Docker, Podman, or Apple `container`, or pass --runtime",
529 ))
530 }
531}
532
533pub(crate) fn runtime_from_str(value: &str) -> Result<Runtime, Error> {
534 match value {
535 "docker" => Ok(Runtime::Docker),
536 "podman" => Ok(Runtime::Podman),
537 "apple" | "container" => Ok(Runtime::Apple),
538 other => Err(Error::new(
539 ErrorCode::InvalidArgument,
540 format!("invalid container runtime '{other}': expected docker, podman, or apple"),
541 )),
542 }
543}
544
545fn on_path(bin: &str) -> bool {
546 let Some(paths) = std::env::var_os("PATH") else {
547 return false;
548 };
549 std::env::split_paths(&paths).any(|dir| dir.join(bin).is_file())
550}
551
552fn start_daemon(runtime: Runtime) {
555 if runtime == Runtime::Apple {
556 let _ = capture(runtime.bin(), &["system".into(), "start".into()]);
557 }
558}
559
560fn image_tag() -> String {
563 format!("{IMAGE_REPO}:{VERSION}")
564}
565
566fn volume_name(name: &str) -> String {
567 format!("{name}-data")
568}
569
570fn endpoint_url(port: u16) -> String {
571 format!("ws://127.0.0.1:{port}")
572}
573
574fn client_command(name: &str, port: u16) -> String {
582 if name == DEFAULT_CONTAINER_NAME && port == DEFAULT_CONTAINER_PORT {
583 return "afhttp fetch https://example.com".to_string();
584 }
585 format!(
586 "afhttp fetch https://example.com --endpoint-url ws://127.0.0.1:{port} --token-secret container:{name}"
587 )
588}
589
590fn target_triple(runtime: Runtime, host_arch: &str) -> &'static str {
593 match runtime {
594 Runtime::Apple => "aarch64-unknown-linux-gnu",
595 Runtime::Docker | Runtime::Podman => match host_arch {
596 "aarch64" | "arm64" => "aarch64-unknown-linux-gnu",
597 _ => "x86_64-unknown-linux-gnu",
598 },
599 }
600}
601
602#[derive(Clone, Copy, Debug, PartialEq, Eq)]
604struct Backend {
605 name: &'static str,
606 build_arg: &'static str,
607}
608
609const BACKENDS: [Backend; 6] = [
610 Backend {
611 name: "lightpanda",
612 build_arg: "WITH_LIGHTPANDA",
613 },
614 Backend {
615 name: "fingerprint-chromium",
616 build_arg: "WITH_FINGERPRINT_CHROMIUM",
617 },
618 Backend {
619 name: "camoufox",
620 build_arg: "WITH_CAMOUFOX",
621 },
622 Backend {
623 name: "chrome",
624 build_arg: "WITH_CHROME",
625 },
626 Backend {
627 name: "brave",
628 build_arg: "WITH_BRAVE",
629 },
630 Backend {
631 name: "kasmvnc",
632 build_arg: "WITH_KASMVNC",
633 },
634];
635
636#[derive(Clone, Copy, Debug, PartialEq, Eq)]
643struct TakeoverBackend {
644 browser: &'static str,
645 component: &'static str,
646 probe_bin: &'static str,
647}
648
649const TAKEOVER_BACKENDS: [TakeoverBackend; 2] = [
662 TakeoverBackend {
663 browser: "brave",
664 component: "brave",
665 probe_bin: "brave-browser",
666 },
667 TakeoverBackend {
668 browser: "chrome",
669 component: "chrome",
670 probe_bin: "google-chrome-stable",
671 },
672];
673
674const DEFAULT_TAKEOVER_BACKEND: TakeoverBackend = TAKEOVER_BACKENDS[0];
675
676fn takeover_backend(browser: &str) -> Option<TakeoverBackend> {
677 TAKEOVER_BACKENDS
678 .iter()
679 .copied()
680 .find(|b| b.browser == browser)
681}
682
683fn selected_takeover_backend(host_args: &[String]) -> TakeoverBackend {
688 host_arg_value(host_args, "--browser")
689 .as_deref()
690 .and_then(takeover_backend)
691 .unwrap_or(DEFAULT_TAKEOVER_BACKEND)
692}
693
694fn takeover_browser_list() -> String {
695 TAKEOVER_BACKENDS
696 .iter()
697 .map(|b| b.browser)
698 .collect::<Vec<_>>()
699 .join("|")
700}
701
702fn resolve_backends(names: &[String]) -> Vec<Backend> {
707 let mut out = Vec::with_capacity(names.len());
708 for name in names {
709 if let Some(backend) = BACKENDS.iter().find(|b| b.name == name)
710 && !out.contains(backend)
711 {
712 out.push(*backend);
713 }
714 }
715 out
716}
717
718fn validate_install_args(args: &InstallArgs, backends: &[Backend]) -> Result<(), Error> {
719 let profile = effective_profile(args);
720 if let Some(provider) = install_takeover_provider(args) {
721 validate_hard_site_install_args(args, backends, provider)?;
722 }
723 let camoufox_built = backends.iter().any(|b| b.name == "camoufox");
724 if profile != "-" && camoufox_built && host_args_select_camoufox(&args.host_args) {
725 return Err(Error::new(
726 ErrorCode::InvalidArgument,
727 "afhttp's camoufox backend does not yet support persistent profiles; use `--profile -` for camoufox hosts. Example: `afhttp container install --profile - --with camoufox -- --browser camoufox`.",
728 ));
729 }
730 Ok(())
731}
732
733fn install_takeover_provider(args: &InstallArgs) -> Option<&'static str> {
737 match args.takeover_provider {
738 Takeover::Off => None,
739 Takeover::On { provider } => Some(provider.as_str()),
740 }
741}
742
743fn apply_hard_site_defaults(args: &mut InstallArgs) {
752 let Some(provider) = install_takeover_provider(args).map(str::to_string) else {
753 return;
754 };
755 let backend = selected_takeover_backend(&args.host_args);
756 push_backend_if_missing(&mut args.with, backend.component);
757 push_backend_if_missing(&mut args.with, "kasmvnc");
758 push_host_arg_default(&mut args.host_args, "--browser", backend.browser);
759 push_host_arg_default(&mut args.host_args, "--takeover-provider", &provider);
760}
761
762fn effective_profile(args: &InstallArgs) -> String {
763 args.profile.clone().unwrap_or_else(|| "-".to_string())
764}
765
766fn effective_shm_size(args: &InstallArgs) -> String {
767 args.shm_size.clone().unwrap_or_else(|| {
768 if install_takeover_provider(args).is_some() {
769 "2g"
770 } else {
771 "1g"
772 }
773 .to_string()
774 })
775}
776
777fn push_backend_if_missing(backends: &mut Vec<String>, backend: &str) {
778 if !backends.iter().any(|b| b == backend) {
779 backends.push(backend.to_string());
780 }
781}
782
783fn push_host_arg_default(host_args: &mut Vec<String>, name: &str, value: &str) {
784 if !host_arg_present(host_args, name) {
785 host_args.push(name.to_string());
786 host_args.push(value.to_string());
787 }
788}
789
790fn validate_hard_site_install_args(
791 args: &InstallArgs,
792 backends: &[Backend],
793 provider: &str,
794) -> Result<(), Error> {
795 let browser = host_arg_value(&args.host_args, "--browser");
796 let Some(backend) = browser.as_deref().and_then(takeover_backend) else {
797 let got = browser
798 .map(|v| format!("; got `--browser {v}`"))
799 .unwrap_or_default();
800 return Err(hard_site_install_error(format!(
801 "takeover requires host arg `--browser <{}>`{got}",
802 takeover_browser_list()
803 )));
804 };
805 if !backends.iter().any(|b| b.name == backend.component) {
806 return Err(hard_site_install_error(format!(
807 "takeover with `--browser {}` requires the {} backend; omit conflicting backend overrides",
808 backend.browser, backend.component
809 )));
810 }
811 if !backends.iter().any(|b| b.name == "kasmvnc") {
812 return Err(hard_site_install_error(
813 "takeover requires the KasmVNC display backend".to_string(),
814 ));
815 }
816 require_hard_site_host_arg(&args.host_args, "--takeover-provider", provider)?;
817 Ok(())
818}
819
820fn require_hard_site_host_arg(
821 host_args: &[String],
822 name: &str,
823 expected: &str,
824) -> Result<(), Error> {
825 let Some(value) = host_arg_value(host_args, name) else {
826 return Err(hard_site_install_error(format!(
827 "takeover requires host arg `{name} {expected}`"
828 )));
829 };
830 if value == expected {
831 return Ok(());
832 }
833 Err(hard_site_install_error(format!(
834 "takeover requires host arg `{name} {expected}`; got `{name} {value}`"
835 )))
836}
837
838fn hard_site_install_error(detail: String) -> Error {
839 Error::new(
840 ErrorCode::InvalidArgument,
841 format!(
842 "{detail}. Use `afhttp container install` (takeover is on by default), or `--takeover-provider off` for a lean host."
843 ),
844 )
845}
846
847fn host_args_select_camoufox(host_args: &[String]) -> bool {
848 host_arg_value(host_args, "--browser").as_deref() == Some("camoufox")
849}
850
851fn host_arg_present(host_args: &[String], name: &str) -> bool {
852 let eq_prefix = format!("{name}=");
853 host_args
854 .iter()
855 .any(|arg| arg == name || arg.starts_with(&eq_prefix))
856}
857
858fn host_arg_value(host_args: &[String], name: &str) -> Option<String> {
859 let eq_prefix = format!("{name}=");
860 let mut value = None;
861 let mut iter = host_args.iter().peekable();
862 while let Some(arg) = iter.next() {
863 if arg == name {
864 if let Some(next) = iter.peek() {
865 value = Some((*next).to_string());
866 }
867 } else if let Some(v) = arg.strip_prefix(&eq_prefix) {
868 value = Some(v.to_string());
869 }
870 }
871 value
872}
873
874fn validate_container_image_host_args(
875 runtime: Runtime,
876 image: &str,
877 host_args: &[String],
878) -> Result<(), Error> {
879 if !host_args_need_takeover_support(host_args) {
880 return Ok(());
881 }
882 let Some(help) = container_image_host_help(runtime, image) else {
883 return Ok(());
884 };
885 if !help.contains("--takeover-quality-percent") {
886 return Err(Error::new(
887 ErrorCode::InvalidArgument,
888 format!(
889 "container image `{image}` contains an older afhttp host binary that does not support the `--takeover-provider <provider>` display surface; rebuild from this source checkout: `afhttp container install --from-source --rebuild`"
890 ),
891 ));
892 }
893 if let Some(backend) = host_arg_value(host_args, "--browser")
894 .as_deref()
895 .and_then(takeover_backend)
896 && !container_image_hard_site_components(runtime, image, backend)
897 {
898 return Err(Error::new(
902 ErrorCode::BackendUnsupported,
903 format!(
904 "container image `{image}` does not expose the {browser} + KasmVNC takeover components; rebuild it with `afhttp container install --rebuild --with {component} -- --browser {browser}`",
905 browser = backend.browser,
906 component = backend.component
907 ),
908 ));
909 }
910 Ok(())
911}
912
913async fn validate_running_hard_site(endpoint: &str, token: &str) -> Result<(), Error> {
914 let client = crate::sdk::Client::connect(endpoint)?.with_token(token.to_string());
915 let health = client.health().await.map_err(|e| {
916 Error::new(
917 e.error_code,
918 format!("takeover host /health failed after startup: {}", e.detail),
919 )
920 .with_retryable(e.retryable)
921 })?;
922 if health.version != VERSION {
923 return Err(Error::new(
924 ErrorCode::InternalError,
925 format!(
926 "takeover host version mismatch after startup: host={}, driver={VERSION}",
927 health.version
928 ),
929 ));
930 }
931 if health.status != "ok" {
932 let detail = health
933 .backend_error
934 .map(|e| format!("{}: {}", e.error_code, e.error))
935 .unwrap_or_else(|| format!("status={}", health.status));
936 return Err(Error::new(
937 ErrorCode::BrowserLaunchFailed,
938 format!("takeover host was not ready after startup: {detail}"),
939 ));
940 }
941 let caps = client.capabilities().await.map_err(|e| {
942 Error::new(
943 e.error_code,
944 format!(
945 "takeover host /capabilities failed after startup: {}",
946 e.detail
947 ),
948 )
949 .with_retryable(e.retryable)
950 })?;
951 validate_hard_site_capabilities(&caps)
952}
953
954fn is_hard_site_capabilities(caps: &crate::sdk::capabilities::CapabilitiesResponse) -> bool {
955 takeover_backend(&caps.backend.family).is_some()
956 && caps.takeover.supported
957 && caps.takeover.provider.as_deref() == Some("kasmvnc")
958}
959
960#[derive(Debug, Clone, PartialEq, Eq)]
961pub(crate) struct LocalHost {
962 pub(crate) endpoint: String,
963 pub(crate) token_secret: Option<SecretString>,
964}
965
966pub(crate) async fn discover_default_local_host(
970 token_override: Option<&str>,
971) -> Result<LocalHost, Error> {
972 let runtime = resolve_runtime(None).map_err(|e| {
973 local_host_error(format!(
974 "could not choose a container runtime to inspect `{DEFAULT_CONTAINER_NAME}`: {}",
975 e.detail
976 ))
977 })?;
978 let running = inspect_container_state(runtime, DEFAULT_CONTAINER_NAME)
979 .map(|s| s.running)
980 .unwrap_or_else(|| container_running(runtime, DEFAULT_CONTAINER_NAME));
981 if !running {
982 return Err(local_host_error(format!(
983 "default local container `{DEFAULT_CONTAINER_NAME}` is not running"
984 )));
985 }
986
987 let token_secret = match token_override {
988 Some(token) => Some(SecretString::new(token)),
989 None => Some(read_token(runtime, DEFAULT_CONTAINER_NAME).await.map(SecretString::new).map_err(|e| {
990 local_host_error(format!(
991 "default local container `{DEFAULT_CONTAINER_NAME}` is running, but its token could not be read: {}",
992 e.detail
993 ))
994 })?),
995 };
996 let endpoint = endpoint_url(DEFAULT_CONTAINER_PORT);
997 let client = match &token_secret {
998 Some(token) => crate::sdk::Client::connect(&endpoint)?.with_token(token.expose_secret()),
999 None => crate::sdk::Client::connect(&endpoint)?,
1000 };
1001 let health = client.health().await.map_err(|e| {
1002 local_host_error(format!(
1003 "default local container `{DEFAULT_CONTAINER_NAME}` at {endpoint} could not be verified: {}",
1004 e.detail
1005 ))
1006 .with_retryable(e.retryable)
1007 })?;
1008 if health.version != VERSION {
1009 return Err(local_host_error(host_version_mismatch_detail(
1010 DEFAULT_CONTAINER_NAME,
1011 &health.version,
1012 )));
1013 }
1014 if health.status != "ok" {
1015 let detail = health
1016 .backend_error
1017 .map(|e| format!("{}: {}", e.error_code, e.error))
1018 .unwrap_or_else(|| format!("status={}", health.status));
1019 return Err(local_host_error(format!(
1020 "default local container `{DEFAULT_CONTAINER_NAME}` at {endpoint} is not ready: {detail}"
1021 )));
1022 }
1023 Ok(LocalHost {
1024 endpoint,
1025 token_secret,
1026 })
1027}
1028
1029pub(crate) async fn discover_default_takeover_host(
1033 token_override: Option<&str>,
1034) -> Result<LocalHost, Error> {
1035 let host = discover_default_local_host(token_override).await?;
1036 let endpoint = &host.endpoint;
1037 let client = match &host.token_secret {
1038 Some(token) => crate::sdk::Client::connect(endpoint)?.with_token(token.expose_secret()),
1039 None => crate::sdk::Client::connect(endpoint)?,
1040 };
1041 let caps = client.capabilities().await.map_err(|e| {
1042 local_host_error(format!(
1043 "default local container `{DEFAULT_CONTAINER_NAME}` at {endpoint} could not be verified: {}",
1044 e.detail
1045 ))
1046 .with_retryable(e.retryable)
1047 })?;
1048 validate_hard_site_capabilities(&caps).map_err(|e| {
1049 local_host_error(format!(
1050 "default local container `{DEFAULT_CONTAINER_NAME}` at {endpoint} is not takeover-ready: {}",
1051 e.detail
1052 ))
1053 })?;
1054 Ok(host)
1055}
1056
1057pub(crate) fn read_host_token(name: &str) -> Result<SecretString, Error> {
1063 let runtime = resolve_runtime(None).map_err(|e| {
1064 container_token_error(
1065 name,
1066 &format!("no container runtime is available: {}", e.detail),
1067 )
1068 })?;
1069 let argv = vec![
1070 "exec".into(),
1071 name.to_string(),
1072 "cat".into(),
1073 HOST_TOKEN_PATH.to_string(),
1074 ];
1075 let out = capture(runtime.bin(), &argv).map_err(|e| {
1076 container_token_error(name, &format!("{} failed: {}", runtime.label(), e.detail))
1077 })?;
1078 if !out.status.success() {
1079 return Err(container_token_error(
1080 name,
1081 "the container is not running, or holds no host token yet",
1082 ));
1083 }
1084 let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
1085 if token.is_empty() {
1086 return Err(container_token_error(name, "its host token file is empty"));
1087 }
1088 Ok(SecretString::new(token))
1089}
1090
1091fn container_token_error(name: &str, detail: &str) -> Error {
1092 Error::new(
1093 ErrorCode::InvalidArgument,
1094 format!(
1095 "--token-secret container:{name} could not read the host token: {detail}. \
1096 Check it with `afhttp container status --name {name}`."
1097 ),
1098 )
1099}
1100
1101fn local_host_error(detail: String) -> Error {
1102 let detail = detail.trim_end_matches('.');
1105 Error::new(
1106 ErrorCode::InvalidArgument,
1107 format!(
1108 "no --endpoint-url or AFHTTP_ENDPOINT_URL was given, so afhttp looked for the default \
1109 local host, and {detail}. Start one with `afhttp container install`, inspect it with \
1110 `afhttp container status`, or pass --endpoint-url/--token-secret explicitly."
1111 ),
1112 )
1113}
1114
1115fn host_version_warning(name: &str, host_version: &str) -> Option<String> {
1116 (host_version != VERSION).then(|| host_version_mismatch_detail(name, host_version))
1117}
1118
1119fn host_version_mismatch_detail(name: &str, host_version: &str) -> String {
1120 format!(
1121 "local container `{name}` is running afhttp host version {host_version}, \
1122 but this driver is version {VERSION}. Run `afhttp container install` to recreate the \
1123 container with the matching image; the `{}` volume is reused, so the host token and \
1124 persistent profiles are preserved.",
1125 volume_name(name)
1126 )
1127}
1128
1129fn host_args_need_takeover_support(host_args: &[String]) -> bool {
1130 match host_arg_value(host_args, "--takeover-provider") {
1133 Some(value) => value != "off",
1134 None => false,
1135 }
1136}
1137
1138fn container_image_host_help(runtime: Runtime, image: &str) -> Option<String> {
1139 let argv = image_host_help_args(image);
1140 let out = capture(runtime.bin(), &argv).ok()?;
1141 if !out.status.success() {
1142 return None;
1143 }
1144 let mut help = String::new();
1145 help.push_str(&String::from_utf8_lossy(&out.stdout));
1146 help.push_str(&String::from_utf8_lossy(&out.stderr));
1147 Some(help)
1148}
1149
1150fn image_host_help_args(image: &str) -> Vec<String> {
1151 vec![
1152 "run".into(),
1153 "--rm".into(),
1154 "--entrypoint".into(),
1155 "/usr/local/bin/afhttp".into(),
1156 image.to_string(),
1157 "host".into(),
1158 "--help".into(),
1159 ]
1160}
1161
1162fn container_image_hard_site_components(
1163 runtime: Runtime,
1164 image: &str,
1165 backend: TakeoverBackend,
1166) -> bool {
1167 let argv = vec![
1168 "run".into(),
1169 "--rm".into(),
1170 "--entrypoint".into(),
1171 "/bin/sh".into(),
1172 image.to_string(),
1173 "-lc".into(),
1174 format!(
1175 "command -v {} >/dev/null 2>&1 && test -x \"${{AFHTTP_KASMVNC_BIN:-/usr/bin/Xvnc}}\" && test -d \"${{AFHTTP_KASMVNC_WEB_ROOT:-/usr/share/kasmvnc/www}}\"",
1176 backend.probe_bin
1177 ),
1178 ];
1179 capture(runtime.bin(), &argv)
1180 .map(|out| out.status.success())
1181 .unwrap_or(false)
1182}
1183
1184enum BuildSource<'a> {
1189 Embedded { ctx: &'a Path, target: &'a str },
1190 FromSource { ctx: &'a Path },
1191}
1192
1193fn build_args(
1194 image: &str,
1195 runtime: Runtime,
1196 source: BuildSource,
1197 backends: &[Backend],
1198) -> Vec<String> {
1199 let mut a: Vec<String> = vec!["build".into()];
1200 if runtime == Runtime::Apple {
1201 a.push("--platform".into());
1202 a.push("linux/arm64".into());
1203 }
1204 let ctx = match source {
1205 BuildSource::Embedded { ctx, target } => {
1206 a.push("--build-arg".into());
1207 a.push("AFHTTP_BIN_FROM=downloader".into());
1208 a.push("--build-arg".into());
1209 a.push(format!("AFHTTP_VERSION={VERSION}"));
1210 a.push("--build-arg".into());
1211 a.push(format!("AFHTTP_TARGET={target}"));
1212 ctx
1213 }
1214 BuildSource::FromSource { ctx } => {
1215 a.push("--build-arg".into());
1216 a.push("AFHTTP_BIN_FROM=builder".into());
1217 ctx
1218 }
1219 };
1220 for b in backends {
1221 a.push("--build-arg".into());
1222 a.push(format!("{}=1", b.build_arg));
1223 }
1224 a.push("-t".into());
1225 a.push(image.to_string());
1226 a.push("-f".into());
1227 a.push(
1228 ctx.join("container/docker/Dockerfile")
1229 .to_string_lossy()
1230 .into_owned(),
1231 );
1232 a.push(ctx.to_string_lossy().into_owned());
1233 a
1234}
1235
1236fn resolve_source_context(arg: Option<&str>) -> Result<PathBuf, Error> {
1238 if let Some(p) = arg {
1239 return validate_source_context(PathBuf::from(p), "--context");
1240 }
1241 let cwd = std::env::current_dir()
1242 .map_err(|e| Error::new(ErrorCode::IoError, format!("cannot read current dir: {e}")))?;
1243 if is_source_context(&cwd) {
1244 return Ok(cwd);
1245 }
1246 let manifest_dir = PathBuf::from(MANIFEST_DIR);
1247 if manifest_dir != cwd && is_source_context(&manifest_dir) {
1248 return Ok(manifest_dir);
1249 }
1250 Err(Error::new(
1251 ErrorCode::InvalidArgument,
1252 format!(
1253 "--from-source needs a source checkout: checked {} and {} \
1254 (run from the spore root or pass --context <dir>)",
1255 cwd.display(),
1256 manifest_dir.display()
1257 ),
1258 ))
1259}
1260
1261fn validate_source_context(dir: PathBuf, source: &str) -> Result<PathBuf, Error> {
1262 let dockerfile = dir.join("container/docker/Dockerfile");
1263 if !dockerfile.is_file() {
1264 return Err(Error::new(
1265 ErrorCode::InvalidArgument,
1266 format!(
1267 "--from-source {source} needs a source checkout: {} not found \
1268 (run from the spore root or pass --context <dir>)",
1269 dockerfile.display()
1270 ),
1271 ));
1272 }
1273 Ok(dir)
1274}
1275
1276fn is_source_context(dir: &Path) -> bool {
1277 dir.join("container/docker/Dockerfile").is_file()
1278}
1279
1280fn run_args(
1281 name: &str,
1282 image: &str,
1283 port: u16,
1284 profile: &str,
1285 shm_size: &str,
1286 host_args: &[String],
1287) -> Vec<String> {
1288 let mut a: Vec<String> = vec![
1289 "run".into(),
1290 "-d".into(),
1291 "--name".into(),
1292 name.to_string(),
1293 "-v".into(),
1294 format!("{}:/data", volume_name(name)),
1295 "-e".into(),
1296 format!("AFHTTP_PORT={port}"),
1297 "-e".into(),
1298 format!("AFHTTP_PROFILE={profile}"),
1299 "--shm-size".into(),
1300 shm_size.to_string(),
1301 "-p".into(),
1302 format!("127.0.0.1:{port}:{port}"),
1303 image.to_string(),
1304 ];
1305 a.extend(host_args.iter().cloned());
1306 a
1307}
1308
1309fn spawn_error(bin: &str, err: &std::io::Error) -> Error {
1312 if err.kind() == std::io::ErrorKind::NotFound {
1313 Error::new(
1314 ErrorCode::InvalidArgument,
1315 format!("container runtime `{bin}` not found on PATH"),
1316 )
1317 } else {
1318 Error::new(
1319 ErrorCode::IoError,
1320 format!("spawning `{bin}` failed: {err}"),
1321 )
1322 }
1323}
1324
1325fn exec_inherit(bin: &str, args: &[String]) -> Result<(), Error> {
1327 let status = Command::new(bin)
1328 .args(args)
1329 .status()
1330 .map_err(|e| spawn_error(bin, &e))?;
1331 if status.success() {
1332 Ok(())
1333 } else {
1334 Err(Error::new(
1335 ErrorCode::InternalError,
1336 format!("`{bin} {}` failed ({status})", args.join(" ")),
1337 ))
1338 }
1339}
1340
1341fn exec_to_log(bin: &str, args: &[String], log_file: &Path) -> Result<(), Error> {
1344 exec_to_log_impl(bin, args, log_file, true)
1345}
1346
1347fn exec_to_log_without_header(bin: &str, args: &[String], log_file: &Path) -> Result<(), Error> {
1348 exec_to_log_impl(bin, args, log_file, false)
1349}
1350
1351fn exec_to_log_impl(
1352 bin: &str,
1353 args: &[String],
1354 log_file: &Path,
1355 write_header: bool,
1356) -> Result<(), Error> {
1357 use std::io::Write;
1358
1359 let mut file = std::fs::OpenOptions::new()
1360 .create(true)
1361 .append(true)
1362 .open(log_file)
1363 .map_err(|e| {
1364 Error::new(
1365 ErrorCode::IoError,
1366 format!("open log file {}: {e}", log_file.display()),
1367 )
1368 })?;
1369 if write_header {
1370 writeln!(file, "\n$ {bin} {}", args.join(" ")).map_err(|e| {
1371 Error::new(
1372 ErrorCode::IoError,
1373 format!("write log file {}: {e}", log_file.display()),
1374 )
1375 })?;
1376 }
1377 let stdout = file.try_clone().map_err(|e| {
1378 Error::new(
1379 ErrorCode::IoError,
1380 format!("clone log file {}: {e}", log_file.display()),
1381 )
1382 })?;
1383 let stderr = file.try_clone().map_err(|e| {
1384 Error::new(
1385 ErrorCode::IoError,
1386 format!("clone log file {}: {e}", log_file.display()),
1387 )
1388 })?;
1389 let status = Command::new(bin)
1390 .args(args)
1391 .stdout(stdout)
1392 .stderr(stderr)
1393 .status()
1394 .map_err(|e| spawn_error(bin, &e))?;
1395 if status.success() {
1396 Ok(())
1397 } else {
1398 Err(Error::new(
1399 ErrorCode::InternalError,
1400 format!(
1401 "`{bin} {}` failed ({status}); full output was written to {}",
1402 args.join(" "),
1403 log_file.display()
1404 ),
1405 ))
1406 }
1407}
1408
1409fn tail_lines_from_file(path: &Path, max_lines: usize) -> Result<(Vec<String>, bool), Error> {
1410 use std::io::{Read, Seek, SeekFrom};
1411
1412 const MAX_TAIL_BYTES: u64 = 256 * 1024;
1413 let mut file = std::fs::File::open(path).map_err(|e| {
1414 Error::new(
1415 ErrorCode::IoError,
1416 format!("open container log file {}: {e}", path.display()),
1417 )
1418 })?;
1419 let len = file
1420 .metadata()
1421 .map_err(|e| {
1422 Error::new(
1423 ErrorCode::IoError,
1424 format!("stat container log file {}: {e}", path.display()),
1425 )
1426 })?
1427 .len();
1428 let start = len.saturating_sub(MAX_TAIL_BYTES);
1429 file.seek(SeekFrom::Start(start)).map_err(|e| {
1430 Error::new(
1431 ErrorCode::IoError,
1432 format!("seek container log file {}: {e}", path.display()),
1433 )
1434 })?;
1435 let mut buf = Vec::new();
1436 file.read_to_end(&mut buf).map_err(|e| {
1437 Error::new(
1438 ErrorCode::IoError,
1439 format!("read container log file {}: {e}", path.display()),
1440 )
1441 })?;
1442 let text = String::from_utf8_lossy(&buf);
1443 let mut lines: Vec<&str> = text.lines().collect();
1444 let truncated_by_bytes = start > 0;
1445 if truncated_by_bytes && !text.starts_with('\n') && !lines.is_empty() {
1446 lines.remove(0);
1447 }
1448 let truncated = truncated_by_bytes || lines.len() > max_lines;
1449 let tail_lines = lines
1450 .iter()
1451 .skip(lines.len().saturating_sub(max_lines))
1452 .map(|line| (*line).to_string())
1453 .collect();
1454 Ok((tail_lines, truncated))
1455}
1456
1457fn capture(bin: &str, args: &[String]) -> Result<std::process::Output, Error> {
1459 Command::new(bin)
1460 .args(args)
1461 .output()
1462 .map_err(|e| spawn_error(bin, &e))
1463}
1464
1465fn container_operation_log_file(name: &str) -> Result<PathBuf, Error> {
1466 let dir = std::env::temp_dir().join("afhttp-container-logs");
1467 std::fs::create_dir_all(&dir).map_err(|e| {
1468 Error::new(
1469 ErrorCode::IoError,
1470 format!("create container log dir {}: {e}", dir.display()),
1471 )
1472 })?;
1473 let safe_name: String = name
1474 .chars()
1475 .map(|c| {
1476 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_') {
1477 c
1478 } else {
1479 '_'
1480 }
1481 })
1482 .collect();
1483 Ok(dir.join(format!("{safe_name}-{}.log", uuid::Uuid::new_v4())))
1484}
1485
1486fn image_exists(runtime: Runtime, image: &str) -> bool {
1487 capture(
1488 runtime.bin(),
1489 &["image".into(), "inspect".into(), image.to_string()],
1490 )
1491 .map(|o| o.status.success())
1492 .unwrap_or(false)
1493}
1494
1495#[derive(Debug, Clone)]
1496struct ContainerState {
1497 running: bool,
1498 exit_code: Option<i64>,
1499}
1500
1501fn inspect_container_state(runtime: Runtime, name: &str) -> Option<ContainerState> {
1502 let out = capture(runtime.bin(), &["inspect".into(), name.to_string()]).ok()?;
1503 if !out.status.success() {
1504 return None;
1505 }
1506 let value: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
1507 let state = value
1508 .as_array()
1509 .and_then(|arr| arr.first())
1510 .and_then(|v| v.get("State"))
1511 .or_else(|| value.get("State"))?;
1512 Some(ContainerState {
1513 running: state
1514 .get("Running")
1515 .and_then(|v| v.as_bool())
1516 .unwrap_or(false),
1517 exit_code: state.get("ExitCode").and_then(|v| v.as_i64()),
1518 })
1519}
1520
1521fn container_running(runtime: Runtime, name: &str) -> bool {
1522 if let Some(state) = inspect_container_state(runtime, name) {
1523 return state.running;
1524 }
1525 capture(runtime.bin(), &["ps".into()])
1527 .map(|o| String::from_utf8_lossy(&o.stdout).contains(name))
1528 .unwrap_or(false)
1529}
1530
1531async fn read_token(runtime: Runtime, name: &str) -> Result<String, Error> {
1534 let argv = vec![
1535 "exec".into(),
1536 name.to_string(),
1537 "cat".into(),
1538 HOST_TOKEN_PATH.to_string(),
1539 ];
1540 for attempt in 0..20 {
1541 if let Ok(out) = capture(runtime.bin(), &argv)
1542 && out.status.success()
1543 {
1544 let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
1545 if !token.is_empty() {
1546 return Ok(token);
1547 }
1548 }
1549 if !container_running(runtime, name) {
1550 return Err(container_launch_failure_error(
1551 runtime,
1552 name,
1553 "container exited before the host token could be read",
1554 ));
1555 }
1556 if attempt < 19 {
1557 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1558 }
1559 }
1560 Err(container_launch_failure_error(
1561 runtime,
1562 name,
1563 "host token was not available before the startup deadline",
1564 ))
1565}
1566
1567async fn wait_for_container_health(
1568 runtime: Runtime,
1569 name: &str,
1570 port: u16,
1571 token: &str,
1572) -> Result<(), Error> {
1573 let endpoint = endpoint_url(port);
1574 let client = crate::sdk::Client::connect(&endpoint)?.with_token(token.to_string());
1575 for attempt in 0..30 {
1576 if !container_running(runtime, name) {
1577 return Err(container_launch_failure_error(
1578 runtime,
1579 name,
1580 "container exited before /health became ready",
1581 ));
1582 }
1583 match client.health().await {
1584 Ok(health) if health.version != VERSION => {
1585 return Err(Error::new(
1586 ErrorCode::InternalError,
1587 format!(
1588 "container host version mismatch after startup: host={}, driver={VERSION}",
1589 health.version
1590 ),
1591 ));
1592 }
1593 Ok(health) if health.status == "ok" => return Ok(()),
1594 Ok(health) => {
1595 if let Some(backend_error) = health.backend_error {
1596 return Err(Error::new(
1597 backend_error.error_code,
1598 format!(
1599 "container host /health reported {}: {}",
1600 health.status, backend_error.error
1601 ),
1602 ));
1603 }
1604 }
1605 Err(_) => {}
1606 }
1607 if attempt < 29 {
1608 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1609 }
1610 }
1611 Err(container_launch_failure_error(
1612 runtime,
1613 name,
1614 "container host did not pass /health before the startup deadline",
1615 ))
1616}
1617
1618fn container_launch_failure_error(runtime: Runtime, name: &str, reason: &str) -> Error {
1619 let logs = container_logs_summary(runtime, name);
1620 let lower = logs.to_ascii_lowercase();
1621 let code = if lower.contains("backend_unsupported")
1622 || lower.contains("persistent profiles")
1623 || lower.contains("does not yet support")
1624 {
1625 ErrorCode::BackendUnsupported
1626 } else {
1627 ErrorCode::BrowserLaunchFailed
1628 };
1629 let mut detail = format!("container host launch failed: {reason}");
1630 if !logs.is_empty() {
1631 detail.push_str("; recent logs: ");
1632 detail.push_str(&logs);
1633 }
1634 Error::new(code, detail)
1635}
1636
1637fn container_logs_summary(runtime: Runtime, name: &str) -> String {
1638 let Ok(out) = capture(runtime.bin(), &["logs".into(), name.to_string()]) else {
1639 return String::new();
1640 };
1641 let mut combined = String::new();
1642 combined.push_str(&String::from_utf8_lossy(&out.stdout));
1643 combined.push_str(&String::from_utf8_lossy(&out.stderr));
1644 let lines: Vec<&str> = combined.lines().rev().take(60).collect();
1645 let mut summary = lines.into_iter().rev().collect::<Vec<_>>().join(" | ");
1646 const MAX: usize = 4000;
1647 if summary.len() > MAX {
1648 let start = summary.len() - MAX;
1649 summary = format!("...{}", &summary[start..]);
1650 }
1651 summary
1652}
1653
1654fn build_failed_error(target: &str, log_file: &Path) -> Error {
1655 Error::new(
1656 ErrorCode::InternalError,
1657 format!(
1658 "image build failed. If v{VERSION} has no published release asset for \
1659 {target}, build from a source checkout instead: \
1660 `afhttp container install --from-source` (or \
1661 docker compose -f container/docker/compose.yaml up --build). Full output: {}",
1662 log_file.display()
1663 ),
1664 )
1665}
1666
1667fn cache_context_dir() -> Result<PathBuf, Error> {
1670 let base = std::env::var_os("XDG_CACHE_HOME")
1671 .map(PathBuf::from)
1672 .filter(|p| p.is_absolute())
1673 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
1674 .ok_or_else(|| {
1675 Error::new(
1676 ErrorCode::IoError,
1677 "cannot resolve cache dir: set HOME or XDG_CACHE_HOME",
1678 )
1679 })?;
1680 Ok(base.join("afhttp").join("container").join(VERSION))
1681}
1682
1683fn write_build_context() -> Result<PathBuf, Error> {
1684 let root = cache_context_dir()?;
1685 let dir = root.join("container").join("docker");
1689 std::fs::create_dir_all(&dir)?;
1690 std::fs::write(dir.join("Dockerfile"), DOCKERFILE)?;
1691 std::fs::write(dir.join("install-backends.sh"), INSTALL_BACKENDS)?;
1692 std::fs::write(dir.join("entrypoint.sh"), ENTRYPOINT)?;
1693 Ok(root)
1694}
1695
1696#[cfg(test)]
1697mod tests {
1698 use super::*;
1699
1700 #[test]
1701 fn runtime_from_str_parses_and_rejects() {
1702 assert_eq!(runtime_from_str("docker").unwrap(), Runtime::Docker);
1703 assert_eq!(runtime_from_str("podman").unwrap(), Runtime::Podman);
1704 assert_eq!(runtime_from_str("apple").unwrap(), Runtime::Apple);
1705 assert_eq!(runtime_from_str("container").unwrap(), Runtime::Apple);
1706 assert_eq!(
1707 runtime_from_str("nerdctl").unwrap_err().error_code,
1708 ErrorCode::InvalidArgument
1709 );
1710 }
1711
1712 #[test]
1713 fn explicit_runtime_wins_over_detection() {
1714 assert_eq!(
1715 resolve_runtime(Some(Runtime::Apple)).unwrap(),
1716 Runtime::Apple
1717 );
1718 assert_eq!(
1719 resolve_runtime(Some(Runtime::Docker)).unwrap(),
1720 Runtime::Docker
1721 );
1722 }
1723
1724 #[test]
1725 fn target_triple_tracks_runtime_and_arch() {
1726 assert_eq!(
1727 target_triple(Runtime::Apple, "x86_64"),
1728 "aarch64-unknown-linux-gnu"
1729 );
1730 assert_eq!(
1731 target_triple(Runtime::Docker, "aarch64"),
1732 "aarch64-unknown-linux-gnu"
1733 );
1734 assert_eq!(
1735 target_triple(Runtime::Docker, "x86_64"),
1736 "x86_64-unknown-linux-gnu"
1737 );
1738 assert_eq!(
1740 target_triple(Runtime::Podman, "aarch64"),
1741 "aarch64-unknown-linux-gnu"
1742 );
1743 assert_eq!(
1744 target_triple(Runtime::Podman, "x86_64"),
1745 "x86_64-unknown-linux-gnu"
1746 );
1747 }
1748
1749 #[test]
1750 fn backend_names_map_to_build_args() {
1751 let resolved = resolve_backends(&["camoufox".into(), "brave".into(), "kasmvnc".into()]);
1752 assert_eq!(resolved.len(), 3);
1753 assert_eq!(resolved[0].build_arg, "WITH_CAMOUFOX");
1754 assert_eq!(resolved[1].build_arg, "WITH_BRAVE");
1755 assert_eq!(resolved[2].build_arg, "WITH_KASMVNC");
1756
1757 let deduped = resolve_backends(&["camoufox".into(), "camoufox".into()]);
1759 assert_eq!(deduped.len(), 1);
1760 }
1761
1762 #[test]
1764 fn every_registry_component_maps_to_a_build_arg() {
1765 for component in crate::cli::spec::CONTAINER_COMPONENTS {
1766 let resolved = resolve_backends(&[component.to_string()]);
1767 assert_eq!(resolved.len(), 1, "{component}");
1768 }
1769 }
1770
1771 #[test]
1772 fn install_precheck_rejects_camoufox_with_persistent_profile() {
1773 let args = InstallArgs {
1774 common: CommonArgs {
1775 runtime: Some(Runtime::Docker),
1776 name: "afhttp-host".into(),
1777 },
1778 port: 9222,
1779 profile: Some("work".into()),
1780 shm_size: Some("1g".into()),
1781 takeover_provider: Takeover::Off,
1782 with: vec!["camoufox".into()],
1783 rebuild: false,
1784 from_source: false,
1785 context: None,
1786 host_args: vec!["--browser".into(), "camoufox".into()],
1787 reveal_token_secret: false,
1788 };
1789 let backends = resolve_backends(&args.with);
1790 let err = validate_install_args(&args, &backends).unwrap_err();
1791 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
1792 assert!(err.detail.contains("--profile -"));
1793 }
1794
1795 #[test]
1796 fn install_precheck_allows_camoufox_ephemeral_profile() {
1797 let args = InstallArgs {
1798 common: CommonArgs {
1799 runtime: Some(Runtime::Docker),
1800 name: "afhttp-host".into(),
1801 },
1802 port: 9222,
1803 profile: Some("-".into()),
1804 shm_size: Some("1g".into()),
1805 takeover_provider: Takeover::Off,
1806 with: vec!["camoufox".into()],
1807 rebuild: false,
1808 from_source: false,
1809 context: None,
1810 host_args: vec!["--browser=camoufox".into()],
1811 reveal_token_secret: false,
1812 };
1813 let backends = resolve_backends(&args.with);
1814 validate_install_args(&args, &backends).unwrap();
1815 }
1816
1817 #[test]
1818 fn install_result_exposes_hard_site_flag() {
1819 let value = serde_json::to_value(InstallResult {
1820 runtime: "docker",
1821 image: "afhttp-host:test".into(),
1822 container: "afhttp-host".into(),
1823 endpoint: "ws://127.0.0.1:9222".into(),
1824 profile: "work".into(),
1825 token_available: true,
1826 token_source: "container_volume",
1827 token_secret: None,
1828 client_command: "afhttp fetch https://example.com".into(),
1829 log_file: Some(PathBuf::from("/tmp/afhttp-container-logs/install.log")),
1830 backends: vec!["brave".into(), "kasmvnc".into()],
1831 takeover_ready: true,
1832 })
1833 .unwrap();
1834 assert_eq!(value["takeover_ready"], true);
1835 assert_eq!(value["token_available"], true);
1836 assert_eq!(value["token_source"], "container_volume");
1837 assert!(value.get("token_secret").is_none());
1838 assert!(value.get("token").is_none());
1839 }
1840
1841 #[test]
1842 fn status_result_hides_token_secret_by_default() {
1843 let value = serde_json::to_value(StatusResult {
1844 runtime: "docker",
1845 container: "afhttp-host".into(),
1846 running: true,
1847 endpoint: "ws://127.0.0.1:9222".into(),
1848 driver_version: VERSION,
1849 host_version: Some(VERSION.into()),
1850 version_match: Some(true),
1851 profile_kind: Some("persistent".into()),
1852 profile: Some("work".into()),
1853 profile_backend: Some("brave".into()),
1854 backend: Some(BackendFamily {
1855 family: "brave".into(),
1856 version: "1".into(),
1857 }),
1858 provider: Some("kasmvnc".into()),
1859 takeover_ready: Some(true),
1860 token_available: true,
1861 token_source: Some("container_volume"),
1862 token_secret: None,
1863 client_command: Some("afhttp fetch https://example.com".into()),
1864 exit_code: None,
1865 log_summary: None,
1866 warnings: Vec::new(),
1867 })
1868 .unwrap();
1869 assert!(value.get("token_secret").is_none());
1870 assert_eq!(value["token_available"], true);
1871 assert_eq!(value["token_source"], "container_volume");
1872 assert_eq!(value["profile_kind"], "persistent");
1873 assert_eq!(value["profile_backend"], "brave");
1874 assert_eq!(value["backend"]["family"], "brave");
1875 assert_eq!(value["takeover_ready"], true);
1876 assert_eq!(value["driver_version"], VERSION);
1877 assert_eq!(value["host_version"], VERSION);
1878 assert_eq!(value["version_match"], true);
1879 assert!(value.get("token").is_none());
1880 }
1881
1882 #[test]
1883 fn status_result_can_report_exited_container_diagnostics() {
1884 let value = serde_json::to_value(StatusResult {
1885 runtime: "docker",
1886 container: "afhttp-host".into(),
1887 running: false,
1888 endpoint: "ws://127.0.0.1:9222".into(),
1889 driver_version: VERSION,
1890 host_version: None,
1891 version_match: None,
1892 profile_kind: None,
1893 profile: None,
1894 profile_backend: None,
1895 backend: None,
1896 provider: None,
1897 takeover_ready: None,
1898 token_available: false,
1899 token_source: None,
1900 token_secret: None,
1901 client_command: None,
1902 exit_code: Some(42),
1903 log_summary: Some("browser stderr tail".into()),
1904 warnings: Vec::new(),
1905 })
1906 .unwrap();
1907 assert_eq!(value["exit_code"], 42);
1908 assert_eq!(value["log_summary"], "browser stderr tail");
1909 assert_eq!(value["driver_version"], VERSION);
1910 assert!(value.get("host_version").is_none());
1911 assert!(value.get("version_match").is_none());
1912 assert!(value.get("client_command").is_none());
1913 }
1914
1915 #[test]
1916 fn host_version_warning_points_to_profile_preserving_reinstall() {
1917 let warning = host_version_warning(DEFAULT_CONTAINER_NAME, "0.5.0").expect("warning");
1918 assert!(warning.contains("0.5.0"));
1919 assert!(warning.contains(VERSION));
1920 assert!(warning.contains("afhttp container install"));
1921 assert!(warning.contains("persistent profiles are preserved"));
1922 assert!(host_version_warning(DEFAULT_CONTAINER_NAME, VERSION).is_none());
1923 }
1924
1925 #[test]
1926 fn local_host_error_names_autodiscovery_and_manual_commands() {
1927 let err = local_host_error("default local container `afhttp-host` is not running".into());
1928 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
1929 assert!(err.detail.contains("afhttp-host"));
1930 assert!(err.detail.contains("afhttp container install"));
1931 assert!(err.detail.contains("--endpoint-url/--token-secret"));
1932 assert!(!err.detail.contains("fetch --takeover"), "{}", err.detail);
1934
1935 let err = local_host_error(host_version_mismatch_detail(
1937 DEFAULT_CONTAINER_NAME,
1938 "0.10.1",
1939 ));
1940 assert!(!err.detail.contains(".."), "{}", err.detail);
1941 assert!(err.detail.contains("0.10.1"), "{}", err.detail);
1942 }
1943
1944 #[test]
1947 fn a_container_token_error_names_the_container_and_the_way_to_inspect_it() {
1948 let err = container_token_error("scratch", "the container is not running");
1949 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
1950 assert!(err.detail.contains("--token-secret container:scratch"));
1951 assert!(
1952 err.detail
1953 .contains("afhttp container status --name scratch")
1954 );
1955 }
1956
1957 #[test]
1958 fn entrypoint_generates_base64url_token_secret() {
1959 assert!(ENTRYPOINT.contains("AFHTTP_TOKEN_SECRET"));
1960 let legacy_env_probe = ["AFHTTP", "TOKEN:-"].join("_");
1961 assert!(!ENTRYPOINT.contains(&legacy_env_probe));
1962 assert!(ENTRYPOINT.contains("head -c 32 /dev/urandom"));
1963 assert!(ENTRYPOINT.contains("base64 | tr '+/' '-_' | tr -d '=\\n'"));
1964 assert!(!ENTRYPOINT.contains("od -An -N32 -tx1"));
1965 }
1966
1967 #[test]
1968 fn hard_site_install_defaults_expand_to_display_brave_preset() {
1969 let mut args = InstallArgs {
1970 common: CommonArgs {
1971 runtime: Some(Runtime::Docker),
1972 name: "afhttp-host".into(),
1973 },
1974 port: 9222,
1975 profile: None,
1976 shm_size: None,
1977 takeover_provider: Takeover::On {
1978 provider: crate::host::bootstrap::TakeoverProviderKind::KasmVnc,
1979 },
1980 with: Vec::new(),
1981 rebuild: false,
1982 from_source: false,
1983 context: None,
1984 host_args: Vec::new(),
1985 reveal_token_secret: false,
1986 };
1987 apply_hard_site_defaults(&mut args);
1988 let backends = resolve_backends(&args.with);
1989 validate_install_args(&args, &backends).unwrap();
1990 assert_eq!(effective_profile(&args), "-");
1991 assert_eq!(effective_shm_size(&args), "2g");
1992 assert_eq!(
1993 backends.iter().map(|b| b.name).collect::<Vec<_>>(),
1994 vec!["brave", "kasmvnc"]
1995 );
1996 assert_eq!(
1997 args.host_args,
1998 vec![
1999 "--browser".to_string(),
2000 "brave".to_string(),
2001 "--takeover-provider".to_string(),
2002 "kasmvnc".to_string(),
2003 ]
2004 );
2005 }
2006
2007 #[test]
2008 fn hard_site_install_keeps_valid_explicit_overrides_and_shm() {
2009 let mut args = InstallArgs {
2010 common: CommonArgs {
2011 runtime: Some(Runtime::Docker),
2012 name: "afhttp-host".into(),
2013 },
2014 port: 9222,
2015 profile: Some("work".into()),
2016 shm_size: Some("3g".into()),
2017 takeover_provider: Takeover::On {
2018 provider: crate::host::bootstrap::TakeoverProviderKind::KasmVnc,
2019 },
2020 with: vec!["kasmvnc".into()],
2021 rebuild: false,
2022 from_source: false,
2023 context: None,
2024 host_args: vec![
2025 "--browser=brave".into(),
2026 "--takeover-provider=kasmvnc".into(),
2027 ],
2028 reveal_token_secret: false,
2029 };
2030 apply_hard_site_defaults(&mut args);
2031 let backends = resolve_backends(&args.with);
2032 validate_install_args(&args, &backends).unwrap();
2033 assert_eq!(effective_shm_size(&args), "3g");
2034 assert_eq!(
2035 host_arg_value(&args.host_args, "--browser").as_deref(),
2036 Some("brave")
2037 );
2038 assert_eq!(
2039 backends.iter().map(|b| b.name).collect::<Vec<_>>(),
2040 vec!["kasmvnc", "brave"]
2041 );
2042 }
2043
2044 #[test]
2051 fn hard_site_install_keeps_user_browser_args_and_adds_none() {
2052 let mut args = InstallArgs {
2053 common: CommonArgs {
2054 runtime: Some(Runtime::Docker),
2055 name: "afhttp-host".into(),
2056 },
2057 port: 9222,
2058 profile: None,
2059 shm_size: None,
2060 takeover_provider: Takeover::On {
2061 provider: crate::host::bootstrap::TakeoverProviderKind::KasmVnc,
2062 },
2063 with: Vec::new(),
2064 rebuild: false,
2065 from_source: false,
2066 context: None,
2067 host_args: vec!["--browser-arg".into(), "--lang=zh-CN".into()],
2068 reveal_token_secret: false,
2069 };
2070 apply_hard_site_defaults(&mut args);
2071 let backends = resolve_backends(&args.with);
2072 validate_install_args(&args, &backends).unwrap();
2073 assert!(
2074 args.host_args.iter().any(|a| a == "--lang=zh-CN"),
2075 "the caller's own browser arg must survive: {:?}",
2076 args.host_args
2077 );
2078 assert_eq!(
2079 args.host_args
2080 .iter()
2081 .filter(|a| a.starts_with("--browser-arg"))
2082 .count(),
2083 1,
2084 "the takeover preset must contribute no --browser-arg of its own: {:?}",
2085 args.host_args
2086 );
2087 assert!(
2088 !args
2089 .host_args
2090 .iter()
2091 .any(|a| a.contains("AutomationControlled")),
2092 "the AutomationControlled flag must not come back: {:?}",
2093 args.host_args
2094 );
2095 }
2096
2097 #[test]
2098 fn hard_site_install_allows_persistent_profile_with_brave() {
2099 let mut args = InstallArgs {
2100 common: CommonArgs {
2101 runtime: Some(Runtime::Docker),
2102 name: "afhttp-host".into(),
2103 },
2104 port: 9222,
2105 profile: Some("work".into()),
2106 shm_size: None,
2107 takeover_provider: Takeover::On {
2108 provider: crate::host::bootstrap::TakeoverProviderKind::KasmVnc,
2109 },
2110 with: Vec::new(),
2111 rebuild: false,
2112 from_source: false,
2113 context: None,
2114 host_args: vec!["--browser".into(), "brave".into()],
2115 reveal_token_secret: false,
2116 };
2117 apply_hard_site_defaults(&mut args);
2118 let backends = resolve_backends(&args.with);
2119 validate_install_args(&args, &backends).unwrap();
2120 assert_eq!(effective_profile(&args), "work");
2121 }
2122
2123 #[test]
2124 fn hard_site_install_allows_ephemeral_initial_profile() {
2125 let mut args = InstallArgs {
2126 common: CommonArgs {
2127 runtime: Some(Runtime::Docker),
2128 name: "afhttp-host".into(),
2129 },
2130 port: 9222,
2131 profile: Some("-".into()),
2132 shm_size: None,
2133 takeover_provider: Takeover::On {
2134 provider: crate::host::bootstrap::TakeoverProviderKind::KasmVnc,
2135 },
2136 with: Vec::new(),
2137 rebuild: false,
2138 from_source: false,
2139 context: None,
2140 host_args: vec!["--browser".into(), "brave".into()],
2141 reveal_token_secret: false,
2142 };
2143 apply_hard_site_defaults(&mut args);
2144 let backends = resolve_backends(&args.with);
2145 validate_install_args(&args, &backends).unwrap();
2146 assert_eq!(effective_profile(&args), "-");
2147 }
2148
2149 #[test]
2150 fn hard_site_install_rejects_non_takeover_browser_override() {
2151 let mut args = InstallArgs {
2152 common: CommonArgs {
2153 runtime: Some(Runtime::Docker),
2154 name: "afhttp-host".into(),
2155 },
2156 port: 9222,
2157 profile: None,
2158 shm_size: None,
2159 takeover_provider: Takeover::On {
2160 provider: crate::host::bootstrap::TakeoverProviderKind::KasmVnc,
2161 },
2162 with: Vec::new(),
2163 rebuild: false,
2164 from_source: false,
2165 context: None,
2166 host_args: vec!["--browser".into(), "chromium".into()],
2167 reveal_token_secret: false,
2168 };
2169 apply_hard_site_defaults(&mut args);
2170 let backends = resolve_backends(&args.with);
2171 let err = validate_install_args(&args, &backends).unwrap_err();
2172 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
2173 assert!(err.detail.contains("--browser <brave|chrome>"));
2174 assert!(err.detail.contains("got `--browser chromium`"));
2175 assert!(err.detail.contains("afhttp container install"));
2176 }
2177
2178 #[test]
2179 fn hard_site_install_rejects_missing_browser_value() {
2180 let mut args = InstallArgs {
2181 common: CommonArgs {
2182 runtime: Some(Runtime::Docker),
2183 name: "afhttp-host".into(),
2184 },
2185 port: 9222,
2186 profile: None,
2187 shm_size: None,
2188 takeover_provider: Takeover::On {
2189 provider: crate::host::bootstrap::TakeoverProviderKind::KasmVnc,
2190 },
2191 with: Vec::new(),
2192 rebuild: false,
2193 from_source: false,
2194 context: None,
2195 host_args: vec!["--browser".into()],
2196 reveal_token_secret: false,
2197 };
2198 apply_hard_site_defaults(&mut args);
2199 let backends = resolve_backends(&args.with);
2200 let err = validate_install_args(&args, &backends).unwrap_err();
2201 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
2202 assert!(err.detail.contains("--browser <brave|chrome>"));
2203 }
2204
2205 #[test]
2208 fn hard_site_install_expands_chrome_override_to_chrome_component() {
2209 let mut args = InstallArgs {
2210 common: CommonArgs {
2211 runtime: Some(Runtime::Docker),
2212 name: "afhttp-host".into(),
2213 },
2214 port: 9222,
2215 profile: None,
2216 shm_size: None,
2217 takeover_provider: Takeover::On {
2218 provider: crate::host::bootstrap::TakeoverProviderKind::KasmVnc,
2219 },
2220 with: Vec::new(),
2221 rebuild: false,
2222 from_source: false,
2223 context: None,
2224 host_args: vec!["--browser".into(), "chrome".into()],
2225 reveal_token_secret: false,
2226 };
2227 apply_hard_site_defaults(&mut args);
2228 let backends = resolve_backends(&args.with);
2229 validate_install_args(&args, &backends).unwrap();
2230 assert_eq!(
2231 backends.iter().map(|b| b.name).collect::<Vec<_>>(),
2232 vec!["chrome", "kasmvnc"]
2233 );
2234 assert_eq!(
2235 host_arg_value(&args.host_args, "--browser").as_deref(),
2236 Some("chrome")
2237 );
2238 }
2239
2240 #[test]
2243 fn takeover_capability_gate_accepts_every_takeover_backend() {
2244 for backend in TAKEOVER_BACKENDS {
2245 assert!(
2246 takeover_backend(backend.browser).is_some(),
2247 "{} should be a takeover backend",
2248 backend.browser
2249 );
2250 assert!(
2251 BACKENDS.iter().any(|b| b.name == backend.component),
2252 "{} needs a --with image component",
2253 backend.component
2254 );
2255 }
2256 assert!(takeover_backend("chromium").is_none());
2257 }
2258
2259 #[test]
2260 fn takeover_host_args_trigger_image_support_probe() {
2261 assert!(host_args_need_takeover_support(&[
2262 "--takeover-provider".into(),
2263 "kasmvnc".into()
2264 ]));
2265 assert!(host_args_need_takeover_support(&[
2266 "--takeover-provider=kasmvnc".into()
2267 ]));
2268 assert!(!host_args_need_takeover_support(&[
2269 "--takeover-provider".into(),
2270 "off".into()
2271 ]));
2272 assert!(!host_args_need_takeover_support(&[
2273 "--browser".into(),
2274 "brave".into()
2275 ]));
2276 }
2277
2278 #[test]
2279 fn image_host_help_args_bypasses_entrypoint() {
2280 let args = image_host_help_args("afhttp-host:dev");
2281 assert_eq!(args[0], "run");
2282 assert!(args.contains(&"--rm".to_string()));
2283 assert!(args.contains(&"--entrypoint".to_string()));
2284 assert!(args.contains(&"/usr/local/bin/afhttp".to_string()));
2285 assert_eq!(args[args.len() - 3], "afhttp-host:dev");
2286 assert_eq!(args[args.len() - 2], "host");
2287 assert_eq!(args[args.len() - 1], "--help");
2288 }
2289
2290 #[test]
2291 fn embedded_build_args_include_version_target_and_apple_platform() {
2292 let ctx = PathBuf::from("/cache/ctx");
2293 let backends = resolve_backends(&["lightpanda".into()]);
2294 let docker = build_args(
2295 "afhttp-host:1.2.3",
2296 Runtime::Docker,
2297 BuildSource::Embedded {
2298 ctx: &ctx,
2299 target: "x86_64-unknown-linux-gnu",
2300 },
2301 &backends,
2302 );
2303 assert_eq!(docker[0], "build");
2304 assert!(!docker.contains(&"--platform".to_string()));
2305 assert!(docker.contains(&"AFHTTP_BIN_FROM=downloader".to_string()));
2306 assert!(docker.contains(&format!("AFHTTP_VERSION={VERSION}")));
2307 assert!(docker.contains(&"AFHTTP_TARGET=x86_64-unknown-linux-gnu".to_string()));
2308 assert!(docker.contains(&"WITH_LIGHTPANDA=1".to_string()));
2309 assert_eq!(
2310 docker[docker.len() - 2],
2311 "/cache/ctx/container/docker/Dockerfile"
2312 );
2313 assert_eq!(docker.last().unwrap(), "/cache/ctx");
2314
2315 let apple = build_args(
2316 "afhttp-host:1.2.3",
2317 Runtime::Apple,
2318 BuildSource::Embedded {
2319 ctx: &ctx,
2320 target: "aarch64-unknown-linux-gnu",
2321 },
2322 &[],
2323 );
2324 let pos = apple.iter().position(|a| a == "--platform").unwrap();
2325 assert_eq!(apple[pos + 1], "linux/arm64");
2326 }
2327
2328 #[test]
2329 fn from_source_build_args_use_canonical_dockerfile_no_release_args() {
2330 let repo = PathBuf::from("/repo");
2331 let backends = resolve_backends(&["camoufox".into()]);
2332 let args = build_args(
2333 "afhttp-host:1.2.3",
2334 Runtime::Podman,
2335 BuildSource::FromSource { ctx: &repo },
2336 &backends,
2337 );
2338 assert!(args.contains(&"AFHTTP_BIN_FROM=builder".to_string()));
2340 assert!(!args.iter().any(|a| a.starts_with("AFHTTP_VERSION=")));
2341 assert!(!args.iter().any(|a| a.starts_with("AFHTTP_TARGET=")));
2342 assert!(args.contains(&"WITH_CAMOUFOX=1".to_string()));
2343 assert_eq!(args[args.len() - 2], "/repo/container/docker/Dockerfile");
2344 assert_eq!(args.last().unwrap(), "/repo");
2345 assert!(!args.contains(&"--platform".to_string()));
2347 }
2348
2349 #[test]
2350 fn run_args_publish_loopback_and_pass_host_args() {
2351 let a = run_args(
2352 "afhttp-host",
2353 "afhttp-host:1.2.3",
2354 9222,
2355 "work",
2356 "1g",
2357 &["--browser".into(), "camoufox".into()],
2358 );
2359 assert!(a.contains(&"afhttp-host-data:/data".to_string()));
2360 assert!(a.contains(&"AFHTTP_PORT=9222".to_string()));
2361 assert!(a.contains(&"AFHTTP_PROFILE=work".to_string()));
2362 assert!(a.contains(&"127.0.0.1:9222:9222".to_string()));
2363 let img = a.iter().position(|x| x == "afhttp-host:1.2.3").unwrap();
2365 let br = a.iter().position(|x| x == "--browser").unwrap();
2366 assert!(img < br);
2367 }
2368
2369 #[test]
2373 fn the_standard_container_needs_no_endpoint_or_token_on_the_command() {
2374 let cmd = client_command(DEFAULT_CONTAINER_NAME, DEFAULT_CONTAINER_PORT);
2375 assert_eq!(cmd, "afhttp fetch https://example.com");
2376 }
2377
2378 #[test]
2381 fn a_nonstandard_container_names_itself_as_the_token_source() {
2382 let cmd = client_command("scratch", 9333);
2383 assert!(cmd.contains("--endpoint-url ws://127.0.0.1:9333"), "{cmd}");
2384 assert!(cmd.contains("--token-secret container:scratch"), "{cmd}");
2385 assert!(!cmd.contains("AFHTTP_TOKEN_SECRET"), "{cmd}");
2386 assert!(!cmd.contains("deadbeef"), "{cmd}");
2387 }
2388
2389 #[test]
2390 fn build_failure_error_points_at_compose_fallback() {
2391 let err = build_failed_error(
2392 "aarch64-unknown-linux-gnu",
2393 Path::new("/tmp/afhttp-container-logs/build.log"),
2394 );
2395 assert_eq!(err.error_code, ErrorCode::InternalError);
2396 assert!(err.detail.contains("compose"));
2397 assert!(err.detail.contains("aarch64-unknown-linux-gnu"));
2398 assert!(err.detail.contains("build.log"));
2399 }
2400
2401 #[test]
2402 fn tail_lines_from_file_reports_truncation_without_full_read() {
2403 let dir = tempfile::tempdir().unwrap();
2404 let path = dir.path().join("container.log");
2405 std::fs::write(&path, "one\ntwo\nthree\n").unwrap();
2406 let (tail, truncated) = tail_lines_from_file(&path, 2).unwrap();
2407 assert_eq!(tail, vec!["two".to_string(), "three".to_string()]);
2408 assert!(truncated);
2409 }
2410}