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