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/// Default container name and image repository.
30const DEFAULT_NAME: &str = "afhttp-host";
31const IMAGE_REPO: &str = "afhttp-host";
32
33#[derive(ClapArgs, Debug)]
34pub struct Args {
35    #[command(subcommand)]
36    pub sub: ContainerSub,
37}
38
39#[derive(Subcommand, Debug)]
40pub enum ContainerSub {
41    /// Build the host image if missing and run the container; print the client command.
42    Install(InstallArgs),
43    /// Stop and remove the container (--purge also removes the image and cache).
44    Uninstall(UninstallArgs),
45    /// Report whether the host is running, with its endpoint and client command.
46    Status(StatusArgs),
47    /// Stream the container logs (raw passthrough, not a JSON envelope).
48    Logs(LogsArgs),
49}
50
51/// Flags shared by every subcommand.
52#[derive(ClapArgs, Debug)]
53pub struct CommonArgs {
54    /// Container runtime: docker, podman, or apple (auto-detected if omitted).
55    #[arg(long, value_enum)]
56    pub runtime: Option<RuntimeArg>,
57    /// Container name.
58    #[arg(long, default_value = DEFAULT_NAME)]
59    pub name: String,
60}
61
62#[derive(ClapArgs, Debug)]
63pub struct InstallArgs {
64    #[command(flatten)]
65    pub common: CommonArgs,
66    /// Host CDP port, published on 127.0.0.1.
67    #[arg(long, default_value_t = 9222)]
68    pub port: u16,
69    /// Profile name inside the container.
70    #[arg(long, default_value = "work")]
71    pub profile: String,
72    /// Chromium /dev/shm size.
73    #[arg(long = "shm-size", default_value = "1g")]
74    pub shm_size: String,
75    /// Optional backend to build in (repeatable): chrome-headless-shell,
76    /// lightpanda, fingerprint-chromium, camoufox, kasmvnc.
77    #[arg(long = "with", value_name = "BACKEND")]
78    pub with: Vec<String>,
79    /// Rebuild the image even if it already exists.
80    #[arg(long)]
81    pub rebuild: bool,
82    /// Build the full image from a source checkout (container/docker/Dockerfile)
83    /// instead of downloading the prebuilt release. Needs the source tree.
84    #[arg(long = "from-source")]
85    pub from_source: bool,
86    /// Source checkout to build from with --from-source (default: current dir).
87    #[arg(long, value_name = "DIR")]
88    pub context: Option<String>,
89    /// Extra args passed through to `afhttp host` inside the container.
90    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
91    pub host_args: Vec<String>,
92}
93
94#[derive(ClapArgs, Debug)]
95pub struct UninstallArgs {
96    #[command(flatten)]
97    pub common: CommonArgs,
98    /// Also remove the built image and the cached build context.
99    #[arg(long)]
100    pub purge: bool,
101}
102
103#[derive(ClapArgs, Debug)]
104pub struct StatusArgs {
105    #[command(flatten)]
106    pub common: CommonArgs,
107    /// Published host port, used to format the endpoint and client command.
108    #[arg(long, default_value_t = 9222)]
109    pub port: u16,
110}
111
112#[derive(ClapArgs, Debug)]
113pub struct LogsArgs {
114    #[command(flatten)]
115    pub common: CommonArgs,
116    /// Follow the log output.
117    #[arg(long, short = 'f')]
118    pub follow: bool,
119}
120
121/// CLI spelling of the runtime selector.
122#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
123pub enum RuntimeArg {
124    Docker,
125    Podman,
126    Apple,
127}
128
129#[derive(Clone, Copy, Debug, PartialEq, Eq)]
130enum Runtime {
131    Docker,
132    Podman,
133    Apple,
134}
135
136impl From<RuntimeArg> for Runtime {
137    fn from(arg: RuntimeArg) -> Self {
138        match arg {
139            RuntimeArg::Docker => Runtime::Docker,
140            RuntimeArg::Podman => Runtime::Podman,
141            RuntimeArg::Apple => Runtime::Apple,
142        }
143    }
144}
145
146impl Runtime {
147    /// The runtime's CLI binary name.
148    fn bin(self) -> &'static str {
149        match self {
150            Runtime::Docker => "docker",
151            Runtime::Podman => "podman",
152            Runtime::Apple => "container",
153        }
154    }
155
156    /// Human label used in output and errors.
157    fn label(self) -> &'static str {
158        match self {
159            Runtime::Docker => "docker",
160            Runtime::Podman => "podman",
161            Runtime::Apple => "apple",
162        }
163    }
164}
165
166pub async fn run(args: Args) -> Result<(), Error> {
167    match args.sub {
168        ContainerSub::Install(a) => install(a).await,
169        ContainerSub::Uninstall(a) => uninstall(a),
170        ContainerSub::Status(a) => status(a).await,
171        ContainerSub::Logs(a) => logs(a),
172    }
173}
174
175// ── install ────────────────────────────────────────────────────────────────
176
177#[derive(Serialize)]
178struct InstallResult {
179    runtime: &'static str,
180    image: String,
181    container: String,
182    endpoint: String,
183    profile: String,
184    token: String,
185    client_command: String,
186    backends: Vec<String>,
187}
188
189async fn install(args: InstallArgs) -> Result<(), Error> {
190    let runtime = resolve_runtime(args.common.runtime)?;
191    let backends = resolve_backends(&args.with)?;
192    let image = image_tag();
193
194    start_daemon(runtime);
195
196    // --from-source always rebuilds (the canonical Dockerfile compiles afhttp);
197    // the embedded path reuses a cached image unless --rebuild is set.
198    if args.from_source {
199        let ctx = resolve_source_context(args.context.as_deref())?;
200        let build = build_args(
201            &image,
202            runtime,
203            BuildSource::FromSource { ctx: &ctx },
204            &backends,
205        );
206        exec_inherit(runtime.bin(), &build)?;
207    } else if args.rebuild || !image_exists(runtime, &image) {
208        let ctx = write_build_context()?;
209        let target = target_triple(runtime, std::env::consts::ARCH);
210        let build = build_args(
211            &image,
212            runtime,
213            BuildSource::Embedded { ctx: &ctx, target },
214            &backends,
215        );
216        exec_inherit(runtime.bin(), &build).map_err(|_| build_failed_error(target))?;
217    }
218
219    // Recreate cleanly. The profile + token live in the named volume, so the
220    // token is stable across recreation.
221    let _ = capture(runtime.bin(), &["stop".into(), args.common.name.clone()]);
222    let _ = capture(runtime.bin(), &["rm".into(), args.common.name.clone()]);
223
224    let run = run_args(
225        &args.common.name,
226        &image,
227        args.port,
228        &args.profile,
229        &args.shm_size,
230        &args.host_args,
231    );
232    exec_inherit(runtime.bin(), &run)?;
233
234    let token = read_token(runtime, &args.common.name).await?;
235    let endpoint = endpoint_url(args.port);
236    output::emit(
237        "container_install",
238        &InstallResult {
239            runtime: runtime.label(),
240            image,
241            container: args.common.name.clone(),
242            endpoint,
243            profile: args.profile.clone(),
244            client_command: client_command(args.port, &token),
245            token,
246            backends: backends.iter().map(|b| b.name.to_string()).collect(),
247        },
248    )
249}
250
251// ── uninstall ──────────────────────────────────────────────────────────────
252
253#[derive(Serialize)]
254struct UninstallResult {
255    runtime: &'static str,
256    container: String,
257    removed: bool,
258    image_removed: bool,
259    purged: bool,
260}
261
262fn uninstall(args: UninstallArgs) -> Result<(), Error> {
263    let runtime = resolve_runtime(args.common.runtime)?;
264    let _ = capture(runtime.bin(), &["stop".into(), args.common.name.clone()]);
265    let removed = capture(runtime.bin(), &["rm".into(), args.common.name.clone()])
266        .map(|o| o.status.success())
267        .unwrap_or(false);
268
269    let mut image_removed = false;
270    if args.purge {
271        let image = image_tag();
272        image_removed = capture(runtime.bin(), &["rmi".into(), image])
273            .map(|o| o.status.success())
274            .unwrap_or(false);
275        if let Ok(ctx) = cache_context_dir() {
276            let _ = std::fs::remove_dir_all(&ctx);
277        }
278    }
279
280    output::emit(
281        "container_uninstall",
282        &UninstallResult {
283            runtime: runtime.label(),
284            container: args.common.name,
285            removed,
286            image_removed,
287            purged: args.purge,
288        },
289    )
290}
291
292// ── status ─────────────────────────────────────────────────────────────────
293
294#[derive(Serialize)]
295struct StatusResult {
296    runtime: &'static str,
297    container: String,
298    running: bool,
299    endpoint: String,
300    #[serde(skip_serializing_if = "Option::is_none")]
301    token: Option<String>,
302    #[serde(skip_serializing_if = "Option::is_none")]
303    client_command: Option<String>,
304}
305
306async fn status(args: StatusArgs) -> Result<(), Error> {
307    let runtime = resolve_runtime(args.common.runtime)?;
308    let running = container_running(runtime, &args.common.name);
309    let endpoint = endpoint_url(args.port);
310
311    let token = if running {
312        read_token(runtime, &args.common.name).await.ok()
313    } else {
314        None
315    };
316    let client_command = token.as_deref().map(|t| client_command(args.port, t));
317
318    output::emit(
319        "container_status",
320        &StatusResult {
321            runtime: runtime.label(),
322            container: args.common.name,
323            running,
324            endpoint,
325            token,
326            client_command,
327        },
328    )
329}
330
331// ── logs ───────────────────────────────────────────────────────────────────
332
333fn logs(args: LogsArgs) -> Result<(), Error> {
334    let runtime = resolve_runtime(args.common.runtime)?;
335    let mut argv: Vec<String> = vec!["logs".into()];
336    if args.follow {
337        argv.push("-f".into());
338    }
339    argv.push(args.common.name);
340    exec_inherit(runtime.bin(), &argv)
341}
342
343// ── runtime resolution ───────────────────────────────────────────────────────
344
345fn resolve_runtime(explicit: Option<RuntimeArg>) -> Result<Runtime, Error> {
346    if let Some(r) = explicit {
347        return Ok(r.into());
348    }
349    if let Some(v) = std::env::var_os("AFHTTP_CONTAINER_RUNTIME") {
350        return runtime_from_str(v.to_string_lossy().trim());
351    }
352    if on_path("docker") {
353        Ok(Runtime::Docker)
354    } else if on_path("podman") {
355        Ok(Runtime::Podman)
356    } else if on_path("container") {
357        Ok(Runtime::Apple)
358    } else {
359        Err(Error::new(
360            ErrorCode::InvalidArgument,
361            "no container runtime found: install Docker, Podman, or Apple `container`, or pass --runtime",
362        ))
363    }
364}
365
366fn runtime_from_str(value: &str) -> Result<Runtime, Error> {
367    match value {
368        "docker" => Ok(Runtime::Docker),
369        "podman" => Ok(Runtime::Podman),
370        "apple" | "container" => Ok(Runtime::Apple),
371        other => Err(Error::new(
372            ErrorCode::InvalidArgument,
373            format!("invalid container runtime '{other}': expected docker, podman, or apple"),
374        )),
375    }
376}
377
378fn on_path(bin: &str) -> bool {
379    let Some(paths) = std::env::var_os("PATH") else {
380        return false;
381    };
382    std::env::split_paths(&paths).any(|dir| dir.join(bin).is_file())
383}
384
385/// Apple's runtime needs its daemon started first; on Docker this is a no-op.
386/// Best-effort — a real failure surfaces at the build step.
387fn start_daemon(runtime: Runtime) {
388    if runtime == Runtime::Apple {
389        let _ = capture(runtime.bin(), &["system".into(), "start".into()]);
390    }
391}
392
393// ── arg builders (pure, unit-tested) ─────────────────────────────────────────
394
395fn image_tag() -> String {
396    format!("{IMAGE_REPO}:{VERSION}")
397}
398
399fn volume_name(name: &str) -> String {
400    format!("{name}-data")
401}
402
403fn endpoint_url(port: u16) -> String {
404    format!("ws://127.0.0.1:{port}")
405}
406
407fn client_command(port: u16, token: &str) -> String {
408    format!(
409        "afhttp fetch https://example.com --endpoint-url ws://127.0.0.1:{port} --token-secret {token}"
410    )
411}
412
413/// The Linux target triple for the image arch. Apple Container always runs
414/// linux/arm64; Docker and Podman match the host arch.
415fn target_triple(runtime: Runtime, host_arch: &str) -> &'static str {
416    match runtime {
417        Runtime::Apple => "aarch64-unknown-linux-gnu",
418        Runtime::Docker | Runtime::Podman => match host_arch {
419            "aarch64" | "arm64" => "aarch64-unknown-linux-gnu",
420            _ => "x86_64-unknown-linux-gnu",
421        },
422    }
423}
424
425/// A resolved optional backend: the `--with` name plus its Dockerfile ARG.
426#[derive(Clone, Copy, Debug, PartialEq, Eq)]
427struct Backend {
428    name: &'static str,
429    build_arg: &'static str,
430}
431
432const BACKENDS: [Backend; 5] = [
433    Backend {
434        name: "chrome-headless-shell",
435        build_arg: "WITH_CHROME_HEADLESS_SHELL",
436    },
437    Backend {
438        name: "lightpanda",
439        build_arg: "WITH_LIGHTPANDA",
440    },
441    Backend {
442        name: "fingerprint-chromium",
443        build_arg: "WITH_FINGERPRINT_CHROMIUM",
444    },
445    Backend {
446        name: "camoufox",
447        build_arg: "WITH_CAMOUFOX",
448    },
449    Backend {
450        name: "kasmvnc",
451        build_arg: "WITH_KASMVNC",
452    },
453];
454
455fn resolve_backends(names: &[String]) -> Result<Vec<Backend>, Error> {
456    let mut out = Vec::with_capacity(names.len());
457    for name in names {
458        let backend = BACKENDS.iter().find(|b| b.name == name).ok_or_else(|| {
459            Error::new(
460                ErrorCode::InvalidArgument,
461                format!(
462                    "unknown backend '{name}': expected one of {}",
463                    BACKENDS
464                        .iter()
465                        .map(|b| b.name)
466                        .collect::<Vec<_>>()
467                        .join(", ")
468                ),
469            )
470        })?;
471        if !out.contains(backend) {
472            out.push(*backend);
473        }
474    }
475    Ok(out)
476}
477
478/// Which `AFHTTP_BIN_FROM` stage of the canonical Dockerfile provides the binary.
479/// `Embedded` selects the `downloader` stage (prebuilt release, the default
480/// `container install` path); `FromSource` selects the `builder` stage (compile
481/// from a checkout). Both build the same `container/docker/Dockerfile`.
482enum BuildSource<'a> {
483    Embedded { ctx: &'a Path, target: &'a str },
484    FromSource { ctx: &'a Path },
485}
486
487fn build_args(
488    image: &str,
489    runtime: Runtime,
490    source: BuildSource,
491    backends: &[Backend],
492) -> Vec<String> {
493    let mut a: Vec<String> = vec!["build".into()];
494    if runtime == Runtime::Apple {
495        a.push("--platform".into());
496        a.push("linux/arm64".into());
497    }
498    let ctx = match source {
499        BuildSource::Embedded { ctx, target } => {
500            a.push("--build-arg".into());
501            a.push("AFHTTP_BIN_FROM=downloader".into());
502            a.push("--build-arg".into());
503            a.push(format!("AFHTTP_VERSION={VERSION}"));
504            a.push("--build-arg".into());
505            a.push(format!("AFHTTP_TARGET={target}"));
506            ctx
507        }
508        BuildSource::FromSource { ctx } => {
509            a.push("--build-arg".into());
510            a.push("AFHTTP_BIN_FROM=builder".into());
511            ctx
512        }
513    };
514    for b in backends {
515        a.push("--build-arg".into());
516        a.push(format!("{}=1", b.build_arg));
517    }
518    a.push("-t".into());
519    a.push(image.to_string());
520    a.push("-f".into());
521    a.push(
522        ctx.join("container/docker/Dockerfile")
523            .to_string_lossy()
524            .into_owned(),
525    );
526    a.push(ctx.to_string_lossy().into_owned());
527    a
528}
529
530/// Resolve and validate the source checkout for `--from-source`.
531fn resolve_source_context(arg: Option<&str>) -> Result<PathBuf, Error> {
532    let dir = match arg {
533        Some(p) => PathBuf::from(p),
534        None => std::env::current_dir()
535            .map_err(|e| Error::new(ErrorCode::IoError, format!("cannot read current dir: {e}")))?,
536    };
537    let dockerfile = dir.join("container/docker/Dockerfile");
538    if !dockerfile.is_file() {
539        return Err(Error::new(
540            ErrorCode::InvalidArgument,
541            format!(
542                "--from-source needs a source checkout: {} not found \
543                 (run from the spore root or pass --context <dir>)",
544                dockerfile.display()
545            ),
546        ));
547    }
548    Ok(dir)
549}
550
551fn run_args(
552    name: &str,
553    image: &str,
554    port: u16,
555    profile: &str,
556    shm_size: &str,
557    host_args: &[String],
558) -> Vec<String> {
559    let mut a: Vec<String> = vec![
560        "run".into(),
561        "-d".into(),
562        "--name".into(),
563        name.to_string(),
564        "-v".into(),
565        format!("{}:/data", volume_name(name)),
566        "-e".into(),
567        format!("AFHTTP_PORT={port}"),
568        "-e".into(),
569        format!("AFHTTP_PROFILE={profile}"),
570        "--shm-size".into(),
571        shm_size.to_string(),
572        "-p".into(),
573        format!("127.0.0.1:{port}:{port}"),
574        image.to_string(),
575    ];
576    a.extend(host_args.iter().cloned());
577    a
578}
579
580// ── process plumbing ─────────────────────────────────────────────────────────
581
582fn spawn_error(bin: &str, err: &std::io::Error) -> Error {
583    if err.kind() == std::io::ErrorKind::NotFound {
584        Error::new(
585            ErrorCode::InvalidArgument,
586            format!("container runtime `{bin}` not found on PATH"),
587        )
588    } else {
589        Error::new(
590            ErrorCode::IoError,
591            format!("spawning `{bin}` failed: {err}"),
592        )
593    }
594}
595
596/// Run a runtime command, inheriting stdio so the user sees build/run progress.
597fn exec_inherit(bin: &str, args: &[String]) -> Result<(), Error> {
598    let status = Command::new(bin)
599        .args(args)
600        .status()
601        .map_err(|e| spawn_error(bin, &e))?;
602    if status.success() {
603        Ok(())
604    } else {
605        Err(Error::new(
606            ErrorCode::InternalError,
607            format!("`{bin} {}` failed ({status})", args.join(" ")),
608        ))
609    }
610}
611
612/// Run a runtime command capturing stdout/stderr.
613fn capture(bin: &str, args: &[String]) -> Result<std::process::Output, Error> {
614    Command::new(bin)
615        .args(args)
616        .output()
617        .map_err(|e| spawn_error(bin, &e))
618}
619
620fn image_exists(runtime: Runtime, image: &str) -> bool {
621    capture(
622        runtime.bin(),
623        &["image".into(), "inspect".into(), image.to_string()],
624    )
625    .map(|o| o.status.success())
626    .unwrap_or(false)
627}
628
629fn container_running(runtime: Runtime, name: &str) -> bool {
630    // Plain `ps` (no --format) so the check works the same on Docker and Apple.
631    capture(runtime.bin(), &["ps".into()])
632        .map(|o| String::from_utf8_lossy(&o.stdout).contains(name))
633        .unwrap_or(false)
634}
635
636/// Read the bearer token the entrypoint persisted to the data volume. The
637/// entrypoint writes it on first start, so retry briefly after `run`.
638async fn read_token(runtime: Runtime, name: &str) -> Result<String, Error> {
639    let argv = vec![
640        "exec".into(),
641        name.to_string(),
642        "cat".into(),
643        "/data/afhttp/host-token".into(),
644    ];
645    for attempt in 0..10 {
646        if let Ok(out) = capture(runtime.bin(), &argv) {
647            if out.status.success() {
648                let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
649                if !token.is_empty() {
650                    return Ok(token);
651                }
652            }
653        }
654        if attempt < 9 {
655            tokio::time::sleep(std::time::Duration::from_millis(300)).await;
656        }
657    }
658    Err(Error::new(
659        ErrorCode::InternalError,
660        format!("could not read host token from container `{name}`"),
661    ))
662}
663
664fn build_failed_error(target: &str) -> Error {
665    Error::new(
666        ErrorCode::InternalError,
667        format!(
668            "image build failed. If v{VERSION} has no published release asset for \
669             {target}, build from a source checkout instead: \
670             `afhttp container install --from-source` (or \
671             docker compose -f container/docker/compose.yaml up --build)"
672        ),
673    )
674}
675
676// ── embedded build context ───────────────────────────────────────────────────
677
678fn cache_context_dir() -> Result<PathBuf, Error> {
679    let base = std::env::var_os("XDG_CACHE_HOME")
680        .map(PathBuf::from)
681        .filter(|p| p.is_absolute())
682        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
683        .ok_or_else(|| {
684            Error::new(
685                ErrorCode::IoError,
686                "cannot resolve cache dir: set HOME or XDG_CACHE_HOME",
687            )
688        })?;
689    Ok(base.join("afhttp").join("container").join(VERSION))
690}
691
692fn write_build_context() -> Result<PathBuf, Error> {
693    let root = cache_context_dir()?;
694    // Mirror the repo's container/docker/ layout so the Dockerfile's COPY paths
695    // resolve the same way they do for a from-source build. The downloader stage
696    // pulls the binary over the network, so no source tree is needed here.
697    let dir = root.join("container").join("docker");
698    std::fs::create_dir_all(&dir)?;
699    std::fs::write(dir.join("Dockerfile"), DOCKERFILE)?;
700    std::fs::write(dir.join("install-backends.sh"), INSTALL_BACKENDS)?;
701    std::fs::write(dir.join("entrypoint.sh"), ENTRYPOINT)?;
702    Ok(root)
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    #[test]
710    fn runtime_from_str_parses_and_rejects() {
711        assert_eq!(runtime_from_str("docker").unwrap(), Runtime::Docker);
712        assert_eq!(runtime_from_str("podman").unwrap(), Runtime::Podman);
713        assert_eq!(runtime_from_str("apple").unwrap(), Runtime::Apple);
714        assert_eq!(runtime_from_str("container").unwrap(), Runtime::Apple);
715        assert_eq!(
716            runtime_from_str("nerdctl").unwrap_err().error_code,
717            ErrorCode::InvalidArgument
718        );
719    }
720
721    #[test]
722    fn explicit_runtime_wins_over_detection() {
723        assert_eq!(
724            resolve_runtime(Some(RuntimeArg::Apple)).unwrap(),
725            Runtime::Apple
726        );
727        assert_eq!(
728            resolve_runtime(Some(RuntimeArg::Docker)).unwrap(),
729            Runtime::Docker
730        );
731    }
732
733    #[test]
734    fn target_triple_tracks_runtime_and_arch() {
735        assert_eq!(
736            target_triple(Runtime::Apple, "x86_64"),
737            "aarch64-unknown-linux-gnu"
738        );
739        assert_eq!(
740            target_triple(Runtime::Docker, "aarch64"),
741            "aarch64-unknown-linux-gnu"
742        );
743        assert_eq!(
744            target_triple(Runtime::Docker, "x86_64"),
745            "x86_64-unknown-linux-gnu"
746        );
747        // Podman matches the host arch, same as Docker.
748        assert_eq!(
749            target_triple(Runtime::Podman, "aarch64"),
750            "aarch64-unknown-linux-gnu"
751        );
752        assert_eq!(
753            target_triple(Runtime::Podman, "x86_64"),
754            "x86_64-unknown-linux-gnu"
755        );
756    }
757
758    #[test]
759    fn backend_names_map_to_build_args_and_reject_unknown() {
760        let resolved = resolve_backends(&["camoufox".into(), "kasmvnc".into()]).unwrap();
761        assert_eq!(resolved.len(), 2);
762        assert_eq!(resolved[0].build_arg, "WITH_CAMOUFOX");
763        assert_eq!(resolved[1].build_arg, "WITH_KASMVNC");
764
765        // Duplicates collapse.
766        let deduped = resolve_backends(&["camoufox".into(), "camoufox".into()]).unwrap();
767        assert_eq!(deduped.len(), 1);
768
769        assert_eq!(
770            resolve_backends(&["nope".into()]).unwrap_err().error_code,
771            ErrorCode::InvalidArgument
772        );
773    }
774
775    #[test]
776    fn embedded_build_args_include_version_target_and_apple_platform() {
777        let ctx = PathBuf::from("/cache/ctx");
778        let backends = resolve_backends(&["lightpanda".into()]).unwrap();
779        let docker = build_args(
780            "afhttp-host:1.2.3",
781            Runtime::Docker,
782            BuildSource::Embedded {
783                ctx: &ctx,
784                target: "x86_64-unknown-linux-gnu",
785            },
786            &backends,
787        );
788        assert_eq!(docker[0], "build");
789        assert!(!docker.contains(&"--platform".to_string()));
790        assert!(docker.contains(&"AFHTTP_BIN_FROM=downloader".to_string()));
791        assert!(docker.contains(&format!("AFHTTP_VERSION={VERSION}")));
792        assert!(docker.contains(&"AFHTTP_TARGET=x86_64-unknown-linux-gnu".to_string()));
793        assert!(docker.contains(&"WITH_LIGHTPANDA=1".to_string()));
794        assert_eq!(
795            docker[docker.len() - 2],
796            "/cache/ctx/container/docker/Dockerfile"
797        );
798        assert_eq!(docker.last().unwrap(), "/cache/ctx");
799
800        let apple = build_args(
801            "afhttp-host:1.2.3",
802            Runtime::Apple,
803            BuildSource::Embedded {
804                ctx: &ctx,
805                target: "aarch64-unknown-linux-gnu",
806            },
807            &[],
808        );
809        let pos = apple.iter().position(|a| a == "--platform").unwrap();
810        assert_eq!(apple[pos + 1], "linux/arm64");
811    }
812
813    #[test]
814    fn from_source_build_args_use_canonical_dockerfile_no_release_args() {
815        let repo = PathBuf::from("/repo");
816        let backends = resolve_backends(&["camoufox".into()]).unwrap();
817        let args = build_args(
818            "afhttp-host:1.2.3",
819            Runtime::Podman,
820            BuildSource::FromSource { ctx: &repo },
821            &backends,
822        );
823        // Selects the builder stage; no download build-args.
824        assert!(args.contains(&"AFHTTP_BIN_FROM=builder".to_string()));
825        assert!(!args.iter().any(|a| a.starts_with("AFHTTP_VERSION=")));
826        assert!(!args.iter().any(|a| a.starts_with("AFHTTP_TARGET=")));
827        assert!(args.contains(&"WITH_CAMOUFOX=1".to_string()));
828        assert_eq!(args[args.len() - 2], "/repo/container/docker/Dockerfile");
829        assert_eq!(args.last().unwrap(), "/repo");
830        // Podman gets no --platform (host arch), same as Docker.
831        assert!(!args.contains(&"--platform".to_string()));
832    }
833
834    #[test]
835    fn run_args_publish_loopback_and_pass_host_args() {
836        let a = run_args(
837            "afhttp-host",
838            "afhttp-host:1.2.3",
839            9222,
840            "work",
841            "1g",
842            &["--browser".into(), "camoufox".into()],
843        );
844        assert!(a.contains(&"afhttp-host-data:/data".to_string()));
845        assert!(a.contains(&"AFHTTP_PORT=9222".to_string()));
846        assert!(a.contains(&"AFHTTP_PROFILE=work".to_string()));
847        assert!(a.contains(&"127.0.0.1:9222:9222".to_string()));
848        // Image precedes the passthrough host args.
849        let img = a.iter().position(|x| x == "afhttp-host:1.2.3").unwrap();
850        let br = a.iter().position(|x| x == "--browser").unwrap();
851        assert!(img < br);
852    }
853
854    #[test]
855    fn client_command_uses_loopback_endpoint() {
856        let cmd = client_command(9333, "deadbeef");
857        assert!(cmd.contains("--endpoint-url ws://127.0.0.1:9333"));
858        assert!(cmd.contains("--token-secret deadbeef"));
859    }
860
861    #[test]
862    fn build_failure_error_points_at_compose_fallback() {
863        let err = build_failed_error("aarch64-unknown-linux-gnu");
864        assert_eq!(err.error_code, ErrorCode::InternalError);
865        assert!(err.detail.contains("compose"));
866        assert!(err.detail.contains("aarch64-unknown-linux-gnu"));
867    }
868}