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; 6] = [
592 Backend {
593 name: "lightpanda",
594 build_arg: "WITH_LIGHTPANDA",
595 },
596 Backend {
597 name: "fingerprint-chromium",
598 build_arg: "WITH_FINGERPRINT_CHROMIUM",
599 },
600 Backend {
601 name: "camoufox",
602 build_arg: "WITH_CAMOUFOX",
603 },
604 Backend {
605 name: "chrome",
606 build_arg: "WITH_CHROME",
607 },
608 Backend {
609 name: "brave",
610 build_arg: "WITH_BRAVE",
611 },
612 Backend {
613 name: "kasmvnc",
614 build_arg: "WITH_KASMVNC",
615 },
616];
617
618#[derive(Clone, Copy, Debug, PartialEq, Eq)]
625struct TakeoverBackend {
626 browser: &'static str,
627 component: &'static str,
628 probe_bin: &'static str,
629}
630
631const TAKEOVER_BACKENDS: [TakeoverBackend; 2] = [
644 TakeoverBackend {
645 browser: "brave",
646 component: "brave",
647 probe_bin: "brave-browser",
648 },
649 TakeoverBackend {
650 browser: "chrome",
651 component: "chrome",
652 probe_bin: "google-chrome-stable",
653 },
654];
655
656const DEFAULT_TAKEOVER_BACKEND: TakeoverBackend = TAKEOVER_BACKENDS[0];
657
658fn takeover_backend(browser: &str) -> Option<TakeoverBackend> {
659 TAKEOVER_BACKENDS
660 .iter()
661 .copied()
662 .find(|b| b.browser == browser)
663}
664
665fn selected_takeover_backend(host_args: &[String]) -> TakeoverBackend {
670 host_arg_value(host_args, "--browser")
671 .as_deref()
672 .and_then(takeover_backend)
673 .unwrap_or(DEFAULT_TAKEOVER_BACKEND)
674}
675
676fn takeover_browser_list() -> String {
677 TAKEOVER_BACKENDS
678 .iter()
679 .map(|b| b.browser)
680 .collect::<Vec<_>>()
681 .join("|")
682}
683
684fn resolve_backends(names: &[String]) -> Vec<Backend> {
689 let mut out = Vec::with_capacity(names.len());
690 for name in names {
691 if let Some(backend) = BACKENDS.iter().find(|b| b.name == name)
692 && !out.contains(backend)
693 {
694 out.push(*backend);
695 }
696 }
697 out
698}
699
700fn validate_install_args(args: &InstallArgs, backends: &[Backend]) -> Result<(), Error> {
701 let profile = effective_profile(args);
702 if let Some(provider) = install_takeover_provider(args) {
703 validate_hard_site_install_args(args, backends, provider)?;
704 }
705 let camoufox_built = backends.iter().any(|b| b.name == "camoufox");
706 if profile != "-" && camoufox_built && host_args_select_camoufox(&args.host_args) {
707 return Err(Error::new(
708 ErrorCode::InvalidArgument,
709 "afhttp's camoufox backend does not yet support persistent profiles; use `--profile -` for camoufox hosts. Example: `afhttp container install --profile - --with camoufox -- --browser camoufox`.",
710 ));
711 }
712 Ok(())
713}
714
715fn install_takeover_provider(args: &InstallArgs) -> Option<&'static str> {
719 match args.takeover_provider {
720 Takeover::Off => None,
721 Takeover::On { provider } => Some(provider.as_str()),
722 }
723}
724
725fn apply_hard_site_defaults(args: &mut InstallArgs) {
734 let Some(provider) = install_takeover_provider(args).map(str::to_string) else {
735 return;
736 };
737 let backend = selected_takeover_backend(&args.host_args);
738 push_backend_if_missing(&mut args.with, backend.component);
739 push_backend_if_missing(&mut args.with, "kasmvnc");
740 push_host_arg_default(&mut args.host_args, "--browser", backend.browser);
741 push_host_arg_default(&mut args.host_args, "--takeover-provider", &provider);
742}
743
744fn effective_profile(args: &InstallArgs) -> String {
745 args.profile.clone().unwrap_or_else(|| "-".to_string())
746}
747
748fn effective_shm_size(args: &InstallArgs) -> String {
749 args.shm_size.clone().unwrap_or_else(|| {
750 if install_takeover_provider(args).is_some() {
751 "2g"
752 } else {
753 "1g"
754 }
755 .to_string()
756 })
757}
758
759fn push_backend_if_missing(backends: &mut Vec<String>, backend: &str) {
760 if !backends.iter().any(|b| b == backend) {
761 backends.push(backend.to_string());
762 }
763}
764
765fn push_host_arg_default(host_args: &mut Vec<String>, name: &str, value: &str) {
766 if !host_arg_present(host_args, name) {
767 host_args.push(name.to_string());
768 host_args.push(value.to_string());
769 }
770}
771
772fn validate_hard_site_install_args(
773 args: &InstallArgs,
774 backends: &[Backend],
775 provider: &str,
776) -> Result<(), Error> {
777 let browser = host_arg_value(&args.host_args, "--browser");
778 let Some(backend) = browser.as_deref().and_then(takeover_backend) else {
779 let got = browser
780 .map(|v| format!("; got `--browser {v}`"))
781 .unwrap_or_default();
782 return Err(hard_site_install_error(format!(
783 "takeover requires host arg `--browser <{}>`{got}",
784 takeover_browser_list()
785 )));
786 };
787 if !backends.iter().any(|b| b.name == backend.component) {
788 return Err(hard_site_install_error(format!(
789 "takeover with `--browser {}` requires the {} backend; omit conflicting backend overrides",
790 backend.browser, backend.component
791 )));
792 }
793 if !backends.iter().any(|b| b.name == "kasmvnc") {
794 return Err(hard_site_install_error(
795 "takeover requires the KasmVNC display backend".to_string(),
796 ));
797 }
798 require_hard_site_host_arg(&args.host_args, "--takeover-provider", provider)?;
799 Ok(())
800}
801
802fn require_hard_site_host_arg(
803 host_args: &[String],
804 name: &str,
805 expected: &str,
806) -> Result<(), Error> {
807 let Some(value) = host_arg_value(host_args, name) else {
808 return Err(hard_site_install_error(format!(
809 "takeover requires host arg `{name} {expected}`"
810 )));
811 };
812 if value == expected {
813 return Ok(());
814 }
815 Err(hard_site_install_error(format!(
816 "takeover requires host arg `{name} {expected}`; got `{name} {value}`"
817 )))
818}
819
820fn hard_site_install_error(detail: String) -> Error {
821 Error::new(
822 ErrorCode::InvalidArgument,
823 format!(
824 "{detail}. Use `afhttp container install` (takeover is on by default), or `--takeover-provider off` for a lean host."
825 ),
826 )
827}
828
829fn host_args_select_camoufox(host_args: &[String]) -> bool {
830 host_arg_value(host_args, "--browser").as_deref() == Some("camoufox")
831}
832
833fn host_arg_present(host_args: &[String], name: &str) -> bool {
834 let eq_prefix = format!("{name}=");
835 host_args
836 .iter()
837 .any(|arg| arg == name || arg.starts_with(&eq_prefix))
838}
839
840fn host_arg_value(host_args: &[String], name: &str) -> Option<String> {
841 let eq_prefix = format!("{name}=");
842 let mut value = None;
843 let mut iter = host_args.iter().peekable();
844 while let Some(arg) = iter.next() {
845 if arg == name {
846 if let Some(next) = iter.peek() {
847 value = Some((*next).to_string());
848 }
849 } else if let Some(v) = arg.strip_prefix(&eq_prefix) {
850 value = Some(v.to_string());
851 }
852 }
853 value
854}
855
856fn validate_container_image_host_args(
857 runtime: Runtime,
858 image: &str,
859 host_args: &[String],
860) -> Result<(), Error> {
861 if !host_args_need_takeover_support(host_args) {
862 return Ok(());
863 }
864 let Some(help) = container_image_host_help(runtime, image) else {
865 return Ok(());
866 };
867 if !help.contains("--takeover-quality-percent") {
868 return Err(Error::new(
869 ErrorCode::InvalidArgument,
870 format!(
871 "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`"
872 ),
873 ));
874 }
875 if let Some(backend) = host_arg_value(host_args, "--browser")
876 .as_deref()
877 .and_then(takeover_backend)
878 && !container_image_hard_site_components(runtime, image, backend)
879 {
880 return Err(Error::new(
884 ErrorCode::BackendUnsupported,
885 format!(
886 "container image `{image}` does not expose the {browser} + KasmVNC takeover components; rebuild it with `afhttp container install --rebuild --with {component} -- --browser {browser}`",
887 browser = backend.browser,
888 component = backend.component
889 ),
890 ));
891 }
892 Ok(())
893}
894
895async fn validate_running_hard_site(endpoint: &str, token: &str) -> Result<(), Error> {
896 let client = crate::sdk::Client::connect(endpoint)?.with_token(token.to_string());
897 let health = client.health().await.map_err(|e| {
898 Error::new(
899 e.error_code,
900 format!("takeover host /health failed after startup: {}", e.detail),
901 )
902 .with_retryable(e.retryable)
903 })?;
904 if health.version != VERSION {
905 return Err(Error::new(
906 ErrorCode::InternalError,
907 format!(
908 "takeover host version mismatch after startup: host={}, driver={VERSION}",
909 health.version
910 ),
911 ));
912 }
913 if health.status != "ok" {
914 let detail = health
915 .backend_error
916 .map(|e| format!("{}: {}", e.error_code, e.error))
917 .unwrap_or_else(|| format!("status={}", health.status));
918 return Err(Error::new(
919 ErrorCode::BrowserLaunchFailed,
920 format!("takeover host was not ready after startup: {detail}"),
921 ));
922 }
923 let caps = client.capabilities().await.map_err(|e| {
924 Error::new(
925 e.error_code,
926 format!(
927 "takeover host /capabilities failed after startup: {}",
928 e.detail
929 ),
930 )
931 .with_retryable(e.retryable)
932 })?;
933 validate_hard_site_capabilities(&caps)
934}
935
936fn is_hard_site_capabilities(caps: &crate::sdk::capabilities::CapabilitiesResponse) -> bool {
937 takeover_backend(&caps.backend.family).is_some()
938 && caps.takeover.supported
939 && caps.takeover.provider.as_deref() == Some("kasmvnc")
940}
941
942#[derive(Debug, Clone, PartialEq, Eq)]
943pub(crate) struct LocalTakeoverHost {
944 pub(crate) endpoint: String,
945 pub(crate) token_secret: Option<String>,
946}
947
948pub(crate) async fn discover_default_takeover_host(
952 token_override: Option<&str>,
953) -> Result<LocalTakeoverHost, Error> {
954 let runtime = resolve_runtime(None).map_err(|e| {
955 local_takeover_error(format!(
956 "could not choose a container runtime to inspect `{DEFAULT_CONTAINER_NAME}`: {}",
957 e.detail
958 ))
959 })?;
960 let running = inspect_container_state(runtime, DEFAULT_CONTAINER_NAME)
961 .map(|s| s.running)
962 .unwrap_or_else(|| container_running(runtime, DEFAULT_CONTAINER_NAME));
963 if !running {
964 return Err(local_takeover_error(format!(
965 "default local container `{DEFAULT_CONTAINER_NAME}` is not running"
966 )));
967 }
968
969 let token_secret = match token_override {
970 Some(token) => Some(token.to_string()),
971 None => Some(read_token(runtime, DEFAULT_CONTAINER_NAME).await.map_err(|e| {
972 local_takeover_error(format!(
973 "default local container `{DEFAULT_CONTAINER_NAME}` is running, but its token could not be read: {}",
974 e.detail
975 ))
976 })?),
977 };
978 let endpoint = endpoint_url(DEFAULT_CONTAINER_PORT);
979 let client = match token_secret.as_deref() {
980 Some(token) => crate::sdk::Client::connect(&endpoint)?.with_token(token.to_string()),
981 None => crate::sdk::Client::connect(&endpoint)?,
982 };
983 let health = client.health().await.map_err(|e| {
984 local_takeover_error(format!(
985 "default local container `{DEFAULT_CONTAINER_NAME}` at {endpoint} could not be verified: {}",
986 e.detail
987 ))
988 .with_retryable(e.retryable)
989 })?;
990 if health.version != VERSION {
991 return Err(local_takeover_error(host_version_mismatch_detail(
992 DEFAULT_CONTAINER_NAME,
993 &health.version,
994 )));
995 }
996 if health.status != "ok" {
997 let detail = health
998 .backend_error
999 .map(|e| format!("{}: {}", e.error_code, e.error))
1000 .unwrap_or_else(|| format!("status={}", health.status));
1001 return Err(local_takeover_error(format!(
1002 "default local container `{DEFAULT_CONTAINER_NAME}` at {endpoint} is not ready: {detail}"
1003 )));
1004 }
1005 let caps = client.capabilities().await.map_err(|e| {
1006 local_takeover_error(format!(
1007 "default local container `{DEFAULT_CONTAINER_NAME}` at {endpoint} could not be verified: {}",
1008 e.detail
1009 ))
1010 .with_retryable(e.retryable)
1011 })?;
1012 validate_hard_site_capabilities(&caps).map_err(|e| {
1013 local_takeover_error(format!(
1014 "default local container `{DEFAULT_CONTAINER_NAME}` at {endpoint} is not takeover-ready: {}",
1015 e.detail
1016 ))
1017 })?;
1018 Ok(LocalTakeoverHost {
1019 endpoint,
1020 token_secret,
1021 })
1022}
1023
1024fn local_takeover_error(detail: String) -> Error {
1025 Error::new(
1026 ErrorCode::InvalidArgument,
1027 format!(
1028 "fetch --takeover did not receive --endpoint-url or AFHTTP_ENDPOINT_URL, and {detail}. \
1029 Start one with `afhttp container install`, inspect it with `afhttp container status`, \
1030 or pass --endpoint-url/--token-secret explicitly."
1031 ),
1032 )
1033}
1034
1035fn host_version_warning(name: &str, host_version: &str) -> Option<String> {
1036 (host_version != VERSION).then(|| host_version_mismatch_detail(name, host_version))
1037}
1038
1039fn host_version_mismatch_detail(name: &str, host_version: &str) -> String {
1040 format!(
1041 "local container `{name}` is running afhttp host version {host_version}, \
1042 but this driver is version {VERSION}. Run `afhttp container install` to recreate the \
1043 container with the matching image; the `{}` volume is reused, so the host token and \
1044 persistent profiles are preserved.",
1045 volume_name(name)
1046 )
1047}
1048
1049fn host_args_need_takeover_support(host_args: &[String]) -> bool {
1050 match host_arg_value(host_args, "--takeover-provider") {
1053 Some(value) => value != "off",
1054 None => false,
1055 }
1056}
1057
1058fn container_image_host_help(runtime: Runtime, image: &str) -> Option<String> {
1059 let argv = image_host_help_args(image);
1060 let out = capture(runtime.bin(), &argv).ok()?;
1061 if !out.status.success() {
1062 return None;
1063 }
1064 let mut help = String::new();
1065 help.push_str(&String::from_utf8_lossy(&out.stdout));
1066 help.push_str(&String::from_utf8_lossy(&out.stderr));
1067 Some(help)
1068}
1069
1070fn image_host_help_args(image: &str) -> Vec<String> {
1071 vec![
1072 "run".into(),
1073 "--rm".into(),
1074 "--entrypoint".into(),
1075 "/usr/local/bin/afhttp".into(),
1076 image.to_string(),
1077 "host".into(),
1078 "--help".into(),
1079 ]
1080}
1081
1082fn container_image_hard_site_components(
1083 runtime: Runtime,
1084 image: &str,
1085 backend: TakeoverBackend,
1086) -> bool {
1087 let argv = vec![
1088 "run".into(),
1089 "--rm".into(),
1090 "--entrypoint".into(),
1091 "/bin/sh".into(),
1092 image.to_string(),
1093 "-lc".into(),
1094 format!(
1095 "command -v {} >/dev/null 2>&1 && test -x \"${{AFHTTP_KASMVNC_BIN:-/usr/bin/Xvnc}}\" && test -d \"${{AFHTTP_KASMVNC_WEB_ROOT:-/usr/share/kasmvnc/www}}\"",
1096 backend.probe_bin
1097 ),
1098 ];
1099 capture(runtime.bin(), &argv)
1100 .map(|out| out.status.success())
1101 .unwrap_or(false)
1102}
1103
1104enum BuildSource<'a> {
1109 Embedded { ctx: &'a Path, target: &'a str },
1110 FromSource { ctx: &'a Path },
1111}
1112
1113fn build_args(
1114 image: &str,
1115 runtime: Runtime,
1116 source: BuildSource,
1117 backends: &[Backend],
1118) -> Vec<String> {
1119 let mut a: Vec<String> = vec!["build".into()];
1120 if runtime == Runtime::Apple {
1121 a.push("--platform".into());
1122 a.push("linux/arm64".into());
1123 }
1124 let ctx = match source {
1125 BuildSource::Embedded { ctx, target } => {
1126 a.push("--build-arg".into());
1127 a.push("AFHTTP_BIN_FROM=downloader".into());
1128 a.push("--build-arg".into());
1129 a.push(format!("AFHTTP_VERSION={VERSION}"));
1130 a.push("--build-arg".into());
1131 a.push(format!("AFHTTP_TARGET={target}"));
1132 ctx
1133 }
1134 BuildSource::FromSource { ctx } => {
1135 a.push("--build-arg".into());
1136 a.push("AFHTTP_BIN_FROM=builder".into());
1137 ctx
1138 }
1139 };
1140 for b in backends {
1141 a.push("--build-arg".into());
1142 a.push(format!("{}=1", b.build_arg));
1143 }
1144 a.push("-t".into());
1145 a.push(image.to_string());
1146 a.push("-f".into());
1147 a.push(
1148 ctx.join("container/docker/Dockerfile")
1149 .to_string_lossy()
1150 .into_owned(),
1151 );
1152 a.push(ctx.to_string_lossy().into_owned());
1153 a
1154}
1155
1156fn resolve_source_context(arg: Option<&str>) -> Result<PathBuf, Error> {
1158 if let Some(p) = arg {
1159 return validate_source_context(PathBuf::from(p), "--context");
1160 }
1161 let cwd = std::env::current_dir()
1162 .map_err(|e| Error::new(ErrorCode::IoError, format!("cannot read current dir: {e}")))?;
1163 if is_source_context(&cwd) {
1164 return Ok(cwd);
1165 }
1166 let manifest_dir = PathBuf::from(MANIFEST_DIR);
1167 if manifest_dir != cwd && is_source_context(&manifest_dir) {
1168 return Ok(manifest_dir);
1169 }
1170 Err(Error::new(
1171 ErrorCode::InvalidArgument,
1172 format!(
1173 "--from-source needs a source checkout: checked {} and {} \
1174 (run from the spore root or pass --context <dir>)",
1175 cwd.display(),
1176 manifest_dir.display()
1177 ),
1178 ))
1179}
1180
1181fn validate_source_context(dir: PathBuf, source: &str) -> Result<PathBuf, Error> {
1182 let dockerfile = dir.join("container/docker/Dockerfile");
1183 if !dockerfile.is_file() {
1184 return Err(Error::new(
1185 ErrorCode::InvalidArgument,
1186 format!(
1187 "--from-source {source} needs a source checkout: {} not found \
1188 (run from the spore root or pass --context <dir>)",
1189 dockerfile.display()
1190 ),
1191 ));
1192 }
1193 Ok(dir)
1194}
1195
1196fn is_source_context(dir: &Path) -> bool {
1197 dir.join("container/docker/Dockerfile").is_file()
1198}
1199
1200fn run_args(
1201 name: &str,
1202 image: &str,
1203 port: u16,
1204 profile: &str,
1205 shm_size: &str,
1206 host_args: &[String],
1207) -> Vec<String> {
1208 let mut a: Vec<String> = vec![
1209 "run".into(),
1210 "-d".into(),
1211 "--name".into(),
1212 name.to_string(),
1213 "-v".into(),
1214 format!("{}:/data", volume_name(name)),
1215 "-e".into(),
1216 format!("AFHTTP_PORT={port}"),
1217 "-e".into(),
1218 format!("AFHTTP_PROFILE={profile}"),
1219 "--shm-size".into(),
1220 shm_size.to_string(),
1221 "-p".into(),
1222 format!("127.0.0.1:{port}:{port}"),
1223 image.to_string(),
1224 ];
1225 a.extend(host_args.iter().cloned());
1226 a
1227}
1228
1229fn spawn_error(bin: &str, err: &std::io::Error) -> Error {
1232 if err.kind() == std::io::ErrorKind::NotFound {
1233 Error::new(
1234 ErrorCode::InvalidArgument,
1235 format!("container runtime `{bin}` not found on PATH"),
1236 )
1237 } else {
1238 Error::new(
1239 ErrorCode::IoError,
1240 format!("spawning `{bin}` failed: {err}"),
1241 )
1242 }
1243}
1244
1245fn exec_inherit(bin: &str, args: &[String]) -> Result<(), Error> {
1247 let status = Command::new(bin)
1248 .args(args)
1249 .status()
1250 .map_err(|e| spawn_error(bin, &e))?;
1251 if status.success() {
1252 Ok(())
1253 } else {
1254 Err(Error::new(
1255 ErrorCode::InternalError,
1256 format!("`{bin} {}` failed ({status})", args.join(" ")),
1257 ))
1258 }
1259}
1260
1261fn exec_to_log(bin: &str, args: &[String], log_file: &Path) -> Result<(), Error> {
1264 exec_to_log_impl(bin, args, log_file, true)
1265}
1266
1267fn exec_to_log_without_header(bin: &str, args: &[String], log_file: &Path) -> Result<(), Error> {
1268 exec_to_log_impl(bin, args, log_file, false)
1269}
1270
1271fn exec_to_log_impl(
1272 bin: &str,
1273 args: &[String],
1274 log_file: &Path,
1275 write_header: bool,
1276) -> Result<(), Error> {
1277 use std::io::Write;
1278
1279 let mut file = std::fs::OpenOptions::new()
1280 .create(true)
1281 .append(true)
1282 .open(log_file)
1283 .map_err(|e| {
1284 Error::new(
1285 ErrorCode::IoError,
1286 format!("open log file {}: {e}", log_file.display()),
1287 )
1288 })?;
1289 if write_header {
1290 writeln!(file, "\n$ {bin} {}", args.join(" ")).map_err(|e| {
1291 Error::new(
1292 ErrorCode::IoError,
1293 format!("write log file {}: {e}", log_file.display()),
1294 )
1295 })?;
1296 }
1297 let stdout = file.try_clone().map_err(|e| {
1298 Error::new(
1299 ErrorCode::IoError,
1300 format!("clone log file {}: {e}", log_file.display()),
1301 )
1302 })?;
1303 let stderr = file.try_clone().map_err(|e| {
1304 Error::new(
1305 ErrorCode::IoError,
1306 format!("clone log file {}: {e}", log_file.display()),
1307 )
1308 })?;
1309 let status = Command::new(bin)
1310 .args(args)
1311 .stdout(stdout)
1312 .stderr(stderr)
1313 .status()
1314 .map_err(|e| spawn_error(bin, &e))?;
1315 if status.success() {
1316 Ok(())
1317 } else {
1318 Err(Error::new(
1319 ErrorCode::InternalError,
1320 format!(
1321 "`{bin} {}` failed ({status}); full output was written to {}",
1322 args.join(" "),
1323 log_file.display()
1324 ),
1325 ))
1326 }
1327}
1328
1329fn tail_lines_from_file(path: &Path, max_lines: usize) -> Result<(Vec<String>, bool), Error> {
1330 use std::io::{Read, Seek, SeekFrom};
1331
1332 const MAX_TAIL_BYTES: u64 = 256 * 1024;
1333 let mut file = std::fs::File::open(path).map_err(|e| {
1334 Error::new(
1335 ErrorCode::IoError,
1336 format!("open container log file {}: {e}", path.display()),
1337 )
1338 })?;
1339 let len = file
1340 .metadata()
1341 .map_err(|e| {
1342 Error::new(
1343 ErrorCode::IoError,
1344 format!("stat container log file {}: {e}", path.display()),
1345 )
1346 })?
1347 .len();
1348 let start = len.saturating_sub(MAX_TAIL_BYTES);
1349 file.seek(SeekFrom::Start(start)).map_err(|e| {
1350 Error::new(
1351 ErrorCode::IoError,
1352 format!("seek container log file {}: {e}", path.display()),
1353 )
1354 })?;
1355 let mut buf = Vec::new();
1356 file.read_to_end(&mut buf).map_err(|e| {
1357 Error::new(
1358 ErrorCode::IoError,
1359 format!("read container log file {}: {e}", path.display()),
1360 )
1361 })?;
1362 let text = String::from_utf8_lossy(&buf);
1363 let mut lines: Vec<&str> = text.lines().collect();
1364 let truncated_by_bytes = start > 0;
1365 if truncated_by_bytes && !text.starts_with('\n') && !lines.is_empty() {
1366 lines.remove(0);
1367 }
1368 let truncated = truncated_by_bytes || lines.len() > max_lines;
1369 let tail_lines = lines
1370 .iter()
1371 .skip(lines.len().saturating_sub(max_lines))
1372 .map(|line| (*line).to_string())
1373 .collect();
1374 Ok((tail_lines, truncated))
1375}
1376
1377fn capture(bin: &str, args: &[String]) -> Result<std::process::Output, Error> {
1379 Command::new(bin)
1380 .args(args)
1381 .output()
1382 .map_err(|e| spawn_error(bin, &e))
1383}
1384
1385fn container_operation_log_file(name: &str) -> Result<PathBuf, Error> {
1386 let dir = std::env::temp_dir().join("afhttp-container-logs");
1387 std::fs::create_dir_all(&dir).map_err(|e| {
1388 Error::new(
1389 ErrorCode::IoError,
1390 format!("create container log dir {}: {e}", dir.display()),
1391 )
1392 })?;
1393 let safe_name: String = name
1394 .chars()
1395 .map(|c| {
1396 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_') {
1397 c
1398 } else {
1399 '_'
1400 }
1401 })
1402 .collect();
1403 Ok(dir.join(format!("{safe_name}-{}.log", uuid::Uuid::new_v4())))
1404}
1405
1406fn image_exists(runtime: Runtime, image: &str) -> bool {
1407 capture(
1408 runtime.bin(),
1409 &["image".into(), "inspect".into(), image.to_string()],
1410 )
1411 .map(|o| o.status.success())
1412 .unwrap_or(false)
1413}
1414
1415#[derive(Debug, Clone)]
1416struct ContainerState {
1417 running: bool,
1418 exit_code: Option<i64>,
1419}
1420
1421fn inspect_container_state(runtime: Runtime, name: &str) -> Option<ContainerState> {
1422 let out = capture(runtime.bin(), &["inspect".into(), name.to_string()]).ok()?;
1423 if !out.status.success() {
1424 return None;
1425 }
1426 let value: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
1427 let state = value
1428 .as_array()
1429 .and_then(|arr| arr.first())
1430 .and_then(|v| v.get("State"))
1431 .or_else(|| value.get("State"))?;
1432 Some(ContainerState {
1433 running: state
1434 .get("Running")
1435 .and_then(|v| v.as_bool())
1436 .unwrap_or(false),
1437 exit_code: state.get("ExitCode").and_then(|v| v.as_i64()),
1438 })
1439}
1440
1441fn container_running(runtime: Runtime, name: &str) -> bool {
1442 if let Some(state) = inspect_container_state(runtime, name) {
1443 return state.running;
1444 }
1445 capture(runtime.bin(), &["ps".into()])
1447 .map(|o| String::from_utf8_lossy(&o.stdout).contains(name))
1448 .unwrap_or(false)
1449}
1450
1451async fn read_token(runtime: Runtime, name: &str) -> Result<String, Error> {
1454 let argv = vec![
1455 "exec".into(),
1456 name.to_string(),
1457 "cat".into(),
1458 "/data/afhttp/host-token".into(),
1459 ];
1460 for attempt in 0..20 {
1461 if let Ok(out) = capture(runtime.bin(), &argv)
1462 && out.status.success()
1463 {
1464 let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
1465 if !token.is_empty() {
1466 return Ok(token);
1467 }
1468 }
1469 if !container_running(runtime, name) {
1470 return Err(container_launch_failure_error(
1471 runtime,
1472 name,
1473 "container exited before the host token could be read",
1474 ));
1475 }
1476 if attempt < 19 {
1477 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1478 }
1479 }
1480 Err(container_launch_failure_error(
1481 runtime,
1482 name,
1483 "host token was not available before the startup deadline",
1484 ))
1485}
1486
1487async fn wait_for_container_health(
1488 runtime: Runtime,
1489 name: &str,
1490 port: u16,
1491 token: &str,
1492) -> Result<(), Error> {
1493 let endpoint = endpoint_url(port);
1494 let client = crate::sdk::Client::connect(&endpoint)?.with_token(token.to_string());
1495 for attempt in 0..30 {
1496 if !container_running(runtime, name) {
1497 return Err(container_launch_failure_error(
1498 runtime,
1499 name,
1500 "container exited before /health became ready",
1501 ));
1502 }
1503 match client.health().await {
1504 Ok(health) if health.version != VERSION => {
1505 return Err(Error::new(
1506 ErrorCode::InternalError,
1507 format!(
1508 "container host version mismatch after startup: host={}, driver={VERSION}",
1509 health.version
1510 ),
1511 ));
1512 }
1513 Ok(health) if health.status == "ok" => return Ok(()),
1514 Ok(health) => {
1515 if let Some(backend_error) = health.backend_error {
1516 return Err(Error::new(
1517 backend_error.error_code,
1518 format!(
1519 "container host /health reported {}: {}",
1520 health.status, backend_error.error
1521 ),
1522 ));
1523 }
1524 }
1525 Err(_) => {}
1526 }
1527 if attempt < 29 {
1528 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1529 }
1530 }
1531 Err(container_launch_failure_error(
1532 runtime,
1533 name,
1534 "container host did not pass /health before the startup deadline",
1535 ))
1536}
1537
1538fn container_launch_failure_error(runtime: Runtime, name: &str, reason: &str) -> Error {
1539 let logs = container_logs_summary(runtime, name);
1540 let lower = logs.to_ascii_lowercase();
1541 let code = if lower.contains("backend_unsupported")
1542 || lower.contains("persistent profiles")
1543 || lower.contains("does not yet support")
1544 {
1545 ErrorCode::BackendUnsupported
1546 } else {
1547 ErrorCode::BrowserLaunchFailed
1548 };
1549 let mut detail = format!("container host launch failed: {reason}");
1550 if !logs.is_empty() {
1551 detail.push_str("; recent logs: ");
1552 detail.push_str(&logs);
1553 }
1554 Error::new(code, detail)
1555}
1556
1557fn container_logs_summary(runtime: Runtime, name: &str) -> String {
1558 let Ok(out) = capture(runtime.bin(), &["logs".into(), name.to_string()]) else {
1559 return String::new();
1560 };
1561 let mut combined = String::new();
1562 combined.push_str(&String::from_utf8_lossy(&out.stdout));
1563 combined.push_str(&String::from_utf8_lossy(&out.stderr));
1564 let lines: Vec<&str> = combined.lines().rev().take(60).collect();
1565 let mut summary = lines.into_iter().rev().collect::<Vec<_>>().join(" | ");
1566 const MAX: usize = 4000;
1567 if summary.len() > MAX {
1568 let start = summary.len() - MAX;
1569 summary = format!("...{}", &summary[start..]);
1570 }
1571 summary
1572}
1573
1574fn build_failed_error(target: &str, log_file: &Path) -> Error {
1575 Error::new(
1576 ErrorCode::InternalError,
1577 format!(
1578 "image build failed. If v{VERSION} has no published release asset for \
1579 {target}, build from a source checkout instead: \
1580 `afhttp container install --from-source` (or \
1581 docker compose -f container/docker/compose.yaml up --build). Full output: {}",
1582 log_file.display()
1583 ),
1584 )
1585}
1586
1587fn cache_context_dir() -> Result<PathBuf, Error> {
1590 let base = std::env::var_os("XDG_CACHE_HOME")
1591 .map(PathBuf::from)
1592 .filter(|p| p.is_absolute())
1593 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
1594 .ok_or_else(|| {
1595 Error::new(
1596 ErrorCode::IoError,
1597 "cannot resolve cache dir: set HOME or XDG_CACHE_HOME",
1598 )
1599 })?;
1600 Ok(base.join("afhttp").join("container").join(VERSION))
1601}
1602
1603fn write_build_context() -> Result<PathBuf, Error> {
1604 let root = cache_context_dir()?;
1605 let dir = root.join("container").join("docker");
1609 std::fs::create_dir_all(&dir)?;
1610 std::fs::write(dir.join("Dockerfile"), DOCKERFILE)?;
1611 std::fs::write(dir.join("install-backends.sh"), INSTALL_BACKENDS)?;
1612 std::fs::write(dir.join("entrypoint.sh"), ENTRYPOINT)?;
1613 Ok(root)
1614}
1615
1616#[cfg(test)]
1617mod tests {
1618 use super::*;
1619
1620 #[test]
1621 fn runtime_from_str_parses_and_rejects() {
1622 assert_eq!(runtime_from_str("docker").unwrap(), Runtime::Docker);
1623 assert_eq!(runtime_from_str("podman").unwrap(), Runtime::Podman);
1624 assert_eq!(runtime_from_str("apple").unwrap(), Runtime::Apple);
1625 assert_eq!(runtime_from_str("container").unwrap(), Runtime::Apple);
1626 assert_eq!(
1627 runtime_from_str("nerdctl").unwrap_err().error_code,
1628 ErrorCode::InvalidArgument
1629 );
1630 }
1631
1632 #[test]
1633 fn explicit_runtime_wins_over_detection() {
1634 assert_eq!(
1635 resolve_runtime(Some(Runtime::Apple)).unwrap(),
1636 Runtime::Apple
1637 );
1638 assert_eq!(
1639 resolve_runtime(Some(Runtime::Docker)).unwrap(),
1640 Runtime::Docker
1641 );
1642 }
1643
1644 #[test]
1645 fn target_triple_tracks_runtime_and_arch() {
1646 assert_eq!(
1647 target_triple(Runtime::Apple, "x86_64"),
1648 "aarch64-unknown-linux-gnu"
1649 );
1650 assert_eq!(
1651 target_triple(Runtime::Docker, "aarch64"),
1652 "aarch64-unknown-linux-gnu"
1653 );
1654 assert_eq!(
1655 target_triple(Runtime::Docker, "x86_64"),
1656 "x86_64-unknown-linux-gnu"
1657 );
1658 assert_eq!(
1660 target_triple(Runtime::Podman, "aarch64"),
1661 "aarch64-unknown-linux-gnu"
1662 );
1663 assert_eq!(
1664 target_triple(Runtime::Podman, "x86_64"),
1665 "x86_64-unknown-linux-gnu"
1666 );
1667 }
1668
1669 #[test]
1670 fn backend_names_map_to_build_args() {
1671 let resolved = resolve_backends(&["camoufox".into(), "brave".into(), "kasmvnc".into()]);
1672 assert_eq!(resolved.len(), 3);
1673 assert_eq!(resolved[0].build_arg, "WITH_CAMOUFOX");
1674 assert_eq!(resolved[1].build_arg, "WITH_BRAVE");
1675 assert_eq!(resolved[2].build_arg, "WITH_KASMVNC");
1676
1677 let deduped = resolve_backends(&["camoufox".into(), "camoufox".into()]);
1679 assert_eq!(deduped.len(), 1);
1680 }
1681
1682 #[test]
1684 fn every_registry_component_maps_to_a_build_arg() {
1685 for component in crate::cli::spec::CONTAINER_COMPONENTS {
1686 let resolved = resolve_backends(&[component.to_string()]);
1687 assert_eq!(resolved.len(), 1, "{component}");
1688 }
1689 }
1690
1691 #[test]
1692 fn install_precheck_rejects_camoufox_with_persistent_profile() {
1693 let args = InstallArgs {
1694 common: CommonArgs {
1695 runtime: Some(Runtime::Docker),
1696 name: "afhttp-host".into(),
1697 },
1698 port: 9222,
1699 profile: Some("work".into()),
1700 shm_size: Some("1g".into()),
1701 takeover_provider: Takeover::Off,
1702 with: vec!["camoufox".into()],
1703 rebuild: false,
1704 from_source: false,
1705 context: None,
1706 host_args: vec!["--browser".into(), "camoufox".into()],
1707 reveal_token_secret: false,
1708 };
1709 let backends = resolve_backends(&args.with);
1710 let err = validate_install_args(&args, &backends).unwrap_err();
1711 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
1712 assert!(err.detail.contains("--profile -"));
1713 }
1714
1715 #[test]
1716 fn install_precheck_allows_camoufox_ephemeral_profile() {
1717 let args = InstallArgs {
1718 common: CommonArgs {
1719 runtime: Some(Runtime::Docker),
1720 name: "afhttp-host".into(),
1721 },
1722 port: 9222,
1723 profile: Some("-".into()),
1724 shm_size: Some("1g".into()),
1725 takeover_provider: Takeover::Off,
1726 with: vec!["camoufox".into()],
1727 rebuild: false,
1728 from_source: false,
1729 context: None,
1730 host_args: vec!["--browser=camoufox".into()],
1731 reveal_token_secret: false,
1732 };
1733 let backends = resolve_backends(&args.with);
1734 validate_install_args(&args, &backends).unwrap();
1735 }
1736
1737 #[test]
1738 fn install_result_exposes_hard_site_flag() {
1739 let value = serde_json::to_value(InstallResult {
1740 runtime: "docker",
1741 image: "afhttp-host:test".into(),
1742 container: "afhttp-host".into(),
1743 endpoint: "ws://127.0.0.1:9222".into(),
1744 profile: "work".into(),
1745 token_available: true,
1746 token_source: "container_volume",
1747 token_secret: None,
1748 client_command: "afhttp fetch https://example.com".into(),
1749 log_file: Some(PathBuf::from("/tmp/afhttp-container-logs/install.log")),
1750 backends: vec!["brave".into(), "kasmvnc".into()],
1751 takeover_ready: true,
1752 })
1753 .unwrap();
1754 assert_eq!(value["takeover_ready"], true);
1755 assert_eq!(value["token_available"], true);
1756 assert_eq!(value["token_source"], "container_volume");
1757 assert!(value.get("token_secret").is_none());
1758 assert!(value.get("token").is_none());
1759 }
1760
1761 #[test]
1762 fn status_result_hides_token_secret_by_default() {
1763 let value = serde_json::to_value(StatusResult {
1764 runtime: "docker",
1765 container: "afhttp-host".into(),
1766 running: true,
1767 endpoint: "ws://127.0.0.1:9222".into(),
1768 driver_version: VERSION,
1769 host_version: Some(VERSION.into()),
1770 version_match: Some(true),
1771 profile_kind: Some("persistent".into()),
1772 profile: Some("work".into()),
1773 profile_backend: Some("brave".into()),
1774 backend: Some(BackendFamily {
1775 family: "brave".into(),
1776 version: "1".into(),
1777 }),
1778 provider: Some("kasmvnc".into()),
1779 takeover_ready: Some(true),
1780 token_available: true,
1781 token_source: Some("container_volume"),
1782 token_secret: None,
1783 client_command: Some("afhttp fetch https://example.com".into()),
1784 exit_code: None,
1785 log_summary: None,
1786 warnings: Vec::new(),
1787 })
1788 .unwrap();
1789 assert!(value.get("token_secret").is_none());
1790 assert_eq!(value["token_available"], true);
1791 assert_eq!(value["token_source"], "container_volume");
1792 assert_eq!(value["profile_kind"], "persistent");
1793 assert_eq!(value["profile_backend"], "brave");
1794 assert_eq!(value["backend"]["family"], "brave");
1795 assert_eq!(value["takeover_ready"], true);
1796 assert_eq!(value["driver_version"], VERSION);
1797 assert_eq!(value["host_version"], VERSION);
1798 assert_eq!(value["version_match"], true);
1799 assert!(value.get("token").is_none());
1800 }
1801
1802 #[test]
1803 fn status_result_can_report_exited_container_diagnostics() {
1804 let value = serde_json::to_value(StatusResult {
1805 runtime: "docker",
1806 container: "afhttp-host".into(),
1807 running: false,
1808 endpoint: "ws://127.0.0.1:9222".into(),
1809 driver_version: VERSION,
1810 host_version: None,
1811 version_match: None,
1812 profile_kind: None,
1813 profile: None,
1814 profile_backend: None,
1815 backend: None,
1816 provider: None,
1817 takeover_ready: None,
1818 token_available: false,
1819 token_source: None,
1820 token_secret: None,
1821 client_command: None,
1822 exit_code: Some(42),
1823 log_summary: Some("browser stderr tail".into()),
1824 warnings: Vec::new(),
1825 })
1826 .unwrap();
1827 assert_eq!(value["exit_code"], 42);
1828 assert_eq!(value["log_summary"], "browser stderr tail");
1829 assert_eq!(value["driver_version"], VERSION);
1830 assert!(value.get("host_version").is_none());
1831 assert!(value.get("version_match").is_none());
1832 assert!(value.get("client_command").is_none());
1833 }
1834
1835 #[test]
1836 fn host_version_warning_points_to_profile_preserving_reinstall() {
1837 let warning = host_version_warning(DEFAULT_CONTAINER_NAME, "0.5.0").expect("warning");
1838 assert!(warning.contains("0.5.0"));
1839 assert!(warning.contains(VERSION));
1840 assert!(warning.contains("afhttp container install"));
1841 assert!(warning.contains("persistent profiles are preserved"));
1842 assert!(host_version_warning(DEFAULT_CONTAINER_NAME, VERSION).is_none());
1843 }
1844
1845 #[test]
1846 fn local_takeover_error_names_autodiscovery_and_manual_commands() {
1847 let err =
1848 local_takeover_error("default local container `afhttp-host` is not running".into());
1849 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
1850 assert!(err.detail.contains("afhttp-host"));
1851 assert!(err.detail.contains("afhttp container install"));
1852 assert!(err.detail.contains("--endpoint-url/--token-secret"));
1853 }
1854
1855 #[test]
1856 fn entrypoint_generates_base64url_token_secret() {
1857 assert!(ENTRYPOINT.contains("AFHTTP_TOKEN_SECRET"));
1858 let legacy_env_probe = ["AFHTTP", "TOKEN:-"].join("_");
1859 assert!(!ENTRYPOINT.contains(&legacy_env_probe));
1860 assert!(ENTRYPOINT.contains("head -c 32 /dev/urandom"));
1861 assert!(ENTRYPOINT.contains("base64 | tr '+/' '-_' | tr -d '=\\n'"));
1862 assert!(!ENTRYPOINT.contains("od -An -N32 -tx1"));
1863 }
1864
1865 #[test]
1866 fn hard_site_install_defaults_expand_to_display_brave_preset() {
1867 let mut args = InstallArgs {
1868 common: CommonArgs {
1869 runtime: Some(Runtime::Docker),
1870 name: "afhttp-host".into(),
1871 },
1872 port: 9222,
1873 profile: None,
1874 shm_size: None,
1875 takeover_provider: Takeover::On {
1876 provider: crate::host::bootstrap::TakeoverProviderKind::KasmVnc,
1877 },
1878 with: Vec::new(),
1879 rebuild: false,
1880 from_source: false,
1881 context: None,
1882 host_args: Vec::new(),
1883 reveal_token_secret: false,
1884 };
1885 apply_hard_site_defaults(&mut args);
1886 let backends = resolve_backends(&args.with);
1887 validate_install_args(&args, &backends).unwrap();
1888 assert_eq!(effective_profile(&args), "-");
1889 assert_eq!(effective_shm_size(&args), "2g");
1890 assert_eq!(
1891 backends.iter().map(|b| b.name).collect::<Vec<_>>(),
1892 vec!["brave", "kasmvnc"]
1893 );
1894 assert_eq!(
1895 args.host_args,
1896 vec![
1897 "--browser".to_string(),
1898 "brave".to_string(),
1899 "--takeover-provider".to_string(),
1900 "kasmvnc".to_string(),
1901 ]
1902 );
1903 }
1904
1905 #[test]
1906 fn hard_site_install_keeps_valid_explicit_overrides_and_shm() {
1907 let mut args = InstallArgs {
1908 common: CommonArgs {
1909 runtime: Some(Runtime::Docker),
1910 name: "afhttp-host".into(),
1911 },
1912 port: 9222,
1913 profile: Some("work".into()),
1914 shm_size: Some("3g".into()),
1915 takeover_provider: Takeover::On {
1916 provider: crate::host::bootstrap::TakeoverProviderKind::KasmVnc,
1917 },
1918 with: vec!["kasmvnc".into()],
1919 rebuild: false,
1920 from_source: false,
1921 context: None,
1922 host_args: vec![
1923 "--browser=brave".into(),
1924 "--takeover-provider=kasmvnc".into(),
1925 ],
1926 reveal_token_secret: false,
1927 };
1928 apply_hard_site_defaults(&mut args);
1929 let backends = resolve_backends(&args.with);
1930 validate_install_args(&args, &backends).unwrap();
1931 assert_eq!(effective_shm_size(&args), "3g");
1932 assert_eq!(
1933 host_arg_value(&args.host_args, "--browser").as_deref(),
1934 Some("brave")
1935 );
1936 assert_eq!(
1937 backends.iter().map(|b| b.name).collect::<Vec<_>>(),
1938 vec!["kasmvnc", "brave"]
1939 );
1940 }
1941
1942 #[test]
1949 fn hard_site_install_keeps_user_browser_args_and_adds_none() {
1950 let mut args = InstallArgs {
1951 common: CommonArgs {
1952 runtime: Some(Runtime::Docker),
1953 name: "afhttp-host".into(),
1954 },
1955 port: 9222,
1956 profile: None,
1957 shm_size: None,
1958 takeover_provider: Takeover::On {
1959 provider: crate::host::bootstrap::TakeoverProviderKind::KasmVnc,
1960 },
1961 with: Vec::new(),
1962 rebuild: false,
1963 from_source: false,
1964 context: None,
1965 host_args: vec!["--browser-arg".into(), "--lang=zh-CN".into()],
1966 reveal_token_secret: false,
1967 };
1968 apply_hard_site_defaults(&mut args);
1969 let backends = resolve_backends(&args.with);
1970 validate_install_args(&args, &backends).unwrap();
1971 assert!(
1972 args.host_args.iter().any(|a| a == "--lang=zh-CN"),
1973 "the caller's own browser arg must survive: {:?}",
1974 args.host_args
1975 );
1976 assert_eq!(
1977 args.host_args
1978 .iter()
1979 .filter(|a| a.starts_with("--browser-arg"))
1980 .count(),
1981 1,
1982 "the takeover preset must contribute no --browser-arg of its own: {:?}",
1983 args.host_args
1984 );
1985 assert!(
1986 !args
1987 .host_args
1988 .iter()
1989 .any(|a| a.contains("AutomationControlled")),
1990 "the AutomationControlled flag must not come back: {:?}",
1991 args.host_args
1992 );
1993 }
1994
1995 #[test]
1996 fn hard_site_install_allows_persistent_profile_with_brave() {
1997 let mut args = InstallArgs {
1998 common: CommonArgs {
1999 runtime: Some(Runtime::Docker),
2000 name: "afhttp-host".into(),
2001 },
2002 port: 9222,
2003 profile: Some("work".into()),
2004 shm_size: None,
2005 takeover_provider: Takeover::On {
2006 provider: crate::host::bootstrap::TakeoverProviderKind::KasmVnc,
2007 },
2008 with: Vec::new(),
2009 rebuild: false,
2010 from_source: false,
2011 context: None,
2012 host_args: vec!["--browser".into(), "brave".into()],
2013 reveal_token_secret: false,
2014 };
2015 apply_hard_site_defaults(&mut args);
2016 let backends = resolve_backends(&args.with);
2017 validate_install_args(&args, &backends).unwrap();
2018 assert_eq!(effective_profile(&args), "work");
2019 }
2020
2021 #[test]
2022 fn hard_site_install_allows_ephemeral_initial_profile() {
2023 let mut args = InstallArgs {
2024 common: CommonArgs {
2025 runtime: Some(Runtime::Docker),
2026 name: "afhttp-host".into(),
2027 },
2028 port: 9222,
2029 profile: Some("-".into()),
2030 shm_size: None,
2031 takeover_provider: Takeover::On {
2032 provider: crate::host::bootstrap::TakeoverProviderKind::KasmVnc,
2033 },
2034 with: Vec::new(),
2035 rebuild: false,
2036 from_source: false,
2037 context: None,
2038 host_args: vec!["--browser".into(), "brave".into()],
2039 reveal_token_secret: false,
2040 };
2041 apply_hard_site_defaults(&mut args);
2042 let backends = resolve_backends(&args.with);
2043 validate_install_args(&args, &backends).unwrap();
2044 assert_eq!(effective_profile(&args), "-");
2045 }
2046
2047 #[test]
2048 fn hard_site_install_rejects_non_takeover_browser_override() {
2049 let mut args = InstallArgs {
2050 common: CommonArgs {
2051 runtime: Some(Runtime::Docker),
2052 name: "afhttp-host".into(),
2053 },
2054 port: 9222,
2055 profile: None,
2056 shm_size: None,
2057 takeover_provider: Takeover::On {
2058 provider: crate::host::bootstrap::TakeoverProviderKind::KasmVnc,
2059 },
2060 with: Vec::new(),
2061 rebuild: false,
2062 from_source: false,
2063 context: None,
2064 host_args: vec!["--browser".into(), "chromium".into()],
2065 reveal_token_secret: false,
2066 };
2067 apply_hard_site_defaults(&mut args);
2068 let backends = resolve_backends(&args.with);
2069 let err = validate_install_args(&args, &backends).unwrap_err();
2070 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
2071 assert!(err.detail.contains("--browser <brave|chrome>"));
2072 assert!(err.detail.contains("got `--browser chromium`"));
2073 assert!(err.detail.contains("afhttp container install"));
2074 }
2075
2076 #[test]
2077 fn hard_site_install_rejects_missing_browser_value() {
2078 let mut args = InstallArgs {
2079 common: CommonArgs {
2080 runtime: Some(Runtime::Docker),
2081 name: "afhttp-host".into(),
2082 },
2083 port: 9222,
2084 profile: None,
2085 shm_size: None,
2086 takeover_provider: Takeover::On {
2087 provider: crate::host::bootstrap::TakeoverProviderKind::KasmVnc,
2088 },
2089 with: Vec::new(),
2090 rebuild: false,
2091 from_source: false,
2092 context: None,
2093 host_args: vec!["--browser".into()],
2094 reveal_token_secret: false,
2095 };
2096 apply_hard_site_defaults(&mut args);
2097 let backends = resolve_backends(&args.with);
2098 let err = validate_install_args(&args, &backends).unwrap_err();
2099 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
2100 assert!(err.detail.contains("--browser <brave|chrome>"));
2101 }
2102
2103 #[test]
2106 fn hard_site_install_expands_chrome_override_to_chrome_component() {
2107 let mut args = InstallArgs {
2108 common: CommonArgs {
2109 runtime: Some(Runtime::Docker),
2110 name: "afhttp-host".into(),
2111 },
2112 port: 9222,
2113 profile: None,
2114 shm_size: None,
2115 takeover_provider: Takeover::On {
2116 provider: crate::host::bootstrap::TakeoverProviderKind::KasmVnc,
2117 },
2118 with: Vec::new(),
2119 rebuild: false,
2120 from_source: false,
2121 context: None,
2122 host_args: vec!["--browser".into(), "chrome".into()],
2123 reveal_token_secret: false,
2124 };
2125 apply_hard_site_defaults(&mut args);
2126 let backends = resolve_backends(&args.with);
2127 validate_install_args(&args, &backends).unwrap();
2128 assert_eq!(
2129 backends.iter().map(|b| b.name).collect::<Vec<_>>(),
2130 vec!["chrome", "kasmvnc"]
2131 );
2132 assert_eq!(
2133 host_arg_value(&args.host_args, "--browser").as_deref(),
2134 Some("chrome")
2135 );
2136 }
2137
2138 #[test]
2141 fn takeover_capability_gate_accepts_every_takeover_backend() {
2142 for backend in TAKEOVER_BACKENDS {
2143 assert!(
2144 takeover_backend(backend.browser).is_some(),
2145 "{} should be a takeover backend",
2146 backend.browser
2147 );
2148 assert!(
2149 BACKENDS.iter().any(|b| b.name == backend.component),
2150 "{} needs a --with image component",
2151 backend.component
2152 );
2153 }
2154 assert!(takeover_backend("chromium").is_none());
2155 }
2156
2157 #[test]
2158 fn takeover_host_args_trigger_image_support_probe() {
2159 assert!(host_args_need_takeover_support(&[
2160 "--takeover-provider".into(),
2161 "kasmvnc".into()
2162 ]));
2163 assert!(host_args_need_takeover_support(&[
2164 "--takeover-provider=kasmvnc".into()
2165 ]));
2166 assert!(!host_args_need_takeover_support(&[
2167 "--takeover-provider".into(),
2168 "off".into()
2169 ]));
2170 assert!(!host_args_need_takeover_support(&[
2171 "--browser".into(),
2172 "brave".into()
2173 ]));
2174 }
2175
2176 #[test]
2177 fn image_host_help_args_bypasses_entrypoint() {
2178 let args = image_host_help_args("afhttp-host:dev");
2179 assert_eq!(args[0], "run");
2180 assert!(args.contains(&"--rm".to_string()));
2181 assert!(args.contains(&"--entrypoint".to_string()));
2182 assert!(args.contains(&"/usr/local/bin/afhttp".to_string()));
2183 assert_eq!(args[args.len() - 3], "afhttp-host:dev");
2184 assert_eq!(args[args.len() - 2], "host");
2185 assert_eq!(args[args.len() - 1], "--help");
2186 }
2187
2188 #[test]
2189 fn embedded_build_args_include_version_target_and_apple_platform() {
2190 let ctx = PathBuf::from("/cache/ctx");
2191 let backends = resolve_backends(&["lightpanda".into()]);
2192 let docker = build_args(
2193 "afhttp-host:1.2.3",
2194 Runtime::Docker,
2195 BuildSource::Embedded {
2196 ctx: &ctx,
2197 target: "x86_64-unknown-linux-gnu",
2198 },
2199 &backends,
2200 );
2201 assert_eq!(docker[0], "build");
2202 assert!(!docker.contains(&"--platform".to_string()));
2203 assert!(docker.contains(&"AFHTTP_BIN_FROM=downloader".to_string()));
2204 assert!(docker.contains(&format!("AFHTTP_VERSION={VERSION}")));
2205 assert!(docker.contains(&"AFHTTP_TARGET=x86_64-unknown-linux-gnu".to_string()));
2206 assert!(docker.contains(&"WITH_LIGHTPANDA=1".to_string()));
2207 assert_eq!(
2208 docker[docker.len() - 2],
2209 "/cache/ctx/container/docker/Dockerfile"
2210 );
2211 assert_eq!(docker.last().unwrap(), "/cache/ctx");
2212
2213 let apple = build_args(
2214 "afhttp-host:1.2.3",
2215 Runtime::Apple,
2216 BuildSource::Embedded {
2217 ctx: &ctx,
2218 target: "aarch64-unknown-linux-gnu",
2219 },
2220 &[],
2221 );
2222 let pos = apple.iter().position(|a| a == "--platform").unwrap();
2223 assert_eq!(apple[pos + 1], "linux/arm64");
2224 }
2225
2226 #[test]
2227 fn from_source_build_args_use_canonical_dockerfile_no_release_args() {
2228 let repo = PathBuf::from("/repo");
2229 let backends = resolve_backends(&["camoufox".into()]);
2230 let args = build_args(
2231 "afhttp-host:1.2.3",
2232 Runtime::Podman,
2233 BuildSource::FromSource { ctx: &repo },
2234 &backends,
2235 );
2236 assert!(args.contains(&"AFHTTP_BIN_FROM=builder".to_string()));
2238 assert!(!args.iter().any(|a| a.starts_with("AFHTTP_VERSION=")));
2239 assert!(!args.iter().any(|a| a.starts_with("AFHTTP_TARGET=")));
2240 assert!(args.contains(&"WITH_CAMOUFOX=1".to_string()));
2241 assert_eq!(args[args.len() - 2], "/repo/container/docker/Dockerfile");
2242 assert_eq!(args.last().unwrap(), "/repo");
2243 assert!(!args.contains(&"--platform".to_string()));
2245 }
2246
2247 #[test]
2248 fn run_args_publish_loopback_and_pass_host_args() {
2249 let a = run_args(
2250 "afhttp-host",
2251 "afhttp-host:1.2.3",
2252 9222,
2253 "work",
2254 "1g",
2255 &["--browser".into(), "camoufox".into()],
2256 );
2257 assert!(a.contains(&"afhttp-host-data:/data".to_string()));
2258 assert!(a.contains(&"AFHTTP_PORT=9222".to_string()));
2259 assert!(a.contains(&"AFHTTP_PROFILE=work".to_string()));
2260 assert!(a.contains(&"127.0.0.1:9222:9222".to_string()));
2261 let img = a.iter().position(|x| x == "afhttp-host:1.2.3").unwrap();
2263 let br = a.iter().position(|x| x == "--browser").unwrap();
2264 assert!(img < br);
2265 }
2266
2267 #[test]
2268 fn client_command_uses_loopback_endpoint() {
2269 let cmd = client_command(9333);
2270 assert!(cmd.contains("--endpoint-url ws://127.0.0.1:9333"));
2271 assert!(cmd.contains("AFHTTP_TOKEN_SECRET=<host-token>"));
2272 assert!(!cmd.contains("deadbeef"));
2273 }
2274
2275 #[test]
2276 fn build_failure_error_points_at_compose_fallback() {
2277 let err = build_failed_error(
2278 "aarch64-unknown-linux-gnu",
2279 Path::new("/tmp/afhttp-container-logs/build.log"),
2280 );
2281 assert_eq!(err.error_code, ErrorCode::InternalError);
2282 assert!(err.detail.contains("compose"));
2283 assert!(err.detail.contains("aarch64-unknown-linux-gnu"));
2284 assert!(err.detail.contains("build.log"));
2285 }
2286
2287 #[test]
2288 fn tail_lines_from_file_reports_truncation_without_full_read() {
2289 let dir = tempfile::tempdir().unwrap();
2290 let path = dir.path().join("container.log");
2291 std::fs::write(&path, "one\ntwo\nthree\n").unwrap();
2292 let (tail, truncated) = tail_lines_from_file(&path, 2).unwrap();
2293 assert_eq!(tail, vec!["two".to_string(), "three".to_string()]);
2294 assert!(truncated);
2295 }
2296}