agentmux_srv/backend/container.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Container management for container-type agent panes (Phase 1).
5//!
6//! Manages persistent Docker containers — one per container agent. Each
7//! container runs the configured image (e.g. `ghcr.io/agentmuxai/agent-claude`)
8//! with `tini` as PID 1 and is kept alive between turns. Individual turns are
9//! executed via `docker exec -i` (no `-t` — avoids CR/LF corruption of NDJSON).
10//!
11//! Platform support (cross-platform, matches SPEC_CONTAINER_PANE_SUPPORT_2026_06_11.md):
12//! - Windows: named pipe `//./pipe/docker_engine`
13//! - macOS: socket resolved via `docker context inspect` (Docker Desktop or Rancher)
14//! - Linux: `/var/run/docker.sock` or rootless path in XDG_RUNTIME_DIR
15//!
16//! Independence boundary: this module has no dependency on a5af/claw.
17//! Docker best practices here were researched independently.
18
19use std::collections::HashMap;
20use std::pin::Pin;
21use std::sync::Arc;
22use tokio::io::AsyncWrite;
23use tokio::sync::Mutex;
24use futures_util::StreamExt as _;
25use bollard::Docker;
26use bollard::container::{
27 Config, CreateContainerOptions, ListContainersOptions, StartContainerOptions,
28 StopContainerOptions,
29};
30use bollard::exec::{CreateExecOptions, StartExecOptions, StartExecResults};
31use bollard::image::CreateImageOptions;
32use bollard::models::{ContainerInspectResponse, HostConfig, Mount, MountTypeEnum};
33use crate::backend::container_cli::{
34 check_says_present, install_failure_detail, reap_argv, reap_says_gone, CliInstall, InstallLimits,
35};
36use crate::backend::container_image::{CONTAINER_CLI_DIR,
37 classify_pull_error, cli_install_failure_message, docker_error_message, docker_unreachable_message, is_legacy_agent_image,
38 looks_like_daemon_unreachable, pull_failure_message, PullFailure, DEFAULT_AGENT_IMAGE,
39};
40
41/// Where the agent CLI's config dir lives inside the image (`CLAUDE_CONFIG_DIR`).
42pub const CONTAINER_CLAUDE_DIR: &str = "/home/agent/.claude";
43/// Where the agent's working directory is bind-mounted inside the image.
44pub const CONTAINER_WORKSPACE_DIR: &str = "/workspace";
45
46/// Shared user-defined Docker bridge network every agent container is
47/// attached to, ADDITIVE to whatever `network_mode`/`extra_hosts` already
48/// set up (container→host reachability, unaffected — see the doc comment
49/// on `network_mode` below). Unlike the default bridge network, a
50/// user-defined one gives per-container DNS resolution and lets the
51/// native dev-proxy (`backend::dev_proxy`) route to a container by its
52/// internal IP. See docs/specs/SPEC_NATIVE_CONTAINER_DEV_PROXY_2026_09_19.md §3.1.
53pub const AGENTMUX_DOCKER_NETWORK: &str = "agentmux-agents";
54
55/// Host directories a container agent needs mounted to be able to work.
56///
57/// Both were missing entirely before 2026-09-02, which is why container agents
58/// could never authenticate and always started with an empty `/workspace`. See
59/// `docs/reports/REPORT_CONTAINER_AGENT_CREDENTIALS_AND_WORKSPACE_2026_09_02.md`.
60#[derive(Debug, Clone, Default, PartialEq, Eq)]
61pub struct ContainerMountSpec {
62 /// The bound account's own resolved Claude config dir on the host —
63 /// i.e. whatever `CLAUDE_CONFIG_DIR` the identity resolver produced for this
64 /// agent. Its `.credentials.json` is bind-mounted into the container so the
65 /// CLI there reads the same token the account login wrote (see
66 /// [`agent_home_mounts`] for why it is that one file and not the whole dir).
67 ///
68 /// This is deliberately the ONE account's isolated dir, never the operator's
69 /// global `~/.claude` — reaching into that would be the credential leak the
70 /// named-volume design was avoiding.
71 ///
72 /// `None` keeps the old behavior (an empty per-agent named volume), which is
73 /// correct only for an agent with no bound oauth account.
74 pub claude_config_host_dir: Option<String>,
75 /// The agent's working directory on the host, bind-mounted at
76 /// [`CONTAINER_WORKSPACE_DIR`]. `None`/empty leaves `/workspace` empty, which
77 /// is what shipped before this existed.
78 pub workspace_host_dir: Option<String>,
79}
80
81/// Normalize a host path for Docker's mount API: backslashes are not accepted in
82/// a bind source, so `C:\Users\x` has to travel as `C:/Users/x`.
83fn normalize_host_path(path: &str) -> String {
84 path.replace('\\', "/")
85}
86
87/// Canonical form of a mount source for comparison.
88///
89/// Case folding is Windows-only, deliberately (codex P2 on PR #2933). Docker on
90/// Windows can report a differently-cased drive letter than we asked for, so a
91/// case-sensitive compare there produces phantom drift and a needless recreate.
92/// Folding UNCONDITIONALLY is the opposite bug: on a case-sensitive host,
93/// switching an agent's workspace from `/work/Foo` to the genuinely different
94/// `/work/foo` would look like a match, `ensure_running` would skip the
95/// recreate, and `/workspace` would keep serving the old directory.
96fn fold_mount_source(source: &str) -> String {
97 let normalized = normalize_host_path(source);
98 if cfg!(windows) {
99 normalized.to_lowercase()
100 } else {
101 normalized
102 }
103}
104
105/// The credentials file inside the account's config dir, and inside the image.
106pub const CLAUDE_CREDENTIALS_FILE: &str = ".credentials.json";
107
108/// The account config dir to hand to [`ContainerMountSpec::claude_config_host_dir`],
109/// or `None` when this account has no file-backed credential to mount.
110///
111/// The existence check is load-bearing, not defensive tidiness (codex P1 on PR
112/// #2933). **On macOS Claude Code keeps OAuth credentials in the encrypted
113/// Keychain and never writes this file at all** — see
114/// `identity/resolver/oauth_probe.rs` and
115/// `docs/retro/retro-macos-keychain-credential-isolation-gap-2026-08-17.md`.
116/// Binding a source that does not exist makes Docker reject `create_container`
117/// outright, so an unguarded bind would turn "container agents cannot
118/// authenticate on macOS" into "container agents cannot START on macOS" — a
119/// strictly worse failure than the one this fix exists to remove.
120///
121/// Returning `None` degrades to exactly the previous behavior (an empty per-agent
122/// volume) instead, and logs why. Keychain-backed provisioning is a separate
123/// piece of work; this only guarantees we do not regress it.
124pub fn credentials_dir_if_file_backed(claude_config_host_dir: &str) -> Option<String> {
125 if claude_config_host_dir.is_empty() {
126 return None;
127 }
128 let creds = std::path::Path::new(claude_config_host_dir).join(CLAUDE_CREDENTIALS_FILE);
129 if creds.is_file() {
130 return Some(claude_config_host_dir.to_string());
131 }
132 tracing::warn!(
133 dir = %claude_config_host_dir,
134 "container agent: no {CLAUDE_CREDENTIALS_FILE} in the bound account's config dir — \
135 not mounting credentials (expected on macOS, where Claude Code stores them in the \
136 Keychain). The agent will start but will not be authenticated."
137 );
138 None
139}
140
141/// The mounts that make up the agent's home dir inside the container.
142///
143/// The per-agent named volume stays mounted at [`CONTAINER_CLAUDE_DIR`] exactly
144/// as before, and a bound account contributes ONE extra mount: its
145/// `.credentials.json`, bind-mounted as a single file on top of that volume.
146///
147/// Binding the account's whole config dir over `…/.claude` would be the more
148/// obvious shape, and it does NOT work — both failures verified live on
149/// 2026-09-02 against `ghcr.io/agentmuxai/agent-claude:latest`:
150///
151/// 1. That dir's `projects` entry is a symlink into the shared identities tree,
152/// which does not resolve in-container (`ls` → "No such file or directory").
153/// 2. Shadowing it with a nested volume at `…/.claude/projects` SILENTLY does
154/// not mount — Docker will not mount over a path that is a symlink inside a
155/// bind mount. `mount` shows only the parent 9p bind, and the CLI is left
156/// writing session state through the broken link. Chowning does not help;
157/// there is nothing there to chown.
158///
159/// Keeping the volume and binding just the credential file avoids both, and
160/// preserves the existing ownership story: the volume is initialised from the
161/// image, so `projects`/`sessions` stay writable by uid 1000. Verified end to
162/// end — a container agent using this shape completed a real turn against the
163/// bound account (`"result":"AUTH_OK"`, `is_error: false`).
164///
165/// Known limitation — HOST UID PARITY. A bind preserves the host file's owner
166/// and mode (`.credentials.json` is typically 0600), while the image always
167/// execs as `agent`, uid 1000. On a host whose AgentMux user is not uid 1000,
168/// the container cannot read the token and authentication stays broken there.
169///
170/// That is deliberately NOT worked around here. It is the container design's
171/// pre-existing, documented assumption — "`agent` non-root user (UID 1000) …
172/// Host filesystem UID parity" (SPEC_CONTAINER_PANE_SUPPORT_2026_06_11.md §
173/// "What claw's containers provide"; the Dockerfile renames node:22-slim's
174/// uid-1000 user for exactly this) — and the `/workspace` bind below has the
175/// identical problem: at uid != 1000 the agent cannot write its own workspace
176/// either. Special-casing credentials would fix one symptom of that assumption
177/// and leave the other, while adding a second provisioning path to maintain.
178/// The real fix is to exec as the host uid/gid, which is an architectural change
179/// affecting every mount, and belongs in its own PR.
180///
181/// Known limitation: a single-file bind follows the inode. If the CLI ever
182/// replaces `.credentials.json` by atomic rename rather than writing in place, a
183/// refreshed token would land on the host but not be seen by the running
184/// container until it is recreated. Reads — the case that was totally broken —
185/// are unaffected.
186fn agent_home_mounts(container_name: &str, spec: &ContainerMountSpec) -> Vec<Mount> {
187 let mut mounts = vec![Mount {
188 target: Some(CONTAINER_CLAUDE_DIR.to_string()),
189 source: Some(format!("agentmux-claude-{container_name}")),
190 typ: Some(MountTypeEnum::VOLUME),
191 read_only: Some(false),
192 ..Default::default()
193 }];
194
195 if let Some(host_dir) = spec.claude_config_host_dir.as_deref().filter(|d| !d.is_empty()) {
196 let host_dir = normalize_host_path(host_dir);
197 mounts.push(Mount {
198 target: Some(format!("{CONTAINER_CLAUDE_DIR}/{CLAUDE_CREDENTIALS_FILE}")),
199 source: Some(format!("{}/{CLAUDE_CREDENTIALS_FILE}", host_dir.trim_end_matches('/'))),
200 typ: Some(MountTypeEnum::BIND),
201 // Writable: an in-place token refresh should reach the host copy
202 // rather than being silently confined to the container.
203 read_only: Some(false),
204 ..Default::default()
205 });
206 }
207 mounts
208}
209
210/// Rewrite a host-loopback `AGENTMUX_LOCAL_URL` (e.g. `http://127.0.0.1:PORT`)
211/// into something a container's network namespace can actually reach.
212///
213/// `docker exec` over the Docker socket never inherits the host process's
214/// env the way a host subprocess does, so a container agent gets no sidecar
215/// URL at all unless one is explicitly injected — and injecting the
216/// loopback value verbatim would be reachable from the srv process but not
217/// from inside the container. `host.docker.internal` is resolvable from
218/// Docker Desktop (macOS/Windows) automatically, and from native Linux via
219/// the `extra_hosts: host.docker.internal:host-gateway` entry
220/// `create_and_start` sets — see #2939 workstream 1.
221///
222/// Only rewrites `127.0.0.1`/`localhost`; any other host (already a real
223/// hostname/IP, or already `host.docker.internal`) passes through
224/// unchanged, so this is safe to call unconditionally.
225pub fn rewrite_local_url_for_container(local_url: &str) -> String {
226 local_url
227 .replace("127.0.0.1", "host.docker.internal")
228 .replace("localhost", "host.docker.internal")
229}
230
231/// Env var names that reference host-filesystem paths and must NOT be forwarded
232/// into a container via `docker exec -e`. The container image supplies its own
233/// values for these (e.g. `CLAUDE_CONFIG_DIR=/home/agent/.claude` baked in).
234///
235/// `CLAUDE_CONFIG_DIR` being here is why a bound account's credentials cannot
236/// reach the container as an env var, and therefore why they are MOUNTED
237/// instead — see [`ContainerMountSpec::claude_config_host_dir`].
238pub const CONTAINER_ENV_DENYLIST: &[&str] = &[
239 "CLAUDE_CONFIG_DIR",
240 // Deliberately dropped even though `backend::gh_guard` sets it on every
241 // agent spawn: the guard dir is a host path that means nothing inside the
242 // image, and the image controls its own `gh` state.
243 "GH_CONFIG_DIR",
244 "PATH",
245 "HOME",
246 "USERPROFILE",
247 "HOMEDRIVE",
248 "HOMEPATH",
249 "TMPDIR",
250 "TEMP",
251 "TMP",
252 // The host path of the agent's workspace; srv sets it for host agents
253 // only, this keeps a stale `cmd:env` copy out of a container too.
254 "AGENTMUX_AGENT_WORKDIR",
255 // A host path; the image's own wrapper may predate prefix mode (SPEC_BASH_STREAMING_VIA_SHELL_PREFIX §2.4).
256 "CLAUDE_CODE_SHELL_PREFIX",
257];
258
259/// Shared container manager. Clone-on-Arc; cheap to pass around.
260#[derive(Clone)]
261pub struct ContainerManager {
262 inner: Arc<ContainerManagerInner>,
263}
264
265struct ContainerManagerInner {
266 docker: Docker,
267 /// Per-container serialization lock: prevents concurrent ensure_running calls
268 /// for the same container from both seeing "not found" in Docker and both
269 /// attempting create_container (which would fail with a 409 name-conflict
270 /// on the second caller).
271 ensure_locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
272 /// Per-container `(uid, gid)` of the user execs run as, resolved once and
273 /// cached — see [`ContainerManager::exec_identity`].
274 exec_identities: Mutex<HashMap<String, (u64, u64)>>,
275 /// `(container, install marker)` pairs already confirmed installed, so a
276 /// running agent pays the check once, not on every turn. Dropped when the
277 /// container is created or removed.
278 cli_ready: Mutex<std::collections::HashSet<String>>,
279 /// Serializes `provision_cli` per container: two quick messages must not
280 /// run two `npm install`s into the same directory.
281 cli_locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
282 /// Dev-proxy routing table, wired in post-construction by
283 /// [`ContainerManager::attach_dev_proxy_registry`] (called by
284 /// `ContainerRuntimeHandle`, which re-attaches after every reconnect
285 /// since each reconnect creates a fresh `ContainerManagerInner`).
286 /// `stop`/`remove` clear this agent's registrations when set — see
287 /// `cleanup_dev_proxy_routes`. Unset (`None` via `OnceLock`) in tests
288 /// and any path that never wires it up; cleanup is then simply
289 /// skipped, never an error. See
290 /// docs/specs/SPEC_NATIVE_CONTAINER_DEV_PROXY_2026_09_19.md.
291 dev_proxy: std::sync::OnceLock<crate::backend::dev_proxy::DevProxyRegistry>,
292}
293
294/// Exec session handle for a single turn.
295///
296/// `input` is the stdin pipe into the container process. It is ONLY usable for a
297/// process that completes on a newline: the write half can never be closed, so
298/// stdin never reaches EOF (see [`ContainerManager::exec`]). Turn input goes in
299/// a file instead — NOT argv, which would leak a pasted secret via `docker top`
300/// / host `ps` / `/proc/<pid>/cmdline`; see
301/// [`ContainerManager::upload_turn_prompt`]. `output` is a bollard `LogOutput`
302/// stream; with
303/// `tty: false` Docker multiplexes stdout as `LogOutput::StdOut` frames and
304/// stderr as `LogOutput::StdErr` frames.
305///
306/// Env vars are passed via `CreateExecOptions.env` (Docker socket) — they never
307/// appear in process argv, preventing CWE-214 credential exposure via ps/proc.
308pub struct ExecSession {
309 /// Docker exec id — pass to [`ContainerManager::inspect_exec`] after the
310 /// output stream ends to retrieve the process's real exit code. The stream
311 /// ending is NOT the exit status, so callers that care about success/failure
312 /// must inspect the exec (the in-container CLI can exit non-zero while still
313 /// closing stdout cleanly).
314 pub exec_id: String,
315 /// Stdin pipe to the container process. Unusable for EOF-terminated readers
316 /// — see the type-level doc and [`ContainerManager::exec`]'s `attach_stdin`.
317 pub input: Pin<Box<dyn AsyncWrite + Send>>,
318 /// Multiplexed stdout/stderr stream (LogOutput::StdOut / LogOutput::StdErr).
319 pub output: Pin<Box<dyn futures_util::Stream<Item = Result<bollard::container::LogOutput, bollard::errors::Error>> + Send>>,
320}
321
322/// Parse `id -u; id -g` output into `(uid, gid)`.
323///
324/// Split out so the parse is testable without a Docker daemon, and so a
325/// malformed or empty probe result is `None` — never a silent `(0, 0)`, which
326/// would produce a prompt file the exec user can neither read nor delete.
327fn parse_id_output(out: &str) -> Option<(u64, u64)> {
328 let mut fields = out.split_whitespace();
329 let uid: u64 = fields.next()?.parse().ok()?;
330 let gid: u64 = fields.next()?.parse().ok()?;
331 Some((uid, gid))
332}
333
334#[cfg(test)]
335mod exec_identity_tests {
336 use super::parse_id_output;
337
338 #[test]
339 fn parses_the_ordinary_two_line_form() {
340 assert_eq!(parse_id_output("1000\n1000\n"), Some((1000, 1000)));
341 }
342
343 #[test]
344 fn parses_root_and_a_split_uid_gid() {
345 assert_eq!(parse_id_output("0\n0\n"), Some((0, 0)));
346 assert_eq!(parse_id_output("1000\n2000\n"), Some((1000, 2000)));
347 }
348
349 /// Docker multiplexes frames, so the two numbers may arrive however they
350 /// arrive — whitespace splitting must not care.
351 #[test]
352 fn tolerates_arbitrary_whitespace_and_framing() {
353 assert_eq!(parse_id_output(" 1000 \r\n 1000 \r\n"), Some((1000, 1000)));
354 assert_eq!(parse_id_output("1000 1000"), Some((1000, 1000)));
355 }
356
357 /// The whole point of returning Option: a failed or truncated probe must
358 /// NOT silently become root (reagent P1, PR #2883).
359 #[test]
360 fn a_missing_or_malformed_probe_is_none_not_root() {
361 assert_eq!(parse_id_output(""), None);
362 assert_eq!(parse_id_output("1000"), None, "gid missing");
363 assert_eq!(parse_id_output("id: command not found"), None);
364 assert_eq!(parse_id_output("uid=1000(agent)"), None, "not the -u form");
365 assert_eq!(parse_id_output("-1\n-1\n"), None, "negative is not a uid");
366 }
367}
368
369/// Errors from container operations.
370#[derive(Debug, thiserror::Error)]
371pub enum ContainerError {
372 #[error("Docker API error: {0}")]
373 Docker(#[from] bollard::errors::Error),
374 #[error("Container has no id after create: {name}")]
375 NoId { name: String },
376 #[error("Docker is not available on this host: {0}")]
377 NotAvailable(String),
378 /// The image could not be downloaded. `detail` is the daemon's own text,
379 /// kept for logs; the pane gets [`ContainerError::user_message`].
380 #[error("could not pull image {image}: {detail}")]
381 ImagePull {
382 image: String,
383 kind: PullFailure,
384 detail: String,
385 },
386 /// The provider CLI could not be installed in the container.
387 #[error("could not install {command} in the container: {detail}")]
388 CliInstall { command: String, detail: String },
389}
390
391impl ContainerError {
392 /// What to tell the person using the agent: what happened and what to do
393 /// next, never the raw Docker error.
394 pub fn user_message(&self) -> String {
395 match self {
396 Self::ImagePull { image, kind, detail } => pull_failure_message(image, *kind, detail),
397 Self::Docker(e) => {
398 let text = e.to_string();
399 if looks_like_daemon_unreachable(&text) {
400 docker_unreachable_message()
401 } else {
402 docker_error_message(&text)
403 }
404 }
405 Self::NotAvailable(_) => docker_unreachable_message(),
406 Self::NoId { .. } => docker_error_message("the container was created without an id"),
407 Self::CliInstall { command, detail } => cli_install_failure_message(command, detail),
408 }
409 }
410}
411
412impl ContainerManager {
413 /// Connect to the local Docker daemon using environment/platform defaults.
414 ///
415 /// Honors `DOCKER_HOST` env var (e.g. for rootless or remote daemons).
416 /// On Windows connects via named pipe; on macOS/Linux via Unix socket.
417 pub fn connect() -> Result<Self, ContainerError> {
418 let docker = Docker::connect_with_local_defaults()
419 .map_err(|e| ContainerError::NotAvailable(e.to_string()))?;
420 Ok(Self {
421 inner: Arc::new(ContainerManagerInner {
422 docker,
423 ensure_locks: Mutex::new(HashMap::new()),
424 exec_identities: Mutex::new(HashMap::new()),
425 cli_ready: Mutex::new(std::collections::HashSet::new()),
426 cli_locks: Mutex::new(HashMap::new()),
427 dev_proxy: std::sync::OnceLock::new(),
428 }),
429 })
430 }
431
432 /// Wires a dev-proxy routing table into this manager so `stop`/`remove`
433 /// can clear an agent's registrations when its container goes away.
434 /// Set once per `ContainerManager` instance (a second call is a
435 /// harmless no-op — `OnceLock::set` fails silently, which is fine
436 /// here: the registry itself doesn't change after boot).
437 /// See docs/specs/SPEC_NATIVE_CONTAINER_DEV_PROXY_2026_09_19.md.
438 pub fn attach_dev_proxy_registry(&self, registry: crate::backend::dev_proxy::DevProxyRegistry) {
439 let _ = self.inner.dev_proxy.set(registry);
440 }
441
442 /// This container's IP address on [`AGENTMUX_DOCKER_NETWORK`] — what
443 /// the dev-proxy registry stores as the routable backend address.
444 /// `None` if the container isn't running, isn't on the network yet, or
445 /// Docker is unreachable. Always a fresh `inspect_container`, never
446 /// cached — the caller (`app_api::dev_server::handle_register_dev_server`)
447 /// needs the CURRENT address, not one from whenever the container was
448 /// first created.
449 pub async fn agent_network_ip(&self, container_name: &str) -> Option<String> {
450 let details = self.inner.docker.inspect_container(container_name, None).await.ok()?;
451 details
452 .network_settings?
453 .networks?
454 .get(AGENTMUX_DOCKER_NETWORK)?
455 .ip_address
456 .clone()
457 .filter(|s| !s.is_empty())
458 }
459
460 /// Ping the Docker daemon. Returns `Ok(())` if available.
461 pub async fn check_available(&self) -> Result<(), ContainerError> {
462 self.inner.docker.ping().await?;
463 Ok(())
464 }
465
466 /// Ensure the container for this agent is created and running.
467 ///
468 /// - If it exists and is running: no-op.
469 /// - If it exists but stopped: starts it.
470 /// - If it doesn't exist: creates and starts it.
471 ///
472 /// Always queries Docker (no in-memory cache) so externally killed containers
473 /// are detected. Concurrent calls for the **same** `container_name` are
474 /// serialized via a per-container mutex. Concurrent calls for **different**
475 /// containers proceed in parallel.
476 pub async fn ensure_running(
477 &self,
478 container_name: &str,
479 image: &str,
480 volumes: &[String],
481 env_vars: &[(String, String)],
482 spec: &ContainerMountSpec,
483 ) -> Result<(), ContainerError> {
484 // Acquire the per-container serialization lock before touching Docker.
485 // Concurrent callers for the same container_name queue here; once the
486 // first caller completes, the second finds the container already running
487 // via the Docker query below and returns immediately.
488 let container_lock = {
489 let mut locks = self.inner.ensure_locks.lock().await;
490 locks.entry(container_name.to_string())
491 .or_insert_with(|| Arc::new(Mutex::new(())))
492 .clone()
493 };
494 let guard = container_lock.lock().await;
495
496 let result = self.ensure_running_locked(container_name, image, volumes, env_vars, spec).await;
497
498 // Release the per-container lock, then evict its map entry when no other
499 // caller is queued on it — otherwise `ensure_locks` grows unbounded (one
500 // entry per distinct container ever seen) over the process lifetime.
501 // A queued caller has already cloned the Arc (strong_count > 2), so we
502 // only drop the entry when it's truly idle (map's ref + our clone == 2),
503 // which can't reintroduce the create-race the lock guards: a new caller
504 // can't clone the old Arc while we hold the map lock, and after eviction
505 // it just creates a fresh one with no contention.
506 drop(guard);
507 {
508 let mut locks = self.inner.ensure_locks.lock().await;
509 if Arc::strong_count(&container_lock) <= 2 {
510 locks.remove(container_name);
511 }
512 }
513
514 result
515 }
516
517 /// Create/reuse logic for [`ensure_running`], run while holding the
518 /// per-container serialization lock. Split out so `ensure_running` can wrap
519 /// it with lock acquisition + map-entry eviction on every exit path.
520 async fn ensure_running_locked(
521 &self,
522 container_name: &str,
523 image: &str,
524 volumes: &[String],
525 env_vars: &[(String, String)],
526 spec: &ContainerMountSpec,
527 ) -> Result<(), ContainerError> {
528 // Always query Docker — do not rely on an in-memory cache. The container
529 // can be stopped or removed externally (e.g. `docker rm -f`), and a
530 // stale cache entry would cause ensure_running to silently no-op while
531 // a subsequent exec call fails.
532 let existing = self.find_container(container_name).await?;
533
534 // A container created before this agent had credentials (or a workspace)
535 // keeps those mounts for its whole life — `docker` cannot add a mount to
536 // an existing container. Without this check the fix would silently never
537 // reach any agent whose container is already up, which is every agent
538 // that has ever run. Recreate on drift; the per-agent named volume
539 // carries session/project state across the swap.
540 let existing = match existing {
541 Some(status) => match self.container_drift(container_name, spec).await {
542 Some(reason) => {
543 tracing::info!(container = container_name, reason, "container spec drift detected — recreating");
544 let _ = self.stop(container_name, 10).await;
545 self.remove(container_name, true).await?;
546 None
547 }
548 None => Some(status),
549 },
550 None => None,
551 };
552
553 match existing {
554 Some(status) if status == "running" => {
555 // Already running — nothing to do.
556 }
557 Some(_) => {
558 // Exists but stopped — start it.
559 self.inner.docker
560 .start_container(container_name, None::<StartContainerOptions<String>>)
561 .await?;
562 tracing::info!(container = container_name, "restarted stopped container");
563 }
564 None => {
565 // Pull the image via Docker socket before create so the Engine API
566 // has the image locally. Unlike `docker run`, the Engine's
567 // create_container does NOT auto-pull — it returns 404 if the image
568 // is absent. pull_image is a no-op when the image already exists.
569 let image = self.pull_with_fallback(image).await?;
570 // Create and start.
571 self.create_and_start(container_name, &image, volumes, env_vars, spec).await?;
572 tracing::info!(container = container_name, image = image, "created and started container");
573 }
574 }
575
576 // Additive, best-effort: attach to the shared dev-proxy network
577 // regardless of which branch above ran, so an already-running
578 // container from before this feature shipped still gets picked up
579 // on its next turn. See `ensure_agent_network`'s own doc comment.
580 self.ensure_agent_network(container_name).await;
581
582 Ok(())
583 }
584
585 /// Launch an exec session inside a running container.
586 ///
587 /// Uses `-i` (not `-t`) to avoid tty CR/LF corruption of NDJSON output.
588 /// The caller receives `ExecSession` whose `output` stream carries
589 /// multiplexed stdout/stderr for piping into the block.
590 ///
591 /// `attach_stdin` MUST be `false` unless the in-container process is a
592 /// reader that completes on a newline rather than on EOF. Attaching stdin
593 /// is a one-way door: bollard's hijacked exec stream cannot half-close the
594 /// write side (see the `exec (io)` integration test below), and on Windows
595 /// the Docker transport is a named pipe, which has no half-close at all —
596 /// so the process's stdin NEVER reaches EOF. Neither `drop(input)` nor an
597 /// explicit `input.shutdown().await` changes that; both were tried against
598 /// a live container on 2026-09-01 and the process hung indefinitely.
599 ///
600 /// Any command that reads stdin to EOF (`cat`, or `claude -p` taking its
601 /// prompt on stdin) will therefore hang forever, producing no output and
602 /// no exit. Give it a file to read instead — see
603 /// [`ContainerManager::upload_turn_prompt`], which also covers why argv and
604 /// env are both the wrong place for that input.
605 pub async fn exec(
606 &self,
607 container_name: &str,
608 cmd: &[String],
609 working_dir: Option<&str>,
610 env_vars: &[(String, String)],
611 attach_stdin: bool,
612 ) -> Result<ExecSession, ContainerError> {
613 let env: Vec<String> = env_vars.iter()
614 .map(|(k, v)| format!("{k}={v}"))
615 .collect();
616
617 let exec = self.inner.docker
618 .create_exec(container_name, CreateExecOptions {
619 attach_stdin: Some(attach_stdin),
620 attach_stdout: Some(true),
621 attach_stderr: Some(true),
622 tty: Some(false), // NO tty — preserves NDJSON newlines
623 cmd: Some(cmd.iter().map(String::as_str).collect()),
624 working_dir,
625 env: if env.is_empty() { None } else { Some(env.iter().map(String::as_str).collect()) },
626 ..Default::default()
627 })
628 .await?;
629
630 let results = self.inner.docker
631 .start_exec(&exec.id, Some(StartExecOptions {
632 detach: false,
633 ..Default::default()
634 }))
635 .await?;
636
637 match results {
638 StartExecResults::Attached { input, output } => {
639 Ok(ExecSession { exec_id: exec.id, input, output })
640 }
641 StartExecResults::Detached => {
642 Err(ContainerError::Docker(
643 bollard::errors::Error::DockerResponseServerError {
644 status_code: 0,
645 message: "start_exec returned Detached unexpectedly (detach=false)".to_string(),
646 }
647 ))
648 }
649 }
650 }
651
652 /// The `(uid, gid)` that `exec` runs as inside this container.
653 ///
654 /// Needed so an uploaded file can be OWNED by that user. A tar entry
655 /// defaults to uid 0, and `/tmp` is sticky (`1777`) in every standard
656 /// image — under which a non-root process cannot unlink a root-owned file
657 /// no matter what its mode is. That is not theoretical: it silently leaked
658 /// one prompt file per turn (`rm -f` fails quietly), including multi-
659 /// hundred-KB ones, until a live check caught the files piling up.
660 ///
661 /// Resolved by asking the container rather than assuming a uid, so this
662 /// holds for any image regardless of which user it runs as.
663 ///
664 /// **Only a successful probe is cached.** An earlier cut cached a `(0, 0)`
665 /// fallback on any failure, including a transient Docker hiccup — which
666 /// poisoned the cache for the container's whole life (reagent P1, PR
667 /// #2883). That is not a degraded mode but a permanent break: at mode
668 /// 0600 a root-owned prompt file is unreadable AND un-unlinkable by the
669 /// real exec user, so every later turn on that container would fail to
670 /// open its prompt and orphan another file. A failure now returns `None`
671 /// and is retried on the next turn.
672 async fn exec_identity(&self, container_name: &str) -> Option<(u64, u64)> {
673 if let Some(hit) = self.inner.exec_identities.lock().await.get(container_name) {
674 return Some(*hit);
675 }
676
677 let probe = async {
678 let session = self
679 .exec(
680 container_name,
681 &["sh".to_string(), "-c".to_string(), "id -u; id -g".to_string()],
682 None,
683 &[],
684 false,
685 )
686 .await
687 .ok()?;
688 let mut out = String::new();
689 let mut stream = session.output;
690 while let Some(Ok(frame)) = stream.next().await {
691 out.push_str(&frame.to_string());
692 }
693 parse_id_output(&out)
694 }
695 .await;
696
697 match probe {
698 Some(resolved) => {
699 self.inner
700 .exec_identities
701 .lock()
702 .await
703 .insert(container_name.to_string(), resolved);
704 Some(resolved)
705 }
706 None => {
707 // NOT cached — the next turn probes again.
708 tracing::warn!(
709 container = container_name,
710 "could not resolve container exec uid/gid; will retry next turn",
711 );
712 None
713 }
714 }
715 }
716
717 /// Remove a prompt file uploaded by [`upload_turn_prompt`].
718 ///
719 /// The turn's own wrapper script removes the file after the CLI exits, so
720 /// this is only for the path where the wrapper never runs at all — the
721 /// upload succeeded but starting the exec failed, which would otherwise
722 /// orphan the file (reagent P2, PR #2883).
723 ///
724 /// Best-effort by design: it runs as the same user that owns the file, and
725 /// a failure here is not worth failing an already-failing turn over.
726 pub async fn remove_turn_prompt(&self, container_name: &str, prompt_path: &str) {
727 let removed = self
728 .exec(
729 container_name,
730 &[
731 "sh".to_string(),
732 "-c".to_string(),
733 r#"rm -f "$1""#.to_string(),
734 "agentmux-cleanup".to_string(),
735 prompt_path.to_string(),
736 ],
737 None,
738 &[],
739 false,
740 )
741 .await;
742 match removed {
743 Ok(session) => {
744 // Drive the stream to completion so the exec actually runs —
745 // Docker starts it lazily on the attached connection.
746 let mut stream = session.output;
747 while let Some(Ok(_)) = stream.next().await {}
748 }
749 Err(e) => tracing::warn!(
750 container = container_name,
751 path = prompt_path,
752 "could not remove orphaned prompt file: {e}",
753 ),
754 }
755 }
756
757 /// Write a turn's prompt into the container as a file, returning its
758 /// absolute in-container path.
759 ///
760 /// This exists because a container turn has no other way to hand the CLI
761 /// its prompt:
762 ///
763 /// * **exec stdin** can never reach EOF — bollard can't half-close the
764 /// hijacked stream's write side, and on Windows the Docker transport is
765 /// a named pipe, which has no half-close at all. A CLI that reads to
766 /// EOF (`claude -p`) hangs forever with no output and no exit.
767 /// * **argv** would expose any secret pasted into a chat message via
768 /// `docker top` / host `ps` / `/proc/<pid>/cmdline`, breaking this
769 /// module's own no-secrets-in-argv invariant (reagent P1, PR #2883).
770 /// * **an env var** keeps it off those host surfaces, but shares argv's
771 /// per-string `MAX_ARG_STRLEN` ceiling — measured in this very image at
772 /// ~128 KiB (130,000 B passes, 200,000 B fails with "Argument list too
773 /// long"). A long paste or a large `# Session Context` would break the
774 /// turn (codex P2, PR #2883).
775 ///
776 /// A file has none of those limits: unbounded size, invisible to every
777 /// host-side process listing, and redirecting from it gives the CLI a real
778 /// stdin that reaches a real EOF.
779 ///
780 /// Ownership is the exec user's (see [`exec_identity`]) so the turn can
781 /// delete the file afterwards. Mode is 0600: only that user ever needs it,
782 /// and it is the same user the CLI already runs as.
783 pub async fn upload_turn_prompt(
784 &self,
785 container_name: &str,
786 turn_id: &str,
787 message: &str,
788 ) -> Result<String, ContainerError> {
789 // No usable uid means no usable file: at 0600 a root-owned prompt is
790 // unreadable by the exec user, and /tmp's sticky bit makes it
791 // un-unlinkable too. Failing here is a clear, retryable error; writing
792 // it anyway would be a permission-denied on open plus a leaked file.
793 let (uid, gid) = self.exec_identity(container_name).await.ok_or_else(|| {
794 ContainerError::NotAvailable(format!(
795 "could not resolve the exec user of container {container_name}; \
796 cannot write a readable turn prompt"
797 ))
798 })?;
799 let file_name = format!("agentmux-turn-{turn_id}");
800 let bytes = message.as_bytes();
801
802 let mut header = tar::Header::new_gnu();
803 header.set_size(bytes.len() as u64);
804 header.set_mode(0o600);
805 header.set_uid(uid);
806 header.set_gid(gid);
807 header.set_mtime(0);
808 header.set_cksum();
809
810 let mut archive = tar::Builder::new(Vec::new());
811 archive
812 .append_data(&mut header, &file_name, bytes)
813 .map_err(|e| ContainerError::NotAvailable(format!("tar build failed: {e}")))?;
814 let tar_bytes = archive
815 .into_inner()
816 .map_err(|e| ContainerError::NotAvailable(format!("tar finish failed: {e}")))?;
817
818 self.inner
819 .docker
820 .upload_to_container(
821 container_name,
822 Some(bollard::container::UploadToContainerOptions {
823 path: "/tmp",
824 no_overwrite_dir_non_dir: "true",
825 }),
826 tar_bytes.into(),
827 )
828 .await?;
829
830 Ok(format!("/tmp/{file_name}"))
831 }
832
833 /// Retrieve the exit code of a finished exec via the Docker socket.
834 ///
835 /// Returns `Ok(Some(code))` once the exec has exited, `Ok(None)` while it is
836 /// still running (no code yet) or if Docker did not report one. Call this
837 /// after the exec's output stream has ended — unlike a child process's
838 /// `wait()`, the attached output stream closing does not carry the exit
839 /// status, so the turn's success/failure can only be known by inspecting.
840 pub async fn inspect_exec(&self, exec_id: &str) -> Result<Option<i64>, ContainerError> {
841 let info = self.inner.docker.inspect_exec(exec_id).await?;
842 // `running == Some(true)` means no meaningful code yet.
843 if info.running == Some(true) {
844 return Ok(None);
845 }
846 Ok(info.exit_code)
847 }
848
849 /// Run `argv` in the container and collect its output and exit code.
850 async fn exec_collect(
851 &self,
852 container_name: &str,
853 argv: &[String],
854 limit: std::time::Duration,
855 ) -> Result<ExecOutcome, ContainerError> {
856 const KEEP_BYTES: usize = 16 * 1024;
857 let session = self.exec(container_name, argv, None, &[], false).await?;
858 let exec_id = session.exec_id.clone();
859 let mut stream = session.output;
860 let mut output = String::new();
861 let drain = async {
862 while let Some(item) = stream.next().await {
863 let chunk = item?;
864 output.push_str(&String::from_utf8_lossy(&chunk.into_bytes()));
865 if output.len() > KEEP_BYTES {
866 let mut cut = output.len() - KEEP_BYTES;
867 while !output.is_char_boundary(cut) {
868 cut += 1;
869 }
870 output.drain(..cut);
871 }
872 }
873 Ok::<(), bollard::errors::Error>(())
874 };
875 let timed_out = match tokio::time::timeout(limit, drain).await {
876 Ok(result) => {
877 result?;
878 false
879 }
880 Err(_) => true,
881 };
882 let exit = if timed_out { None } else { self.inspect_exec(&exec_id).await? };
883 Ok(ExecOutcome { exec_id, exit, output, timed_out })
884 }
885
886 /// Poll until the exec has stopped running, or `limit` passes. True when it
887 /// is confirmed finished.
888 async fn wait_exec_finished(&self, exec_id: &str, limit: std::time::Duration) -> bool {
889 let deadline = std::time::Instant::now() + limit;
890 loop {
891 match self.docker_exec_running(exec_id).await {
892 Some(false) => return true,
893 Some(true) => {}
894 None => return false,
895 }
896 if std::time::Instant::now() >= deadline {
897 return false;
898 }
899 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
900 }
901 }
902
903 /// Whether the exec is still running; `None` when Docker can't say.
904 async fn docker_exec_running(&self, exec_id: &str) -> Option<bool> {
905 self.inner.docker.inspect_exec(exec_id).await.ok().map(|i| i.running == Some(true))
906 }
907
908 /// Kill any `npm install` into the CLI directory and wait until none is left.
909 /// True only when that is confirmed.
910 async fn reap_cli_install(&self, container_name: &str) -> bool {
911 match self
912 .exec_collect(container_name, &reap_argv(), std::time::Duration::from_secs(30))
913 .await
914 {
915 Ok(out) => out.exit == Some(0) && reap_says_gone(&out.output),
916 Err(e) => {
917 tracing::warn!(container = container_name, error = %e, "could not check for a running CLI install");
918 false
919 }
920 }
921 }
922
923 /// Make sure the provider CLI is usable in this container, installing it on
924 /// first use. `on_install_start` runs just before an install begins (the
925 /// caller shows "Installing ..." then), never when nothing needs installing.
926 pub async fn provision_cli(
927 &self,
928 container_name: &str,
929 install: &CliInstall,
930 on_install_start: impl FnOnce(),
931 ) -> Result<CliProvision, ContainerError> {
932 self.provision_cli_with(container_name, install, InstallLimits::default(), on_install_start).await
933 }
934
935 /// [`provision_cli`](Self::provision_cli) with explicit time limits.
936 async fn provision_cli_with(
937 &self,
938 container_name: &str,
939 install: &CliInstall,
940 limits: InstallLimits,
941 on_install_start: impl FnOnce(),
942 ) -> Result<CliProvision, ContainerError> {
943 let key = install.cache_key(container_name);
944 if self.inner.cli_ready.lock().await.contains(&key) {
945 return Ok(CliProvision::Present);
946 }
947
948 let lock = {
949 let mut locks = self.inner.cli_locks.lock().await;
950 locks.entry(container_name.to_string()).or_insert_with(|| Arc::new(Mutex::new(()))).clone()
951 };
952 let guard = lock.lock().await;
953 let result = self.provision_cli_locked(container_name, install, &key, limits, on_install_start).await;
954 drop(guard);
955 {
956 let mut locks = self.inner.cli_locks.lock().await;
957 if Arc::strong_count(&lock) <= 2 {
958 locks.remove(container_name);
959 }
960 }
961 result
962 }
963
964 async fn provision_cli_locked(
965 &self,
966 container_name: &str,
967 install: &CliInstall,
968 key: &str,
969 limits: InstallLimits,
970 on_install_start: impl FnOnce(),
971 ) -> Result<CliProvision, ContainerError> {
972 if self.inner.cli_ready.lock().await.contains(key) {
973 return Ok(CliProvision::Present);
974 }
975 let check = self
976 .exec_collect(container_name, &install.check_argv(), std::time::Duration::from_secs(30))
977 .await?;
978 if check.exit == Some(0) && check_says_present(&check.output) {
979 self.inner.cli_ready.lock().await.insert(key.to_string());
980 return Ok(CliProvision::Present);
981 }
982
983 on_install_start();
984
985 // An earlier attempt may still be running (it timed out, or srv restarted
986 // under it). A second npm into the same volume could corrupt it, so
987 // nothing starts until the first is confirmed gone.
988 let fail = |detail: String| ContainerError::CliInstall { command: install.command.clone(), detail };
989 if !self.reap_cli_install(container_name).await {
990 return Err(fail("an earlier install is still running; wait a minute and try again".to_string()));
991 }
992
993 tracing::info!(container = container_name, command = %install.command, specs = ?install.specs, "installing the provider CLI in the container");
994 let started = std::time::Instant::now();
995 let run = self
996 .exec_collect(container_name, &install.install_argv(limits), limits.run)
997 .await?;
998 if run.exit == Some(0) {
999 self.inner.cli_ready.lock().await.insert(key.to_string());
1000 return Ok(CliProvision::Installed { seconds: started.elapsed().as_secs_f64() });
1001 }
1002
1003 // The host-side limit passed while the install was still running. Stop
1004 // it and confirm it is gone BEFORE returning: the caller's lock is held
1005 // until then, so a retry cannot start a second install on top of it.
1006 let stopped = if run.timed_out {
1007 let reaped = self.reap_cli_install(container_name).await;
1008 let finished = self.wait_exec_finished(&run.exec_id, std::time::Duration::from_secs(30)).await;
1009 Some(reaped && finished)
1010 } else {
1011 None
1012 };
1013 let detail = install_failure_detail(run.exit, run.timed_out, stopped, &run.output);
1014 tracing::warn!(container = container_name, command = %install.command, detail = %detail, "provider CLI install failed");
1015 Err(fail(detail))
1016 }
1017
1018 async fn forget_cli_ready(&self, container_name: &str) {
1019 let prefix = format!("{container_name}\u{0}");
1020 self.inner.cli_ready.lock().await.retain(|k| !k.starts_with(&prefix));
1021 }
1022
1023 /// Best-effort interruption of the turn's process(es) inside a container.
1024 ///
1025 /// Docker/bollard has no "kill exec" API, so we `pkill` the matching process
1026 /// via a short detached exec. The persistent-container model runs one turn at
1027 /// a time (guarded by `run_lock`), so a single CLI process matches `pattern`
1028 /// (the command name, e.g. `claude`). `-f` matches the full command line
1029 /// because the CLI runs under `node`, whose process name isn't the CLI's.
1030 ///
1031 /// Requires `pkill` (procps) in the image. Fire-and-forget (detached); a
1032 /// non-match (`pkill` exit 1, e.g. the turn already exited) is not an error.
1033 pub async fn signal_exec_process(
1034 &self,
1035 container_name: &str,
1036 pattern: &str,
1037 force: bool,
1038 ) -> Result<(), ContainerError> {
1039 let signal = if force { "-KILL" } else { "-TERM" };
1040 let cmd: Vec<&str> = vec!["pkill", signal, "-f", pattern];
1041 let exec = self.inner.docker
1042 .create_exec(container_name, CreateExecOptions {
1043 cmd: Some(cmd),
1044 attach_stdout: Some(false),
1045 attach_stderr: Some(false),
1046 ..Default::default()
1047 })
1048 .await?;
1049 self.inner.docker
1050 .start_exec(&exec.id, Some(StartExecOptions { detach: true, ..Default::default() }))
1051 .await?;
1052 Ok(())
1053 }
1054
1055 /// Gracefully stop a container. Uses SIGTERM → SIGKILL after `timeout_secs`.
1056 pub async fn stop(&self, container_name: &str, timeout_secs: i64) -> Result<(), ContainerError> {
1057 self.inner.docker
1058 .stop_container(container_name, Some(StopContainerOptions { t: timeout_secs }))
1059 .await?;
1060 tracing::info!(container = container_name, "stopped container");
1061 self.cleanup_dev_proxy_routes(container_name).await;
1062 Ok(())
1063 }
1064
1065 /// Remove a container (must be stopped first or use `force = true`).
1066 pub async fn remove(&self, container_name: &str, force: bool) -> Result<(), ContainerError> {
1067 use bollard::container::RemoveContainerOptions;
1068 self.inner.docker
1069 .remove_container(container_name, Some(RemoveContainerOptions {
1070 force,
1071 ..Default::default()
1072 }))
1073 .await?;
1074 tracing::info!(container = container_name, "removed container");
1075 self.forget_cli_ready(container_name).await;
1076 self.cleanup_dev_proxy_routes(container_name).await;
1077 Ok(())
1078 }
1079
1080 /// Clears this container's dev-proxy routes (see
1081 /// docs/specs/SPEC_NATIVE_CONTAINER_DEV_PROXY_2026_09_19.md) if a
1082 /// registry was ever wired in via `attach_dev_proxy_registry` — a
1083 /// no-op everywhere else (tests, non-agent Docker use). Never fails
1084 /// `stop`/`remove`: the container operation itself already succeeded
1085 /// by the time this runs, and a routing-table cleanup miss is
1086 /// recoverable (the entry just goes stale until re-registered) in a
1087 /// way an interrupted `stop`/`remove` wouldn't be.
1088 async fn cleanup_dev_proxy_routes(&self, container_name: &str) {
1089 if let Some(registry) = self.inner.dev_proxy.get() {
1090 if let Some(agent_id) = agent_id_from_container_name(container_name) {
1091 registry.unregister_agent(agent_id).await;
1092 }
1093 }
1094 }
1095
1096 // ---- private helpers ----
1097
1098 /// Every mount target this module owns. Anything NOT in here (the caller's
1099 /// `agent:container_volumes`) is the user's business and is never a reason
1100 /// to rebuild their container.
1101 fn owned_mount_targets() -> [String; 3] {
1102 [
1103 CONTAINER_CLAUDE_DIR.to_string(),
1104 format!("{CONTAINER_CLAUDE_DIR}/{CLAUDE_CREDENTIALS_FILE}"),
1105 CONTAINER_WORKSPACE_DIR.to_string(),
1106 ]
1107 }
1108
1109 /// The `(target -> source)` this module wants mounted for `spec`. A target
1110 /// absent from the map is one that must NOT be mounted.
1111 fn desired_owned_mounts(
1112 container_name: &str,
1113 spec: &ContainerMountSpec,
1114 ) -> HashMap<String, String> {
1115 let mut desired: HashMap<String, String> = agent_home_mounts(container_name, spec)
1116 .into_iter()
1117 .filter_map(|m| Some((m.target?, m.source?)))
1118 .collect();
1119 if let Some(dir) = spec.workspace_host_dir.as_deref().filter(|d| !d.is_empty()) {
1120 desired.insert(CONTAINER_WORKSPACE_DIR.to_string(), normalize_host_path(dir));
1121 }
1122 desired
1123 }
1124
1125 /// Single Docker inspect covering both drift checks below — mounts and
1126 /// extra_hosts previously each did their own `inspect_container` round
1127 /// trip, doubling the Docker-socket call on every `ensure_running` for
1128 /// the common case of an already-compliant container (reagent P2 on PR
1129 /// #3393). Returns a human-readable reason if either has drifted, or
1130 /// `None` if the container matches (or the inspect itself failed —
1131 /// fails safe, same as the two checks below always did: a spurious
1132 /// recreate kills a live agent and loses its container-local state,
1133 /// which is worse than one more restart on stale state we couldn't
1134 /// even confirm).
1135 async fn container_drift(&self, container_name: &str, spec: &ContainerMountSpec) -> Option<&'static str> {
1136 let Ok(details) = self.inner.docker.inspect_container(container_name, None).await else {
1137 return None;
1138 };
1139 if !Self::mounts_match(&details, container_name, spec) {
1140 return Some("mounts differ from the desired spec (credentials/workspace)");
1141 }
1142 if !Self::extra_hosts_match(&details) {
1143 return Some("extra_hosts missing host.docker.internal:host-gateway");
1144 }
1145 None
1146 }
1147
1148 /// Does the existing container already carry exactly the mounts `spec` asks
1149 /// for? Used to decide whether an agent that is already up has to be
1150 /// recreated to pick up (or lose) credentials and a workspace.
1151 ///
1152 /// Compares the owned set in BOTH directions — a mount that should now be
1153 /// ABSENT is drift too, not just a missing or changed one. That case is the
1154 /// security-relevant one (reagent P1 on PR #2933): unbinding an agent's
1155 /// account is a normal transition that never reaches
1156 /// `SpawnGateError::MissingCredentials` (that gate fires when credentials
1157 /// are expected and missing, not when an agent legitimately has none). A
1158 /// presence-only check would leave the old account's `.credentials.json`
1159 /// bind-mounted and WRITABLE in the running container forever, so every
1160 /// later turn would keep authenticating — and refreshing tokens — as the
1161 /// account the operator just unbound.
1162 ///
1163 /// Only this module's own targets are considered; the caller's extra
1164 /// `container_volumes` are ignored, so a user adding an unrelated mount is
1165 /// never a reason to destroy and rebuild their container underneath them.
1166 ///
1167 /// Fails SAFE: missing mount data on the passed-in inspect result returns
1168 /// `true` ("matches"), leaving the container alone — the inspect-error case
1169 /// itself is now handled once, by `container_drift`'s caller.
1170 fn mounts_match(details: &ContainerInspectResponse, container_name: &str, spec: &ContainerMountSpec) -> bool {
1171 let Some(actual) = details.mounts.as_ref() else {
1172 return true;
1173 };
1174
1175 let source_at = |target: &str| -> Option<String> {
1176 actual
1177 .iter()
1178 .find(|m| m.destination.as_deref() == Some(target))
1179 .and_then(|m| m.name.clone().or_else(|| m.source.clone()))
1180 .map(|s| fold_mount_source(&s))
1181 };
1182
1183 let desired = Self::desired_owned_mounts(container_name, spec);
1184 Self::owned_mount_targets().iter().all(|target| {
1185 let want = desired.get(target).map(|s| fold_mount_source(s));
1186 source_at(target) == want
1187 })
1188 }
1189
1190 /// Whether an existing container's `HostConfig.extra_hosts` already has
1191 /// the `host.docker.internal:host-gateway` entry `create_and_start` sets
1192 /// (reagent P1, PR #3393). Docker cannot add an extra_hosts entry to an
1193 /// already-existing container — only `container_drift`'s recreate path
1194 /// (via `ensure_running_locked`) can actually fix a container that
1195 /// predates this.
1196 fn extra_hosts_match(details: &ContainerInspectResponse) -> bool {
1197 let Some(extra_hosts) = details.host_config.as_ref().and_then(|hc| hc.extra_hosts.as_ref()) else {
1198 return false;
1199 };
1200 extra_hosts
1201 .iter()
1202 .any(|h| h == "host.docker.internal:host-gateway")
1203 }
1204
1205 /// Running Docker Compose containers whose project directory is `dir` or
1206 /// inside it (Compose's `com.docker.compose.project.working_dir` label):
1207 /// the ones an agent working there may have started. Names only.
1208 pub async fn running_compose_containers_under(&self, dir: &std::path::Path) -> Result<Vec<String>, ContainerError> {
1209 const WORKING_DIR: &str = "com.docker.compose.project.working_dir";
1210 let mut filters = HashMap::new();
1211 filters.insert("label", vec![WORKING_DIR]);
1212 filters.insert("status", vec!["running"]);
1213 let list = self.inner.docker
1214 .list_containers(Some(ListContainersOptions { all: false, filters, ..Default::default() }))
1215 .await?;
1216 Ok(list
1217 .into_iter()
1218 .filter(|c| {
1219 c.labels
1220 .as_ref()
1221 .and_then(|l| l.get(WORKING_DIR))
1222 .is_some_and(|wd| std::path::Path::new(wd).starts_with(dir))
1223 })
1224 .filter_map(|c| c.names.and_then(|n| n.first().map(|n| n.trim_start_matches('/').to_string())))
1225 .collect())
1226 }
1227
1228 /// Returns the container status string ("running", "exited", …) or `None` if not found.
1229 async fn find_container(&self, name: &str) -> Result<Option<String>, ContainerError> {
1230 let mut filters = HashMap::new();
1231 filters.insert("name", vec![name]);
1232 let list = self.inner.docker
1233 .list_containers(Some(ListContainersOptions {
1234 all: true,
1235 filters,
1236 ..Default::default()
1237 }))
1238 .await?;
1239
1240 // `name` filter is a prefix match — verify exact name.
1241 let canonical = format!("/{name}");
1242 for c in &list {
1243 if let Some(names) = &c.names {
1244 if names.iter().any(|n| n == &canonical) {
1245 return Ok(c.state.clone());
1246 }
1247 }
1248 }
1249 Ok(None)
1250 }
1251
1252 /// Pull `image`; a legacy image that the registry refuses or no longer has
1253 /// is replaced by the public base image. Returns the image to run.
1254 async fn pull_with_fallback(&self, image: &str) -> Result<String, ContainerError> {
1255 match self.pull_image(image).await {
1256 Ok(()) => Ok(image.to_string()),
1257 Err(ContainerError::ImagePull { kind, .. }) if kind.is_unavailable_image() && is_legacy_agent_image(image) => {
1258 tracing::warn!(
1259 image = image,
1260 fallback = DEFAULT_AGENT_IMAGE,
1261 "legacy agent image can't be pulled; using the public base image"
1262 );
1263 self.pull_image(DEFAULT_AGENT_IMAGE).await?;
1264 Ok(DEFAULT_AGENT_IMAGE.to_string())
1265 }
1266 Err(e) => Err(e),
1267 }
1268 }
1269
1270 /// Whether the daemon already has `image`.
1271 pub async fn has_image_locally(&self, image: &str) -> bool {
1272 self.inner.docker.inspect_image(image).await.is_ok()
1273 }
1274
1275 /// Pull `image` via the Docker socket (create_image API).
1276 ///
1277 /// Streams the pull response to completion before returning. If the image is
1278 /// already present locally, Docker returns an empty stream immediately — this
1279 /// function treats that as success (no pull needed).
1280 ///
1281 /// Errors only on genuine pull failures (network, auth, no such image).
1282 async fn pull_image(&self, image: &str) -> Result<(), ContainerError> {
1283 // Skip the pull entirely when the image is already present locally.
1284 // `create_image` always contacts the registry, so on an offline host the
1285 // pull stream errors out even when the image is cached — aborting
1286 // container creation even though `docker run` would start it from the
1287 // local cache. An `inspect_image` short-circuit makes the cached-offline
1288 // path work (and avoids a redundant registry round-trip when online).
1289 if self.inner.docker.inspect_image(image).await.is_ok() {
1290 tracing::info!(image = image, "image already present locally; skipping pull");
1291 return Ok(());
1292 }
1293
1294 tracing::info!(image = image, "pulling image (not cached locally)");
1295 let mut stream = self.inner.docker.create_image(
1296 Some(CreateImageOptions {
1297 from_image: image,
1298 ..Default::default()
1299 }),
1300 None,
1301 None,
1302 );
1303 while let Some(item) = stream.next().await {
1304 match item {
1305 Ok(info) => {
1306 if let Some(status) = info.status {
1307 tracing::debug!(image = image, status = %status, "pull progress");
1308 }
1309 }
1310 Err(e) => {
1311 tracing::warn!(image = image, error = %e, "image pull error");
1312 return Err(pull_error(image, &e));
1313 }
1314 }
1315 }
1316 tracing::info!(image = image, "image pull complete (or already cached)");
1317 Ok(())
1318 }
1319
1320 async fn create_and_start(
1321 &self,
1322 container_name: &str,
1323 image: &str,
1324 volumes: &[String],
1325 env_vars: &[(String, String)],
1326 spec: &ContainerMountSpec,
1327 ) -> Result<(), ContainerError> {
1328 // A container name can be reused: `ensure_running` detects an
1329 // externally removed container and recreates it under the same name.
1330 // The replacement may exec as a DIFFERENT user -- a changed custom
1331 // image, or the same mutable tag repulled -- and a stale cached
1332 // identity would then chown every prompt file to the wrong uid, making
1333 // it unreadable (mode 0600) for the life of the process (codex P2,
1334 // PR #2883). The cache is keyed by name, so evict here, at the one
1335 // place a name starts pointing at a new container.
1336 self.inner.exec_identities.lock().await.remove(container_name);
1337 self.forget_cli_ready(container_name).await;
1338
1339 let env: Vec<String> = env_vars.iter()
1340 .map(|(k, v)| format!("{k}={v}"))
1341 .collect();
1342
1343 let mounts: Vec<Mount> = volumes.iter().filter_map(|spec| {
1344 let (source, target, read_only) = parse_volume_spec(spec)?;
1345 if target.is_empty() {
1346 tracing::warn!(spec = %spec, "ignoring malformed volume spec (expected source:target)");
1347 return None;
1348 }
1349 let mount_type = if source.starts_with('/') || source.starts_with('~')
1350 || (source.len() >= 2 && source.as_bytes()[1] == b':')
1351 {
1352 MountTypeEnum::BIND
1353 } else {
1354 MountTypeEnum::VOLUME
1355 };
1356 Some(Mount {
1357 target: Some(target.to_string()),
1358 source: Some(source.to_string()),
1359 typ: Some(mount_type),
1360 read_only: Some(read_only),
1361 ..Default::default()
1362 })
1363 }).collect();
1364
1365 let mut all_mounts = agent_home_mounts(container_name, spec);
1366 if let Some(workspace) = spec.workspace_host_dir.as_deref().filter(|d| !d.is_empty()) {
1367 all_mounts.push(Mount {
1368 target: Some(CONTAINER_WORKSPACE_DIR.to_string()),
1369 source: Some(normalize_host_path(workspace)),
1370 typ: Some(MountTypeEnum::BIND),
1371 read_only: Some(false),
1372 ..Default::default()
1373 });
1374 }
1375 all_mounts.push(cli_dir_mount(container_name));
1376 all_mounts.extend(mounts);
1377
1378 let config: Config<String> = Config {
1379 image: Some(image.to_string()),
1380 env: if env.is_empty() { None } else { Some(env) },
1381 // Keep container alive between turns — idle PID 1 (`sleep infinity`
1382 // via tini) stays running until `docker stop`. Agent turns run via
1383 // `docker exec`, not as the PID-1 process.
1384 tty: Some(false),
1385 open_stdin: Some(true),
1386 host_config: Some(HostConfig {
1387 mounts: Some(all_mounts),
1388 // Security: no host network, no privileged mode.
1389 network_mode: Some("bridge".to_string()),
1390 // Lets the container reach the sidecar (agentmux-srv) via
1391 // `host.docker.internal`, which `run_agent_turn`/`agent_io`'s
1392 // container branch rewrites AGENTMUX_LOCAL_URL to (see
1393 // #2939 workstream 1). Docker Desktop (macOS/Windows) already
1394 // resolves this name automatically; `host-gateway` is what
1395 // makes it resolve on native Linux's default bridge network
1396 // too, where nothing does this by default. Harmless no-op
1397 // where Docker Desktop already handles it.
1398 extra_hosts: Some(vec!["host.docker.internal:host-gateway".to_string()]),
1399 ..Default::default()
1400 }),
1401 ..Default::default()
1402 };
1403
1404 let container = self.inner.docker
1405 .create_container(
1406 Some(CreateContainerOptions {
1407 name: container_name,
1408 platform: None,
1409 }),
1410 config,
1411 )
1412 .await?;
1413
1414 let id = container.id;
1415 if id.is_empty() {
1416 return Err(ContainerError::NoId { name: container_name.to_string() });
1417 }
1418
1419 self.inner.docker
1420 .start_container(&id, None::<StartContainerOptions<String>>)
1421 .await?;
1422
1423 Ok(())
1424 }
1425
1426 /// Best-effort: create the shared [`AGENTMUX_DOCKER_NETWORK`] (if it
1427 /// doesn't already exist) and attach `container_name` to it, additive
1428 /// to whatever `network_mode`/`extra_hosts` `create_and_start` already
1429 /// set up (container→host reachability via `host.docker.internal`,
1430 /// entirely unaffected). Never fails `ensure_running` — this is purely
1431 /// in service of dev-proxy routing
1432 /// (docs/specs/SPEC_NATIVE_CONTAINER_DEV_PROXY_2026_09_19.md §3.1), not
1433 /// required for a container agent's core turn-execution path. Called
1434 /// on every `ensure_running`, not just at creation, so an
1435 /// already-running container from before this feature shipped is
1436 /// picked up too, and so a network deleted out-of-band (`docker
1437 /// network rm`) is recreated automatically.
1438 async fn ensure_agent_network(&self, container_name: &str) {
1439 use bollard::models::EndpointSettings;
1440 use bollard::network::{ConnectNetworkOptions, CreateNetworkOptions};
1441
1442 let create_opts = CreateNetworkOptions {
1443 name: AGENTMUX_DOCKER_NETWORK,
1444 driver: "bridge",
1445 ..Default::default()
1446 };
1447 if let Err(e) = self.inner.docker.create_network(create_opts).await {
1448 let already_exists = matches!(
1449 &e,
1450 bollard::errors::Error::DockerResponseServerError { message, .. }
1451 if message.to_lowercase().contains("already exists")
1452 );
1453 if !already_exists {
1454 tracing::warn!(
1455 network = AGENTMUX_DOCKER_NETWORK,
1456 error = %e,
1457 "dev-proxy: failed to create shared Docker network — dev-server routing unavailable this run"
1458 );
1459 return;
1460 }
1461 }
1462
1463 let connect_opts = ConnectNetworkOptions {
1464 container: container_name,
1465 endpoint_config: EndpointSettings {
1466 aliases: Some(vec![container_name.to_string()]),
1467 ..Default::default()
1468 },
1469 };
1470 if let Err(e) = self.inner.docker.connect_network(AGENTMUX_DOCKER_NETWORK, connect_opts).await {
1471 let already_attached = matches!(
1472 &e,
1473 bollard::errors::Error::DockerResponseServerError { message, .. }
1474 if message.to_lowercase().contains("already exists")
1475 );
1476 if !already_attached {
1477 tracing::warn!(
1478 container = container_name,
1479 network = AGENTMUX_DOCKER_NETWORK,
1480 error = %e,
1481 "dev-proxy: failed to attach container to shared Docker network"
1482 );
1483 }
1484 }
1485 }
1486}
1487
1488/// State of the container-runtime slot held by [`ContainerRuntimeHandle`].
1489enum RuntimeSlot {
1490 /// Test/no-docker-expected fixtures — `get()` never attempts a real
1491 /// connect, always reports unavailable.
1492 #[cfg(test)]
1493 Disabled,
1494 /// No manager yet: never connected, or the last connect attempt
1495 /// failed. `get()` retries `ContainerManager::connect()` on every
1496 /// call while in this state.
1497 Empty,
1498 Connected(ContainerManager),
1499}
1500
1501/// Self-healing holder for the container runtime connection.
1502///
1503/// A plain `Option<ContainerManager>` fixed at process boot means a Docker
1504/// daemon that starts (or a socket/named-pipe that appears) AFTER AgentMux
1505/// launched is never picked up without an app restart — every consumer
1506/// (the availability probe AND the actual container-launch code paths)
1507/// would see a permanent `None`. This type retries on demand instead, so
1508/// "start Docker Desktop while AgentMux is already running" is picked up
1509/// within one call, everywhere `container_manager` is read.
1510///
1511/// See docs/retro/RETRO_DOCKER_DETECTION_DIVERGENCE_2026_07_04.md.
1512pub struct ContainerRuntimeHandle {
1513 slot: tokio::sync::RwLock<RuntimeSlot>,
1514 /// Dev-proxy registry to attach to every `ContainerManager` this handle
1515 /// ever produces — including across reconnects, each of which creates
1516 /// a fresh manager instance with its own unset `attach_dev_proxy_registry`
1517 /// state. `None` until `set_dev_proxy_registry` is called (bootstrap
1518 /// wires this in once both this handle and the registry exist); tests
1519 /// using `disabled()` never set it, and `get()` never even constructs
1520 /// a manager in that case. See
1521 /// docs/specs/SPEC_NATIVE_CONTAINER_DEV_PROXY_2026_09_19.md.
1522 dev_proxy: std::sync::RwLock<Option<crate::backend::dev_proxy::DevProxyRegistry>>,
1523}
1524
1525impl ContainerRuntimeHandle {
1526 /// Boot-time constructor. Attempts one connect (same as the prior
1527 /// behavior) but never permanently gives up on failure — a failed
1528 /// attempt leaves the slot `Empty` so `get()`/`is_available()` retry
1529 /// later instead of staying stuck for the process lifetime.
1530 pub fn connect_at_startup() -> Self {
1531 let slot = match ContainerManager::connect() {
1532 Ok(mgr) => RuntimeSlot::Connected(mgr),
1533 Err(_) => RuntimeSlot::Empty,
1534 };
1535 Self {
1536 slot: tokio::sync::RwLock::new(slot),
1537 dev_proxy: std::sync::RwLock::new(None),
1538 }
1539 }
1540
1541 /// Test-only constructor: permanently reports unavailable and never
1542 /// attempts a real connect, keeping host-only unit tests hermetic and
1543 /// deterministic regardless of whether the test box has Docker.
1544 #[cfg(test)]
1545 pub fn disabled() -> Self {
1546 Self {
1547 slot: tokio::sync::RwLock::new(RuntimeSlot::Disabled),
1548 dev_proxy: std::sync::RwLock::new(None),
1549 }
1550 }
1551
1552 /// Wire a dev-proxy registry into this handle so every
1553 /// `ContainerManager` it produces — now and after any future Docker
1554 /// reconnect — has `stop`/`remove` clear that agent's routes. Call
1555 /// once at bootstrap, after both this handle and the registry exist
1556 /// (see `bootstrap::build_app_state`).
1557 pub fn set_dev_proxy_registry(&self, registry: crate::backend::dev_proxy::DevProxyRegistry) {
1558 if let Ok(mut slot) = self.dev_proxy.write() {
1559 *slot = Some(registry.clone());
1560 }
1561 // Attach immediately to whatever manager already exists (the
1562 // common case — `connect_at_startup` usually connects
1563 // synchronously before this is called), not just future ones.
1564 if let Ok(slot) = self.slot.try_read() {
1565 if let RuntimeSlot::Connected(mgr) = &*slot {
1566 mgr.attach_dev_proxy_registry(registry);
1567 }
1568 }
1569 }
1570
1571 /// Returns a connected manager, retrying `ContainerManager::connect()`
1572 /// if the slot is currently `Empty`. Cheap on the happy path (a
1573 /// read-lock plus a cheap `Arc`-backed clone); only takes the write
1574 /// lock and re-dials when we don't already have a manager.
1575 pub async fn get(&self) -> Option<ContainerManager> {
1576 {
1577 let slot = self.slot.read().await;
1578 match &*slot {
1579 RuntimeSlot::Connected(mgr) => return Some(mgr.clone()),
1580 #[cfg(test)]
1581 RuntimeSlot::Disabled => return None,
1582 RuntimeSlot::Empty => {}
1583 }
1584 }
1585 let mut slot = self.slot.write().await;
1586 // Re-check under the write lock — another caller may have already
1587 // connected between our read-unlock and this write-lock.
1588 if let RuntimeSlot::Connected(mgr) = &*slot {
1589 return Some(mgr.clone());
1590 }
1591 #[cfg(test)]
1592 if matches!(&*slot, RuntimeSlot::Disabled) {
1593 return None;
1594 }
1595 match ContainerManager::connect() {
1596 Ok(mgr) => {
1597 // A reconnect makes a brand-new `ContainerManagerInner`
1598 // (fresh `OnceLock`s) — re-attach the registry here so
1599 // `stop`/`remove` cleanup survives a Docker daemon
1600 // restart, not just the initial connect.
1601 if let Ok(guard) = self.dev_proxy.read() {
1602 if let Some(registry) = guard.as_ref() {
1603 mgr.attach_dev_proxy_registry(registry.clone());
1604 }
1605 }
1606 *slot = RuntimeSlot::Connected(mgr.clone());
1607 Some(mgr)
1608 }
1609 Err(_) => None,
1610 }
1611 }
1612
1613 /// True iff a manager is available AND its daemon answers a live ping
1614 /// right now. Never trusts a cached `Connected` slot alone — the
1615 /// daemon can go down again after a successful connect, and
1616 /// `check_available` is a real `docker.ping()`, not a cached flag.
1617 pub async fn is_available(&self) -> bool {
1618 match self.get().await {
1619 Some(mgr) => mgr.check_available().await.is_ok(),
1620 None => false,
1621 }
1622 }
1623}
1624
1625/// What a collected exec produced.
1626struct ExecOutcome {
1627 exec_id: String,
1628 exit: Option<i64>,
1629 output: String,
1630 timed_out: bool,
1631}
1632
1633/// Whether `provision_cli` found the CLI in place or had to install it.
1634#[derive(Debug, Clone, Copy, PartialEq)]
1635pub enum CliProvision {
1636 Present,
1637 Installed { seconds: f64 },
1638}
1639
1640/// The per-agent volume the provider CLI is installed into.
1641fn cli_dir_mount(container_name: &str) -> Mount {
1642 Mount {
1643 target: Some(CONTAINER_CLI_DIR.to_string()),
1644 source: Some(format!("agentmux-cli-{container_name}")),
1645 typ: Some(MountTypeEnum::VOLUME),
1646 read_only: Some(false),
1647 ..Default::default()
1648 }
1649}
1650
1651/// Map a bollard pull error to [`ContainerError::ImagePull`].
1652fn pull_error(image: &str, e: &bollard::errors::Error) -> ContainerError {
1653 let status = match e {
1654 bollard::errors::Error::DockerResponseServerError { status_code, .. } => Some(*status_code),
1655 _ => None,
1656 };
1657 let detail = e.to_string();
1658 ContainerError::ImagePull {
1659 image: image.to_string(),
1660 kind: classify_pull_error(status, &detail),
1661 detail,
1662 }
1663}
1664
1665/// Derive the stable container name for an agent from its slug.
1666/// Format: `agentmux-<slug>`. Deterministic so restarts reuse the same container.
1667pub fn container_name_for_slug(slug: &str) -> String {
1668 format!("agentmux-{slug}")
1669}
1670
1671/// Reverses [`container_name_for_slug`] — `"agentmux-foo"` → `Some("foo")`.
1672/// Used by `cleanup_dev_proxy_routes` to key off the container name alone
1673/// (`stop`/`remove` only get a container_name, not the agent's own id).
1674fn agent_id_from_container_name(container_name: &str) -> Option<&str> {
1675 container_name.strip_prefix("agentmux-").filter(|s| !s.is_empty())
1676}
1677
1678/// Parse a Docker volume spec into `(source, target, read_only)`.
1679///
1680/// Format: `source:target` or `source:target:options`.
1681///
1682/// Handles Windows drive-letter bind paths (e.g. `C:\Users\me\repo:/workspace:ro`)
1683/// by treating the drive-letter colon (`X:`) as part of the source path, not as
1684/// the source/target separator. A plain `splitn(3, ':')` would split `C:\path`
1685/// into `C` and `\path`, losing the drive letter.
1686///
1687/// Returns `None` for malformed specs (no target separator found).
1688fn parse_volume_spec(spec: &str) -> Option<(&str, &str, bool)> {
1689 let bytes = spec.as_bytes();
1690
1691 // Detect Windows drive-letter prefix: single ASCII letter followed by ':\' or ':/'
1692 let (source, rest) = if bytes.len() >= 3
1693 && bytes[0].is_ascii_alphabetic()
1694 && bytes[1] == b':'
1695 && (bytes[2] == b'\\' || bytes[2] == b'/')
1696 {
1697 // Windows path: skip the drive colon and split on the next ':'.
1698 let tail = &spec[2..]; // starts at '\' or '/'
1699 let pos = tail.find(':')?;
1700 (&spec[..2 + pos], &tail[pos + 1..])
1701 } else {
1702 let pos = spec.find(':')?;
1703 (&spec[..pos], &spec[pos + 1..])
1704 };
1705
1706 // Split target from optional options.
1707 let (target, options) = if let Some(pos) = rest.find(':') {
1708 (&rest[..pos], &rest[pos + 1..])
1709 } else {
1710 (rest, "")
1711 };
1712
1713 Some((source, target, options.contains("ro")))
1714}
1715
1716#[cfg(test)]
1717mod tests {
1718 use super::*;
1719
1720 #[test]
1721 fn test_container_name_for_slug() {
1722 assert_eq!(container_name_for_slug("my-agent"), "agentmux-my-agent");
1723 assert_eq!(container_name_for_slug("agent1"), "agentmux-agent1");
1724 }
1725
1726 #[test]
1727 fn agent_id_from_container_name_reverses_container_name_for_slug() {
1728 assert_eq!(agent_id_from_container_name("agentmux-my-agent"), Some("my-agent"));
1729 assert_eq!(agent_id_from_container_name("agentmux-agent1"), Some("agent1"));
1730 assert_eq!(agent_id_from_container_name("agentmux-"), None, "empty slug is not a real agent id");
1731 assert_eq!(agent_id_from_container_name("not-agentmux-prefixed"), None);
1732 }
1733
1734 // ── AGENTMUX_LOCAL_URL rewrite for container reachability ───────────────
1735 // See #2939 workstream 1.
1736
1737 #[test]
1738 fn rewrites_loopback_ip_to_host_docker_internal() {
1739 assert_eq!(
1740 rewrite_local_url_for_container("http://127.0.0.1:54321"),
1741 "http://host.docker.internal:54321"
1742 );
1743 }
1744
1745 #[test]
1746 fn rewrites_localhost_to_host_docker_internal() {
1747 assert_eq!(
1748 rewrite_local_url_for_container("http://localhost:54321"),
1749 "http://host.docker.internal:54321"
1750 );
1751 }
1752
1753 #[test]
1754 fn leaves_a_non_loopback_host_unchanged() {
1755 // Already a real hostname/IP, or already host.docker.internal —
1756 // must pass through unchanged, not get mangled.
1757 assert_eq!(
1758 rewrite_local_url_for_container("http://192.168.1.5:54321"),
1759 "http://192.168.1.5:54321"
1760 );
1761 assert_eq!(
1762 rewrite_local_url_for_container("http://host.docker.internal:54321"),
1763 "http://host.docker.internal:54321"
1764 );
1765 }
1766
1767 // ── Credential + workspace mounts ───────────────────────────────────────
1768 // See docs/reports/REPORT_CONTAINER_AGENT_CREDENTIALS_AND_WORKSPACE_2026_09_02.md
1769
1770 fn mount_at<'a>(mounts: &'a [Mount], target: &str) -> Option<&'a Mount> {
1771 mounts.iter().find(|m| m.target.as_deref() == Some(target))
1772 }
1773
1774 /// THE fix: the bound account's `.credentials.json` is bind-mounted into the
1775 /// container, so the CLI there reads the same token the account login wrote.
1776 /// Without it a container agent authenticates NEVER — `CLAUDE_CONFIG_DIR` is
1777 /// a host path and is stripped by CONTAINER_ENV_DENYLIST before exec.
1778 #[test]
1779 fn a_bound_accounts_credentials_file_is_bind_mounted_into_the_container() {
1780 let spec = ContainerMountSpec {
1781 claude_config_host_dir: Some(r"C:\Users\me\.agentmux\identities\acct\claude".into()),
1782 workspace_host_dir: None,
1783 };
1784 let mounts = agent_home_mounts("agentmux-x", &spec);
1785
1786 let creds = mount_at(&mounts, "/home/agent/.claude/.credentials.json")
1787 .expect("credentials must be mounted");
1788 assert_eq!(creds.typ, Some(MountTypeEnum::BIND));
1789 assert_eq!(
1790 creds.source.as_deref(),
1791 Some("C:/Users/me/.agentmux/identities/acct/claude/.credentials.json"),
1792 "backslashes are not accepted in a Docker bind source",
1793 );
1794 assert_eq!(creds.read_only, Some(false), "an in-place token refresh must reach the host");
1795 }
1796
1797 /// The named volume must REMAIN at ~/.claude. Binding the account's whole
1798 /// config dir there instead looks tidier and is broken: its `projects` entry
1799 /// is a symlink that does not resolve in-container, and a nested volume to
1800 /// shadow it silently fails to mount (both verified live, 2026-09-02).
1801 /// Keeping the volume is what leaves projects/sessions writable by uid 1000.
1802 #[test]
1803 fn the_claude_dir_itself_stays_the_per_agent_named_volume() {
1804 let spec = ContainerMountSpec {
1805 claude_config_host_dir: Some("/host/acct/claude".into()),
1806 workspace_host_dir: None,
1807 };
1808 let mounts = agent_home_mounts("agentmux-x", &spec);
1809
1810 let claude = mount_at(&mounts, CONTAINER_CLAUDE_DIR).expect("claude dir mounted");
1811 assert_eq!(
1812 claude.typ,
1813 Some(MountTypeEnum::VOLUME),
1814 "must NOT become a bind of the host config dir — see agent_home_mounts",
1815 );
1816 assert_eq!(claude.source.as_deref(), Some("agentmux-claude-agentmux-x"));
1817 assert!(
1818 mount_at(&mounts, "/home/agent/.claude/projects").is_none(),
1819 "a nested projects mount does not work and must not be reintroduced",
1820 );
1821 }
1822
1823 /// An agent with no bound account keeps exactly the shape that shipped
1824 /// before — this fix adds a mount, it does not rewrite the existing one.
1825 #[test]
1826 fn without_a_bound_account_the_named_volume_shape_is_unchanged() {
1827 let mounts = agent_home_mounts("agentmux-x", &ContainerMountSpec::default());
1828
1829 assert_eq!(mounts.len(), 1, "no credential bind");
1830 let claude = mount_at(&mounts, CONTAINER_CLAUDE_DIR).expect("claude dir mounted");
1831 assert_eq!(claude.typ, Some(MountTypeEnum::VOLUME));
1832 assert_eq!(claude.source.as_deref(), Some("agentmux-claude-agentmux-x"));
1833 }
1834
1835 /// An empty config dir is "no account", not a bind of "/.credentials.json".
1836 #[test]
1837 fn an_empty_config_dir_is_treated_as_no_bound_account() {
1838 let spec = ContainerMountSpec {
1839 claude_config_host_dir: Some(String::new()),
1840 workspace_host_dir: None,
1841 };
1842 assert_eq!(agent_home_mounts("agentmux-x", &spec).len(), 1);
1843 }
1844
1845 // ── Drift detection must be bidirectional ───────────────────────────────
1846 // reagent P1 on PR #2933. `mounts_match` compares against these, so the
1847 // desired-set logic is what the tests pin; the Docker inspect half needs a
1848 // live daemon and is covered by the ignored integration test.
1849
1850 /// Unbinding an agent's account must show up as drift. It is a normal
1851 /// transition that never trips SpawnGateError::MissingCredentials, so if the
1852 /// desired set still claimed a credentials mount — or if a presence-only
1853 /// check ignored the now-absent one — the OLD account's token would stay
1854 /// bind-mounted and writable in the running container forever.
1855 #[test]
1856 fn unbinding_an_account_drops_the_credentials_mount_from_the_desired_set() {
1857 let bound = ContainerMountSpec {
1858 claude_config_host_dir: Some("/host/acct/claude".into()),
1859 workspace_host_dir: None,
1860 };
1861 let unbound = ContainerMountSpec::default();
1862 let creds_target = format!("{CONTAINER_CLAUDE_DIR}/{CLAUDE_CREDENTIALS_FILE}");
1863
1864 let while_bound = ContainerManager::desired_owned_mounts("agentmux-x", &bound);
1865 assert_eq!(
1866 while_bound.get(&creds_target).map(String::as_str),
1867 Some("/host/acct/claude/.credentials.json"),
1868 );
1869
1870 let after_unbind = ContainerManager::desired_owned_mounts("agentmux-x", &unbound);
1871 assert!(
1872 !after_unbind.contains_key(&creds_target),
1873 "an unbound agent must want NO credentials mount — otherwise the old \
1874 account stays authenticated inside the running container",
1875 );
1876 assert!(
1877 ContainerManager::owned_mount_targets().contains(&creds_target),
1878 "and that target must be one we compare, or its absence is never noticed",
1879 );
1880 }
1881
1882 /// Clearing the working directory is the same shape of transition.
1883 #[test]
1884 fn clearing_the_working_directory_drops_the_workspace_from_the_desired_set() {
1885 let with_ws = ContainerMountSpec {
1886 claude_config_host_dir: None,
1887 workspace_host_dir: Some(r"C:\repo".into()),
1888 };
1889 let desired = ContainerManager::desired_owned_mounts("agentmux-x", &with_ws);
1890 assert_eq!(desired.get(CONTAINER_WORKSPACE_DIR).map(String::as_str), Some("C:/repo"));
1891
1892 let without = ContainerManager::desired_owned_mounts("agentmux-x", &ContainerMountSpec::default());
1893 assert!(!without.contains_key(CONTAINER_WORKSPACE_DIR));
1894 }
1895
1896 /// The comparison set must stay scoped to this module's own mounts, so a
1897 /// user's `container_volumes` can never trigger a rebuild of their container.
1898 #[test]
1899 fn only_this_modules_own_mount_targets_are_compared() {
1900 let owned = ContainerManager::owned_mount_targets();
1901 assert!(owned.contains(&CONTAINER_CLAUDE_DIR.to_string()));
1902 assert!(owned.contains(&CONTAINER_WORKSPACE_DIR.to_string()));
1903 assert!(owned.contains(&format!("{CONTAINER_CLAUDE_DIR}/{CLAUDE_CREDENTIALS_FILE}")));
1904 assert_eq!(owned.len(), 3, "a user volume target must never appear here");
1905 }
1906
1907 /// macOS keeps OAuth credentials in the Keychain and never writes this file
1908 /// (oauth_probe.rs). Binding a nonexistent source makes Docker refuse to
1909 /// create the container, so an unguarded bind would turn "cannot
1910 /// authenticate on macOS" into "cannot START on macOS".
1911 #[test]
1912 fn a_config_dir_with_no_credentials_file_is_not_offered_for_mounting() {
1913 let dir = std::env::temp_dir().join("agentmux-creds-guard-empty");
1914 std::fs::create_dir_all(&dir).unwrap();
1915 let _ = std::fs::remove_file(dir.join(CLAUDE_CREDENTIALS_FILE));
1916
1917 assert_eq!(
1918 credentials_dir_if_file_backed(&dir.to_string_lossy()),
1919 None,
1920 "no credentials file (the macOS Keychain case) must not produce a bind",
1921 );
1922 assert_eq!(credentials_dir_if_file_backed(""), None);
1923 }
1924
1925 /// …and when the file IS there, it is offered, so the fix still applies on
1926 /// every platform that writes it.
1927 #[test]
1928 fn a_config_dir_holding_a_credentials_file_is_offered_for_mounting() {
1929 let dir = std::env::temp_dir().join("agentmux-creds-guard-present");
1930 std::fs::create_dir_all(&dir).unwrap();
1931 std::fs::write(dir.join(CLAUDE_CREDENTIALS_FILE), b"{}").unwrap();
1932
1933 assert_eq!(
1934 credentials_dir_if_file_backed(&dir.to_string_lossy()).as_deref(),
1935 Some(dir.to_string_lossy().as_ref()),
1936 );
1937 let _ = std::fs::remove_dir_all(&dir);
1938 }
1939
1940 /// Case folding must be Windows-only: on a case-sensitive host `/work/Foo`
1941 /// and `/work/foo` are different directories, and folding them together
1942 /// would hide real drift and leave /workspace on the stale one.
1943 #[test]
1944 fn mount_source_case_is_folded_only_where_the_filesystem_is_case_insensitive() {
1945 let a = fold_mount_source("/work/Foo");
1946 let b = fold_mount_source("/work/foo");
1947 if cfg!(windows) {
1948 assert_eq!(a, b, "windows paths are case-insensitive");
1949 } else {
1950 assert_ne!(a, b, "a case-sensitive host must see these as different mounts");
1951 }
1952 // Slash normalization is unconditional either way.
1953 assert_eq!(fold_mount_source(r"C:\a\b"), fold_mount_source("C:/a/b"));
1954 }
1955
1956 #[test]
1957 fn host_paths_are_normalized_for_the_docker_mount_api() {
1958 assert_eq!(normalize_host_path(r"C:\Users\me\dir"), "C:/Users/me/dir");
1959 assert_eq!(normalize_host_path("/already/posix"), "/already/posix");
1960 }
1961
1962 #[test]
1963 fn test_parse_volume_spec_unix_named() {
1964 let (src, tgt, ro) = parse_volume_spec("myvolume:/data").unwrap();
1965 assert_eq!(src, "myvolume");
1966 assert_eq!(tgt, "/data");
1967 assert!(!ro);
1968 }
1969
1970 #[test]
1971 fn test_parse_volume_spec_unix_bind_readonly() {
1972 let (src, tgt, ro) = parse_volume_spec("/host/path:/container/path:ro").unwrap();
1973 assert_eq!(src, "/host/path");
1974 assert_eq!(tgt, "/container/path");
1975 assert!(ro);
1976 }
1977
1978 #[test]
1979 fn test_parse_volume_spec_windows_bind() {
1980 // Drive-letter path must not be split at the drive colon.
1981 let (src, tgt, ro) = parse_volume_spec("C:\\Users\\me\\repo:/workspace").unwrap();
1982 assert_eq!(src, "C:\\Users\\me\\repo");
1983 assert_eq!(tgt, "/workspace");
1984 assert!(!ro);
1985 }
1986
1987 #[test]
1988 fn test_parse_volume_spec_windows_bind_readonly() {
1989 let (src, tgt, ro) = parse_volume_spec("C:/Users/me/repo:/workspace:ro").unwrap();
1990 assert_eq!(src, "C:/Users/me/repo");
1991 assert_eq!(tgt, "/workspace");
1992 assert!(ro);
1993 }
1994
1995 #[test]
1996 fn test_parse_volume_spec_malformed() {
1997 assert!(parse_volume_spec("nocodon").is_none());
1998 }
1999
2000 /// Docker-gated integration test for the container lifecycle + exec path.
2001 /// Requires a reachable Docker daemon (Colima/Docker Desktop), so it is
2002 /// `#[ignore]` by default. Run with:
2003 /// DOCKER_HOST=unix://$HOME/.colima/default/docker.sock \
2004 /// cargo test -p agentmux-srv -- --ignored --nocapture itest_container
2005 ///
2006 /// Validates the foundation Phase 2 rests on against a real daemon:
2007 /// `ensure_running` create→reuse, env delivered into the exec via the Docker
2008 /// socket (`CreateExecOptions.env`, NOT argv — the CWE-214 guard), and the
2009 /// stdin→stdout exec I/O contract `spawn_container_turn` depends on.
2010 #[tokio::test]
2011 #[ignore]
2012 async fn itest_container_lifecycle_exec_env_and_io() {
2013 use tokio::io::AsyncWriteExt as _;
2014
2015 async fn drain_stdout<S>(mut out: S) -> String
2016 where
2017 S: futures_util::Stream<
2018 Item = Result<bollard::container::LogOutput, bollard::errors::Error>,
2019 > + Unpin,
2020 {
2021 use futures_util::StreamExt as _;
2022 let mut s = String::new();
2023 while let Some(item) = out.next().await {
2024 if let Ok(bollard::container::LogOutput::StdOut { message }) = item {
2025 s.push_str(&String::from_utf8_lossy(&message));
2026 }
2027 }
2028 s
2029 }
2030
2031 let cm = ContainerManager::connect().expect("connect to docker (set DOCKER_HOST)");
2032 cm.check_available().await.expect("docker daemon must be reachable");
2033
2034 let name = "agentmux-itest-1357";
2035 // Public, pullable image with a long-lived default CMD (nginx daemon) and
2036 // a shell — `create_and_start` sets no cmd, so the image default must keep
2037 // PID 1 alive between exec turns (the persistent-container model).
2038 let image = "nginx:alpine";
2039 let _ = cm.remove(name, true).await; // clean slate; ignore if absent
2040
2041 // create path: pull + create + start
2042 cm.ensure_running(name, image, &[], &[], &ContainerMountSpec::default()).await.expect("ensure_running (create)");
2043 // reuse path: already running → must no-op, not error or re-create
2044 cm.ensure_running(name, image, &[], &[], &ContainerMountSpec::default()).await.expect("ensure_running (reuse)");
2045
2046 // env reaches the in-container process via the Docker socket, not argv.
2047 // Drop the (unused) stdin half first: bollard's exec output stream does
2048 // not EOF while the write-half is held open — the same reason
2049 // spawn_container_turn drops `input` before reading output.
2050 let ExecSession { input, output, .. } = cm
2051 .exec(
2052 name,
2053 &["sh".into(), "-c".into(), "printf %s \"$ITEST_KEY\"".into()],
2054 None,
2055 &[("ITEST_KEY".into(), "val-42".into())],
2056 false,
2057 )
2058 .await
2059 .expect("exec (env)");
2060 drop(input);
2061 let out = drain_stdout(output).await;
2062 assert!(out.contains("val-42"), "env must reach container; got {out:?}");
2063
2064 // stdin → stdout I/O contract: write a newline-terminated message and
2065 // read it back. Use `read` (completes on the first '\n', then the shell
2066 // exits) rather than `cat` (which blocks until stdin EOF) — `claude`
2067 // likewise consumes newline-delimited JSON without waiting for EOF, and
2068 // bollard's hijacked exec stream does not reliably half-close stdin on
2069 // `drop(input)`, so an EOF-dependent reader would hang.
2070 let ExecSession { mut input, output, .. } = cm
2071 .exec(
2072 name,
2073 &["sh".into(), "-c".into(), "read line; printf 'got:%s' \"$line\"".into()],
2074 None,
2075 &[],
2076 true, // this test is specifically the stdin contract
2077 )
2078 .await
2079 .expect("exec (io)");
2080 input.write_all(b"hello-stdin\n").await.expect("write stdin");
2081 input.flush().await.expect("flush stdin");
2082 drop(input);
2083 let out = drain_stdout(output).await;
2084 assert!(out.contains("got:hello-stdin"), "stdin must reach the container process; got {out:?}");
2085
2086 // cleanup
2087 cm.remove(name, true).await.expect("remove");
2088 }
2089
2090 fn server_error(status_code: u16, message: &str) -> bollard::errors::Error {
2091 bollard::errors::Error::DockerResponseServerError { status_code, message: message.to_string() }
2092 }
2093
2094 #[test]
2095 fn a_refused_pull_becomes_a_plain_message_not_a_docker_api_error() {
2096 let err = pull_error(
2097 "ghcr.io/agentmuxai/agent-claude:latest",
2098 &server_error(500, "Head \"https://ghcr.io/v2/agentmuxai/agent-claude/manifests/latest\": denied"),
2099 );
2100 assert!(matches!(&err, ContainerError::ImagePull { kind: PullFailure::Denied, .. }), "{err:?}");
2101 let msg = err.user_message();
2102 assert!(msg.contains("ghcr.io/agentmuxai/agent-claude:latest"), "{msg}");
2103 assert!(msg.contains("refused access"), "{msg}");
2104 assert!(!msg.contains("Docker API error") && !msg.contains("manifests/latest"), "{msg}");
2105 }
2106
2107 #[test]
2108 fn a_missing_image_and_an_unreachable_registry_each_get_their_own_message() {
2109 let missing = pull_error("x/y:1", &server_error(404, "manifest for x/y:1 not found"));
2110 assert!(missing.user_message().contains("was not found"), "{}", missing.user_message());
2111
2112 let offline = pull_error("x/y:1", &server_error(500, "Get \"https://registry-1.docker.io/v2/\": dial tcp: lookup registry-1.docker.io: no such host"));
2113 assert!(offline.user_message().contains("couldn't reach the registry"), "{}", offline.user_message());
2114 }
2115
2116 #[test]
2117 fn the_image_pull_error_keeps_the_daemons_text_for_logs() {
2118 let err = pull_error("x/y:1", &server_error(500, "denied: some detail"));
2119 assert!(err.to_string().contains("some detail"), "{err}");
2120 }
2121
2122 #[test]
2123 fn a_stopped_daemon_and_an_install_failure_read_plainly() {
2124 let down = ContainerError::NotAvailable("connect failed".into()).user_message();
2125 assert!(down.contains("Docker isn't responding"), "{down}");
2126
2127 let install = ContainerError::CliInstall { command: "claude".into(), detail: "npm error code ENOTFOUND".into() }.user_message();
2128 assert!(install.contains("couldn't install claude"), "{install}");
2129 assert!(install.contains("ENOTFOUND"), "{install}");
2130 assert!(install.starts_with("Couldn't start the container"), "{install}");
2131 }
2132
2133 #[test]
2134 fn the_cli_directory_is_a_per_agent_named_volume_outside_the_drift_set() {
2135 let mount = cli_dir_mount("agentmux-abc");
2136 assert_eq!(mount.target.as_deref(), Some("/home/agent/.agentmux/cli"));
2137 assert_eq!(mount.source.as_deref(), Some("agentmux-cli-agentmux-abc"));
2138 assert_eq!(mount.typ, Some(MountTypeEnum::VOLUME));
2139 assert!(
2140 !ContainerManager::owned_mount_targets().contains(&CONTAINER_CLI_DIR.to_string()),
2141 "adding it to the drift set would recreate every existing container"
2142 );
2143 }
2144
2145 /// Docker-gated: an install that outruns the host-side limit is killed and
2146 /// confirmed gone before `provision_cli` returns, and leaves the volume
2147 /// usable: no marker, no live tree, and a retry installs cleanly. Same setup
2148 /// as the other install test (AGENTMUX_ITEST_BASE_IMAGE, network to npm).
2149 #[tokio::test]
2150 #[ignore]
2151 async fn itest_provision_cli_timeout_stops_the_install_before_returning() {
2152 let image = std::env::var("AGENTMUX_ITEST_BASE_IMAGE").expect("set AGENTMUX_ITEST_BASE_IMAGE to a built base image");
2153 let cm = ContainerManager::connect().expect("connect to docker");
2154 cm.check_available().await.expect("docker daemon must be reachable");
2155
2156 let name = "agentmux-itest-provision-timeout";
2157 let _ = cm.remove(name, true).await;
2158 let _ = cm.inner.docker.remove_volume(&format!("agentmux-cli-{name}"), None).await;
2159 let _ = cm.inner.docker.remove_volume(&format!("agentmux-claude-{name}"), None).await;
2160 cm.ensure_running(name, &image, &[], &[], &ContainerMountSpec::default()).await.expect("ensure_running");
2161 let install = CliInstall::for_provider("claude", "claude").unwrap();
2162
2163 // The host gives up after 3 s; the script's own limit is far away, so
2164 // only the host-side backstop can stop it.
2165 let tight = InstallLimits {
2166 run: std::time::Duration::from_secs(3),
2167 script: std::time::Duration::from_secs(600),
2168 };
2169 let err = cm.provision_cli_with(name, &install, tight, || {}).await.unwrap_err();
2170 let msg = err.user_message();
2171 assert!(msg.contains("was stopped"), "{msg}");
2172
2173 // Confirmed gone: nothing from the install is still running.
2174 let alive = cm
2175 .exec_collect(
2176 name,
2177 &["sh".into(), "-c".into(), "pgrep -f '[n]pm install --prefix' >/dev/null && echo alive || echo gone".into()],
2178 std::time::Duration::from_secs(30),
2179 )
2180 .await
2181 .expect("pgrep");
2182 assert!(alive.output.contains("gone"), "{}", alive.output);
2183
2184 // The volume is not left looking installed.
2185 let state = cm
2186 .exec_collect(
2187 name,
2188 &["sh".into(), "-c".into(), "ls -A /home/agent/.agentmux/cli | tr '\\n' ' '".into()],
2189 std::time::Duration::from_secs(30),
2190 )
2191 .await
2192 .expect("ls");
2193 assert!(!state.output.contains(".installed-") && !state.output.contains("node_modules"), "{}", state.output);
2194
2195 // And the retry the pane suggests installs cleanly.
2196 let retry = cm.provision_cli(name, &install, || {}).await.expect("retry installs");
2197 assert!(matches!(retry, CliProvision::Installed { .. }), "{retry:?}");
2198
2199 // The script's own limit is the other path: it kills npm itself.
2200 let _ = cm.remove(name, true).await;
2201 let _ = cm.inner.docker.remove_volume(&format!("agentmux-cli-{name}"), None).await;
2202 cm.ensure_running(name, &image, &[], &[], &ContainerMountSpec::default()).await.expect("recreate");
2203 let script_limited = InstallLimits {
2204 run: std::time::Duration::from_secs(300),
2205 script: std::time::Duration::from_secs(2),
2206 };
2207 let err = cm.provision_cli_with(name, &install, script_limited, || {}).await.unwrap_err();
2208 assert!(err.user_message().contains("took too long"), "{}", err.user_message());
2209
2210 cm.remove(name, true).await.expect("cleanup");
2211 let _ = cm.inner.docker.remove_volume(&format!("agentmux-cli-{name}"), None).await;
2212 let _ = cm.inner.docker.remove_volume(&format!("agentmux-claude-{name}"), None).await;
2213 }
2214
2215 /// Docker-gated: a legacy image the registry refuses falls back to the base
2216 /// image. Needs the base image already present under its published name
2217 /// (`docker tag <built-image> ghcr.io/agentmuxai/agent-base:latest`), so the
2218 /// fallback pull is satisfied locally, and network access so the legacy pull
2219 /// is really refused. Run with:
2220 /// cargo test -p agentmux-srv --bin agentmux-srv -- --ignored --nocapture itest_legacy_image_falls_back
2221 #[tokio::test]
2222 #[ignore]
2223 async fn itest_legacy_image_falls_back_to_the_base_image() {
2224 let cm = ContainerManager::connect().expect("connect to docker");
2225 cm.check_available().await.expect("docker daemon must be reachable");
2226 assert!(cm.has_image_locally(DEFAULT_AGENT_IMAGE).await, "tag a built base image as {DEFAULT_AGENT_IMAGE} first");
2227
2228 let legacy_but_absent = "ghcr.io/agentmuxai/agent-claude:itest-no-such-tag";
2229 assert!(!cm.has_image_locally(legacy_but_absent).await);
2230 let chosen = cm.pull_with_fallback(legacy_but_absent).await.expect("fallback");
2231 assert_eq!(chosen, DEFAULT_AGENT_IMAGE);
2232
2233 // A custom image gets no fallback: its refusal is reported as it is.
2234 let err = cm.pull_with_fallback("ghcr.io/agentmuxai/agent-claude-not-legacy:itest").await.unwrap_err();
2235 assert!(matches!(err, ContainerError::ImagePull { kind: PullFailure::Denied, .. }), "{err:?}");
2236 println!("{}", err.user_message());
2237 }
2238
2239 /// Docker-gated: installs the pinned CLI into a container made from the real
2240 /// base image, then proves it is idempotent and that a turn-style command finds
2241 /// the binary. Needs a built base image, named by AGENTMUX_ITEST_BASE_IMAGE
2242 /// (`docker build -f docker/Dockerfile.agent-agentmux -t <tag> .`) and network
2243 /// access to npm. Run with:
2244 /// AGENTMUX_ITEST_BASE_IMAGE=<tag> cargo test -p agentmux-srv --bin agentmux-srv -- --ignored --nocapture itest_provision_cli
2245 #[tokio::test]
2246 #[ignore]
2247 async fn itest_provision_cli_installs_once_and_a_turn_can_run_it() {
2248 let image = std::env::var("AGENTMUX_ITEST_BASE_IMAGE").expect("set AGENTMUX_ITEST_BASE_IMAGE to a built base image");
2249 let cm = ContainerManager::connect().expect("connect to docker");
2250 cm.check_available().await.expect("docker daemon must be reachable");
2251
2252 let name = "agentmux-itest-provision-cli";
2253 let _ = cm.remove(name, true).await;
2254 let _ = cm.inner.docker.remove_volume(&format!("agentmux-cli-{name}"), None).await;
2255 let _ = cm.inner.docker.remove_volume(&format!("agentmux-claude-{name}"), None).await;
2256
2257 cm.ensure_running(name, &image, &[], &[], &ContainerMountSpec::default()).await.expect("ensure_running");
2258 let install = CliInstall::for_provider("claude", "claude").unwrap();
2259
2260 let mut announced = 0;
2261 let first = cm.provision_cli(name, &install, || announced += 1).await.expect("first provision");
2262 assert!(matches!(first, CliProvision::Installed { .. }), "{first:?}");
2263 assert_eq!(announced, 1, "the install is announced once");
2264
2265 // Same manager: answered from the in-memory cache, nothing announced.
2266 let again = cm.provision_cli(name, &install, || announced += 1).await.expect("second provision");
2267 assert_eq!(again, CliProvision::Present);
2268 assert_eq!(announced, 1);
2269
2270 // A fresh manager has no cache: the marker on the volume answers.
2271 let fresh = ContainerManager::connect().expect("connect");
2272 let after_restart = fresh.provision_cli(name, &install, || announced += 1).await.expect("provision after restart");
2273 assert_eq!(after_restart, CliProvision::Present);
2274 assert_eq!(announced, 1, "a marker on the volume means no reinstall");
2275
2276 // A turn-style command (the PATH append the wrapper does) runs the CLI.
2277 let out = fresh
2278 .exec_collect(
2279 name,
2280 &["sh".into(), "-c".into(), format!("PATH=\"$PATH:{}\"; export PATH; claude --version", crate::backend::container_cli::cli_bin_dir())],
2281 std::time::Duration::from_secs(60),
2282 )
2283 .await
2284 .expect("run claude");
2285 assert_eq!(out.exit, Some(0), "{}", out.output);
2286 assert!(out.output.contains(&install.version), "{}", out.output);
2287
2288 // The container is recreated (an image update, say): the volume keeps the install.
2289 cm.remove(name, true).await.expect("remove");
2290 cm.ensure_running(name, &image, &[], &[], &ContainerMountSpec::default()).await.expect("recreate");
2291 let recreated = cm.provision_cli(name, &install, || announced += 1).await.expect("provision after recreate");
2292 assert_eq!(recreated, CliProvision::Present);
2293 assert_eq!(announced, 1, "the volume survives the container");
2294
2295 cm.remove(name, true).await.expect("cleanup");
2296 let _ = cm.inner.docker.remove_volume(&format!("agentmux-cli-{name}"), None).await;
2297 let _ = cm.inner.docker.remove_volume(&format!("agentmux-claude-{name}"), None).await;
2298 }
2299
2300 /// Docker-gated integration test for the dev-proxy network attachment
2301 /// added by docs/specs/SPEC_NATIVE_CONTAINER_DEV_PROXY_2026_09_19.md.
2302 /// Requires a reachable Docker daemon; `#[ignore]` by default. Run with:
2303 /// cargo test -p agentmux-srv -- --ignored --nocapture itest_dev_proxy
2304 ///
2305 /// Confirms, against a REAL daemon (not a mock): `ensure_running`
2306 /// creates the shared `AGENTMUX_DOCKER_NETWORK` and attaches the
2307 /// container to it, `agent_network_ip` resolves a real IP on that
2308 /// network, and `remove` clears the container's dev-proxy
2309 /// registrations via the attached registry — the same path
2310 /// `bootstrap::build_app_state` wires up for real srv instances.
2311 #[tokio::test]
2312 #[ignore]
2313 async fn itest_dev_proxy_network_attach_and_ip_resolution() {
2314 let cm = ContainerManager::connect().expect("connect to docker (set DOCKER_HOST)");
2315 cm.check_available().await.expect("docker daemon must be reachable");
2316
2317 let name = "agentmux-itest-devproxy";
2318 let image = "nginx:alpine";
2319 let _ = cm.remove(name, true).await; // clean slate; ignore if absent
2320
2321 let registry = crate::backend::dev_proxy::DevProxyRegistry::new();
2322 cm.attach_dev_proxy_registry(registry.clone());
2323
2324 cm.ensure_running(name, image, &[], &[], &ContainerMountSpec::default())
2325 .await
2326 .expect("ensure_running (create)");
2327
2328 let ip = cm
2329 .agent_network_ip(name)
2330 .await
2331 .expect("container must have an IP on AGENTMUX_DOCKER_NETWORK after ensure_running");
2332 assert!(
2333 ip.parse::<std::net::Ipv4Addr>().is_ok(),
2334 "agent_network_ip must return a real IPv4 address, got {ip:?}"
2335 );
2336
2337 // Simulate a real registration (what `handle_register_dev_server`
2338 // does) so we can prove `remove` actually clears it below.
2339 let addr: std::net::SocketAddr = format!("{ip}:3000").parse().expect("valid addr");
2340 registry.register("itest-proj", "itest-devproxy", addr).await;
2341 assert_eq!(
2342 registry.lookup("itest-proj-itest-devproxy").await,
2343 Some(addr),
2344 "registration must be visible via lookup before cleanup"
2345 );
2346
2347 // cleanup — also exercises `cleanup_dev_proxy_routes`.
2348 cm.remove(name, true).await.expect("remove");
2349
2350 assert_eq!(
2351 registry.lookup("itest-proj-itest-devproxy").await,
2352 None,
2353 "remove() must clear this agent's dev-proxy registrations, not leave a stale route"
2354 );
2355 }
2356}