1use crate::backend::container::ContainerManager;
15use crate::backend::registry_probe::probe_anonymous;
16use crate::backend::rpc_types::ContainerImageAccess;
17
18pub const DEFAULT_AGENT_IMAGE: &str = "ghcr.io/agentmuxai/agent-base:latest";
21
22const LEGACY_AGENT_IMAGE_REPO: &str = "ghcr.io/agentmuxai/agent-claude";
25
26pub const CONTAINER_CLI_DIR: &str = "/home/agent/.agentmux/cli";
28
29pub 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
39pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum PullFailure {
51 Denied,
54 NotFound,
56 Network,
58 Other,
59}
60
61impl PullFailure {
62 pub fn is_unavailable_image(self) -> bool {
65 matches!(self, Self::Denied | Self::NotFound)
66 }
67}
68
69pub 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
108pub 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
135pub 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
145pub 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
153pub 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
161pub 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#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct ImageRef {
188 pub registry: String,
189 pub repo: String,
190 pub reference: String,
191}
192
193pub 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#[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
242pub 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
259fn 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
277pub 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
301pub 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 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}