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