mirror of
https://github.com/hanzoai/visor.git
synced 2026-09-24 17:04:14 +00:00
CI / build (push) Waiting to run
Go / generate (push) Waiting to run
FD 3 arrives without FD_CLOEXEC, which is how it crosses the exec into execd, and execd never set it again, so every command, pty and git it ran held the host's channel: it could read requests meant for the daemon and answer in its name. newDaemon now sets FD_CLOEXEC. The test harness made its pair with SOCK_CLOEXEC, which hid this; the daemon's end now arrives inheritable, as under runsc. The workspace descriptor dup'd for a top-level name closes on exec too. The git ceiling governs only discovery, so -C, --git-dir, --work-tree and a GIT_DIR or GIT_CONFIG_GLOBAL in env reached a repository or a config file outside the workspace. Before its subcommand git now takes -c and options that move nothing, and refuses the rest, since an option execd does not know could take a value that hides the next. GIT_* in env is refused and the global config is /dev/null, so HOME picks no file. The ceiling test now covers each of these. Co-authored-by: Hanzo Dev <[email protected]>
267 lines
12 KiB
Markdown
267 lines
12 KiB
Markdown
# execd — the execution daemon inside the sandbox
|
|
|
|
`execd` is the small program that runs inside a visor sandbox and does what a
|
|
coding agent asks of an operating system: run a program, drive a pty, read and
|
|
write files, watch them, apply a diff, run git. It holds no model, no
|
|
conversation, no plan and no credential. Those live outside the sandbox and
|
|
reach it as actions (HIP-1330).
|
|
|
|
THINK (outside) DO (inside)
|
|
/v1/dev + dev core ── action ──▶ execd ──▶ exec, pty, files, git
|
|
◀── result ──
|
|
|
|
## The FD 3 contract
|
|
|
|
`execd` does not listen, does not connect, and has no path in any filesystem.
|
|
It is handed a descriptor that is already connected: **FD 3**, one end of a
|
|
`AF_UNIX`/`SOCK_SEQPACKET` socketpair the host created before the sandbox
|
|
started. A sequenced-packet socket keeps message boundaries, so one packet is
|
|
one ZAP frame and there is no length prefix and no framing layer.
|
|
|
|
At startup `execd` refuses anything that is not that contract:
|
|
|
|
| Check | Refusal |
|
|
|---|---|
|
|
| `SO_TYPE` is `SOCK_SEQPACKET` | `descriptor 3 is socket type N, not SOCK_SEQPACKET` |
|
|
| `getpeername` succeeds (connected) | `descriptor 3 is not connected` |
|
|
| `-workspace` given and openable | `-workspace is required` |
|
|
|
|
execd -workspace /workspace # fd defaults to 3
|
|
execd -workspace /workspace -fd 3
|
|
|
|
A frame is at most 128 KiB. A larger request cannot be received, and is
|
|
answered with the size that arrived instead of being truncated in silence; a
|
|
reply that would not fit is refused the same way. That is why a read takes a
|
|
range and a listing takes a page.
|
|
|
|
### How the host makes the descriptor
|
|
|
|
```go
|
|
fds, _ := unix.Socketpair(unix.AF_UNIX, unix.SOCK_SEQPACKET|unix.SOCK_CLOEXEC, 0)
|
|
mine, theirs := fds[0], fds[1]
|
|
|
|
cmd := exec.Command("runsc", "run", "-bundle", bundle, id)
|
|
cmd.ExtraFiles = []*os.File{os.NewFile(uintptr(theirs), "execd")} // becomes FD 3
|
|
```
|
|
|
|
`ExtraFiles[0]` is FD 3 in the child. `runsc` passes the descriptors it was
|
|
given through to the sandboxed process, so the OCI config runs `execd` with
|
|
that same descriptor:
|
|
|
|
```json
|
|
{
|
|
"process": {
|
|
"args": ["/sbin/execd", "-workspace", "/workspace"],
|
|
"cwd": "/"
|
|
}
|
|
}
|
|
```
|
|
|
|
Two things to keep true:
|
|
|
|
- The descriptor must not carry `FD_CLOEXEC` at the moment of exec. Go clears
|
|
it for `ExtraFiles`; if you dup by hand, use `unix.Dup3(fd, 3, 0)`.
|
|
- Nothing else may be handed in below FD 3, or the numbering shifts. `execd`
|
|
takes `-fd` for the case where the host cannot place it at 3.
|
|
|
|
`execd` sets `FD_CLOEXEC` on the descriptor as it starts. The socket crosses
|
|
the exec that starts `execd` and no other: a command `execd` runs gets its
|
|
stdio, and cannot read a request meant for `execd` or answer one in its name.
|
|
|
|
The host keeps `mine` and speaks ZAP on it. A zero-length packet is a packet
|
|
like any other and asks for nothing — it is the socket, not a length, that says
|
|
the host has hung up. When it has, every pty is killed, every watch is closed
|
|
and the process exits.
|
|
|
|
## The protocol
|
|
|
|
One ZAP message per packet. Every request carries an **action id**, and every
|
|
reply names the id it answers. Requests are carried out concurrently, so
|
|
replies can arrive in any order — match on the id, never on arrival order.
|
|
|
|
### Ops
|
|
|
|
| Op | Code | Takes | Answers |
|
|
|---|---|---|---|
|
|
| `exec` | 1 | `argv` or `command`, `dir`, `env`, `timeout`, `data` as stdin | `exit`, `data` (stdout), `log` (stderr) |
|
|
| `spawn` | 2 | `argv` or `command`, `dir`, `env`, `cols`, `rows` | `handle` |
|
|
| `pty.write` | 3 | `handle`, `data` | `size` written |
|
|
| `resize` | 4 | `handle`, `cols`, `rows` | `handle` |
|
|
| `kill` | 5 | `handle`, `signal` | `handle` |
|
|
| `read` | 6 | `path`, `off`, `length` | `data`, `size`, `mode`, `mtime` |
|
|
| `write` | 7 | `path`, `data`, `mode` | `size`, `mode` |
|
|
| `stat` | 8 | `path` | `size`, `mode`, `mtime`, `flags` |
|
|
| `list` | 9 | `path`, `off` (first index), `length` (count) | `entries`, `size` (the whole count) |
|
|
| `watch` | 10 | `path` | `handle`, `path` (resolved) |
|
|
| `patch` | 11 | `data` (a unified diff) | `entries` (the files changed), `size` |
|
|
| `git` | 12 | `argv`, `dir`, `env`, `timeout` | `exit`, `data`, `log` |
|
|
|
|
`mode` is permission bits — `0644`, `0755` — in a request and in every reply
|
|
that carries one. Whether a path is a directory is `flags`, not the mode.
|
|
|
|
### A reply that did not happen, and one that did
|
|
|
|
`err` is a sentence, not a code: `away/secret: invalid cross-device link`,
|
|
`no answer within 200ms`, `src/main.go:2: the file has "x" where the diff
|
|
expects "two"`. There are two kinds of it, and which one it is decides what a
|
|
repeat of the id does.
|
|
|
|
- **Refused.** The request was not carried out: a path outside the workspace, a
|
|
diff that does not fit, `argv` and `command` both set, a handle that is gone,
|
|
a frame over the bound, a program that could not be started. Nothing changed,
|
|
so nothing is recorded and the same id sent again runs.
|
|
- **What came of it.** The action ran and this is its outcome: a nonzero
|
|
`exit`, `killed by SIGKILL`, `no answer within 200ms` for a command its
|
|
deadline killed and that may have left half its work behind. It is recorded
|
|
under its id like any other answer. Running it again is a new action and
|
|
takes an id of its own.
|
|
|
|
### Kinds
|
|
|
|
A reply is one of four kinds. Only `reply` answers a request; the rest are
|
|
pushed by a handle the host already holds, under the action id that created
|
|
it.
|
|
|
|
| Kind | Code | Meaning |
|
|
|---|---|---|
|
|
| `reply` | 0 | the answer to the request with this id |
|
|
| `data` | 1 | pty output |
|
|
| `event` | 2 | a watch event: `path` is the name, `mask` the inotify bits |
|
|
| `exit` | 3 | the spawned process ended: `exit`, and `err` if a signal ended it |
|
|
|
|
### An action happens once
|
|
|
|
The ledger records the reply of every action that ran, under its id. A repeated
|
|
id is answered from that record and the effect is **not** run again: if the
|
|
host loses an answer to `Exec{id: 7821, argv: ["cargo", "test"]}`, asking again
|
|
returns the recorded result rather than a second test run. The same holds for
|
|
`git commit`, a write and a patch. A refused request is not recorded, because
|
|
nothing happened.
|
|
|
|
The table is bounded twice, by count and by bytes: the newest 1024 replies are
|
|
held while they take no more than 8 MiB together, and the 1024 ids evicted
|
|
before those are kept as bare marks. An id whose result was evicted is refused
|
|
(`action N completed and its result is no longer held`) rather than run twice.
|
|
Past that window an id is forgotten and a repeat of it runs, so a host that may
|
|
replay should not reuse an id older than the window.
|
|
|
|
An id still running is refused with `action N is already running`. An id of
|
|
zero is refused: every action carries one.
|
|
|
|
## The workspace is the boundary
|
|
|
|
Every path in a request is resolved relative to `-workspace` with
|
|
`openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)` — for a write and a patch as
|
|
much as for a read, and on the path as it was written rather than on a cleaned
|
|
copy of it, so one string cannot mean two places. The kernel does the refusing,
|
|
not a string check:
|
|
|
|
- `../outside/secret`, and any `..` that would leave the workspace
|
|
- `/etc/passwd`, and any absolute path
|
|
- a symlink whose target leaves the tree, and any path through one
|
|
- an absolute symlink, even one aimed back inside the workspace, because
|
|
resolution would restart at `/`
|
|
|
|
A `..` that stays beneath the workspace resolves normally, so `src/../src/main.go`
|
|
is `src/main.go`; so does a relative symlink that stays beneath it.
|
|
|
|
`exec` and `git` take their working directory the same way, and run in the
|
|
resolved directory. For `git`, `dir` is the only thing in a request that says
|
|
where it works:
|
|
|
|
- Before the subcommand, `argv` takes `-c` and the options that change how git
|
|
reads and prints: `-P`, `--no-pager`, `--no-optional-locks`,
|
|
`--no-replace-objects` and the `--*-pathspecs` switches. `-C`, `--git-dir`,
|
|
`--work-tree`, `--exec-path` and any option not listed are refused; one that
|
|
is not listed might take a value and hide the option after it.
|
|
- A `GIT_*` variable in `env` is refused. `GIT_DIR`, `GIT_WORK_TREE` or a
|
|
`GIT_CONFIG_*` would name a place of the request's choosing; `-c` says how
|
|
git is configured.
|
|
- git reads the repository's configuration, the system's and what `-c` states.
|
|
`GIT_CONFIG_GLOBAL` is `/dev/null`, so a `HOME` in `env` picks no file.
|
|
|
|
After the subcommand every word is git's own: `git log -C` is copy detection,
|
|
and a path a subcommand is given, like the directory of `git init <dir>`, is
|
|
git's to resolve, as a path in `exec`'s `argv` is.
|
|
|
|
`git` gets `GIT_CEILING_DIRECTORIES` set to the workspace's **parent**, not to
|
|
the workspace. git stops a repository search at a ceiling directory on the way
|
|
up, and a ceiling equal to the directory the search starts from is never on
|
|
that way — so a ceiling of the workspace itself would leave a request that runs
|
|
at the workspace root free to find, read and write a repository above it. With
|
|
the parent as the ceiling, the search ends at the workspace, from the root and
|
|
from any directory under it. What it finds there git follows as it always
|
|
does: a `.git` file or a `core.worktree` in the workspace that names a place
|
|
outside it takes git there. `GIT_DISCOVERY_ACROSS_FILESYSTEM=0` is set as well.
|
|
`git` takes `argv` only: a command string for a shell is refused.
|
|
|
|
A child starts from a declared environment — `PATH`, `LANG`, `TERM` — or from
|
|
the `env` the request names, and never from the daemon's own, so nothing the
|
|
host handed `execd` reaches a command unasked.
|
|
|
|
## Writing is atomic, not durable
|
|
|
|
`write` and `patch` fill a staged file beside the target and rename over it, so
|
|
a reader sees the old bytes or the new ones and never a half-written file. The
|
|
staged name is `.execd` and a random suffix — a name no request can name, so a
|
|
file the workspace already holds is never in the way of a write.
|
|
|
|
`write` sets the mode: the one the request names, or the one the file already
|
|
has, or `0644` for a file that is new. The reply names the mode the file ended
|
|
up with, not the one that was asked for.
|
|
|
|
`patch` is all or nothing. Every file in the diff is read, its hunks applied
|
|
and the result staged before anything is moved, and a file that was already
|
|
there is kept on a second hard link until every move has landed. So a diff
|
|
whose second file does not fit changes nothing, and a move that fails puts back
|
|
the moves before it. A diff that names one file twice is refused, as is one
|
|
that gives two names for one file, and one that would delete a directory.
|
|
|
|
There is no `fsync`. The workspace is served to the sandbox by the host, which
|
|
caches locally and persists to s3; making bytes durable is the host's job, and
|
|
an fsync per write inside the cell buys nothing and can cost seconds.
|
|
|
|
The diff `patch` reads is a plain unified diff: `---`/`+++` headers, `@@`
|
|
hunks, `/dev/null` on one side for a file created or deleted, and `\ No newline
|
|
at end of file`. Anything else is refused with the line number.
|
|
|
|
The path is the reading that makes the two header lines agree: `a/src/main.go`
|
|
and `b/src/main.go` are both `src/main.go`, while a diff written with no
|
|
prefixes names the path it writes even when that path starts with `a/`. A pair
|
|
that agrees under neither reading is refused instead of guessed at, and so is a
|
|
`rename from`/`rename to` pair, which a diff of hunks cannot express — send the
|
|
delete and the create. A context or removed line that does not match the file
|
|
is refused with what was there and what the diff expected.
|
|
|
|
## Bounds
|
|
|
|
| Bound | Value | What happens past it |
|
|
|---|---|---|
|
|
| one frame | 128 KiB | refused with the size |
|
|
| one read | 96 KiB | refused; ask for a range |
|
|
| stdout, stderr per `exec` | 48 KiB each | kept bytes plus `err` saying how many were dropped |
|
|
| one listing | what a frame holds | keep going from `off` |
|
|
| ledger | 1024 results, 8 MiB, 1024 marks | an evicted id is refused, not re-run |
|
|
|
|
A listing is sorted by name, because a page is only meaningful against a stable
|
|
order, and answers with the entries from `off` that fit in one frame. `size` is
|
|
the whole directory's count, so a host pages by advancing `off` by the number
|
|
of entries it got. A read whose offset is past the end of the file is refused
|
|
rather than answered with no bytes.
|
|
|
|
## Build
|
|
|
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build ./cmd/execd
|
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ./cmd/execd
|
|
|
|
Linux only: it is `openat2`, `inotify` and a pty, and there is nothing to
|
|
pretend about on another kernel.
|
|
|
|
## Tests
|
|
|
|
The tests need no visor and no root. They make a socketpair in-process, hand
|
|
the daemon one end without `FD_CLOEXEC`, as runsc hands over FD 3, and speak
|
|
ZAP on the other — the same code path as FD 3 under runsc:
|
|
|
|
go test ./cmd/execd
|