execkit 0.9.0

Stateful, structured, safe shell sessions for AI agents on real infrastructure.
Documentation
// SPDX-License-Identifier: Apache-2.0
//! Docker transport: a session inside a running container via `docker exec`.
//!
//! This is the local PTY transport driving the `docker` CLI, plus in-container
//! cleanup. Killing the `docker exec` *client* (what the PTY child kill does)
//! does NOT stop the shell it started inside the container, so on drop we
//! best-effort kill our marked process tree there - otherwise a dropped or
//! timed-out session would leave a shell (and any still-running command) alive.

use std::process::{Command, Stdio};
use std::time::Duration;

use super::local::LocalPty;
use super::Transport;
use crate::error::{Error, Result};

pub struct DockerExec {
    inner: LocalPty,
    container: String,
    marker: String,
}

impl DockerExec {
    /// `marker` is a per-session token; the container shell and every command it
    /// spawns inherit it as `EXECKIT_SID`, so drop-cleanup can find the tree.
    pub fn spawn(container: &str, marker: &str) -> Result<Self> {
        check_running(container)?;
        let env = format!("EXECKIT_SID={marker}");
        // -t so the container shell line-buffers (a pipe block-buffers and the
        // sentinel markers never flush); -- so a `-`-leading name can't be a flag.
        let inner = LocalPty::spawn(
            "docker",
            &["exec", "-it", "-e", &env, "--", container, "/bin/sh"],
        )?;
        Ok(Self {
            inner,
            container: container.to_string(),
            marker: marker.to_string(),
        })
    }
}

/// Fail fast, with an actionable message, instead of leaving the agent to
/// puzzle out a hung/garbled PTY when the container is missing/stopped or the
/// `docker` CLI itself is absent.
fn check_running(container: &str) -> Result<()> {
    let out = Command::new("docker")
        .args(["inspect", "-f", "{{.State.Running}}", "--", container])
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .output();
    match out {
        Ok(o) if o.status.success() && String::from_utf8_lossy(&o.stdout).trim() == "true" => {
            Ok(())
        }
        Ok(_) => Err(Error::Transport(format!(
            "docker: container '{container}' not found or not running"
        ))),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(Error::Transport(
            "docker: 'docker' CLI not found on PATH".into(),
        )),
        Err(_) => Err(Error::Transport(format!(
            "docker: container '{container}' not found or not running"
        ))),
    }
}

impl Transport for DockerExec {
    fn write_all(&mut self, bytes: &[u8]) -> Result<()> {
        self.inner.write_all(bytes)
    }

    fn recv_timeout(&self, dur: Duration) -> Option<Vec<u8>> {
        self.inner.recv_timeout(dur)
    }
}

impl Drop for DockerExec {
    fn drop(&mut self) {
        // Best-effort: kill every process in the container whose environ carries
        // our marker (the shell and its children). Portable across busybox/dash
        // (uses /proc, tr, grep, kill). Runs before `inner` (LocalPty) drops and
        // kills the client. Best-effort only: an adversarial command that unsets
        // EXECKIT_SID before forking can evade it.
        let script = format!(
            "for p in /proc/[0-9]*; do \
               tr '\\0' '\\n' < \"$p/environ\" 2>/dev/null | grep -qx 'EXECKIT_SID={m}' && \
               kill -9 \"${{p#/proc/}}\" 2>/dev/null; \
             done",
            m = self.marker
        );
        let _ = Command::new("docker")
            .args(["exec", "--", &self.container, "/bin/sh", "-c", &script])
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();
    }
}