The lifecycle hook paths are fixed by the platform, so they stay unversioned.
Everything this project owns lives under /v1/. Every response carries a
microvms-agentd-version header.
Routes
Section titled “Routes”Hooks are served under /aws/lambda-microvms/runtime/v1/, abbreviated HOOKS
below. That prefix is fixed by the service, so a daemon serving a bare /run
never gets bootstrapped.
| Route | Auth | Purpose |
|---|---|---|
POST HOOKS/ready |
none (platform hook) | image-build readiness probe |
POST HOOKS/validate |
none (platform hook) | image-build validation probe |
POST HOOKS/run |
none (platform hook) | one-shot token bootstrap from runHookPayload, plus the optional launch environment |
POST HOOKS/suspend |
none (platform hook) | acknowledged and logged |
POST HOOKS/resume |
none (platform hook) | acknowledged; signals in-memory state loss |
POST HOOKS/terminate |
none (platform hook) | acknowledged; begins graceful shutdown |
POST /v1/exec/start |
bearer | start a command under a caller-minted exec_id |
GET /v1/exec/{id} |
bearer | poll status and output; never mutates |
GET /v1/exec/{id}/stream?offset= |
bearer | follow output as SSE from a byte offset |
POST /v1/exec/{id}/stdin |
bearer | write to a child’s stdin, or signal EOF |
POST /v1/exec/{id}/ack |
bearer | release output, enter TTL collection |
POST /v1/exec/{id}/kill |
bearer | signal escalation to the process group |
GET /v1/procs |
bearer | process accounting: every exec’s group and its live pids, read from /proc |
GET /v1/tcp?port=&identity= |
bearer | WebSocket relay to 127.0.0.1:<port> in the guest; close codes carry the outcome |
PUT /v1/fs/tar |
bearer | streaming tar upload and confined extraction |
GET /v1/fs/tar?path= |
bearer | streaming tar download |
PUT /v1/fs/file |
bearer | write one file |
GET /v1/fs/file?path=&start_line=&end_line= |
bearer | read one file, or a 1-based inclusive line range of it |
GET /v1/health |
none | liveness, version, bootstrap state, exec-activity, hook observations |
GET /v1/schema |
none | this contract as a JSON Schema document: every route, shape, status, and limit |
Every hook invocation is recorded and reported on /v1/health. The daemon
records each invocation in memory — the hook’s name and the daemon’s clock, in
arrival order — before the handler does any work or chooses a status, and
GET /v1/health reports the log as hooks ([{hook, fired_at}], oldest first)
beside hooks_dropped, a count of invocations past the log’s cap. A run hook
that arrives malformed or answers 409 still leaves a run observation: the
record is of the invocation, not the verdict. The platform writes no CloudWatch
logs for the validate hook, so this record is the only trace that hook leaves
anywhere. Build-time hooks (ready, validate) fire in the snapshot VM before
the snapshot is taken, so their in-memory records ride the memory image into
every VM launched from it and are visible on the first health poll.
One caveat is part of this contract rather than a footnote. The hook routes are
unauthenticated and arrive over loopback indistinguishably from an in-VM
process (see “Trust boundary” below), so a hostile workload can forge
ADDITIONAL observations by posting the hook paths itself. It cannot remove or
alter real ones: the daemon records before it responds, and the capped log
keeps the earliest entries — the platform’s real lifecycle firings, which
arrive before anything runs in the guest — while later spam is dropped and
counted in hooks_dropped. Read an observation as “the daemon saw this hook
fire”, never as “only the platform could have fired it”. Both fields are
#[serde(default)] like busy and execs, and for the same reason: an older
daemon omits them, and empty/zero is the honest reading.
Rules that exist because a defect proved them necessary
Section titled “Rules that exist because a defect proved them necessary”Each of these rules comes from a real bug found during the Harbor PR #2469 integration. They are part of the protocol contract, so implementations must follow them.
Bootstrap is one-shot, and a replay of the identical token succeeds. A first
/run installs the token and returns 200. A later /run carrying the same token
returns 200, because the platform may retry its own hook and must not be told the
VM is broken. A later /run carrying a different token returns 409 and changes
nothing. The model in model/ checks this over every interleaving, including a
racing in-VM caller.
Control routes answer 503 before bootstrap. They do not answer 404, and they do not drop the connection.
A missing or malformed body key is 400, never 404. Clients map 404 onto “file not found”. Because of that mapping, returning 404 for a protocol typo makes the client believe an artifact is absent when it is not. One defect went undetected this way.
Authorization is decided before any body byte is read. An unauthenticated caller must not be able to make the daemon allocate. Rejected requests still drain a small body so pooled client connections keep working; larger ones close.
Token comparison happens on bytes, in constant time. Comparing str values
raises on non-ASCII input in some languages. Any caller controls the header, so
a non-ASCII header value could crash the connection.
No exception on the parse, auth, or routing path may drop a connection. A catch-all returns 500. Raw TLS handshake bytes get a 400 and a debug log, since something in the platform’s path probes the port with TLS first.
cwd is omitted when unset. When the client sends no working directory, the
daemon emits no cd prefix, and the child inherits the daemon’s own working
directory. Because the daemon is the container CMD, that directory is the
image WORKDIR. Forcing / breaks prebuilt-image tasks and defeats any harness
that discovers the image workdir with pwd.
Exec is idempotent on a caller-minted exec_id. A retried /exec/start
returns success without spawning a second child. Polling is read-only. Output
lives until the caller acks, and only acked entries are collected. The Python
predecessor unlinked output files at child exit, which destroyed anything a
backgrounded grandchild wrote afterward.
A shell wraps the command only when the caller asks for one. An argv array
execs directly. shell: true wraps in
sh -c with the command as a single argument. The predecessor’s brace-group
wrapper turned empty and comment-terminated commands into syntax errors and let
an unbalanced } escape the group.
Tar extraction mirrors the CPython data filter contract. In-tree symlinks
are preserved, because harnesses legitimately pack them. Absolute link targets
are refused. Relative targets must resolve under the root, using normpath
semantics rather than realpath. Symlinks resolve relative to their own
directory, while hard links resolve against the archive root. Member count and
total size are capped. Modes are applied after content lands.
A symlink an archive wrote cannot redirect a later member, and the kernel is
what enforces that. The normpath rule above judges a member at the depth its
name implies, and that is not the same as the depth the write reaches once a
symlink is in the path. So the daemon opens the extraction root once and creates
every member relative to that descriptor with openat2, using
RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_MAGICLINKS. A member whose
path would traverse a symlink is refused with 400 rather than written somewhere
else. openat2 needs Linux 5.6 or newer. On an older kernel the syscall answers
ENOSYS and extraction answers 500, which is a refusal rather than a silent
fall back to the weaker check.
Bodies stream to disk, and caps are enforced on the wire. The predecessor
buffered whole archives in memory on a VM whose baseline can be 512 MiB, where an
OOM-killed daemon is unrecoverable. It also measured archive size inside the gzip
with block, where the stream is unflushed. There, tell() reported 10 bytes
for a 327-byte archive, so the size guard almost never fired.
Output is bounded, and truncation is marked explicitly. A post-exit linger deadline bounds how long the daemon waits on grandchildren still holding the pipe.
A grandchild outlives its exec unless the caller asks otherwise, and
/v1/procs is how anyone finds it. Each exec runs in its own process group,
and until now only /v1/exec/{id}/kill ever signalled that group: a command that
backgrounded something and exited left it running with nothing able to enumerate
it (issue #157, measured 2026-09-11 — a ticker still counting six seconds after
its exec reported exit 0). GET /v1/procs answers {procs: [{exec_id, pgid, started_at, child_exited, reap, pids}]}, one entry per registered exec in any
phase. pids are the live pids whose process group is pgid, read from
/proc/<pid>/stat inside the guest, so the route needs no ps in the image;
zombies are not live and are not listed, and an entry whose pgid was never
captured lists none. child_exited is read from the same write-once marker
busy uses, never from the polled result, so an acked exec still reads exited.
started_at is epoch seconds on the daemon’s clock, like a hook’s fired_at.
The row worth reading is child_exited: true with a non-empty pids: that is
the survivor, and its exec_id is what the kill route takes.
reap_group_on_exit: true on /v1/exec/start is the opt-in that closes the gap
at the source. When set, the daemon runs the kill route’s SIGTERM-then-SIGKILL
escalation against the group as soon as the child’s exit is observed and before
the linger begins, so the survivors die, the pipe closes, and
writers_may_be_alive reads false because the linger saw EOF rather than its
deadline. A timed-out exec is already escalated and is not escalated twice. The
escalation’s grace ends as soon as /proc shows no live member of the group —
not when killpg(pgid, 0) fails, because the daemon is PID 1 in the guest and a
signalled grandchild stays a zombie that still answers that probe. The
flag defaults to false and the default is the contract above: a backgrounded
grandchild that keeps writing is a feature for the caller who did not set it.
reap on a procs entry echoes what the start request asked.
The launch environment
Section titled “The launch environment”The run-hook payload may carry an env map alongside agent_token. It becomes
the base environment of every later exec.
{"runHookPayload": "{\"agent_token\": \"...\", \"env\": {\"KEY\": \"VALUE\"}}"}The per-request env wins on a key both set. The daemon applies the launch
env first and the request’s own map second, so a launch env is a default for the
whole VM and a request is the specific thing happening now. A caller who never
sends a launch env sees no change: the child’s environment is the request’s map
and nothing else, exactly as before.
The agent token never becomes part of it. env_clear() still runs, so the
daemon’s own environment reaches no child, and the launch env and the token are
separate parameters through the whole install path — there is no field a refactor
could forward one into. Proven by a test that spawns /usr/bin/env with a launch
env installed and asserts the child’s environment is exactly that map: an extra
variable of any name fails it, which is what a leak would look like.
Only the first successful bootstrap sets it. A replay of the identical token answers 200 and leaves the installed env alone, and a conflicting token answers 409 and leaves it alone. Without that, a caller who cannot win the token could still rewrite the environment every later child runs in.
Every value is a string, and a malformed env is 400 naming the problem. A
non-object env, or a value that is a number or a nested object, is refused with
a body that names the key or the shape. The body never quotes a value, because
the payload carries the token.
Unknown payload keys are ignored. A 400 at this hook makes the platform terminate the VM before forwarding any traffic, so a newer client sending a field this daemon has never heard of must still be able to bootstrap it. Forward compatibility here is the difference between an ignored field and a dead launch.
The token and the env share one 4096-byte budget, measured in UTF-8 bytes and
inclusive (PLATFORM.md). The daemon cannot enforce what the platform already
rejected — an over-ceiling payload never reaches the guest — so the check is on
the client side, in microvms-core’s RunHookPayload::for_launch, and it fires
before any control-plane call. botocore does not enforce the ceiling either, so
without that local check there is no signal at all until AWS answers with a
ValidationException on a member the caller did not know they were filling. The
refusal names the byte count, the ceiling, and how much of it the env is, because
“4142 bytes, ceiling 4096” alone does not say whether to shorten the token or drop
a variable. One bearer token fits with room to spare; a set of AWS session
credentials does not, and that is what makes this ceiling reachable in practice.
Line-ranged text reads
Section titled “Line-ranged text reads”GET /v1/fs/file takes optional start_line and end_line. The semantics are
the AI SDK harness contract’s readTextFile, copied rather than chosen, because
this route is what that method is implemented on top of:
Both bounds are 1-based and inclusive. start_line=2&end_line=4 is three
lines. start_line absent means 1 and end_line absent means through EOF.
An end_line past the last line reads through EOF without an error. Lines
1..1000 of a twelve-line file is a 200 carrying twelve lines, never a 416. A
start_line past the last line is an empty 200, not a 404: the file is there and
the window is empty, which is a different fact from the file being absent.
A line owns its terminating newline. Lines 1..2 and lines 3..5 concatenate back into the file rather than losing a separator at the seam. A last line with no trailing newline has none to own and is returned as it is.
start_line=0 and end_line < start_line are 400. Neither is 416: 416 is
about a range the file cannot satisfy, and both of these are ranges no file could,
so a client sent to look at the file would be looking in the wrong place. A
non-integer bound is also 400, and it does not masquerade as the missing-path
refusal.
A range still streams. The read is filtered chunk by chunk and stops reading once the window closes, so lines 1..5 of a large file cost the first chunk. Nothing buffers a file to slice it, for the same reason nothing buffers an upload: an OOM-killed daemon in a MicroVM is unrecoverable.
With no range the response is byte-identical to what it always was. The un-ranged read hands back the reader stream untouched, so the path every existing caller uses does not acquire the range feature’s bug surface.
Idle policy, and why liveness is a field rather than a route
Section titled “Idle policy, and why liveness is a field rather than a route”The platform measures idleness by inbound traffic through the endpoint proxy
(PLATFORM.md, “idlePolicy”). A workload holding an outbound connection, or one
simply computing for hours, receives none, so auto-suspend can freeze a VM
mid-work. Multi-hour agent runs are the real case.
A guest-side request cannot fix this, and the daemon does not pretend
otherwise. The endpoint proxy terminates outside the VM and forwards over
loopback — measured, PLATFORM.md, “The platform’s own hook arrives over
loopback”. A request an in-VM process sends to the daemon’s own port therefore
never reaches the thing counting traffic. A “keep myself alive” route would be a
keepalive that keeps nothing alive, and it would be discovered as broken by a
multi-hour run auto-suspending mid-work, which is the failure it was added to
prevent.
So GET /v1/health carries busy and execs, for an orchestrator outside the
VM. The orchestrator’s own poll is the inbound traffic, and busy is what
makes that poll informed rather than unconditional. The assertion of liveness is
therefore repeated and is explicitly the caller’s, which is the property that rules
out the daemon self-keepaliving: a hung process would then bill to the 8-hour
maximumDurationInSeconds ceiling with nobody having asked.
busy means producing, not unfinished. An exec whose child has exited and
whose result is waiting to be acked is not busy — nothing is running, and holding a
VM alive at baseline billing for a command that is over is the mistake this
distinction exists to prevent. execs counts every registered entry in any phase,
so busy: false with a non-zero count is a VM holding unacked output somebody
still has to collect before terminating it.
Both fields default to false and 0 when absent, unlike every other field on
the response. The daemon is baked into an image while a client is installed
separately, so a current client routinely talks to a daemon from whenever that
image was built; a required field would make a health call fail outright against an
older daemon, turning a missing signal into an unreachable VM.
Streaming and stdin
Section titled “Streaming and stdin”Both features serve one consumer, an agent harness running inside the VM. The harness emits output for minutes and may need a prompt written to it. Polling serves neither need well, because it re-sends the whole buffer each time and truncates at the output cap.
The stream is a read-only view of the exec. An exec is a server-side record
keyed by its caller-minted exec_id. Attaching, detaching, or dropping a
connection must not affect the command. Both views must keep working, so poll
returns the buffer, stream follows it, and neither disturbs the other.
Resume is by byte offset. ?offset=N yields exactly the bytes after N, so a
client that reconnects can pick up where it left off. For comparison, E2B’s
reattach takes no offset, so a reconnecting E2B client loses everything produced
during the gap. A reattach past the retained window gets an explicit gap event
naming the missing range. Without that event, the client would keep streaming
and never learn that bytes were skipped.
SSE is used because it can carry a typed terminal event. A raw chunked byte
stream cannot distinguish a finished command from a dropped connection, because
the bytes are identical in both cases. The stream therefore emits a typed exit
event carrying the status and then ends. Keep-alive comments fill silences, so
an agent harness thinking for two minutes does not look like a dead connection.
stdin is opt-in and a separate request. A command that does not ask for stdin
gets Stdio::null(), so nothing inherits a surprise descriptor. Writing to a
command that did not request stdin returns 409. Writes go to
POST /v1/exec/{id}/stdin and are never multiplexed onto the output connection.
Because the two connections are separate, a dropped attach cannot corrupt stdin.
EOF is an explicit signal rather than inferred, because a child reading stdin
cannot exit until the daemon drops its own handle. Child::wait() drops the
child’s copy of the handle, not the daemon’s.
Reconnect-at-cursor across a real suspend, measured
Section titled “Reconnect-at-cursor across a real suspend, measured”The claim above — that resume by byte offset is what distinguishes this from a backend whose reattach loses the gap — was asserted from the daemon’s side only. The cut under unit test is a response body ending without an exit frame, which is the condition the reconnect keys on, but nothing had put a real platform suspend across a live exec.
Measured 2026-08-15, us-east-1, through Session.spawn in microvms-js against a
guest ticking once a second and also appending each tick to a file, so the guest’s own
record is an oracle independent of the stream under test:
| Observation | |
|---|---|
| Before the suspend | tick-1, tick-2, tick-3 delivered on the handle’s stdout |
| Suspend / resume | SUSPENDED, held ~20s, RUNNING |
| The handle already held | Resumed at tick-4 — contiguous, no restart, no duplicate |
| Gaps recorded | None, on either the held handle or a fresh one |
A fresh handle at offset: 0 |
Replayed 200 of 200 ticks, indices contiguous 1..200 |
| The guest’s own file | Agreed with the stream at every point |
So the property holds against the thing it was designed for, and it holds in both
shapes: the handle a caller was already reading recovers at its cursor, and a new
handle for the same exec_id replays the whole output across the suspension. The exec
itself is untouched by the freeze, which is the PLATFORM.md finding this depends on.
The other three ExecProcess properties were measured on the same VM. stdout and
stderr arrive as separate streams carrying only their own bytes, from one interleaved
SSE channel with a per-frame discriminator. wait() reports the daemon’s real exit code
— 0 for a success, 42 for exit 42 — because it reads the exec record rather than
inferring from the stream ending. kill() terminates the process group and a second
call succeeds rather than 404ing, and wait() afterwards reports signal: 15 with no
exit code, so a killed build cannot read as passing.
Credentials for a caller’s own connection
Section titled “Credentials for a caller’s own connection”Session::connect_headers(port) and connect_subprotocols(port) hand a caller what it
needs to open its own connection to some other port on the same VM — the header pair for
HTTPS, the three subprotocols for a WebSocket.
Both mint a token scoped to the port they name, and that is load-bearing rather than an
implementation detail. The service scopes a proxy token at mint time, so a token minted
for the agent port answers 403 Access to port denied for any other port and close code
1006 with no reason on a WebSocket. An implementation that reused the session’s cached
token would therefore return a correct-looking port value behind a credential that does
not authorize it — which is what this client did until the live run of 2026-08-15 caught
it. See PLATFORM.md, “allowedPorts is a union of three forms”.
The scope is extended rather than replaced: a mint for a new port asks for every port
already covered plus that one, so warming the cache for a workload port does not cost the
session access to its own. One extra control-plane call per new port, cached thereafter,
and proxy_mint_count() is the observable that says so.
A caller debugging a failed WebSocket should retry the same port over HTTPS with
connect_headers. Every handshake failure is 1006 and none of them says why; the HTTPS
request distinguishes 403 (wrong scope) from 502 (right scope, nothing listening).
Trust boundary
Section titled “Trust boundary”The platform’s /run hook arrives from 127.0.0.1 and is indistinguishable at
the socket level from a request sent by a process inside the VM (measured; see
PLATFORM.md). Filtering by source address therefore cannot separate the
platform from an in-VM process, so it provides no protection here.
The remaining defenses, all checked in model/, are the following:
- Bootstrap is one-shot, so a losing racer never replaces the winner’s token.
- A post-bootstrap hijack attempt is refused at the hook with 409 and at the control API with 401.
- The agent token never enters an exec’d child’s environment.
One risk remains. The design assumes the daemon is the container CMD and that
the harness issues its first exec only after readiness. The daemon does not
enforce this invariant. A base image that starts its own background process
before bootstrap breaks it. model/ includes that configuration and reports the
counterexample path, so the consequence of breaking the invariant is a checked
result rather than a prediction. Enforcing the invariant is the responsibility
of whoever builds the image, not of this daemon.