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