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