Skip to main content

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}