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::{HostConfig, Mount, MountTypeEnum};
33
34/// Env var names that reference host-filesystem paths and must NOT be forwarded
35/// into a container via `docker exec -e`. The container image supplies its own
36/// values for these (e.g. `CLAUDE_CONFIG_DIR=/home/agent/.claude` baked in).
37pub const CONTAINER_ENV_DENYLIST: &[&str] = &[
38    "CLAUDE_CONFIG_DIR",
39    "GH_CONFIG_DIR",
40    "PATH",
41    "HOME",
42    "USERPROFILE",
43    "HOMEDRIVE",
44    "HOMEPATH",
45    "TMPDIR",
46    "TEMP",
47    "TMP",
48];
49
50/// Shared container manager. Clone-on-Arc; cheap to pass around.
51#[derive(Clone)]
52pub struct ContainerManager {
53    inner: Arc<ContainerManagerInner>,
54}
55
56struct ContainerManagerInner {
57    docker: Docker,
58    /// Per-container serialization lock: prevents concurrent ensure_running calls
59    /// for the same container from both seeing "not found" in Docker and both
60    /// attempting create_container (which would fail with a 409 name-conflict
61    /// on the second caller).
62    ensure_locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
63}
64
65/// Exec session handle for a single turn.
66///
67/// `input` is the stdin pipe into the container process (write JSON message here,
68/// then drop/flush to send EOF). `output` is a bollard `LogOutput` stream; with
69/// `tty: false` Docker multiplexes stdout as `LogOutput::StdOut` frames and
70/// stderr as `LogOutput::StdErr` frames.
71///
72/// Env vars are passed via `CreateExecOptions.env` (Docker socket) — they never
73/// appear in process argv, preventing CWE-214 credential exposure via ps/proc.
74pub struct ExecSession {
75    /// Docker exec id — pass to [`ContainerManager::inspect_exec`] after the
76    /// output stream ends to retrieve the process's real exit code. The stream
77    /// ending is NOT the exit status, so callers that care about success/failure
78    /// must inspect the exec (the in-container CLI can exit non-zero while still
79    /// closing stdout cleanly).
80    pub exec_id: String,
81    /// Stdin pipe to the container process. Write the JSON message then flush/drop.
82    pub input: Pin<Box<dyn AsyncWrite + Send>>,
83    /// Multiplexed stdout/stderr stream (LogOutput::StdOut / LogOutput::StdErr).
84    pub output: Pin<Box<dyn futures_util::Stream<Item = Result<bollard::container::LogOutput, bollard::errors::Error>> + Send>>,
85}
86
87/// Errors from container operations.
88#[derive(Debug, thiserror::Error)]
89pub enum ContainerError {
90    #[error("Docker API error: {0}")]
91    Docker(#[from] bollard::errors::Error),
92    #[error("Container has no id after create: {name}")]
93    NoId { name: String },
94    #[error("Docker is not available on this host: {0}")]
95    NotAvailable(String),
96}
97
98impl ContainerManager {
99    /// Connect to the local Docker daemon using environment/platform defaults.
100    ///
101    /// Honors `DOCKER_HOST` env var (e.g. for rootless or remote daemons).
102    /// On Windows connects via named pipe; on macOS/Linux via Unix socket.
103    pub fn connect() -> Result<Self, ContainerError> {
104        let docker = Docker::connect_with_local_defaults()
105            .map_err(|e| ContainerError::NotAvailable(e.to_string()))?;
106        Ok(Self {
107            inner: Arc::new(ContainerManagerInner {
108                docker,
109                ensure_locks: Mutex::new(HashMap::new()),
110            }),
111        })
112    }
113
114    /// Ping the Docker daemon. Returns `Ok(())` if available.
115    pub async fn check_available(&self) -> Result<(), ContainerError> {
116        self.inner.docker.ping().await?;
117        Ok(())
118    }
119
120    /// Ensure the container for this agent is created and running.
121    ///
122    /// - If it exists and is running: no-op.
123    /// - If it exists but stopped: starts it.
124    /// - If it doesn't exist: creates and starts it.
125    ///
126    /// Always queries Docker (no in-memory cache) so externally killed containers
127    /// are detected. Concurrent calls for the **same** `container_name` are
128    /// serialized via a per-container mutex. Concurrent calls for **different**
129    /// containers proceed in parallel.
130    pub async fn ensure_running(
131        &self,
132        container_name: &str,
133        image: &str,
134        volumes: &[String],
135        env_vars: &[(String, String)],
136    ) -> Result<(), ContainerError> {
137        // Acquire the per-container serialization lock before touching Docker.
138        // Concurrent callers for the same container_name queue here; once the
139        // first caller completes, the second finds the container already running
140        // via the Docker query below and returns immediately.
141        let container_lock = {
142            let mut locks = self.inner.ensure_locks.lock().await;
143            locks.entry(container_name.to_string())
144                .or_insert_with(|| Arc::new(Mutex::new(())))
145                .clone()
146        };
147        let guard = container_lock.lock().await;
148
149        let result = self.ensure_running_locked(container_name, image, volumes, env_vars).await;
150
151        // Release the per-container lock, then evict its map entry when no other
152        // caller is queued on it — otherwise `ensure_locks` grows unbounded (one
153        // entry per distinct container ever seen) over the process lifetime.
154        // A queued caller has already cloned the Arc (strong_count > 2), so we
155        // only drop the entry when it's truly idle (map's ref + our clone == 2),
156        // which can't reintroduce the create-race the lock guards: a new caller
157        // can't clone the old Arc while we hold the map lock, and after eviction
158        // it just creates a fresh one with no contention.
159        drop(guard);
160        {
161            let mut locks = self.inner.ensure_locks.lock().await;
162            if Arc::strong_count(&container_lock) <= 2 {
163                locks.remove(container_name);
164            }
165        }
166
167        result
168    }
169
170    /// Create/reuse logic for [`ensure_running`], run while holding the
171    /// per-container serialization lock. Split out so `ensure_running` can wrap
172    /// it with lock acquisition + map-entry eviction on every exit path.
173    async fn ensure_running_locked(
174        &self,
175        container_name: &str,
176        image: &str,
177        volumes: &[String],
178        env_vars: &[(String, String)],
179    ) -> Result<(), ContainerError> {
180        // Always query Docker — do not rely on an in-memory cache. The container
181        // can be stopped or removed externally (e.g. `docker rm -f`), and a
182        // stale cache entry would cause ensure_running to silently no-op while
183        // a subsequent exec call fails.
184        let existing = self.find_container(container_name).await?;
185
186        match existing {
187            Some(status) if status == "running" => {
188                // Already running — nothing to do.
189            }
190            Some(_) => {
191                // Exists but stopped — start it.
192                self.inner.docker
193                    .start_container(container_name, None::<StartContainerOptions<String>>)
194                    .await?;
195                tracing::info!(container = container_name, "restarted stopped container");
196            }
197            None => {
198                // Pull the image via Docker socket before create so the Engine API
199                // has the image locally. Unlike `docker run`, the Engine's
200                // create_container does NOT auto-pull — it returns 404 if the image
201                // is absent. pull_image is a no-op when the image already exists.
202                self.pull_image(image).await?;
203                // Create and start.
204                self.create_and_start(container_name, image, volumes, env_vars).await?;
205                tracing::info!(container = container_name, image = image, "created and started container");
206            }
207        }
208        Ok(())
209    }
210
211    /// Launch an exec session inside a running container.
212    ///
213    /// Uses `-i` (not `-t`) to avoid tty CR/LF corruption of NDJSON output.
214    /// The caller receives `ExecSession` whose `output` stream carries
215    /// multiplexed stdout/stderr for piping into the block.
216    pub async fn exec(
217        &self,
218        container_name: &str,
219        cmd: &[String],
220        working_dir: Option<&str>,
221        env_vars: &[(String, String)],
222    ) -> Result<ExecSession, ContainerError> {
223        let env: Vec<String> = env_vars.iter()
224            .map(|(k, v)| format!("{k}={v}"))
225            .collect();
226
227        let exec = self.inner.docker
228            .create_exec(container_name, CreateExecOptions {
229                attach_stdin: Some(true),
230                attach_stdout: Some(true),
231                attach_stderr: Some(true),
232                tty: Some(false), // NO tty — preserves NDJSON newlines
233                cmd: Some(cmd.iter().map(String::as_str).collect()),
234                working_dir,
235                env: if env.is_empty() { None } else { Some(env.iter().map(String::as_str).collect()) },
236                ..Default::default()
237            })
238            .await?;
239
240        let results = self.inner.docker
241            .start_exec(&exec.id, Some(StartExecOptions {
242                detach: false,
243                ..Default::default()
244            }))
245            .await?;
246
247        match results {
248            StartExecResults::Attached { input, output } => {
249                Ok(ExecSession { exec_id: exec.id, input, output })
250            }
251            StartExecResults::Detached => {
252                Err(ContainerError::Docker(
253                    bollard::errors::Error::DockerResponseServerError {
254                        status_code: 0,
255                        message: "start_exec returned Detached unexpectedly (detach=false)".to_string(),
256                    }
257                ))
258            }
259        }
260    }
261
262    /// Retrieve the exit code of a finished exec via the Docker socket.
263    ///
264    /// Returns `Ok(Some(code))` once the exec has exited, `Ok(None)` while it is
265    /// still running (no code yet) or if Docker did not report one. Call this
266    /// after the exec's output stream has ended — unlike a child process's
267    /// `wait()`, the attached output stream closing does not carry the exit
268    /// status, so the turn's success/failure can only be known by inspecting.
269    pub async fn inspect_exec(&self, exec_id: &str) -> Result<Option<i64>, ContainerError> {
270        let info = self.inner.docker.inspect_exec(exec_id).await?;
271        // `running == Some(true)` means no meaningful code yet.
272        if info.running == Some(true) {
273            return Ok(None);
274        }
275        Ok(info.exit_code)
276    }
277
278    /// Best-effort interruption of the turn's process(es) inside a container.
279    ///
280    /// Docker/bollard has no "kill exec" API, so we `pkill` the matching process
281    /// via a short detached exec. The persistent-container model runs one turn at
282    /// a time (guarded by `run_lock`), so a single CLI process matches `pattern`
283    /// (the command name, e.g. `claude`). `-f` matches the full command line
284    /// because the CLI runs under `node`, whose process name isn't the CLI's.
285    ///
286    /// Requires `pkill` (procps) in the image. Fire-and-forget (detached); a
287    /// non-match (`pkill` exit 1, e.g. the turn already exited) is not an error.
288    pub async fn signal_exec_process(
289        &self,
290        container_name: &str,
291        pattern: &str,
292        force: bool,
293    ) -> Result<(), ContainerError> {
294        let signal = if force { "-KILL" } else { "-TERM" };
295        let cmd: Vec<&str> = vec!["pkill", signal, "-f", pattern];
296        let exec = self.inner.docker
297            .create_exec(container_name, CreateExecOptions {
298                cmd: Some(cmd),
299                attach_stdout: Some(false),
300                attach_stderr: Some(false),
301                ..Default::default()
302            })
303            .await?;
304        self.inner.docker
305            .start_exec(&exec.id, Some(StartExecOptions { detach: true, ..Default::default() }))
306            .await?;
307        Ok(())
308    }
309
310    /// Gracefully stop a container. Uses SIGTERM → SIGKILL after `timeout_secs`.
311    pub async fn stop(&self, container_name: &str, timeout_secs: i64) -> Result<(), ContainerError> {
312        self.inner.docker
313            .stop_container(container_name, Some(StopContainerOptions { t: timeout_secs }))
314            .await?;
315        tracing::info!(container = container_name, "stopped container");
316        Ok(())
317    }
318
319    /// Remove a container (must be stopped first or use `force = true`).
320    pub async fn remove(&self, container_name: &str, force: bool) -> Result<(), ContainerError> {
321        use bollard::container::RemoveContainerOptions;
322        self.inner.docker
323            .remove_container(container_name, Some(RemoveContainerOptions {
324                force,
325                ..Default::default()
326            }))
327            .await?;
328        tracing::info!(container = container_name, "removed container");
329        Ok(())
330    }
331
332    // ---- private helpers ----
333
334    /// Returns the container status string ("running", "exited", …) or `None` if not found.
335    async fn find_container(&self, name: &str) -> Result<Option<String>, ContainerError> {
336        let mut filters = HashMap::new();
337        filters.insert("name", vec![name]);
338        let list = self.inner.docker
339            .list_containers(Some(ListContainersOptions {
340                all: true,
341                filters,
342                ..Default::default()
343            }))
344            .await?;
345
346        // `name` filter is a prefix match — verify exact name.
347        let canonical = format!("/{name}");
348        for c in &list {
349            if let Some(names) = &c.names {
350                if names.iter().any(|n| n == &canonical) {
351                    return Ok(c.state.clone());
352                }
353            }
354        }
355        Ok(None)
356    }
357
358    /// Pull `image` via the Docker socket (create_image API).
359    ///
360    /// Streams the pull response to completion before returning. If the image is
361    /// already present locally, Docker returns an empty stream immediately — this
362    /// function treats that as success (no pull needed).
363    ///
364    /// Errors only on genuine pull failures (network, auth, no such image).
365    async fn pull_image(&self, image: &str) -> Result<(), ContainerError> {
366        // Skip the pull entirely when the image is already present locally.
367        // `create_image` always contacts the registry, so on an offline host the
368        // pull stream errors out even when the image is cached — aborting
369        // container creation even though `docker run` would start it from the
370        // local cache. An `inspect_image` short-circuit makes the cached-offline
371        // path work (and avoids a redundant registry round-trip when online).
372        if self.inner.docker.inspect_image(image).await.is_ok() {
373            tracing::info!(image = image, "image already present locally; skipping pull");
374            return Ok(());
375        }
376
377        tracing::info!(image = image, "pulling image (not cached locally)");
378        let mut stream = self.inner.docker.create_image(
379            Some(CreateImageOptions {
380                from_image: image,
381                ..Default::default()
382            }),
383            None,
384            None,
385        );
386        while let Some(item) = stream.next().await {
387            match item {
388                Ok(info) => {
389                    if let Some(status) = info.status {
390                        tracing::debug!(image = image, status = %status, "pull progress");
391                    }
392                }
393                Err(e) => {
394                    tracing::warn!(image = image, error = %e, "image pull error");
395                    return Err(ContainerError::Docker(e));
396                }
397            }
398        }
399        tracing::info!(image = image, "image pull complete (or already cached)");
400        Ok(())
401    }
402
403    async fn create_and_start(
404        &self,
405        container_name: &str,
406        image: &str,
407        volumes: &[String],
408        env_vars: &[(String, String)],
409    ) -> Result<(), ContainerError> {
410        let env: Vec<String> = env_vars.iter()
411            .map(|(k, v)| format!("{k}={v}"))
412            .collect();
413
414        let mounts: Vec<Mount> = volumes.iter().filter_map(|spec| {
415            let (source, target, read_only) = parse_volume_spec(spec)?;
416            if target.is_empty() {
417                tracing::warn!(spec = %spec, "ignoring malformed volume spec (expected source:target)");
418                return None;
419            }
420            let mount_type = if source.starts_with('/') || source.starts_with('~')
421                || (source.len() >= 2 && source.as_bytes()[1] == b':')
422            {
423                MountTypeEnum::BIND
424            } else {
425                MountTypeEnum::VOLUME
426            };
427            Some(Mount {
428                target: Some(target.to_string()),
429                source: Some(source.to_string()),
430                typ: Some(mount_type),
431                read_only: Some(read_only),
432                ..Default::default()
433            })
434        }).collect();
435
436        // claude-config named volume: ensure ~/.claude persists across container restarts.
437        // Using a named volume (not a host bind mount) avoids credential leakage from the
438        // host's .claude directory into the container.
439        let mut all_mounts = vec![
440            Mount {
441                target: Some("/home/agent/.claude".to_string()),
442                source: Some(format!("agentmux-claude-{container_name}")),
443                typ: Some(MountTypeEnum::VOLUME),
444                read_only: Some(false),
445                ..Default::default()
446            },
447        ];
448        all_mounts.extend(mounts);
449
450        let config: Config<String> = Config {
451            image: Some(image.to_string()),
452            env: if env.is_empty() { None } else { Some(env) },
453            // Keep container alive between turns — idle PID 1 (`sleep infinity`
454            // via tini) stays running until `docker stop`. Agent turns run via
455            // `docker exec`, not as the PID-1 process.
456            tty: Some(false),
457            open_stdin: Some(true),
458            host_config: Some(HostConfig {
459                mounts: Some(all_mounts),
460                // Security: no host network, no privileged mode.
461                network_mode: Some("bridge".to_string()),
462                ..Default::default()
463            }),
464            ..Default::default()
465        };
466
467        let container = self.inner.docker
468            .create_container(
469                Some(CreateContainerOptions {
470                    name: container_name,
471                    platform: None,
472                }),
473                config,
474            )
475            .await?;
476
477        let id = container.id;
478        if id.is_empty() {
479            return Err(ContainerError::NoId { name: container_name.to_string() });
480        }
481
482        self.inner.docker
483            .start_container(&id, None::<StartContainerOptions<String>>)
484            .await?;
485
486        Ok(())
487    }
488}
489
490/// Derive the stable container name for an agent from its slug.
491/// Format: `agentmux-<slug>`. Deterministic so restarts reuse the same container.
492pub fn container_name_for_slug(slug: &str) -> String {
493    format!("agentmux-{slug}")
494}
495
496/// Parse a Docker volume spec into `(source, target, read_only)`.
497///
498/// Format: `source:target` or `source:target:options`.
499///
500/// Handles Windows drive-letter bind paths (e.g. `C:\Users\me\repo:/workspace:ro`)
501/// by treating the drive-letter colon (`X:`) as part of the source path, not as
502/// the source/target separator. A plain `splitn(3, ':')` would split `C:\path`
503/// into `C` and `\path`, losing the drive letter.
504///
505/// Returns `None` for malformed specs (no target separator found).
506fn parse_volume_spec(spec: &str) -> Option<(&str, &str, bool)> {
507    let bytes = spec.as_bytes();
508
509    // Detect Windows drive-letter prefix: single ASCII letter followed by ':\'  or ':/'
510    let (source, rest) = if bytes.len() >= 3
511        && bytes[0].is_ascii_alphabetic()
512        && bytes[1] == b':'
513        && (bytes[2] == b'\\' || bytes[2] == b'/')
514    {
515        // Windows path: skip the drive colon and split on the next ':'.
516        let tail = &spec[2..]; // starts at '\' or '/'
517        let pos = tail.find(':')?;
518        (&spec[..2 + pos], &tail[pos + 1..])
519    } else {
520        let pos = spec.find(':')?;
521        (&spec[..pos], &spec[pos + 1..])
522    };
523
524    // Split target from optional options.
525    let (target, options) = if let Some(pos) = rest.find(':') {
526        (&rest[..pos], &rest[pos + 1..])
527    } else {
528        (rest, "")
529    };
530
531    Some((source, target, options.contains("ro")))
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    #[test]
539    fn test_container_name_for_slug() {
540        assert_eq!(container_name_for_slug("my-agent"), "agentmux-my-agent");
541        assert_eq!(container_name_for_slug("agent1"), "agentmux-agent1");
542    }
543
544    #[test]
545    fn test_parse_volume_spec_unix_named() {
546        let (src, tgt, ro) = parse_volume_spec("myvolume:/data").unwrap();
547        assert_eq!(src, "myvolume");
548        assert_eq!(tgt, "/data");
549        assert!(!ro);
550    }
551
552    #[test]
553    fn test_parse_volume_spec_unix_bind_readonly() {
554        let (src, tgt, ro) = parse_volume_spec("/host/path:/container/path:ro").unwrap();
555        assert_eq!(src, "/host/path");
556        assert_eq!(tgt, "/container/path");
557        assert!(ro);
558    }
559
560    #[test]
561    fn test_parse_volume_spec_windows_bind() {
562        // Drive-letter path must not be split at the drive colon.
563        let (src, tgt, ro) = parse_volume_spec("C:\\Users\\me\\repo:/workspace").unwrap();
564        assert_eq!(src, "C:\\Users\\me\\repo");
565        assert_eq!(tgt, "/workspace");
566        assert!(!ro);
567    }
568
569    #[test]
570    fn test_parse_volume_spec_windows_bind_readonly() {
571        let (src, tgt, ro) = parse_volume_spec("C:/Users/me/repo:/workspace:ro").unwrap();
572        assert_eq!(src, "C:/Users/me/repo");
573        assert_eq!(tgt, "/workspace");
574        assert!(ro);
575    }
576
577    #[test]
578    fn test_parse_volume_spec_malformed() {
579        assert!(parse_volume_spec("nocodon").is_none());
580    }
581
582    /// Docker-gated integration test for the container lifecycle + exec path.
583    /// Requires a reachable Docker daemon (Colima/Docker Desktop), so it is
584    /// `#[ignore]` by default. Run with:
585    ///   DOCKER_HOST=unix://$HOME/.colima/default/docker.sock \
586    ///     cargo test -p agentmux-srv -- --ignored --nocapture itest_container
587    ///
588    /// Validates the foundation Phase 2 rests on against a real daemon:
589    /// `ensure_running` create→reuse, env delivered into the exec via the Docker
590    /// socket (`CreateExecOptions.env`, NOT argv — the CWE-214 guard), and the
591    /// stdin→stdout exec I/O contract `spawn_container_turn` depends on.
592    #[tokio::test]
593    #[ignore]
594    async fn itest_container_lifecycle_exec_env_and_io() {
595        use tokio::io::AsyncWriteExt as _;
596
597        async fn drain_stdout<S>(mut out: S) -> String
598        where
599            S: futures_util::Stream<
600                    Item = Result<bollard::container::LogOutput, bollard::errors::Error>,
601                > + Unpin,
602        {
603            use futures_util::StreamExt as _;
604            let mut s = String::new();
605            while let Some(item) = out.next().await {
606                if let Ok(bollard::container::LogOutput::StdOut { message }) = item {
607                    s.push_str(&String::from_utf8_lossy(&message));
608                }
609            }
610            s
611        }
612
613        let cm = ContainerManager::connect().expect("connect to docker (set DOCKER_HOST)");
614        cm.check_available().await.expect("docker daemon must be reachable");
615
616        let name = "agentmux-itest-1357";
617        // Public, pullable image with a long-lived default CMD (nginx daemon) and
618        // a shell — `create_and_start` sets no cmd, so the image default must keep
619        // PID 1 alive between exec turns (the persistent-container model).
620        let image = "nginx:alpine";
621        let _ = cm.remove(name, true).await; // clean slate; ignore if absent
622
623        // create path: pull + create + start
624        cm.ensure_running(name, image, &[], &[]).await.expect("ensure_running (create)");
625        // reuse path: already running → must no-op, not error or re-create
626        cm.ensure_running(name, image, &[], &[]).await.expect("ensure_running (reuse)");
627
628        // env reaches the in-container process via the Docker socket, not argv.
629        // Drop the (unused) stdin half first: bollard's exec output stream does
630        // not EOF while the write-half is held open — the same reason
631        // spawn_container_turn drops `input` before reading output.
632        let ExecSession { input, output, .. } = cm
633            .exec(
634                name,
635                &["sh".into(), "-c".into(), "printf %s \"$ITEST_KEY\"".into()],
636                None,
637                &[("ITEST_KEY".into(), "val-42".into())],
638            )
639            .await
640            .expect("exec (env)");
641        drop(input);
642        let out = drain_stdout(output).await;
643        assert!(out.contains("val-42"), "env must reach container; got {out:?}");
644
645        // stdin → stdout I/O contract: write a newline-terminated message and
646        // read it back. Use `read` (completes on the first '\n', then the shell
647        // exits) rather than `cat` (which blocks until stdin EOF) — `claude`
648        // likewise consumes newline-delimited JSON without waiting for EOF, and
649        // bollard's hijacked exec stream does not reliably half-close stdin on
650        // `drop(input)`, so an EOF-dependent reader would hang.
651        let ExecSession { mut input, output, .. } = cm
652            .exec(
653                name,
654                &["sh".into(), "-c".into(), "read line; printf 'got:%s' \"$line\"".into()],
655                None,
656                &[],
657            )
658            .await
659            .expect("exec (io)");
660        input.write_all(b"hello-stdin\n").await.expect("write stdin");
661        input.flush().await.expect("flush stdin");
662        drop(input);
663        let out = drain_stdout(output).await;
664        assert!(out.contains("got:hello-stdin"), "stdin must reach the container process; got {out:?}");
665
666        // cleanup
667        cm.remove(name, true).await.expect("remove");
668    }
669}