Request
The payload is the tag byte 1 followed by the request as JSON: the image, the limits and the mounts.
The payload of the request is the tag byte followed by the container as JSON, read as Notation provides.
[1][container JSON …]
-
image. Exactly one of three objects:{"client":{"name":…,"digest":…}}, an image the client holds, which the server pulls from its own registry and fetches from the client by digest;{"server":{"name":…,"digest":…}}, an image the server obtains from a source of its own;{"registry":{"reference":…}}, a reference the server pulls.nameis a repository path without a host;digestis<algorithm>:<hex>;referenceis what the server’s runtime accepts. -
memory,disk. Limits in bytes: the memory the container may use, and the bytes the container may write to its own filesystem. Neither governs a mount. -
volume_mounts. Ordered; the server applies them in order.host_nameis anamein the client’s listing;host_relative_pathandcontainer_pathare components. An emptycontainer_pathnames the root, which the server refuses. -
identity_file_mounts,identity_directory_mounts. Each names content byidentity,<size>:<base64url sha256>with the hash of the bytes for a file and of the manifest for a directory, as the volumes::stat response defines the manifest. Every one is mounted read-only before the container starts. -
fuse_file_mounts,fuse_directory_mounts. Each names a path, anidthe client chose, andreadonly, absent meaningfalse. Every one is mounted before the id is sent. -
Paths. No component of any mount’s path is empty,
.or... No two mounts have one path. No mount lies inside a FUSE directory mount. A FUSE file mount may lie inside a directory another mount provides. No mount’s path is the root. -
Ids. No two FUSE mounts, on either list, have one
id. -
Malformed. A payload with no byte, a payload whose first byte is not
1, and a payload whose bytes after the tag do not parse as the request are malformed. Endpoints states how a server answers a request it cannot read.
The request is defined by diverge-provider-sdk/src/endpoints/containers/tools/run/client/request/frame.rs:
//! What a client's request frame carries for a tool container run.
use crate::decode::Decode;
use crate::encode::{Encode, Writer};
use crate::shared::containers::request::Container;
/// Ask a provider to create a tool container.
///
/// A [`Container`] and nothing else: the image, the limits, the
/// mounts. What makes it a tool container rather than the other kind is not in
/// the request — it is the image, and it is what the caller does on
/// the channels once it runs: the five MCP exchanges in
/// [`shared::mcp`](crate::shared::mcp), into the server the container
/// runs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame(
/// What to run.
pub Container,
);
/// This frame's tag among the scope-opening requests.
///
/// One byte at the front of the payload, which is what tells a reader
/// which request it holds. The frame layer does not discriminate them
/// — [`ClientFrame::Request`](crate::frame::client::ClientFrame::Request)
/// is one type carrying bytes — so the distinction has to be in the
/// bytes, and each request owns the value that names it.
///
/// See the table in [`endpoints`](crate::endpoints) for the whole
/// allocation. The values are chosen across modules that do not know
/// about each other, so the table is the only place they can be seen
/// at once.
const TAG: u8 = 1;
/// JSON, matching [`images::check`](crate::endpoints::images::check)
/// rather than the postcard [`volumes`](crate::endpoints::volumes)
/// uses. One of these is sent per container rather than per
/// filesystem event, so there is no throughput to optimize for — and
/// it names an image the same way a check does, which is reason
/// enough for the two to look alike on the wire.
impl Encode for Frame {
/// The ordinary JSON failure. The tag cannot fail.
type Error = serde_json::Error;
fn encode(&self, out: &mut Writer<'_>) -> Result<(), Self::Error> {
out.extend_from_slice(&[TAG]);
serde_json::to_writer(out, &self.0)
}
}
impl Decode<'_> for Frame {
/// Three ways to fail, and only one of them is JSON.
type Error = FrameError;
fn decode(bytes: &[u8]) -> Result<Self, Self::Error> {
let (tag, rest) = bytes.split_first().ok_or(FrameError::Empty)?;
if *tag != TAG {
return Err(FrameError::UnexpectedTag(*tag));
}
serde_json::from_slice(rest).map(Frame).map_err(FrameError::Body)
}
}
/// A tool container run request that could not be read.
#[derive(Debug)]
pub enum FrameError {
/// No bytes at all, so not even a tag.
Empty,
/// A tag naming some other request.
///
/// A reader that dispatched on the tag will not see this. One that
/// assumed which request it held, and was wrong, will — which is
/// the point of checking a tag rather than skipping it.
UnexpectedTag(u8),
/// The request did not parse.
Body(serde_json::Error),
}
impl std::fmt::Display for FrameError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FrameError::Empty => {
f.write_str("tools run request frame is empty")
}
FrameError::UnexpectedTag(tag) => {
write!(f, "expected tools run request tag {TAG}, found {tag}")
}
FrameError::Body(error) => {
write!(f, "tools run request did not parse: {error}")
}
}
}
}
impl std::error::Error for FrameError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
FrameError::Body(error) => Some(error),
FrameError::Empty | FrameError::UnexpectedTag(_) => None,
}
}
}
The container is defined by diverge-provider-sdk/src/shared/containers/request/container.rs:
//! Asking for a container.
use serde::{Deserialize, Serialize};
use super::{FuseMount, IdentityMount, Image, VolumeMount};
/// Ask a provider to create a container.
///
/// Everything here is what a caller may CHOOSE, and it is the same for
/// every kind of container: what differs between an agent and a tool
/// server is asked once the container runs, on a channel, not here.
/// What a caller may not choose is not here at all rather than here
/// and ignored — the container's name, its published ports, its
/// entrypoint and its environment are the provider's, because they
/// are how the provider reaches the container and how the container
/// reaches back. There is no environment: the mounts are the caller's
/// only provisioning channel, and a field that is accepted and
/// ignored is a field callers will believe in.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Container {
/// The image, and who supplies it.
///
/// See [`Image`]. Which variant it is decides how the image is
/// named, which is why the source and the name are one field
/// rather than two that only make sense together.
pub image: Image,
/// How much memory the container may have, in BYTES.
///
/// A ceiling, not a hint. A process that exceeds what the
/// container is allowed is killed by the kernel rather than told —
/// no failed allocation to catch, no warning first — and the
/// container will not see this number in its own
/// `/proc/meminfo`, which reports the host's. An image that sizes
/// itself off what it thinks it has will size itself wrong.
///
/// Bytes rather than megabytes because a unit that has to be
/// spelled out in prose is a unit half of everyone gets wrong.
pub memory: u64,
/// How much the container may WRITE, in BYTES.
///
/// Its own filesystem only — what it adds to or changes over the
/// image it came from. The image's layers are read-only and are
/// not counted, so a container starts at nothing however large the
/// image is.
///
/// # It does not govern the mounts
///
/// A volume is storage that already existed, with a size of its
/// own that a [`volume`](crate::endpoints::volumes) stated when it
/// was made, and hash-mounted content is read-only. A number here
/// that silently applied to either would be this request deciding
/// how much of somebody else's storage a container may fill.
///
/// Bytes rather than megabytes, for the reason
/// [`memory`](Self::memory) gives.
pub disk: u64,
/// Volumes the provider offers, made visible inside the container.
///
/// Ordered, and a provider applies them in order. See
/// [`VolumeMount`] for how one is named without a host path.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub volume_mounts: Vec<VolumeMount>,
/// Files the caller holds, by content, mounted read-only.
///
/// Each names a file by its identity — see [`IdentityMount`].
/// The provider MUST mount every one before the container starts,
/// fetching what it does not hold from the caller by that
/// identity.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub identity_file_mounts: Vec<IdentityMount>,
/// Directories the caller holds, by content, mounted read-only.
///
/// Each names a directory by its identity — see
/// [`IdentityMount`]. No mount's path, in any of the three lists, is
/// a prefix of another's: mounting INTO a directory the image owns
/// is the point, and mounts stacking on each other is not.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub identity_directory_mounts: Vec<IdentityMount>,
/// Files the caller serves LIVE, mounted one each over FUSE.
///
/// Each names a file by a path, an id of the caller's, and
/// whether the container may write it — see [`FuseMount`]. The
/// provider MUST mount every one before the container starts, and
/// every open and every changed close inside the container is one
/// ask back to the caller, by that id. The file is overwritten in
/// place only; a program that replaces its file by rename needs a
/// directory mount. A path may lie inside a directory another
/// mount provides; it may not equal another mount's path.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fuse_file_mounts: Vec<FuseMount>,
/// Directories the caller serves LIVE, mounted one each over FUSE.
///
/// Each names a directory by a path, an id of the caller's, and
/// whether the container may change it — see [`FuseMount`]. The
/// whole tree under the path is the caller's: every listing,
/// read, write, creation, removal and rename inside the container
/// is one ask back to the caller, by that id. No other mount may
/// lie inside it.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fuse_directory_mounts: Vec<FuseMount>,
}
and by diverge-provider-sdk/src/shared/containers/request/image.rs:
//! What image a container is made from, and who supplies it.
use serde::{Deserialize, Serialize};
/// The image, named the way whoever supplies it can be asked for it.
///
/// One question — can the provider get these bytes, and if not, who
/// can — and the answer decides how the image is named, which is why
/// this is one field rather than a source beside a string that means
/// something different depending on it.
///
/// # Two of the three are pinned, and the third is a choice
///
/// [`Client`](Self::Client) and [`Server`](Self::Server) carry a
/// repository name and a manifest digest, the same pair
/// [`images::check`](crate::endpoints::images::check::client::request::Frame)
/// asks about — so a check that came back available names an image a
/// run can ask for, with nothing to translate between them.
///
/// A digest cannot be repointed at different content, so those two
/// mean the same bytes every time. [`Registry`](Self::Registry) need
/// not, and that is the point: a caller writing `ubuntu:22.04` is
/// asking to track it, exactly as a loose version constraint on a
/// Python requirement is. One that wants the guarantee writes a digest
/// into the reference and gets it.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Image {
/// The caller holds it.
///
/// For images that exist nowhere a provider can reach — built
/// locally, never pushed, carrying a digest no registry has heard
/// of.
///
/// The provider runs a registry of its own and its runtime pulls
/// from that; what the registry does not hold, the provider asks
/// the caller for by digest — a manifest, a blob — over
/// [`oci`](crate::shared::containers::oci). The caller needs no
/// registry and no HTTP: it needs the image's manifest and blobs
/// in a store keyed by digest, which is what an image is once it
/// has been saved anywhere, and it answers two kinds of fetch.
///
/// The runtime never learns the registry is a proxy, and every
/// header it relies on — `Content-Type`, `Content-Length`,
/// `Range`, `Docker-Content-Digest` — is the provider's registry
/// answering from its store. A runtime already indexes layers by
/// digest and skips the ones it holds, so letting it pull means
/// that logic is USED rather than reimplemented beside it.
Client {
/// The repository path — `library/nginx`, `myorg/myimage`.
///
/// # It lands in a URL path
///
/// The provider builds its pull reference by concatenation —
/// its registry's address, a repository segment, then this —
/// with no parsing. Which makes it a path fragment wearing the costume
/// of a name: a `..` in it walks out of the scope segment and
/// into another caller's namespace, so a provider normalizes
/// or refuses before concatenating. The field cannot enforce
/// that and does not pretend to.
name: String,
/// The manifest digest, `<algorithm>:<hex>`.
///
/// What actually identifies the image, and the reason nothing
/// here has to trust the name: the provider hashes what the
/// caller sent before storing it, and the runtime hashes again
/// on pull, so a caller holding the wrong bytes under the
/// right digest fails before anything runs.
digest: String,
},
/// The provider produces it, however it likes.
///
/// Its own mirror, a pull-through cache, a private registry it
/// holds credentials for, or something already on disk. A caller
/// does not know and is not told.
///
/// Which is what makes proprietary images expressible: a provider
/// serves an image no public registry carries, and a caller asks
/// for it, without the caller ever being able to fetch it itself.
///
/// Ask [`images::check`](crate::endpoints::images::check) first if
/// the answer matters before the container does. It takes this
/// same pair, so what a check said yes to is what a run names.
Server {
/// The repository path — `library/nginx`, `myorg/myimage`.
///
/// Kept alongside the digest because a digest alone is not
/// resolvable: every registry API is repository-scoped, and
/// there is no lookup from a digest to wherever it lives.
///
/// No host. Where a provider gets the image is the provider's
/// business, and a caller naming a source it cannot reach
/// would be asserting something it has no standing to assert.
name: String,
/// The manifest digest, `<algorithm>:<hex>`.
///
/// What actually identifies the image. Any registry serving
/// these bytes serves the same image, which is what lets the
/// provider choose where to get them.
digest: String,
},
/// The provider pulls from where the caller says.
///
/// The one case where the CALLER chooses the source, for public
/// images where it knows what it wants and the provider has no
/// opinion.
///
/// Which makes the reference a host a caller picked, and the
/// provider connects there and runs what it finds. Which
/// registries are reachable is a provider's policy to set and
/// enforce, and nothing here can express that policy — a caller
/// learns it by being refused.
Registry {
/// Whatever a container runtime accepts —
/// `ghcr.io/org/image@sha256:…`, `docker.io/library/ubuntu:22.04`.
///
/// One string rather than a name and a digest, because the
/// other two variants split them for a reason that does not
/// apply here. There the pair exists so a provider can go to a
/// source the caller did not name; here the caller named the
/// source, and a reference is how a source is named — host,
/// repository, and tag or digest, in the form the runtime
/// already parses.
reference: String,
},
}
and by diverge-provider-sdk/src/shared/containers/request/volume_mount.rs:
//! One volume made visible inside a container.
use serde::{Deserialize, Serialize};
/// A volume from a listing, mounted into the container.
///
/// Host side first, then the container side — source before
/// destination, the order a mount reads in everywhere else.
///
/// # Why the host side is a name and an offset
///
/// Because a host path is not something a caller is allowed to state.
/// [`host_name`](Self::host_name) is a
/// [`Volume::name`](crate::endpoints::volumes::list::server::response::Volume::name)
/// the provider published, and
/// [`host_relative_path`](Self::host_relative_path) descends from
/// wherever that maps to — so a caller reaches a subdirectory of
/// something it was offered, and nothing else.
///
/// That is the same access model as
/// [`watch`](crate::endpoints::volumes::watch), and it holds for the same
/// reason: a provider never validates a path, it resolves a name it
/// chose and then descends. A caller cannot escape upward, because
/// there is no component it can write that means "up" — the offset is
/// components, and `..` is a name, not an instruction.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub struct VolumeMount {
/// Which offered volume, by the name a listing gave it.
///
/// Names come from
/// [`Volume::name`](crate::endpoints::volumes::list::server::response::Volume::name)
/// and mean nothing outside the provider that published them.
pub host_name: String,
/// How far into that volume to start, as path components
/// relative to it.
///
/// Empty mounts the volume itself, which is the common case;
/// anything else mounts a subdirectory of it.
pub host_relative_path: Vec<String>,
/// Where it appears inside the container, as path components from
/// the container's root.
///
/// Empty means the root itself, which a provider will almost
/// certainly refuse — the image's own filesystem is there.
pub container_path: Vec<String>,
}
and by diverge-provider-sdk/src/shared/containers/request/identity_mount.rs:
//! One thing the caller mounts into the container.
use serde::{Deserialize, Serialize};
/// Content the caller wants present in the container's filesystem:
/// where, and what.
///
/// One shape for a file and for a directory — which it is, the field
/// it sits in says ([`identity_file_mounts`](super::Container::identity_file_mounts) or
/// [`identity_directory_mounts`](super::Container::identity_directory_mounts)), and the
/// [`identity`](Self::identity) grammar agrees. The server MUST mount every
/// one — read-only — before the container starts: the request
/// naming it IS the requirement. What the server does not hold it
/// MAY fetch from the client, by the hash, over the fetch exchanges.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct IdentityMount {
/// Where it appears inside the container, as path components
/// from the container's root — the shape every path in this
/// crate takes, as
/// [`container_path`](super::VolumeMount::container_path)
/// does for a volume.
///
/// No component is empty, `.` or `..` — an offset is components,
/// and `..` is a name, not an instruction — and no mount's path
/// is a prefix of another mount's, file or directory. Mounting
/// INTO a directory the image owns is the point; mounts stacking
/// on each other is not. Empty would name the root, which a
/// provider refuses: the image's own filesystem is there.
pub container_path: Vec<String>,
/// The content's size-bearing identity:
/// `<size>:<base64url sha256>` — the size in bytes, then the
/// hash. For a file the hash is of its bytes; for a directory it
/// is of its manifest — one `<hash> <size> <path>` line per file,
/// paths relative and `/`-separated, sorted bytewise — and the
/// size is the total. Which of the two it is, the field it sits in
/// says, as does the fetch that asks for it; the value does not
/// need to.
///
/// Because the size rides the identity, a server can refuse an
/// oversized request up front, as a request error, with nothing
/// fetched.
pub identity: String,
}
and by diverge-provider-sdk/src/shared/containers/request/fuse_mount.rs:
//! One file, or one directory, the caller serves live into the
//! container.
use serde::{Deserialize, Serialize};
/// A file or a directory the caller keeps, mounted into the container
/// over FUSE: where, under what id, and whether the container may
/// change it. Which of the two it is, is which list of the
/// [`Container`](super::Container) it is on.
///
/// The provider MUST mount every one before it answers the run's id
/// — one request to the proxy inside the container for each, on
/// [`/fuse/mount`](crate::container_proxy::fuse::mount), each
/// complete before the agent is registered and before any filetree
/// is opened — as a filesystem the caller serves: the mount point is the file or the
/// directory itself, made if absent with every missing parent
/// directory made too, and the directory around it stays whatever
/// the image or another mount made it. Nothing in the container or
/// the provider copies the contents in or reads them back: every
/// read, write, listing, removal, rename and new directory is one
/// exchange in [`fuse`](super::super::fuse), carrying the id, and the
/// caller serves it from wherever it keeps the thing. It is for the
/// credential files vendor CLIs rewrite when they refresh a login.
///
/// A FILE mount is one regular file that can be read and, unless
/// [`readonly`](Self::readonly), overwritten in place — opened,
/// truncated, written, closed — but never deleted or moved, and
/// never replaced by a rename: the mount point is the file itself,
/// and the kernel refuses to unlink or rename a mount point, so a
/// program that saves by writing a temporary beside the file and
/// renaming it over the file fails at the rename. A DIRECTORY mount
/// is for that program: a whole tree the caller serves, whose every
/// entry can be created, overwritten by either method, renamed and
/// deleted, and whose root alone is fixed.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FuseMount {
/// Where the mount appears inside the container, as path
/// components from the container's root — the shape every path in
/// this crate takes, as
/// [`container_path`](super::IdentityMount::container_path) does
/// for content.
///
/// No component is empty, `.` or `..`. A path inside a directory
/// another mount provides is allowed — a file over a directory a
/// volume brought is the ordinary case — but a path equal to
/// another mount's, or inside another FUSE directory mount, is
/// not, and neither is the root.
pub container_path: Vec<String>,
/// The caller's own id for the mount: opaque, minted by the caller
/// when it named the mount, and echoed back on every ask the
/// provider sends for it. Two mounts on one request, on either
/// list, may not share an id.
pub id: String,
/// Whether the container may change it. `true`: every open for
/// writing, truncate, write, create, rename, removal and new
/// directory inside the container fails, and the provider never
/// sends a mutation for this id. `false`: the file is overwritable
/// in place, or the tree fully editable, every change stored with
/// the caller as it lands.
#[serde(default)]
pub readonly: bool,
}