Skip to main content

agentmux_srv/backend/
container_image.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Which container image an agent runs, and what happens when it can't be had.
5//!
6//! - The default and the legacy image names, and the fallback between them.
7//! - Classifying a failed pull into something a person can act on, instead of
8//!   passing bollard's text through.
9//! - An anonymous registry check ("could the daemon pull this?") for the
10//!   create flow, which has to decide before anything is pulled.
11//!
12//! Spec: docs/specs/SPEC_CONTAINER_AGENTS_WORK_FOR_EVERYONE_2026_10_07.md.
13
14use crate::backend::container::ContainerManager;
15use crate::backend::registry_probe::probe_anonymous;
16use crate::backend::rpc_types::ContainerImageAccess;
17
18/// The public base image: everything a container agent needs except the
19/// provider CLI, which is installed on first start.
20pub const DEFAULT_AGENT_IMAGE: &str = "ghcr.io/agentmuxai/agent-base:latest";
21
22/// Repository of the earlier image that bundled Claude Code. It is private, so
23/// only machines that already hold it can use it.
24const LEGACY_AGENT_IMAGE_REPO: &str = "ghcr.io/agentmuxai/agent-claude";
25
26/// Where the provider CLI is installed inside the container.
27pub const CONTAINER_CLI_DIR: &str = "/home/agent/.agentmux/cli";
28
29/// The image a stored `agent:container_image` value means: empty is the default.
30pub fn resolve_container_image(stored: &str) -> String {
31    let stored = stored.trim();
32    if stored.is_empty() {
33        DEFAULT_AGENT_IMAGE.to_string()
34    } else {
35        stored.to_string()
36    }
37}
38
39/// Whether `image` is the private legacy image, at any tag.
40pub fn is_legacy_agent_image(image: &str) -> bool {
41    let image = image.trim();
42    match image.strip_prefix(LEGACY_AGENT_IMAGE_REPO) {
43        Some(rest) => rest.is_empty() || rest.starts_with(':') || rest.starts_with('@'),
44        None => false,
45    }
46}
47
48/// Why a pull failed, as far as the daemon's text lets us tell.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum PullFailure {
51    /// The registry refused access: private image, or no such repository for
52    /// an anonymous caller.
53    Denied,
54    /// The registry says the image or tag does not exist.
55    NotFound,
56    /// The registry could not be reached.
57    Network,
58    Other,
59}
60
61impl PullFailure {
62    /// Whether falling back to the base image can help: the legacy image is
63    /// refused or gone, not merely unreachable right now.
64    pub fn is_unavailable_image(self) -> bool {
65        matches!(self, Self::Denied | Self::NotFound)
66    }
67}
68
69/// Classify a pull error from the daemon's HTTP status (when it had one) and text.
70pub fn classify_pull_error(status: Option<u16>, message: &str) -> PullFailure {
71    let m = message.to_ascii_lowercase();
72    let has = |needles: &[&str]| needles.iter().any(|n| m.contains(n));
73
74    if matches!(status, Some(401 | 403))
75        || has(&[
76            "denied",
77            "unauthorized",
78            "authentication required",
79            "forbidden",
80            "insufficient_scope",
81        ])
82    {
83        return PullFailure::Denied;
84    }
85    if status == Some(404) || has(&["manifest unknown", "name unknown", "not found", "no such image"]) {
86        return PullFailure::NotFound;
87    }
88    if has(&[
89        "no such host",
90        "lookup ",
91        "timeout",
92        "timed out",
93        "deadline exceeded",
94        "connection refused",
95        "connection reset",
96        "tls handshake",
97        "no route to host",
98        "network is unreachable",
99        "dial tcp",
100        "proxyconnect",
101        "unexpected eof",
102    ]) {
103        return PullFailure::Network;
104    }
105    PullFailure::Other
106}
107
108/// The sentence for a failed pull. Names the image, says what happened, and
109/// says what to do next. Never carries the daemon's raw text except for
110/// `Other`, where there is nothing better to say.
111pub fn pull_failure_message(image: &str, kind: PullFailure, detail: &str) -> String {
112    match kind {
113        PullFailure::Denied => format!(
114            "Couldn't start the container for this agent: the registry refused access to {image}. \
115             AgentMux downloads images without signing in, so the image has to be public. \
116             Create the agent to run on this computer (host) instead, or use a public image."
117        ),
118        PullFailure::NotFound => format!(
119            "Couldn't start the container for this agent: {image} was not found in its registry. \
120             Check the image name in the agent's settings, or create the agent to run on this computer (host)."
121        ),
122        PullFailure::Network => format!(
123            "Couldn't start the container for this agent: couldn't reach the registry to download {image}. \
124             Check your internet connection and any proxy set in Docker, then send your message again, \
125             or create the agent to run on this computer (host)."
126        ),
127        PullFailure::Other => format!(
128            "Couldn't start the container for this agent: couldn't download {image}: {}. \
129             Send your message again, or create the agent to run on this computer (host).",
130            truncate(detail.trim(), 300)
131        ),
132    }
133}
134
135/// The sentence for a provider CLI that couldn't be installed in the container.
136pub fn cli_install_failure_message(command: &str, detail: &str) -> String {
137    format!(
138        "Couldn't start the container for this agent: couldn't install {command} in it: {}. \
139         Check your internet connection, then send your message again, \
140         or create the agent to run on this computer (host).",
141        truncate(detail.trim(), 400)
142    )
143}
144
145/// The sentence for a Docker daemon that isn't answering.
146pub fn docker_unreachable_message() -> String {
147    "Couldn't start the container for this agent: Docker isn't responding. \
148     Start Docker Desktop (or your container runtime) and send your message again, \
149     or create the agent to run on this computer (host)."
150        .to_string()
151}
152
153/// The sentence for any other Docker failure while starting the container.
154pub fn docker_error_message(detail: &str) -> String {
155    format!(
156        "Couldn't start the container for this agent: Docker reported an error: {}",
157        truncate(detail.trim(), 300)
158    )
159}
160
161/// Whether a Docker error is the daemon being unreachable, not a request it refused.
162pub fn looks_like_daemon_unreachable(message: &str) -> bool {
163    let m = message.to_ascii_lowercase();
164    [
165        "error trying to connect",
166        "cannot connect to the docker daemon",
167        "is the docker daemon running",
168        "the system cannot find the file specified",
169        "the system cannot find the path specified",
170        "docker.sock",
171        "docker_engine",
172    ]
173    .iter()
174    .any(|n| m.contains(n))
175}
176
177fn truncate(s: &str, max: usize) -> String {
178    if s.chars().count() <= max {
179        return s.to_string();
180    }
181    let cut: String = s.chars().take(max).collect();
182    format!("{cut}...")
183}
184
185/// A parsed image reference: where to ask, which repository, which tag or digest.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct ImageRef {
188    pub registry: String,
189    pub repo: String,
190    pub reference: String,
191}
192
193/// Split `image` the way Docker does. `None` for a reference we won't probe
194/// (a local or port-qualified registry, which may well speak plain HTTP).
195pub fn parse_image_ref(image: &str) -> Option<ImageRef> {
196    let image = image.trim();
197    if image.is_empty() {
198        return None;
199    }
200    let (name, reference) = if let Some((name, digest)) = image.split_once('@') {
201        (name, digest.to_string())
202    } else {
203        let last_slash = image.rfind('/').map_or(0, |i| i + 1);
204        match image[last_slash..].rfind(':') {
205            Some(i) => (&image[..last_slash + i], image[last_slash + i + 1..].to_string()),
206            None => (image, "latest".to_string()),
207        }
208    };
209    if reference.is_empty() {
210        return None;
211    }
212
213    let (registry, repo) = match name.split_once('/') {
214        Some((first, rest)) if first.contains('.') || first.contains(':') || first == "localhost" => {
215            (first.to_string(), rest.to_string())
216        }
217        _ => ("docker.io".to_string(), name.to_string()),
218    };
219    if registry == "localhost" || registry.starts_with("localhost:") || registry.contains(':') {
220        return None;
221    }
222    let (registry, repo) = if registry == "docker.io" || registry == "index.docker.io" {
223        let repo = if repo.contains('/') { repo } else { format!("library/{repo}") };
224        ("registry-1.docker.io".to_string(), repo)
225    } else {
226        (registry, repo)
227    };
228    if repo.is_empty() || repo.contains("..") {
229        return None;
230    }
231    Some(ImageRef { registry, repo, reference })
232}
233
234/// The pieces of a `WWW-Authenticate: Bearer ...` challenge.
235#[derive(Debug, Clone, PartialEq, Eq, Default)]
236pub struct BearerChallenge {
237    pub realm: String,
238    pub service: Option<String>,
239    pub scope: Option<String>,
240}
241
242/// Parse a Bearer challenge; `None` for any other scheme or a missing realm.
243pub fn parse_bearer_challenge(header: &str) -> Option<BearerChallenge> {
244    let rest = header.trim().strip_prefix("Bearer ").or_else(|| header.trim().strip_prefix("bearer "))?;
245    let mut out = BearerChallenge::default();
246    for part in split_challenge_params(rest) {
247        let Some((key, value)) = part.split_once('=') else { continue };
248        let value = value.trim().trim_matches('"').to_string();
249        match key.trim().to_ascii_lowercase().as_str() {
250            "realm" => out.realm = value,
251            "service" => out.service = Some(value),
252            "scope" => out.scope = Some(value),
253            _ => {}
254        }
255    }
256    (!out.realm.is_empty()).then_some(out)
257}
258
259/// Split on commas that are outside double quotes (a scope can hold `,`).
260fn split_challenge_params(s: &str) -> Vec<&str> {
261    let mut parts = Vec::new();
262    let (mut start, mut in_quotes) = (0, false);
263    for (i, c) in s.char_indices() {
264        match c {
265            '"' => in_quotes = !in_quotes,
266            ',' if !in_quotes => {
267                parts.push(s[start..i].trim());
268                start = i + 1;
269            }
270            _ => {}
271        }
272    }
273    parts.push(s[start..].trim());
274    parts.into_iter().filter(|p| !p.is_empty()).collect()
275}
276
277/// Whether `image` can be had here. A stored legacy image is also checked
278/// against the base image it falls back to, so the answer matches what a
279/// start would actually do.
280pub async fn check_image_access(manager: Option<&ContainerManager>, image: &str) -> ContainerImageAccess {
281    let image = resolve_container_image(image);
282    let mut candidates = vec![image.clone()];
283    if is_legacy_agent_image(&image) {
284        candidates.push(DEFAULT_AGENT_IMAGE.to_string());
285    }
286    let mut best: Option<ContainerImageAccess> = None;
287    for candidate in &candidates {
288        let local = match manager {
289            Some(m) => m.has_image_locally(candidate).await,
290            None => false,
291        };
292        let access = if local { ContainerImageAccess::Local } else { probe_anonymous(candidate).await };
293        best = Some(match best {
294            Some(prev) => better_access(prev, access),
295            None => access,
296        });
297    }
298    best.unwrap_or(ContainerImageAccess::Unknown)
299}
300
301/// The better of two answers for "can this be had": something usable beats
302/// something unknown, which beats a refusal.
303pub fn better_access(a: ContainerImageAccess, b: ContainerImageAccess) -> ContainerImageAccess {
304    fn rank(x: ContainerImageAccess) -> u8 {
305        match x {
306            ContainerImageAccess::Local => 4,
307            ContainerImageAccess::Public => 3,
308            ContainerImageAccess::Unknown => 2,
309            ContainerImageAccess::Denied => 1,
310            ContainerImageAccess::NotFound => 0,
311        }
312    }
313    if rank(b) > rank(a) { b } else { a }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn an_empty_stored_image_means_the_public_base_image() {
322        assert_eq!(resolve_container_image(""), DEFAULT_AGENT_IMAGE);
323        assert_eq!(resolve_container_image("  "), DEFAULT_AGENT_IMAGE);
324        assert_eq!(resolve_container_image("my/own:1"), "my/own:1");
325    }
326
327    #[test]
328    fn the_legacy_image_is_recognised_at_any_tag_and_nothing_else_is() {
329        assert!(is_legacy_agent_image("ghcr.io/agentmuxai/agent-claude:latest"));
330        assert!(is_legacy_agent_image("ghcr.io/agentmuxai/agent-claude:2026-06-14"));
331        assert!(is_legacy_agent_image("ghcr.io/agentmuxai/agent-claude"));
332        assert!(is_legacy_agent_image("ghcr.io/agentmuxai/agent-claude@sha256:abc"));
333        assert!(!is_legacy_agent_image("ghcr.io/agentmuxai/agent-claude-extra:latest"));
334        assert!(!is_legacy_agent_image(DEFAULT_AGENT_IMAGE));
335        assert!(!is_legacy_agent_image("someone/agent-claude:latest"));
336    }
337
338    #[test]
339    fn real_daemon_texts_classify_as_denied() {
340        for text in [
341            "Head \"https://ghcr.io/v2/agentmuxai/agent-claude/manifests/latest\": denied",
342            "Error response from daemon: Head \"https://ghcr.io/v2/x/manifests/latest\": unauthorized",
343            "pull access denied for x, repository does not exist or may require 'docker login'",
344            "unauthorized: authentication required",
345            "denied: requested access to the resource is denied",
346        ] {
347            assert_eq!(classify_pull_error(Some(500), text), PullFailure::Denied, "{text}");
348        }
349        assert_eq!(classify_pull_error(Some(401), ""), PullFailure::Denied);
350        assert_eq!(classify_pull_error(Some(403), "whatever"), PullFailure::Denied);
351    }
352
353    #[test]
354    fn missing_images_classify_as_not_found() {
355        assert_eq!(classify_pull_error(Some(404), ""), PullFailure::NotFound);
356        assert_eq!(
357            classify_pull_error(Some(500), "manifest for ghcr.io/x/y:nope not found: manifest unknown: manifest unknown"),
358            PullFailure::NotFound
359        );
360        assert_eq!(classify_pull_error(None, "No such image: foo"), PullFailure::NotFound);
361    }
362
363    #[test]
364    fn unreachable_registries_classify_as_network() {
365        for text in [
366            "Get \"https://ghcr.io/v2/\": dial tcp: lookup ghcr.io on 127.0.0.11:53: no such host",
367            "Get \"https://ghcr.io/v2/\": net/http: TLS handshake timeout",
368            "Get \"https://ghcr.io/v2/\": context deadline exceeded",
369            "dial tcp 140.82.112.34:443: connect: connection refused",
370            "proxyconnect tcp: dial tcp 10.0.0.1:3128: i/o timeout",
371        ] {
372            assert_eq!(classify_pull_error(Some(500), text), PullFailure::Network, "{text}");
373        }
374    }
375
376    #[test]
377    fn denied_wins_over_not_found_when_both_words_appear() {
378        // Docker's own text for a private or missing repo mentions both.
379        assert_eq!(
380            classify_pull_error(Some(500), "pull access denied for x, repository does not exist or not found"),
381            PullFailure::Denied
382        );
383    }
384
385    #[test]
386    fn anything_else_is_other() {
387        assert_eq!(classify_pull_error(Some(500), "toomanyrequests: rate limit"), PullFailure::Other);
388        assert_eq!(classify_pull_error(None, ""), PullFailure::Other);
389    }
390
391    #[test]
392    fn only_a_refused_or_missing_image_is_worth_a_fallback() {
393        assert!(PullFailure::Denied.is_unavailable_image());
394        assert!(PullFailure::NotFound.is_unavailable_image());
395        assert!(!PullFailure::Network.is_unavailable_image());
396        assert!(!PullFailure::Other.is_unavailable_image());
397    }
398
399    #[test]
400    fn every_message_names_the_image_and_a_next_step_and_never_a_raw_docker_error() {
401        for kind in [PullFailure::Denied, PullFailure::NotFound, PullFailure::Network, PullFailure::Other] {
402            let msg = pull_failure_message("ghcr.io/x/y:1", kind, "some detail");
403            assert!(msg.starts_with("Couldn't start the container for this agent"), "{msg}");
404            assert!(msg.contains("ghcr.io/x/y:1"), "{msg}");
405            assert!(msg.contains("(host)"), "offers the host fallback: {msg}");
406            assert!(!msg.contains("Docker API error"), "{msg}");
407        }
408    }
409
410    #[test]
411    fn only_the_catch_all_message_carries_the_daemons_text() {
412        let denied = pull_failure_message("img", PullFailure::Denied, "Head https://secret-host/v2: denied");
413        assert!(!denied.contains("secret-host"));
414        let other = pull_failure_message("img", PullFailure::Other, "odd failure");
415        assert!(other.contains("odd failure"));
416    }
417
418    #[test]
419    fn a_long_detail_is_cut() {
420        let msg = pull_failure_message("img", PullFailure::Other, &"x".repeat(2000));
421        assert!(msg.len() < 700, "{}", msg.len());
422    }
423
424    #[test]
425    fn daemon_down_texts_are_recognised() {
426        assert!(looks_like_daemon_unreachable("error trying to connect: The system cannot find the file specified. (os error 2)"));
427        assert!(looks_like_daemon_unreachable("Cannot connect to the Docker daemon at unix:///var/run/docker.sock"));
428        assert!(!looks_like_daemon_unreachable("Docker responded with status code 409: name conflict"));
429    }
430
431    #[test]
432    fn image_refs_split_like_docker_does() {
433        let r = parse_image_ref("ghcr.io/agentmuxai/agent-base:latest").unwrap();
434        assert_eq!((r.registry.as_str(), r.repo.as_str(), r.reference.as_str()), ("ghcr.io", "agentmuxai/agent-base", "latest"));
435
436        let r = parse_image_ref("ghcr.io/agentmuxai/agent-base").unwrap();
437        assert_eq!(r.reference, "latest");
438
439        let r = parse_image_ref("alpine").unwrap();
440        assert_eq!((r.registry.as_str(), r.repo.as_str(), r.reference.as_str()), ("registry-1.docker.io", "library/alpine", "latest"));
441
442        let r = parse_image_ref("someone/tool:1.2").unwrap();
443        assert_eq!((r.registry.as_str(), r.repo.as_str(), r.reference.as_str()), ("registry-1.docker.io", "someone/tool", "1.2"));
444
445        let r = parse_image_ref("docker.io/library/node:24-slim").unwrap();
446        assert_eq!((r.registry.as_str(), r.repo.as_str()), ("registry-1.docker.io", "library/node"));
447
448        let r = parse_image_ref("ghcr.io/o/r@sha256:0123").unwrap();
449        assert_eq!((r.repo.as_str(), r.reference.as_str()), ("o/r", "sha256:0123"));
450    }
451
452    #[test]
453    fn local_and_port_registries_are_not_probed() {
454        assert_eq!(parse_image_ref("localhost/foo:1"), None);
455        assert_eq!(parse_image_ref("localhost:5000/foo:1"), None);
456        assert_eq!(parse_image_ref("registry.local:5000/foo:1"), None);
457        assert_eq!(parse_image_ref(""), None);
458        assert_eq!(parse_image_ref("foo:"), None);
459    }
460
461    #[test]
462    fn a_bearer_challenge_is_parsed_including_a_scope_with_a_comma() {
463        let c = parse_bearer_challenge(
464            r#"Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:agentmuxai/agent-base:pull""#,
465        )
466        .unwrap();
467        assert_eq!(c.realm, "https://ghcr.io/token");
468        assert_eq!(c.service.as_deref(), Some("ghcr.io"));
469        assert_eq!(c.scope.as_deref(), Some("repository:agentmuxai/agent-base:pull"));
470
471        let c = parse_bearer_challenge(r#"Bearer realm="https://auth.example/t",scope="repository:a/b:pull,push""#).unwrap();
472        assert_eq!(c.scope.as_deref(), Some("repository:a/b:pull,push"));
473        assert_eq!(c.service, None);
474    }
475
476    #[test]
477    fn a_challenge_that_is_not_bearer_or_has_no_realm_is_none() {
478        assert_eq!(parse_bearer_challenge("Basic realm=\"x\""), None);
479        assert_eq!(parse_bearer_challenge("Bearer service=\"x\""), None);
480        assert_eq!(parse_bearer_challenge(""), None);
481    }
482
483    #[test]
484    fn a_usable_answer_beats_unknown_which_beats_a_refusal() {
485        use ContainerImageAccess::*;
486        assert_eq!(better_access(Denied, Public), Public);
487        assert_eq!(better_access(Public, Denied), Public);
488        assert_eq!(better_access(Denied, Unknown), Unknown);
489        assert_eq!(better_access(NotFound, Denied), Denied);
490        assert_eq!(better_access(Local, Public), Local);
491    }
492}