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