Concepts
Every term envend uses, defined once, in the order you meet them. Nothing here is aspirational: it is what the gateway does today.
The words
- Gateway
- The stateful service in the middle. It holds the MCP endpoint, the idle clock, the tunnel keys and the token issuer. It is the only part that remembers anything.
- Provider
- A vendor that will rent you a computer — Fly Machines, exe.dev, AWS EC2.
- Connector
- The module inside the gateway that talks to one provider. The gateway never talks to a vendor any other way.
- Sandbox
- One disposable machine. Also called an environment in the
tool names, which are all spelled
env_*. It has a short name you choose or the gateway generates, and it is cattle: if one gets wedged, destroy it and make another. - envd
- Our agent, a single static binary, running as pid 1 inside every sandbox. It is the SSH server, the file server, the process supervisor and the tunnel client, in one process.
- envend
- The CLI on your machine, also a single static binary. It is the tunnel client, the mount, ssh, and every command that changes a sandbox.
- Workspace
- What a sandbox is for: a directory and three shell strings that put the work there and say where later commands run.
- Profile
- A named shape of sandbox the gateway already knows — image, size, identities, workspace — so that creating one is a word rather than a paragraph.
The four planes
Everything envend does falls into one of four planes. Two are live — they carry the work. Two are scaffold: they set things up and then get out of the way. The topology diagram colours every wire by its plane.
- Control
- The harness asks over MCP, the gateway decides, and
envdcarries it out inside the sandbox. Creating, listing, exec, sleep, wake and the activity heartbeat are all control. - Data
- ssh, TCP and NFS, riding one key-pinned tunnel between your machine and the sandbox. The gateway is not on this wire at all — it only handed each end its key.
- Vending
- The gateway driving a provider's control plane to make and unmake machines. Enabling, never in the byte path.
- Trust
- Keys and tokens. The tunnel keypairs minted at create, and the OIDC issuer that signs a sandbox's short-lived identity tokens.
A sandbox's lifecycle
- Create. The gateway mints this sandbox's three keys, decides what image to boot, and asks the connector for a machine. The keys, the gateway's tunnel address, the declared services, the identities and the workspace all travel into the machine as its process environment.
- Boot.
envdstarts first. It installs the SSH key, opens its tunnel, starts every declared service, and reports ready. - Set up. If a workspace was declared, the
createhook runs once — a clone, an image pull, a container build, which on a first boot is minutes — then thestarthook.env_createreturns before any of that finishes, so watch it withenv_logsorenvend logsuntil it saysready. - Work. Mount
/work, ssh in, run commands. Eachenv_execis a freshbash -lc: nothing carries between calls except the filesystem. - Sleep. On a backend that can stop a machine, the gateway does so once
nothing has touched it for a while.
envend holdbuys a bounded exemption when a long job is running unattended. - Wake. The next command, ssh connection, mount or shared-URL request starts it again and waits. Nothing you set up needs redoing.
- Destroy. The machine, its disk, its keys, every share and every file
link, gone. There is no undo, and unlike sleep this does take
/workwith it.
The gateway measures idleness itself, because a provider cannot tell you it.
envd reports every thirty seconds whether anybody is using the machine — ssh
sessions, file traffic, load — and the gateway's sweep stops what has stayed quiet. A sandbox
it has stopped hearing from is left alone rather than reaped: silence is a reason not
to act.
The capability vocabulary
Backends differ, and the gateway refuses to guess. Every connector declares which of six optional behaviours are real for it, and every sentence the gateway writes about a machine is composed from those declarations. Nothing anywhere branches on which connector module it is holding.
| Capability | Fly | exe.dev | AWS |
|---|---|---|---|
:oci_images | yes | yes | no |
:sleep | yes | no | yes |
:durable_disk | yes | yes | yes |
:persistent_root | no | yes | yes |
:snapshots | yes | no | yes |
:public_url | no | yes | no |
What matters about each is what the gateway does when it is absent, because that is the sentence an agent gets told:
:oci_images- The connector installs
envditself at first boot, and animageargument is refused in a sentence naming the provider — before any wrapping is paid for. :sleep- Nothing ever stops the machine. The idle sweep skips it
and logs the reason once,
env_sleeprefuses, and the create answer says it runs and bills until it is destroyed. :durable_disk- The honest answer is that nothing on the filesystem outlives a sleep, and the gateway says so in as many words.
:persistent_root- The root filesystem is rebuilt from the image
at every boot, so every agent is told to treat
/workas home. :snapshots- Nothing. Declared only, so the vocabulary exists before the feature does.
:public_url- Nothing. Declared, not wired: shares stay the gateway's, on the gateway's domain, whatever a backend offers alongside.
So there are three honest answers to "what survives?", and a sandbox tells you which one it
is when you create it: the whole disk persists (exe.dev); /work persists and the
root is rebuilt on every wake (Fly); or nothing persists at all. Work in /work
either way — it means the same thing on every backend, and it is what the file share serves.
There is no capability for publishing a port, because no backend is ever asked to. The AWS column is written and unit-tested against a stubbed HTTP layer and has never been run against a real account; it is hypothetical, and it exists to prove the vocabulary is a vocabulary.
The tunnel, and the one egress hole
A sandbox has no public port. Not for ssh, not for the file share, not for anything you start inside it, on any backend, and there is no flag that adds one. Everything reaches it over tailcat — Tailscale's data plane (WireGuard, NAT traversal, a DERP rendezvous relay) without its control plane. A peer's address is derived from its public key, and the listener admits exactly the peers it was told to at create. Holding the address without a key gets you silence.
Two tunnels exist. Yours is peer-to-peer: your envend dials
the sandbox directly, and the gateway is nowhere on that wire. The gateway's
is how a shared port, a file link and the sandbox's own control calls travel.
That leaves a sandbox needing exactly one hole in its egress:
| Required, outbound | TCP to relay.envend.dev:443. That is the whole list. |
|---|---|
| Optional | UDP to the relay and to arbitrary hosts, which lets two peers find a direct path and stop relaying. |
| Required, inbound | Nothing. A sandbox accepts no unsolicited connection. |
The relay forwards encrypted WireGuard packets between peers that have already authenticated to each other by key. It never sees plaintext and is not trusted to. The gateway's own public URL is not on the list: nothing inside a sandbox needs to reach it, and nothing inside a sandbox is even told where it is. Whatever your workload needs — npm, GitHub, an API — is your egress question, not this one.
Identity
A sandbox stores no long-lived credential. It has one identity of its own: the private half of the key it dials the gateway with. tailcat derives a peer's address from its public key and WireGuard enforces it, so the gateway reads who is calling off the connection rather than believing anything the caller said. There is no shared secret in the machine to read, to leak or to rotate.
From that, the gateway's OIDC issuer mints short-lived, audience-bound ID tokens — one
audience per token, so a relying party's trust policy can pin it. A sandbox
declares who it may be elsewhere, and envd performs each
exchange behind the interface the tool already uses:
- AWS
envdwrites acredential_processline into/root/.aws/config. The AWS CLI and every SDK call it themselves, soaws s3 lsworks with no code and no knowledge that envend exists. On the AWS side, once: an IAM OIDC provider for the issuer, and a role whose trust policy pins both the issuer and this sandbox's subject.- Doppler
- A JWT is exchanged for a short-lived Doppler token, kept in a
0600file on tmpfs and exported to every service and login shell asDOPPLER_TOKEN. - GitHub
- A git credential helper, installed system-wide, so
git cloneandgit pushare authenticated in every shell with nothing configured per repository. This is the one exchange the gateway completes itself: a GitHub App's private key reaches every repository the App is installed on, and a disposable machine an agent has root on is the last place it should live. The sandbox asks over its own tunnel, and the gateway answers with a token GitHub has already narrowed to the repositories that sandbox declared.
Every credential lives in memory or in a 0600 file under
/run/envend, which is tmpfs. Nothing is written to /work, and that is
load-bearing rather than tidy: /work is what the sandbox exports over NFS, so
anything under it is readable by whoever holds the mount.
Workspaces, hooks and profiles
A machine with a shell and an empty disk is not somewhere anybody can work. Something has to put the work there, and it is the same something every time. So it is declared:
{
"dir": "/work/omega",
"create": "git clone https://github.com/airbooksio/omega /work/omega",
"start": "devcontainer-up /work/omega",
"exec": "devcontainer exec --workspace-folder /work/omega --"
}
Two hooks, because there are two questions with different answers. create is a
one-time act — a second clone over an existing checkout throws work away — so it is guarded by
a marker on the disk rather than by anything the gateway remembers. start runs on
every boot, because on a backend that rebuilds the root filesystem the containers and daemons
are gone even though the files are not. A start hook has to be idempotent, and
that is the author's problem, not envend's.
exec is a command prefix, and it is the whole routing rule: everything a
session runs is handed to it, and --host is how you get the VM around it instead.
Nothing is probed first, and there is no fallback — if the prefix fails, its exit status and
its stderr are the answer.
A profile is where all of that is written down under a name, along with the image, the size and the identities. It fills in only what the call left out — a default, never an override — and it generates the name, so several sandboxes of one project can exist at once. See Profiles & workspaces for the worked example.
The ownership model
Two Unixes that never agreed on a uid map have to share one tree. The file share answers each side in its own numbers, which is what Docker Desktop does for a bind mount and for the same reason.
- To a client, everything under
/workbelongs to that client. Every NFSv3 request carries the caller's uid and gid, and the server reports it straight back. A Mac sees501:20; another machine sees its own.ls -lreads right, and every "is this mine?" check in git, make and npm comes out right with it. - On disk, everything belongs to the workspace user — whoever the
sandbox's hooks, shells and containers run as. That is what a container bind-mounting
/worksees, and it is what makes a checkout editable from inside. - Mode bits are not translated. A
0755script is executable and a0600file is private, and the ownership above is what makes them apply. - Files already on the disk keep their real owner. Chowning somebody's whole volume at
boot is not
envd's job.
Who the workspace user is is a fact about the image, decided in four steps and said
out loud in the boot log: the profile's workspace.user; then the image's own
USER; then the first user in the image with a home directory of its own —
ubuntu on Ubuntu, node on node:22; then root, for an
image with nobody else in it. That is why there is no chown in the example create
hook above: the clone is made by the user who has to build with it.