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}