Skip to main content

agent_first_http/cli/cmd/
container.rs

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