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