Skip to main content

agent_first_http/cli/cmd/
container.rs

1//! `afhttp container` subcommand. Builds the host image and runs it under
2//! Docker, Podman, or Apple Container — one command to stand up a long-lived
3//! afhttp *host* locally, the orchestration counterpart to `afhttp host` (the
4//! in-container browser process). It embeds the canonical `container/docker/
5//! Dockerfile` and by default selects its `downloader` stage, which pulls the
6//! matching prebuilt release (version hard-pinned to this binary) — so a
7//! brew-only user needs no source tree. `--from-source` instead selects the
8//! `builder` stage to compile from a checkout. See docs/deployment.md.
9
10use std::path::{Path, PathBuf};
11use std::process::Command;
12
13use clap::{Args as ClapArgs, Subcommand, ValueEnum};
14use serde::Serialize;
15
16use crate::cli::output;
17use crate::shared::error::{Error, ErrorCode};
18
19/// Build context embedded in the binary and written to the cache dir at
20/// `install` time. It is the SAME canonical Dockerfile used for from-source
21/// builds — the embedded path just selects its `downloader` stage via
22/// `--build-arg AFHTTP_BIN_FROM=downloader` (single source of truth, no fork).
23const DOCKERFILE: &str = include_str!("../../../container/docker/Dockerfile");
24const INSTALL_BACKENDS: &str = include_str!("../../../container/docker/install-backends.sh");
25const ENTRYPOINT: &str = include_str!("../../../container/docker/entrypoint.sh");
26
27/// This binary's version — the image downloads exactly this release.
28const VERSION: &str = env!("CARGO_PKG_VERSION");
29/// Source checkout used to compile this binary. Useful when `--from-source` is
30/// requested from a different working directory, such as an agent scratch dir.
31const MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR");
32/// Default container name and image repository.
33const DEFAULT_NAME: &str = "afhttp-host";
34const IMAGE_REPO: &str = "afhttp-host";
35
36#[derive(ClapArgs, Debug)]
37pub struct Args {
38    #[command(subcommand)]
39    pub sub: ContainerSub,
40}
41
42#[derive(Subcommand, Debug)]
43pub enum ContainerSub {
44    /// Build the host image if missing and run the container; print the client command.
45    Install(InstallArgs),
46    /// Stop and remove the container (--purge also removes the image and cache).
47    Uninstall(UninstallArgs),
48    /// Report whether the host is running, with its endpoint and client command.
49    Status(StatusArgs),
50    /// Stream the container logs (raw passthrough, not a JSON envelope).
51    Logs(LogsArgs),
52}
53
54/// Flags shared by every subcommand.
55#[derive(ClapArgs, Debug)]
56pub struct CommonArgs {
57    /// Container runtime: docker, podman, or apple (auto-detected if omitted).
58    #[arg(long, value_enum)]
59    pub runtime: Option<Runtime>,
60    /// Container name.
61    #[arg(long, default_value = DEFAULT_NAME)]
62    pub name: String,
63}
64
65#[derive(ClapArgs, Debug)]
66pub struct InstallArgs {
67    #[command(flatten)]
68    pub common: CommonArgs,
69    /// Host CDP port, published on 127.0.0.1.
70    #[arg(long, default_value_t = 9222)]
71    pub port: u16,
72    /// Profile name inside the container.
73    #[arg(long, default_value = "work")]
74    pub profile: String,
75    /// Chromium /dev/shm size.
76    #[arg(long = "shm-size", default_value = "1g")]
77    pub shm_size: String,
78    /// Optional backend to build in (repeatable): chrome-headless-shell,
79    /// lightpanda, fingerprint-chromium, camoufox, kasmvnc.
80    #[arg(long = "with", value_name = "BACKEND")]
81    pub with: Vec<String>,
82    /// Rebuild the image even if it already exists.
83    #[arg(long)]
84    pub rebuild: bool,
85    /// Build the full image from a source checkout (container/docker/Dockerfile)
86    /// instead of downloading the prebuilt release. Needs the source tree.
87    #[arg(long = "from-source")]
88    pub from_source: bool,
89    /// Source checkout to build from with --from-source (default: current dir,
90    /// then the checkout this afhttp binary was built from).
91    #[arg(long, value_name = "DIR")]
92    pub context: Option<String>,
93    /// Extra args passed through to `afhttp host` inside the container.
94    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
95    pub host_args: Vec<String>,
96}
97
98#[derive(ClapArgs, Debug)]
99pub struct UninstallArgs {
100    #[command(flatten)]
101    pub common: CommonArgs,
102    /// Also remove the built image and the cached build context.
103    #[arg(long)]
104    pub purge: bool,
105}
106
107#[derive(ClapArgs, Debug)]
108pub struct StatusArgs {
109    #[command(flatten)]
110    pub common: CommonArgs,
111    /// Published host port, used to format the endpoint and client command.
112    #[arg(long, default_value_t = 9222)]
113    pub port: u16,
114}
115
116#[derive(ClapArgs, Debug)]
117pub struct LogsArgs {
118    #[command(flatten)]
119    pub common: CommonArgs,
120    /// Follow the log output.
121    #[arg(long, short = 'f')]
122    pub follow: bool,
123}
124
125/// Container runtime selector. Parsed from `--runtime` (clap `ValueEnum`) and
126/// from `AFHTTP_CONTAINER_RUNTIME` via [`runtime_from_str`].
127#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
128pub enum Runtime {
129    Docker,
130    Podman,
131    /// Apple's `container` CLI. Accepts `apple` or `container` on the
132    /// command line; its binary is `container` (see [`Runtime::bin`]).
133    #[value(alias = "container")]
134    Apple,
135}
136
137impl Runtime {
138    /// The runtime's CLI binary name.
139    fn bin(self) -> &'static str {
140        match self {
141            Runtime::Docker => "docker",
142            Runtime::Podman => "podman",
143            Runtime::Apple => "container",
144        }
145    }
146
147    /// Human label used in output and errors.
148    fn label(self) -> &'static str {
149        match self {
150            Runtime::Docker => "docker",
151            Runtime::Podman => "podman",
152            Runtime::Apple => "apple",
153        }
154    }
155}
156
157pub async fn run(args: Args) -> Result<(), Error> {
158    match args.sub {
159        ContainerSub::Install(a) => install(a).await,
160        ContainerSub::Uninstall(a) => uninstall(a),
161        ContainerSub::Status(a) => status(a).await,
162        ContainerSub::Logs(a) => logs(a),
163    }
164}
165
166// ── install ────────────────────────────────────────────────────────────────
167
168#[derive(Serialize)]
169struct InstallResult {
170    runtime: &'static str,
171    image: String,
172    container: String,
173    endpoint: String,
174    profile: String,
175    token: String,
176    client_command: String,
177    backends: Vec<String>,
178}
179
180async fn install(args: InstallArgs) -> Result<(), Error> {
181    let runtime = resolve_runtime(args.common.runtime)?;
182    let backends = resolve_backends(&args.with)?;
183    validate_install_args(&args, &backends)?;
184    let image = image_tag();
185
186    start_daemon(runtime);
187
188    // --from-source always rebuilds (the canonical Dockerfile compiles afhttp);
189    // the embedded path reuses a cached image unless --rebuild is set.
190    if args.from_source {
191        let ctx = resolve_source_context(args.context.as_deref())?;
192        let build = build_args(
193            &image,
194            runtime,
195            BuildSource::FromSource { ctx: &ctx },
196            &backends,
197        );
198        exec_inherit(runtime.bin(), &build)?;
199    } else if args.rebuild || !image_exists(runtime, &image) {
200        let ctx = write_build_context()?;
201        let target = target_triple(runtime, std::env::consts::ARCH);
202        let build = build_args(
203            &image,
204            runtime,
205            BuildSource::Embedded { ctx: &ctx, target },
206            &backends,
207        );
208        exec_inherit(runtime.bin(), &build).map_err(|_| build_failed_error(target))?;
209    }
210    validate_container_image_host_args(runtime, &image, &args.host_args)?;
211
212    // Recreate cleanly. The profile + token live in the named volume, so the
213    // token is stable across recreation.
214    let _ = capture(runtime.bin(), &["stop".into(), args.common.name.clone()]);
215    let _ = capture(runtime.bin(), &["rm".into(), args.common.name.clone()]);
216
217    let run = run_args(
218        &args.common.name,
219        &image,
220        args.port,
221        &args.profile,
222        &args.shm_size,
223        &args.host_args,
224    );
225    exec_inherit(runtime.bin(), &run)?;
226
227    let token = read_token(runtime, &args.common.name).await?;
228    let endpoint = endpoint_url(args.port);
229    wait_for_container_health(runtime, &args.common.name, args.port, &token).await?;
230    output::emit(
231        "container_install",
232        &InstallResult {
233            runtime: runtime.label(),
234            image,
235            container: args.common.name.clone(),
236            endpoint,
237            profile: args.profile.clone(),
238            client_command: client_command(args.port, &token),
239            token,
240            backends: backends.iter().map(|b| b.name.to_string()).collect(),
241        },
242    )
243}
244
245// ── uninstall ──────────────────────────────────────────────────────────────
246
247#[derive(Serialize)]
248struct UninstallResult {
249    runtime: &'static str,
250    container: String,
251    removed: bool,
252    image_removed: bool,
253    purged: bool,
254}
255
256fn uninstall(args: UninstallArgs) -> Result<(), Error> {
257    let runtime = resolve_runtime(args.common.runtime)?;
258    let _ = capture(runtime.bin(), &["stop".into(), args.common.name.clone()]);
259    let removed = capture(runtime.bin(), &["rm".into(), args.common.name.clone()])
260        .map(|o| o.status.success())
261        .unwrap_or(false);
262
263    let mut image_removed = false;
264    if args.purge {
265        let image = image_tag();
266        image_removed = capture(runtime.bin(), &["rmi".into(), image])
267            .map(|o| o.status.success())
268            .unwrap_or(false);
269        if let Ok(ctx) = cache_context_dir() {
270            let _ = std::fs::remove_dir_all(&ctx);
271        }
272    }
273
274    output::emit(
275        "container_uninstall",
276        &UninstallResult {
277            runtime: runtime.label(),
278            container: args.common.name,
279            removed,
280            image_removed,
281            purged: args.purge,
282        },
283    )
284}
285
286// ── status ─────────────────────────────────────────────────────────────────
287
288#[derive(Serialize)]
289struct StatusResult {
290    runtime: &'static str,
291    container: String,
292    running: bool,
293    endpoint: String,
294    #[serde(skip_serializing_if = "Option::is_none")]
295    token: Option<String>,
296    #[serde(skip_serializing_if = "Option::is_none")]
297    client_command: Option<String>,
298}
299
300async fn status(args: StatusArgs) -> Result<(), Error> {
301    let runtime = resolve_runtime(args.common.runtime)?;
302    let running = container_running(runtime, &args.common.name);
303    let endpoint = endpoint_url(args.port);
304
305    let token = if running {
306        read_token(runtime, &args.common.name).await.ok()
307    } else {
308        None
309    };
310    let client_command = token.as_deref().map(|t| client_command(args.port, t));
311
312    output::emit(
313        "container_status",
314        &StatusResult {
315            runtime: runtime.label(),
316            container: args.common.name,
317            running,
318            endpoint,
319            token,
320            client_command,
321        },
322    )
323}
324
325// ── logs ───────────────────────────────────────────────────────────────────
326
327fn logs(args: LogsArgs) -> Result<(), Error> {
328    let runtime = resolve_runtime(args.common.runtime)?;
329    let mut argv: Vec<String> = vec!["logs".into()];
330    if args.follow {
331        argv.push("-f".into());
332    }
333    argv.push(args.common.name);
334    exec_inherit(runtime.bin(), &argv)
335}
336
337// ── runtime resolution ───────────────────────────────────────────────────────
338
339fn resolve_runtime(explicit: Option<Runtime>) -> Result<Runtime, Error> {
340    if let Some(r) = explicit {
341        return Ok(r);
342    }
343    if let Some(v) = std::env::var_os("AFHTTP_CONTAINER_RUNTIME") {
344        return runtime_from_str(v.to_string_lossy().trim());
345    }
346    if on_path("docker") {
347        Ok(Runtime::Docker)
348    } else if on_path("podman") {
349        Ok(Runtime::Podman)
350    } else if on_path("container") {
351        Ok(Runtime::Apple)
352    } else {
353        Err(Error::new(
354            ErrorCode::InvalidArgument,
355            "no container runtime found: install Docker, Podman, or Apple `container`, or pass --runtime",
356        ))
357    }
358}
359
360fn runtime_from_str(value: &str) -> Result<Runtime, Error> {
361    match value {
362        "docker" => Ok(Runtime::Docker),
363        "podman" => Ok(Runtime::Podman),
364        "apple" | "container" => Ok(Runtime::Apple),
365        other => Err(Error::new(
366            ErrorCode::InvalidArgument,
367            format!("invalid container runtime '{other}': expected docker, podman, or apple"),
368        )),
369    }
370}
371
372fn on_path(bin: &str) -> bool {
373    let Some(paths) = std::env::var_os("PATH") else {
374        return false;
375    };
376    std::env::split_paths(&paths).any(|dir| dir.join(bin).is_file())
377}
378
379/// Apple's runtime needs its daemon started first; on Docker this is a no-op.
380/// Best-effort — a real failure surfaces at the build step.
381fn start_daemon(runtime: Runtime) {
382    if runtime == Runtime::Apple {
383        let _ = capture(runtime.bin(), &["system".into(), "start".into()]);
384    }
385}
386
387// ── arg builders (pure, unit-tested) ─────────────────────────────────────────
388
389fn image_tag() -> String {
390    format!("{IMAGE_REPO}:{VERSION}")
391}
392
393fn volume_name(name: &str) -> String {
394    format!("{name}-data")
395}
396
397fn endpoint_url(port: u16) -> String {
398    format!("ws://127.0.0.1:{port}")
399}
400
401fn client_command(port: u16, token: &str) -> String {
402    format!(
403        "afhttp fetch https://example.com --endpoint-url ws://127.0.0.1:{port} --token-secret {token}"
404    )
405}
406
407/// The Linux target triple for the image arch. Apple Container always runs
408/// linux/arm64; Docker and Podman match the host arch.
409fn target_triple(runtime: Runtime, host_arch: &str) -> &'static str {
410    match runtime {
411        Runtime::Apple => "aarch64-unknown-linux-gnu",
412        Runtime::Docker | Runtime::Podman => match host_arch {
413            "aarch64" | "arm64" => "aarch64-unknown-linux-gnu",
414            _ => "x86_64-unknown-linux-gnu",
415        },
416    }
417}
418
419/// A resolved optional backend: the `--with` name plus its Dockerfile ARG.
420#[derive(Clone, Copy, Debug, PartialEq, Eq)]
421struct Backend {
422    name: &'static str,
423    build_arg: &'static str,
424}
425
426const BACKENDS: [Backend; 5] = [
427    Backend {
428        name: "chrome-headless-shell",
429        build_arg: "WITH_CHROME_HEADLESS_SHELL",
430    },
431    Backend {
432        name: "lightpanda",
433        build_arg: "WITH_LIGHTPANDA",
434    },
435    Backend {
436        name: "fingerprint-chromium",
437        build_arg: "WITH_FINGERPRINT_CHROMIUM",
438    },
439    Backend {
440        name: "camoufox",
441        build_arg: "WITH_CAMOUFOX",
442    },
443    Backend {
444        name: "kasmvnc",
445        build_arg: "WITH_KASMVNC",
446    },
447];
448
449fn resolve_backends(names: &[String]) -> Result<Vec<Backend>, Error> {
450    let mut out = Vec::with_capacity(names.len());
451    for name in names {
452        let backend = BACKENDS.iter().find(|b| b.name == name).ok_or_else(|| {
453            Error::new(
454                ErrorCode::InvalidArgument,
455                format!(
456                    "unknown backend '{name}': expected one of {}",
457                    BACKENDS
458                        .iter()
459                        .map(|b| b.name)
460                        .collect::<Vec<_>>()
461                        .join(", ")
462                ),
463            )
464        })?;
465        if !out.contains(backend) {
466            out.push(*backend);
467        }
468    }
469    Ok(out)
470}
471
472fn validate_install_args(args: &InstallArgs, backends: &[Backend]) -> Result<(), Error> {
473    let camoufox_built = backends.iter().any(|b| b.name == "camoufox");
474    if args.profile != "-" && camoufox_built && host_args_select_camoufox(&args.host_args) {
475        return Err(Error::new(
476            ErrorCode::InvalidArgument,
477            "camoufox does not yet support persistent profiles in afhttp; `container install` defaults to `--profile work`. Use `afhttp container install --profile - --with camoufox -- --browser camoufox`.",
478        ));
479    }
480    Ok(())
481}
482
483fn host_args_select_camoufox(host_args: &[String]) -> bool {
484    host_args
485        .windows(2)
486        .any(|pair| pair[0] == "--browser" && pair[1].as_str() == "camoufox")
487        || host_args.iter().any(|arg| arg == "--browser=camoufox")
488}
489
490fn validate_container_image_host_args(
491    runtime: Runtime,
492    image: &str,
493    host_args: &[String],
494) -> Result<(), Error> {
495    if !host_args_need_display_takeover_support(host_args) {
496        return Ok(());
497    }
498    let Some(help) = container_image_host_help(runtime, image) else {
499        return Ok(());
500    };
501    if help.contains("--display-provider") {
502        return Ok(());
503    }
504    Err(Error::new(
505        ErrorCode::InvalidArgument,
506        format!(
507            "container image `{image}` contains an older afhttp host binary that does not support `--takeover display --display-provider kasmvnc`; rebuild the image from this source checkout: `afhttp container install --from-source --with kasmvnc -- --takeover display --display-provider kasmvnc`"
508        ),
509    ))
510}
511
512fn host_args_need_display_takeover_support(host_args: &[String]) -> bool {
513    host_args.iter().any(|arg| arg == "--display-provider")
514        || host_args
515            .iter()
516            .any(|arg| arg.starts_with("--display-provider="))
517        || host_args
518            .windows(2)
519            .any(|pair| pair[0] == "--takeover" && pair[1].as_str() == "display")
520        || host_args.iter().any(|arg| arg == "--takeover=display")
521}
522
523fn container_image_host_help(runtime: Runtime, image: &str) -> Option<String> {
524    let argv = image_host_help_args(image);
525    let out = capture(runtime.bin(), &argv).ok()?;
526    if !out.status.success() {
527        return None;
528    }
529    let mut help = String::new();
530    help.push_str(&String::from_utf8_lossy(&out.stdout));
531    help.push_str(&String::from_utf8_lossy(&out.stderr));
532    Some(help)
533}
534
535fn image_host_help_args(image: &str) -> Vec<String> {
536    vec![
537        "run".into(),
538        "--rm".into(),
539        "--entrypoint".into(),
540        "/usr/local/bin/afhttp".into(),
541        image.to_string(),
542        "host".into(),
543        "--help".into(),
544    ]
545}
546
547/// Which `AFHTTP_BIN_FROM` stage of the canonical Dockerfile provides the binary.
548/// `Embedded` selects the `downloader` stage (prebuilt release, the default
549/// `container install` path); `FromSource` selects the `builder` stage (compile
550/// from a checkout). Both build the same `container/docker/Dockerfile`.
551enum BuildSource<'a> {
552    Embedded { ctx: &'a Path, target: &'a str },
553    FromSource { ctx: &'a Path },
554}
555
556fn build_args(
557    image: &str,
558    runtime: Runtime,
559    source: BuildSource,
560    backends: &[Backend],
561) -> Vec<String> {
562    let mut a: Vec<String> = vec!["build".into()];
563    if runtime == Runtime::Apple {
564        a.push("--platform".into());
565        a.push("linux/arm64".into());
566    }
567    let ctx = match source {
568        BuildSource::Embedded { ctx, target } => {
569            a.push("--build-arg".into());
570            a.push("AFHTTP_BIN_FROM=downloader".into());
571            a.push("--build-arg".into());
572            a.push(format!("AFHTTP_VERSION={VERSION}"));
573            a.push("--build-arg".into());
574            a.push(format!("AFHTTP_TARGET={target}"));
575            ctx
576        }
577        BuildSource::FromSource { ctx } => {
578            a.push("--build-arg".into());
579            a.push("AFHTTP_BIN_FROM=builder".into());
580            ctx
581        }
582    };
583    for b in backends {
584        a.push("--build-arg".into());
585        a.push(format!("{}=1", b.build_arg));
586    }
587    a.push("-t".into());
588    a.push(image.to_string());
589    a.push("-f".into());
590    a.push(
591        ctx.join("container/docker/Dockerfile")
592            .to_string_lossy()
593            .into_owned(),
594    );
595    a.push(ctx.to_string_lossy().into_owned());
596    a
597}
598
599/// Resolve and validate the source checkout for `--from-source`.
600fn resolve_source_context(arg: Option<&str>) -> Result<PathBuf, Error> {
601    if let Some(p) = arg {
602        return validate_source_context(PathBuf::from(p), "--context");
603    }
604    let cwd = std::env::current_dir()
605        .map_err(|e| Error::new(ErrorCode::IoError, format!("cannot read current dir: {e}")))?;
606    if is_source_context(&cwd) {
607        return Ok(cwd);
608    }
609    let manifest_dir = PathBuf::from(MANIFEST_DIR);
610    if manifest_dir != cwd && is_source_context(&manifest_dir) {
611        return Ok(manifest_dir);
612    }
613    Err(Error::new(
614        ErrorCode::InvalidArgument,
615        format!(
616            "--from-source needs a source checkout: checked {} and {} \
617             (run from the spore root or pass --context <dir>)",
618            cwd.display(),
619            manifest_dir.display()
620        ),
621    ))
622}
623
624fn validate_source_context(dir: PathBuf, source: &str) -> Result<PathBuf, Error> {
625    let dockerfile = dir.join("container/docker/Dockerfile");
626    if !dockerfile.is_file() {
627        return Err(Error::new(
628            ErrorCode::InvalidArgument,
629            format!(
630                "--from-source {source} needs a source checkout: {} not found \
631                 (run from the spore root or pass --context <dir>)",
632                dockerfile.display()
633            ),
634        ));
635    }
636    Ok(dir)
637}
638
639fn is_source_context(dir: &Path) -> bool {
640    dir.join("container/docker/Dockerfile").is_file()
641}
642
643fn run_args(
644    name: &str,
645    image: &str,
646    port: u16,
647    profile: &str,
648    shm_size: &str,
649    host_args: &[String],
650) -> Vec<String> {
651    let mut a: Vec<String> = vec![
652        "run".into(),
653        "-d".into(),
654        "--name".into(),
655        name.to_string(),
656        "-v".into(),
657        format!("{}:/data", volume_name(name)),
658        "-e".into(),
659        format!("AFHTTP_PORT={port}"),
660        "-e".into(),
661        format!("AFHTTP_PROFILE={profile}"),
662        "--shm-size".into(),
663        shm_size.to_string(),
664        "-p".into(),
665        format!("127.0.0.1:{port}:{port}"),
666        image.to_string(),
667    ];
668    a.extend(host_args.iter().cloned());
669    a
670}
671
672// ── process plumbing ─────────────────────────────────────────────────────────
673
674fn spawn_error(bin: &str, err: &std::io::Error) -> Error {
675    if err.kind() == std::io::ErrorKind::NotFound {
676        Error::new(
677            ErrorCode::InvalidArgument,
678            format!("container runtime `{bin}` not found on PATH"),
679        )
680    } else {
681        Error::new(
682            ErrorCode::IoError,
683            format!("spawning `{bin}` failed: {err}"),
684        )
685    }
686}
687
688/// Run a runtime command, inheriting stdio so the user sees build/run progress.
689fn exec_inherit(bin: &str, args: &[String]) -> Result<(), Error> {
690    let status = Command::new(bin)
691        .args(args)
692        .status()
693        .map_err(|e| spawn_error(bin, &e))?;
694    if status.success() {
695        Ok(())
696    } else {
697        Err(Error::new(
698            ErrorCode::InternalError,
699            format!("`{bin} {}` failed ({status})", args.join(" ")),
700        ))
701    }
702}
703
704/// Run a runtime command capturing stdout/stderr.
705fn capture(bin: &str, args: &[String]) -> Result<std::process::Output, Error> {
706    Command::new(bin)
707        .args(args)
708        .output()
709        .map_err(|e| spawn_error(bin, &e))
710}
711
712fn image_exists(runtime: Runtime, image: &str) -> bool {
713    capture(
714        runtime.bin(),
715        &["image".into(), "inspect".into(), image.to_string()],
716    )
717    .map(|o| o.status.success())
718    .unwrap_or(false)
719}
720
721fn container_running(runtime: Runtime, name: &str) -> bool {
722    // Plain `ps` (no --format) so the check works the same on Docker and Apple.
723    capture(runtime.bin(), &["ps".into()])
724        .map(|o| String::from_utf8_lossy(&o.stdout).contains(name))
725        .unwrap_or(false)
726}
727
728/// Read the bearer token the entrypoint persisted to the data volume. The
729/// entrypoint writes it on first start, so retry briefly after `run`.
730async fn read_token(runtime: Runtime, name: &str) -> Result<String, Error> {
731    let argv = vec![
732        "exec".into(),
733        name.to_string(),
734        "cat".into(),
735        "/data/afhttp/host-token".into(),
736    ];
737    for attempt in 0..20 {
738        if let Ok(out) = capture(runtime.bin(), &argv) {
739            if out.status.success() {
740                let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
741                if !token.is_empty() {
742                    return Ok(token);
743                }
744            }
745        }
746        if !container_running(runtime, name) {
747            return Err(container_launch_failure_error(
748                runtime,
749                name,
750                "container exited before the host token could be read",
751            ));
752        }
753        if attempt < 19 {
754            tokio::time::sleep(std::time::Duration::from_millis(500)).await;
755        }
756    }
757    Err(container_launch_failure_error(
758        runtime,
759        name,
760        "host token was not available before the startup deadline",
761    ))
762}
763
764async fn wait_for_container_health(
765    runtime: Runtime,
766    name: &str,
767    port: u16,
768    token: &str,
769) -> Result<(), Error> {
770    let endpoint = endpoint_url(port);
771    let client = crate::sdk::Client::connect(&endpoint)?.with_token(token.to_string());
772    for attempt in 0..30 {
773        if !container_running(runtime, name) {
774            return Err(container_launch_failure_error(
775                runtime,
776                name,
777                "container exited before /health became ready",
778            ));
779        }
780        match client.health().await {
781            Ok(health) if health.status == "ok" => return Ok(()),
782            Ok(health) => {
783                if let Some(backend_error) = health.backend_error {
784                    return Err(Error::new(
785                        backend_error.error_code,
786                        format!(
787                            "container host /health reported {}: {}",
788                            health.status, backend_error.error
789                        ),
790                    ));
791                }
792            }
793            Err(_) => {}
794        }
795        if attempt < 29 {
796            tokio::time::sleep(std::time::Duration::from_millis(500)).await;
797        }
798    }
799    Err(container_launch_failure_error(
800        runtime,
801        name,
802        "container host did not pass /health before the startup deadline",
803    ))
804}
805
806fn container_launch_failure_error(runtime: Runtime, name: &str, reason: &str) -> Error {
807    let logs = container_logs_summary(runtime, name);
808    let lower = logs.to_ascii_lowercase();
809    let code = if lower.contains("backend_unsupported")
810        || lower.contains("persistent profiles")
811        || lower.contains("does not yet support")
812    {
813        ErrorCode::BackendUnsupported
814    } else {
815        ErrorCode::BrowserLaunchFailed
816    };
817    let mut detail = format!("container host launch failed: {reason}");
818    if !logs.is_empty() {
819        detail.push_str("; recent logs: ");
820        detail.push_str(&logs);
821    }
822    Error::new(code, detail)
823}
824
825fn container_logs_summary(runtime: Runtime, name: &str) -> String {
826    let Ok(out) = capture(runtime.bin(), &["logs".into(), name.to_string()]) else {
827        return String::new();
828    };
829    let mut combined = String::new();
830    combined.push_str(&String::from_utf8_lossy(&out.stdout));
831    combined.push_str(&String::from_utf8_lossy(&out.stderr));
832    let lines: Vec<&str> = combined.lines().rev().take(60).collect();
833    let mut summary = lines.into_iter().rev().collect::<Vec<_>>().join(" | ");
834    const MAX: usize = 4000;
835    if summary.len() > MAX {
836        let start = summary.len() - MAX;
837        summary = format!("...{}", &summary[start..]);
838    }
839    summary
840}
841
842fn build_failed_error(target: &str) -> Error {
843    Error::new(
844        ErrorCode::InternalError,
845        format!(
846            "image build failed. If v{VERSION} has no published release asset for \
847             {target}, build from a source checkout instead: \
848             `afhttp container install --from-source` (or \
849             docker compose -f container/docker/compose.yaml up --build)"
850        ),
851    )
852}
853
854// ── embedded build context ───────────────────────────────────────────────────
855
856fn cache_context_dir() -> Result<PathBuf, Error> {
857    let base = std::env::var_os("XDG_CACHE_HOME")
858        .map(PathBuf::from)
859        .filter(|p| p.is_absolute())
860        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
861        .ok_or_else(|| {
862            Error::new(
863                ErrorCode::IoError,
864                "cannot resolve cache dir: set HOME or XDG_CACHE_HOME",
865            )
866        })?;
867    Ok(base.join("afhttp").join("container").join(VERSION))
868}
869
870fn write_build_context() -> Result<PathBuf, Error> {
871    let root = cache_context_dir()?;
872    // Mirror the repo's container/docker/ layout so the Dockerfile's COPY paths
873    // resolve the same way they do for a from-source build. The downloader stage
874    // pulls the binary over the network, so no source tree is needed here.
875    let dir = root.join("container").join("docker");
876    std::fs::create_dir_all(&dir)?;
877    std::fs::write(dir.join("Dockerfile"), DOCKERFILE)?;
878    std::fs::write(dir.join("install-backends.sh"), INSTALL_BACKENDS)?;
879    std::fs::write(dir.join("entrypoint.sh"), ENTRYPOINT)?;
880    Ok(root)
881}
882
883#[cfg(test)]
884mod tests {
885    use super::*;
886
887    #[test]
888    fn runtime_from_str_parses_and_rejects() {
889        assert_eq!(runtime_from_str("docker").unwrap(), Runtime::Docker);
890        assert_eq!(runtime_from_str("podman").unwrap(), Runtime::Podman);
891        assert_eq!(runtime_from_str("apple").unwrap(), Runtime::Apple);
892        assert_eq!(runtime_from_str("container").unwrap(), Runtime::Apple);
893        assert_eq!(
894            runtime_from_str("nerdctl").unwrap_err().error_code,
895            ErrorCode::InvalidArgument
896        );
897    }
898
899    #[test]
900    fn explicit_runtime_wins_over_detection() {
901        assert_eq!(
902            resolve_runtime(Some(Runtime::Apple)).unwrap(),
903            Runtime::Apple
904        );
905        assert_eq!(
906            resolve_runtime(Some(Runtime::Docker)).unwrap(),
907            Runtime::Docker
908        );
909    }
910
911    #[test]
912    fn target_triple_tracks_runtime_and_arch() {
913        assert_eq!(
914            target_triple(Runtime::Apple, "x86_64"),
915            "aarch64-unknown-linux-gnu"
916        );
917        assert_eq!(
918            target_triple(Runtime::Docker, "aarch64"),
919            "aarch64-unknown-linux-gnu"
920        );
921        assert_eq!(
922            target_triple(Runtime::Docker, "x86_64"),
923            "x86_64-unknown-linux-gnu"
924        );
925        // Podman matches the host arch, same as Docker.
926        assert_eq!(
927            target_triple(Runtime::Podman, "aarch64"),
928            "aarch64-unknown-linux-gnu"
929        );
930        assert_eq!(
931            target_triple(Runtime::Podman, "x86_64"),
932            "x86_64-unknown-linux-gnu"
933        );
934    }
935
936    #[test]
937    fn backend_names_map_to_build_args_and_reject_unknown() {
938        let resolved = resolve_backends(&["camoufox".into(), "kasmvnc".into()]).unwrap();
939        assert_eq!(resolved.len(), 2);
940        assert_eq!(resolved[0].build_arg, "WITH_CAMOUFOX");
941        assert_eq!(resolved[1].build_arg, "WITH_KASMVNC");
942
943        // Duplicates collapse.
944        let deduped = resolve_backends(&["camoufox".into(), "camoufox".into()]).unwrap();
945        assert_eq!(deduped.len(), 1);
946
947        assert_eq!(
948            resolve_backends(&["nope".into()]).unwrap_err().error_code,
949            ErrorCode::InvalidArgument
950        );
951    }
952
953    #[test]
954    fn install_precheck_rejects_camoufox_with_persistent_profile() {
955        let args = InstallArgs {
956            common: CommonArgs {
957                runtime: Some(Runtime::Docker),
958                name: "afhttp-host".into(),
959            },
960            port: 9222,
961            profile: "work".into(),
962            shm_size: "1g".into(),
963            with: vec!["camoufox".into()],
964            rebuild: false,
965            from_source: false,
966            context: None,
967            host_args: vec!["--browser".into(), "camoufox".into()],
968        };
969        let backends = resolve_backends(&args.with).unwrap();
970        let err = validate_install_args(&args, &backends).unwrap_err();
971        assert_eq!(err.error_code, ErrorCode::InvalidArgument);
972        assert!(err.detail.contains("--profile -"));
973    }
974
975    #[test]
976    fn install_precheck_allows_camoufox_ephemeral_profile() {
977        let args = InstallArgs {
978            common: CommonArgs {
979                runtime: Some(Runtime::Docker),
980                name: "afhttp-host".into(),
981            },
982            port: 9222,
983            profile: "-".into(),
984            shm_size: "1g".into(),
985            with: vec!["camoufox".into()],
986            rebuild: false,
987            from_source: false,
988            context: None,
989            host_args: vec!["--browser=camoufox".into()],
990        };
991        let backends = resolve_backends(&args.with).unwrap();
992        validate_install_args(&args, &backends).unwrap();
993    }
994
995    #[test]
996    fn display_host_args_trigger_image_support_probe() {
997        assert!(host_args_need_display_takeover_support(&[
998            "--takeover".into(),
999            "display".into()
1000        ]));
1001        assert!(host_args_need_display_takeover_support(&[
1002            "--takeover=display".into()
1003        ]));
1004        assert!(host_args_need_display_takeover_support(&[
1005            "--display-provider".into(),
1006            "kasmvnc".into()
1007        ]));
1008        assert!(host_args_need_display_takeover_support(&[
1009            "--display-provider=kasmvnc".into()
1010        ]));
1011        assert!(!host_args_need_display_takeover_support(&[
1012            "--takeover".into(),
1013            "screencast".into()
1014        ]));
1015    }
1016
1017    #[test]
1018    fn image_host_help_args_bypasses_entrypoint() {
1019        let args = image_host_help_args("afhttp-host:dev");
1020        assert_eq!(args[0], "run");
1021        assert!(args.contains(&"--rm".to_string()));
1022        assert!(args.contains(&"--entrypoint".to_string()));
1023        assert!(args.contains(&"/usr/local/bin/afhttp".to_string()));
1024        assert_eq!(args[args.len() - 3], "afhttp-host:dev");
1025        assert_eq!(args[args.len() - 2], "host");
1026        assert_eq!(args[args.len() - 1], "--help");
1027    }
1028
1029    #[test]
1030    fn embedded_build_args_include_version_target_and_apple_platform() {
1031        let ctx = PathBuf::from("/cache/ctx");
1032        let backends = resolve_backends(&["lightpanda".into()]).unwrap();
1033        let docker = build_args(
1034            "afhttp-host:1.2.3",
1035            Runtime::Docker,
1036            BuildSource::Embedded {
1037                ctx: &ctx,
1038                target: "x86_64-unknown-linux-gnu",
1039            },
1040            &backends,
1041        );
1042        assert_eq!(docker[0], "build");
1043        assert!(!docker.contains(&"--platform".to_string()));
1044        assert!(docker.contains(&"AFHTTP_BIN_FROM=downloader".to_string()));
1045        assert!(docker.contains(&format!("AFHTTP_VERSION={VERSION}")));
1046        assert!(docker.contains(&"AFHTTP_TARGET=x86_64-unknown-linux-gnu".to_string()));
1047        assert!(docker.contains(&"WITH_LIGHTPANDA=1".to_string()));
1048        assert_eq!(
1049            docker[docker.len() - 2],
1050            "/cache/ctx/container/docker/Dockerfile"
1051        );
1052        assert_eq!(docker.last().unwrap(), "/cache/ctx");
1053
1054        let apple = build_args(
1055            "afhttp-host:1.2.3",
1056            Runtime::Apple,
1057            BuildSource::Embedded {
1058                ctx: &ctx,
1059                target: "aarch64-unknown-linux-gnu",
1060            },
1061            &[],
1062        );
1063        let pos = apple.iter().position(|a| a == "--platform").unwrap();
1064        assert_eq!(apple[pos + 1], "linux/arm64");
1065    }
1066
1067    #[test]
1068    fn from_source_build_args_use_canonical_dockerfile_no_release_args() {
1069        let repo = PathBuf::from("/repo");
1070        let backends = resolve_backends(&["camoufox".into()]).unwrap();
1071        let args = build_args(
1072            "afhttp-host:1.2.3",
1073            Runtime::Podman,
1074            BuildSource::FromSource { ctx: &repo },
1075            &backends,
1076        );
1077        // Selects the builder stage; no download build-args.
1078        assert!(args.contains(&"AFHTTP_BIN_FROM=builder".to_string()));
1079        assert!(!args.iter().any(|a| a.starts_with("AFHTTP_VERSION=")));
1080        assert!(!args.iter().any(|a| a.starts_with("AFHTTP_TARGET=")));
1081        assert!(args.contains(&"WITH_CAMOUFOX=1".to_string()));
1082        assert_eq!(args[args.len() - 2], "/repo/container/docker/Dockerfile");
1083        assert_eq!(args.last().unwrap(), "/repo");
1084        // Podman gets no --platform (host arch), same as Docker.
1085        assert!(!args.contains(&"--platform".to_string()));
1086    }
1087
1088    #[test]
1089    fn run_args_publish_loopback_and_pass_host_args() {
1090        let a = run_args(
1091            "afhttp-host",
1092            "afhttp-host:1.2.3",
1093            9222,
1094            "work",
1095            "1g",
1096            &["--browser".into(), "camoufox".into()],
1097        );
1098        assert!(a.contains(&"afhttp-host-data:/data".to_string()));
1099        assert!(a.contains(&"AFHTTP_PORT=9222".to_string()));
1100        assert!(a.contains(&"AFHTTP_PROFILE=work".to_string()));
1101        assert!(a.contains(&"127.0.0.1:9222:9222".to_string()));
1102        // Image precedes the passthrough host args.
1103        let img = a.iter().position(|x| x == "afhttp-host:1.2.3").unwrap();
1104        let br = a.iter().position(|x| x == "--browser").unwrap();
1105        assert!(img < br);
1106    }
1107
1108    #[test]
1109    fn client_command_uses_loopback_endpoint() {
1110        let cmd = client_command(9333, "deadbeef");
1111        assert!(cmd.contains("--endpoint-url ws://127.0.0.1:9333"));
1112        assert!(cmd.contains("--token-secret deadbeef"));
1113    }
1114
1115    #[test]
1116    fn build_failure_error_points_at_compose_fallback() {
1117        let err = build_failed_error("aarch64-unknown-linux-gnu");
1118        assert_eq!(err.error_code, ErrorCode::InternalError);
1119        assert!(err.detail.contains("compose"));
1120        assert!(err.detail.contains("aarch64-unknown-linux-gnu"));
1121    }
1122}