Skip to main content

ContainerManager

Struct ContainerManager 

Source
pub struct ContainerManager {
    inner: Arc<ContainerManagerInner>,
}
Expand description

Shared container manager. Clone-on-Arc; cheap to pass around.

Fields§

§inner: Arc<ContainerManagerInner>

Implementations§

Source§

impl ContainerManager

Source

pub fn connect() -> Result<Self, ContainerError>

Connect to the local Docker daemon using environment/platform defaults.

Honors DOCKER_HOST env var (e.g. for rootless or remote daemons). On Windows connects via named pipe; on macOS/Linux via Unix socket.

Source

pub fn attach_dev_proxy_registry(&self, registry: DevProxyRegistry)

Wires a dev-proxy routing table into this manager so stop/remove can clear an agent’s registrations when its container goes away. Set once per ContainerManager instance (a second call is a harmless no-op — OnceLock::set fails silently, which is fine here: the registry itself doesn’t change after boot). See docs/specs/SPEC_NATIVE_CONTAINER_DEV_PROXY_2026_09_19.md.

Source

pub async fn agent_network_ip(&self, container_name: &str) -> Option<String>

This container’s IP address on AGENTMUX_DOCKER_NETWORK — what the dev-proxy registry stores as the routable backend address. None if the container isn’t running, isn’t on the network yet, or Docker is unreachable. Always a fresh inspect_container, never cached — the caller (app_api::dev_server::handle_register_dev_server) needs the CURRENT address, not one from whenever the container was first created.

Source

pub async fn check_available(&self) -> Result<(), ContainerError>

Ping the Docker daemon. Returns Ok(()) if available.

Source

pub async fn ensure_running( &self, container_name: &str, image: &str, volumes: &[String], env_vars: &[(String, String)], spec: &ContainerMountSpec, ) -> Result<(), ContainerError>

Ensure the container for this agent is created and running.

  • If it exists and is running: no-op.
  • If it exists but stopped: starts it.
  • If it doesn’t exist: creates and starts it.

Always queries Docker (no in-memory cache) so externally killed containers are detected. Concurrent calls for the same container_name are serialized via a per-container mutex. Concurrent calls for different containers proceed in parallel.

Source

async fn ensure_running_locked( &self, container_name: &str, image: &str, volumes: &[String], env_vars: &[(String, String)], spec: &ContainerMountSpec, ) -> Result<(), ContainerError>

Create/reuse logic for [ensure_running], run while holding the per-container serialization lock. Split out so ensure_running can wrap it with lock acquisition + map-entry eviction on every exit path.

Source

pub async fn exec( &self, container_name: &str, cmd: &[String], working_dir: Option<&str>, env_vars: &[(String, String)], attach_stdin: bool, ) -> Result<ExecSession, ContainerError>

Launch an exec session inside a running container.

Uses -i (not -t) to avoid tty CR/LF corruption of NDJSON output. The caller receives ExecSession whose output stream carries multiplexed stdout/stderr for piping into the block.

attach_stdin MUST be false unless the in-container process is a reader that completes on a newline rather than on EOF. Attaching stdin is a one-way door: bollard’s hijacked exec stream cannot half-close the write side (see the exec (io) integration test below), and on Windows the Docker transport is a named pipe, which has no half-close at all — so the process’s stdin NEVER reaches EOF. Neither drop(input) nor an explicit input.shutdown().await changes that; both were tried against a live container on 2026-09-01 and the process hung indefinitely.

Any command that reads stdin to EOF (cat, or claude -p taking its prompt on stdin) will therefore hang forever, producing no output and no exit. Give it a file to read instead — see ContainerManager::upload_turn_prompt, which also covers why argv and env are both the wrong place for that input.

Source

async fn exec_identity(&self, container_name: &str) -> Option<(u64, u64)>

The (uid, gid) that exec runs as inside this container.

Needed so an uploaded file can be OWNED by that user. A tar entry defaults to uid 0, and /tmp is sticky (1777) in every standard image — under which a non-root process cannot unlink a root-owned file no matter what its mode is. That is not theoretical: it silently leaked one prompt file per turn (rm -f fails quietly), including multi- hundred-KB ones, until a live check caught the files piling up.

Resolved by asking the container rather than assuming a uid, so this holds for any image regardless of which user it runs as.

Only a successful probe is cached. An earlier cut cached a (0, 0) fallback on any failure, including a transient Docker hiccup — which poisoned the cache for the container’s whole life (reagent P1, PR #2883). That is not a degraded mode but a permanent break: at mode 0600 a root-owned prompt file is unreadable AND un-unlinkable by the real exec user, so every later turn on that container would fail to open its prompt and orphan another file. A failure now returns None and is retried on the next turn.

Source

pub async fn remove_turn_prompt(&self, container_name: &str, prompt_path: &str)

Remove a prompt file uploaded by [upload_turn_prompt].

The turn’s own wrapper script removes the file after the CLI exits, so this is only for the path where the wrapper never runs at all — the upload succeeded but starting the exec failed, which would otherwise orphan the file (reagent P2, PR #2883).

Best-effort by design: it runs as the same user that owns the file, and a failure here is not worth failing an already-failing turn over.

Source

pub async fn upload_turn_prompt( &self, container_name: &str, turn_id: &str, message: &str, ) -> Result<String, ContainerError>

Write a turn’s prompt into the container as a file, returning its absolute in-container path.

This exists because a container turn has no other way to hand the CLI its prompt:

  • exec stdin can never reach EOF — bollard can’t half-close the hijacked stream’s write side, and on Windows the Docker transport is a named pipe, which has no half-close at all. A CLI that reads to EOF (claude -p) hangs forever with no output and no exit.
  • argv would expose any secret pasted into a chat message via docker top / host ps / /proc/<pid>/cmdline, breaking this module’s own no-secrets-in-argv invariant (reagent P1, PR #2883).
  • an env var keeps it off those host surfaces, but shares argv’s per-string MAX_ARG_STRLEN ceiling — measured in this very image at ~128 KiB (130,000 B passes, 200,000 B fails with “Argument list too long”). A long paste or a large # Session Context would break the turn (codex P2, PR #2883).

A file has none of those limits: unbounded size, invisible to every host-side process listing, and redirecting from it gives the CLI a real stdin that reaches a real EOF.

Ownership is the exec user’s (see [exec_identity]) so the turn can delete the file afterwards. Mode is 0600: only that user ever needs it, and it is the same user the CLI already runs as.

Source

pub async fn inspect_exec( &self, exec_id: &str, ) -> Result<Option<i64>, ContainerError>

Retrieve the exit code of a finished exec via the Docker socket.

Returns Ok(Some(code)) once the exec has exited, Ok(None) while it is still running (no code yet) or if Docker did not report one. Call this after the exec’s output stream has ended — unlike a child process’s wait(), the attached output stream closing does not carry the exit status, so the turn’s success/failure can only be known by inspecting.

Source

async fn exec_collect( &self, container_name: &str, argv: &[String], limit: Duration, ) -> Result<ExecOutcome, ContainerError>

Run argv in the container and collect its output and exit code.

Source

async fn wait_exec_finished(&self, exec_id: &str, limit: Duration) -> bool

Poll until the exec has stopped running, or limit passes. True when it is confirmed finished.

Source

async fn docker_exec_running(&self, exec_id: &str) -> Option<bool>

Whether the exec is still running; None when Docker can’t say.

Source

async fn reap_cli_install(&self, container_name: &str) -> bool

Kill any npm install into the CLI directory and wait until none is left. True only when that is confirmed.

Source

pub async fn provision_cli( &self, container_name: &str, install: &CliInstall, on_install_start: impl FnOnce(), ) -> Result<CliProvision, ContainerError>

Make sure the provider CLI is usable in this container, installing it on first use. on_install_start runs just before an install begins (the caller shows “Installing …” then), never when nothing needs installing.

Source

async fn provision_cli_with( &self, container_name: &str, install: &CliInstall, limits: InstallLimits, on_install_start: impl FnOnce(), ) -> Result<CliProvision, ContainerError>

provision_cli with explicit time limits.

Source

async fn provision_cli_locked( &self, container_name: &str, install: &CliInstall, key: &str, limits: InstallLimits, on_install_start: impl FnOnce(), ) -> Result<CliProvision, ContainerError>

Source

async fn forget_cli_ready(&self, container_name: &str)

Source

pub async fn signal_exec_process( &self, container_name: &str, pattern: &str, force: bool, ) -> Result<(), ContainerError>

Best-effort interruption of the turn’s process(es) inside a container.

Docker/bollard has no “kill exec” API, so we pkill the matching process via a short detached exec. The persistent-container model runs one turn at a time (guarded by run_lock), so a single CLI process matches pattern (the command name, e.g. claude). -f matches the full command line because the CLI runs under node, whose process name isn’t the CLI’s.

Requires pkill (procps) in the image. Fire-and-forget (detached); a non-match (pkill exit 1, e.g. the turn already exited) is not an error.

Source

pub async fn stop( &self, container_name: &str, timeout_secs: i64, ) -> Result<(), ContainerError>

Gracefully stop a container. Uses SIGTERM → SIGKILL after timeout_secs.

Source

pub async fn remove( &self, container_name: &str, force: bool, ) -> Result<(), ContainerError>

Remove a container (must be stopped first or use force = true).

Source

async fn cleanup_dev_proxy_routes(&self, container_name: &str)

Clears this container’s dev-proxy routes (see docs/specs/SPEC_NATIVE_CONTAINER_DEV_PROXY_2026_09_19.md) if a registry was ever wired in via attach_dev_proxy_registry — a no-op everywhere else (tests, non-agent Docker use). Never fails stop/remove: the container operation itself already succeeded by the time this runs, and a routing-table cleanup miss is recoverable (the entry just goes stale until re-registered) in a way an interrupted stop/remove wouldn’t be.

Source

fn owned_mount_targets() -> [String; 3]

Every mount target this module owns. Anything NOT in here (the caller’s agent:container_volumes) is the user’s business and is never a reason to rebuild their container.

Source

fn desired_owned_mounts( container_name: &str, spec: &ContainerMountSpec, ) -> HashMap<String, String>

The (target -> source) this module wants mounted for spec. A target absent from the map is one that must NOT be mounted.

Source

async fn container_drift( &self, container_name: &str, spec: &ContainerMountSpec, ) -> Option<&'static str>

Single Docker inspect covering both drift checks below — mounts and extra_hosts previously each did their own inspect_container round trip, doubling the Docker-socket call on every ensure_running for the common case of an already-compliant container (reagent P2 on PR #3393). Returns a human-readable reason if either has drifted, or None if the container matches (or the inspect itself failed — fails safe, same as the two checks below always did: a spurious recreate kills a live agent and loses its container-local state, which is worse than one more restart on stale state we couldn’t even confirm).

Source

fn mounts_match( details: &ContainerInspectResponse, container_name: &str, spec: &ContainerMountSpec, ) -> bool

Does the existing container already carry exactly the mounts spec asks for? Used to decide whether an agent that is already up has to be recreated to pick up (or lose) credentials and a workspace.

Compares the owned set in BOTH directions — a mount that should now be ABSENT is drift too, not just a missing or changed one. That case is the security-relevant one (reagent P1 on PR #2933): unbinding an agent’s account is a normal transition that never reaches SpawnGateError::MissingCredentials (that gate fires when credentials are expected and missing, not when an agent legitimately has none). A presence-only check would leave the old account’s .credentials.json bind-mounted and WRITABLE in the running container forever, so every later turn would keep authenticating — and refreshing tokens — as the account the operator just unbound.

Only this module’s own targets are considered; the caller’s extra container_volumes are ignored, so a user adding an unrelated mount is never a reason to destroy and rebuild their container underneath them.

Fails SAFE: missing mount data on the passed-in inspect result returns true (“matches”), leaving the container alone — the inspect-error case itself is now handled once, by container_drift’s caller.

Source

fn extra_hosts_match(details: &ContainerInspectResponse) -> bool

Whether an existing container’s HostConfig.extra_hosts already has the host.docker.internal:host-gateway entry create_and_start sets (reagent P1, PR #3393). Docker cannot add an extra_hosts entry to an already-existing container — only container_drift’s recreate path (via ensure_running_locked) can actually fix a container that predates this.

Source

pub async fn running_compose_containers_under( &self, dir: &Path, ) -> Result<Vec<String>, ContainerError>

Running Docker Compose containers whose project directory is dir or inside it (Compose’s com.docker.compose.project.working_dir label): the ones an agent working there may have started. Names only.

Source

async fn find_container( &self, name: &str, ) -> Result<Option<String>, ContainerError>

Returns the container status string (“running”, “exited”, …) or None if not found.

Source

async fn pull_with_fallback( &self, image: &str, ) -> Result<String, ContainerError>

Pull image; a legacy image that the registry refuses or no longer has is replaced by the public base image. Returns the image to run.

Source

pub async fn has_image_locally(&self, image: &str) -> bool

Whether the daemon already has image.

Source

async fn pull_image(&self, image: &str) -> Result<(), ContainerError>

Pull image via the Docker socket (create_image API).

Streams the pull response to completion before returning. If the image is already present locally, Docker returns an empty stream immediately — this function treats that as success (no pull needed).

Errors only on genuine pull failures (network, auth, no such image).

Source

async fn create_and_start( &self, container_name: &str, image: &str, volumes: &[String], env_vars: &[(String, String)], spec: &ContainerMountSpec, ) -> Result<(), ContainerError>

Source

async fn ensure_agent_network(&self, container_name: &str)

Best-effort: create the shared AGENTMUX_DOCKER_NETWORK (if it doesn’t already exist) and attach container_name to it, additive to whatever network_mode/extra_hosts create_and_start already set up (container→host reachability via host.docker.internal, entirely unaffected). Never fails ensure_running — this is purely in service of dev-proxy routing (docs/specs/SPEC_NATIVE_CONTAINER_DEV_PROXY_2026_09_19.md §3.1), not required for a container agent’s core turn-execution path. Called on every ensure_running, not just at creation, so an already-running container from before this feature shipped is picked up too, and so a network deleted out-of-band (docker network rm) is recreated automatically.

Trait Implementations§

Source§

impl Clone for ContainerManager

Source§

fn clone(&self) -> ContainerManager

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<T> Downcast for T
where T: Any,

§

fn into_any(self: Box<T>) -> Box<dyn Any>

Convert Box<dyn Trait> (where Trait: Downcast) to Box<dyn Any>. Box<dyn Any> can then be further downcast into Box<ConcreteType> where ConcreteType implements Trait.
§

fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>

Convert Rc<Trait> (where Trait: Downcast) to Rc<Any>. Rc<Any> can then be further downcast into Rc<ConcreteType> where ConcreteType implements Trait.
§

fn as_any(&self) -> &(dyn Any + 'static)

Convert &Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot generate &Any’s vtable from &Trait’s.
§

fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)

Convert &mut Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot generate &mut Any’s vtable from &mut Trait’s.
§

impl<T> DowncastSync for T
where T: Any + Send + Sync,

§

fn into_any_arc(self: Arc<T>) -> Arc<dyn Any + Send + Sync> ⓘ

Convert Arc<Trait> (where Trait: Downcast) to Arc<Any>. Arc<Any> can then be further downcast into Arc<ConcreteType> where ConcreteType implements Trait.
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

§

impl<T> FromRef<T> for T
where T: Clone,

§

fn from_ref(input: &T) -> T

Converts to this type from a reference to the input type.
§

impl<A, B, T> HttpServerConnExec<A, B> for T
where B: Body,

§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self> ⓘ

Instruments this type with the provided [Span], returning an Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self> ⓘ

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> IntoEither for T

Source§

fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ

Converts self into a Left variant of Either<Self, Self> if into_left is true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
where F: FnOnce(&Self) -> bool,

Converts self into a Left variant of Either<Self, Self> if into_left(&self) returns true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
§

impl<T> PolicyExt for T
where T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where T: Sized + Policy<B, E>, P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where T: Sized + Policy<B, E>, P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self> ⓘ
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a [WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self> ⓘ

Attaches the current default Subscriber to this type, returning a [WithDispatch] wrapper. Read more