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