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
impl ContainerManager
Sourcepub fn connect() -> Result<Self, ContainerError>
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.
Sourcepub fn attach_dev_proxy_registry(&self, registry: DevProxyRegistry)
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.
Sourcepub async fn agent_network_ip(&self, container_name: &str) -> Option<String>
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.
Sourcepub async fn check_available(&self) -> Result<(), ContainerError>
pub async fn check_available(&self) -> Result<(), ContainerError>
Ping the Docker daemon. Returns Ok(()) if available.
Sourcepub async fn ensure_running(
&self,
container_name: &str,
image: &str,
volumes: &[String],
env_vars: &[(String, String)],
spec: &ContainerMountSpec,
) -> Result<(), ContainerError>
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.
Sourceasync fn ensure_running_locked(
&self,
container_name: &str,
image: &str,
volumes: &[String],
env_vars: &[(String, String)],
spec: &ContainerMountSpec,
) -> Result<(), ContainerError>
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.
Sourcepub async fn exec(
&self,
container_name: &str,
cmd: &[String],
working_dir: Option<&str>,
env_vars: &[(String, String)],
attach_stdin: bool,
) -> Result<ExecSession, ContainerError>
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.
Sourceasync fn exec_identity(&self, container_name: &str) -> Option<(u64, u64)>
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.
Sourcepub async fn remove_turn_prompt(&self, container_name: &str, prompt_path: &str)
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.
Sourcepub async fn upload_turn_prompt(
&self,
container_name: &str,
turn_id: &str,
message: &str,
) -> Result<String, ContainerError>
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/ hostps//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_STRLENceiling — 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 Contextwould 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.
Sourcepub async fn inspect_exec(
&self,
exec_id: &str,
) -> Result<Option<i64>, ContainerError>
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.
Sourceasync fn exec_collect(
&self,
container_name: &str,
argv: &[String],
limit: Duration,
) -> Result<ExecOutcome, ContainerError>
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.
Sourceasync fn wait_exec_finished(&self, exec_id: &str, limit: Duration) -> bool
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.
Sourceasync fn docker_exec_running(&self, exec_id: &str) -> Option<bool>
async fn docker_exec_running(&self, exec_id: &str) -> Option<bool>
Whether the exec is still running; None when Docker can’t say.
Sourceasync fn reap_cli_install(&self, container_name: &str) -> bool
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.
Sourcepub async fn provision_cli(
&self,
container_name: &str,
install: &CliInstall,
on_install_start: impl FnOnce(),
) -> Result<CliProvision, ContainerError>
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.
Sourceasync fn provision_cli_with(
&self,
container_name: &str,
install: &CliInstall,
limits: InstallLimits,
on_install_start: impl FnOnce(),
) -> Result<CliProvision, ContainerError>
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.
async fn provision_cli_locked( &self, container_name: &str, install: &CliInstall, key: &str, limits: InstallLimits, on_install_start: impl FnOnce(), ) -> Result<CliProvision, ContainerError>
async fn forget_cli_ready(&self, container_name: &str)
Sourcepub async fn signal_exec_process(
&self,
container_name: &str,
pattern: &str,
force: bool,
) -> Result<(), ContainerError>
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.
Sourcepub async fn stop(
&self,
container_name: &str,
timeout_secs: i64,
) -> Result<(), ContainerError>
pub async fn stop( &self, container_name: &str, timeout_secs: i64, ) -> Result<(), ContainerError>
Gracefully stop a container. Uses SIGTERM → SIGKILL after timeout_secs.
Sourcepub async fn remove(
&self,
container_name: &str,
force: bool,
) -> Result<(), ContainerError>
pub async fn remove( &self, container_name: &str, force: bool, ) -> Result<(), ContainerError>
Remove a container (must be stopped first or use force = true).
Sourceasync fn cleanup_dev_proxy_routes(&self, container_name: &str)
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.
Sourcefn owned_mount_targets() -> [String; 3]
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.
Sourcefn desired_owned_mounts(
container_name: &str,
spec: &ContainerMountSpec,
) -> HashMap<String, String>
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.
Sourceasync fn container_drift(
&self,
container_name: &str,
spec: &ContainerMountSpec,
) -> Option<&'static str>
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).
Sourcefn mounts_match(
details: &ContainerInspectResponse,
container_name: &str,
spec: &ContainerMountSpec,
) -> bool
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.
Sourcefn extra_hosts_match(details: &ContainerInspectResponse) -> bool
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.
Sourcepub async fn running_compose_containers_under(
&self,
dir: &Path,
) -> Result<Vec<String>, ContainerError>
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.
Sourceasync fn find_container(
&self,
name: &str,
) -> Result<Option<String>, ContainerError>
async fn find_container( &self, name: &str, ) -> Result<Option<String>, ContainerError>
Returns the container status string (“running”, “exited”, …) or None if not found.
Sourceasync fn pull_with_fallback(
&self,
image: &str,
) -> Result<String, ContainerError>
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.
Sourcepub async fn has_image_locally(&self, image: &str) -> bool
pub async fn has_image_locally(&self, image: &str) -> bool
Whether the daemon already has image.
Sourceasync fn pull_image(&self, image: &str) -> Result<(), ContainerError>
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).
async fn create_and_start( &self, container_name: &str, image: &str, volumes: &[String], env_vars: &[(String, String)], spec: &ContainerMountSpec, ) -> Result<(), ContainerError>
Sourceasync fn ensure_agent_network(&self, container_name: &str)
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
impl Clone for ContainerManager
Source§fn clone(&self) -> ContainerManager
fn clone(&self) -> ContainerManager
1.0.0 (const: unstable) · Source§fn clone_from(&mut self, source: &Self)
fn clone_from(&mut self, source: &Self)
source. Read moreAuto Trait Implementations§
impl !RefUnwindSafe for ContainerManager
impl !UnwindSafe for ContainerManager
impl Freeze for ContainerManager
impl Send for ContainerManager
impl Sync for ContainerManager
impl Unpin for ContainerManager
impl UnsafeUnpin for ContainerManager
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
Source§impl<T> CloneToUninit for Twhere
T: Clone,
impl<T> CloneToUninit for Twhere
T: Clone,
§impl<T> Downcast for Twhere
T: Any,
impl<T> Downcast for Twhere
T: Any,
§fn into_any(self: Box<T>) -> Box<dyn Any>
fn into_any(self: Box<T>) -> Box<dyn Any>
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>
fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>
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)
fn as_any(&self) -> &(dyn Any + 'static)
&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)
fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)
&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
impl<T> DowncastSync for T
impl<A, B, T> HttpServerConnExec<A, B> for Twhere
B: Body,
§impl<T> Instrument for T
impl<T> Instrument for T
§fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
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 moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
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