envend
concepts

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 envd carries 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

  1. 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.
  2. Boot. envd starts first. It installs the SSH key, opens its tunnel, starts every declared service, and reports ready.
  3. Set up. If a workspace was declared, the create hook runs once — a clone, an image pull, a container build, which on a first boot is minutes — then the start hook. env_create returns before any of that finishes, so watch it with env_logs or envend logs until it says ready.
  4. Work. Mount /work, ssh in, run commands. Each env_exec is a fresh bash -lc: nothing carries between calls except the filesystem.
  5. Sleep. On a backend that can stop a machine, the gateway does so once nothing has touched it for a while. envend hold buys a bounded exemption when a long job is running unattended.
  6. Wake. The next command, ssh connection, mount or shared-URL request starts it again and waits. Nothing you set up needs redoing.
  7. Destroy. The machine, its disk, its keys, every share and every file link, gone. There is no undo, and unlike sleep this does take /work with 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.

CapabilityFlyexe.devAWS
:oci_imagesyesyesno
:sleepyesnoyes
:durable_diskyesyesyes
:persistent_rootnoyesyes
:snapshotsyesnoyes
:public_urlnoyesno

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 envd itself at first boot, and an image argument 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_sleep refuses, 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 /work as 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, outboundTCP to relay.envend.dev:443. That is the whole list.
OptionalUDP to the relay and to arbitrary hosts, which lets two peers find a direct path and stop relaying.
Required, inboundNothing. 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
envd writes a credential_process line into /root/.aws/config. The AWS CLI and every SDK call it themselves, so aws s3 ls works 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 0600 file on tmpfs and exported to every service and login shell as DOPPLER_TOKEN.
GitHub
A git credential helper, installed system-wide, so git clone and git push are 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 /work belongs to that client. Every NFSv3 request carries the caller's uid and gid, and the server reports it straight back. A Mac sees 501:20; another machine sees its own. ls -l reads 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 /work sees, and it is what makes a checkout editable from inside.
  • Mode bits are not translated. A 0755 script is executable and a 0600 file 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.