18 Commits
Author SHA1 Message Date
zeekayandHanzo Dev 02d6346192 Pin zap-proto/zip to a published release
The ZAP wire was pinned to a pseudo-version of a commit that exists only
in one checkout, so a clean fetch could not resolve it. v1.36.49 carries
the same Layouts generator and SCM_RIGHTS file passing.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-22 23:12:21 -07:00
zeekayandHanzo Dev 1fda956476 Set a call's files after its result, and check the magic
Reading a ZAP result writes every field its layout states, the file payload
included, so files attached before decoding were overwritten with none. A
frame whose header is not ZAP is refused at the magic rather than read as a
length.


Co-authored-by: Hanzo Dev <[email protected]>
2026-09-22 23:10:33 -07:00
zeekayandHanzo Dev f42ae7beb2 Carry urpc payloads as ZAP, not JSON
encoding/json walked every payload on every call. ZAP reads a field at a
constant offset out of the buffer that arrived, and //tools/zap has
already stated those offsets for every payload, so the transport now asks
the payload for its own bytes and the parse step is gone.

The envelope collapses with it. clientCall and serverCall existed only
because JSON needed one type to hold an any and another to hold a
RawMessage; bytes are bytes, so one Call serves both ends. Framing comes
from the ZAP header, which declares its own length.

A payload that is nothing -- the *struct{} a method takes when it has no
argument or no result -- carries nothing. Anything else without a layout
fails the call naming the type, so a payload that would have arrived
silently empty does not arrive at all.

The methods that took a bare string or returned a bare int now take and
return named payloads, because a scalar has no layout to state and the
rest of the API was already shaped this way.


Co-authored-by: Hanzo Dev <[email protected]>
2026-09-22 23:10:33 -07:00
zeekayandHanzo Dev e1280967b6 Give the last two payloads a wire to state
A prometheus Data held its labels in a map, and control.ExecArgs held the
live kernel objects an exec runs against -- a mount namespace, an fd
table, a limit set. Neither has offsets, so neither payload could state
a layout.

Labels become a list, which also retires the O(n^2) walk that existed to
read a map in sorted order. The kernel objects move to control.Env, which
is what the sentry supplies and what a call arriving on the wire cannot:
a namespace is a reference, not a value.

Every urpc payload now states its own wire.


Co-authored-by: Hanzo Dev <[email protected]>
2026-09-22 23:10:33 -07:00
zeekayandHanzo Dev 3da05638df State the ZAP wire of every urpc payload
urpc encodes with encoding/json, which walks the payload on every call: a
realistic control.ExecArgs costs 7084ns and 10 allocations to cross. ZAP
reads a field at a constant offset out of the buffer that arrived, so the
same payload is 740ns and one allocation -- but only if the offsets are
stated, and a stated offset that is wrong is silent corruption rather than
a compile error. So state them from the types themselves.

//tools/zap reflects over the payloads at build time and writes the wire
as Go source with the offsets as constants. It lives inside Bazel because
pkg/sentry/control cannot be imported from outside it: the *_go_proto
packages it depends on have no source in the tree.

Six payloads had no layout to state. A map, an interface and a type from
another module have no offsets, so each is answered in the type:

  SaveOpts.Metadata and StartContainerArgs.InitialCgroups/Limits cross as
  lists of pairs; boot.Route.Destination restates net.IPNet as Subnet;
  OpenMountArgs restates the OCI mount fields the call actually uses;
  StartArgs.Spec and SinkConfig.Config carry their documents as the bytes
  they arrived as; MetricsRegistrationResponse carries the protocol
  buffer the same way; and seccheck's session options become a type
  instead of a map of any.

ExecArgs and MetricsExportData still have none: ExecArgs holds live
kernel objects behind an interface, and MetricsExportData reaches a
prometheus snapshot whose labels are a map.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-22 23:10:33 -07:00
zeekayandHanzo Dev c814594092 The version gate asked the runtime for a string only its guest can say
`runsc --version` prints two lines, and -visor is in neither:

    runsc version <STABLE_VERSION>
    spec: <OCI spec version>

STABLE_VERSION is `git describe --always --tags` — tools/workspace_status.sh,
wired by .bazelrc, substituted through runsc/BUILD's x_defs. The spec line is
opencontainers/runtime-spec's. The -visor suffix lives in
pkg/sentry/kernel/version/version.go: it is the release the sandbox advertises
through uname(2), which only a process inside a running sandbox can read. Its
own comment says as much — the two builds "are told apart by asking the guest".

So the gate fails for every build that can exist. visor-20260919.0 is queued and
has never run; it would have compiled all of gVisor and died on the last line
before creating the release, reading as a broken fork rather than a bad grep.

Assert what the binary does report, and what a release needs anyway: that the
bytes carry the tag being published. At a tag, describe returns the tag name, so
the line is exactly `runsc version $TAG`. Everything that should be rejected
still is — VERSION_MISSING with no stamp, 0.0.0 or a bare commit with no tag in
reach, -dirty from a modified tree, and upstream, whose tags are release-*.

The bazelisk asset now names the pool it runs on. bazelisk publishes <os>-<arch>
with exactly the labels our runners carry, so the arm64 row cannot come back
downloading an amd64 bazel.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 10:19:16 -07:00
zeekayandHanzo Dev afb70653f9 The publish lane moves to where our CI looks
The lane that builds visor and attaches verified binaries to its release has
existed since e3027ddbc, at .github/workflows/publish.yml. Tag visor-20260911.0
was pushed at a commit that already carried it, and no release was ever created:
this estate runs CI on Hanzo Git Actions from .hanzo/workflows, and has no
runners on GitHub Actions at all. A workflow in the wrong directory does not
fail, it waits — which reads exactly like a lane nobody triggered, and is why
every node still runs a visor built by hand from a working tree.

Also takes the canonical runner label. RUNNERS.md: "A runs-on is one of exactly
these" — linux-amd64. hanzo-linux-amd64 still routes, as an alias kept so
existing workflows keep running, and none of it should be written into a new one.

Nothing else changes: same bazelisk bootstrap, same single bazel invocation for
the runtime and the shim it execs, same gate refusing a binary that does not
report -visor, same sha512 beside every artifact.


Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 09:56:31 -07:00
zeekayandHanzo Dev 0cc5982824 fix(execd): keep the host socket from children, and git where dir says
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]>
2026-09-19 04:05:18 -07:00
zeekayandHanzo Dev 4855f06fd1 fix(execd): confine git, make patch all-or-nothing, answer only what happened
CI / build (push) Failing after 33s
Go / generate (push) Failing after 46s
The git ceiling named the workspace, which git only applies to a proper
ancestor of the directory it starts from, so a request with no dir found,
read and wrote a repository above the workspace. The ceiling is now the
workspace's parent, which refuses from the root and from any directory
under it.

patch staged by action id, so two changes naming one file collided and
the second failed with the first's bytes already in place, and a delete
that could not happen left earlier renames behind. Every change now
stages under a random name, the file that was there is kept on a second
link until every move has landed, and a move that fails is put back. A
diff that names one file twice, gives two names for one file, would
delete a directory, or renames is refused.

A zero-length packet is a packet on SOCK_SEQPACKET, and reading it as end
of file ended the daemon for good. POLLRDHUP tells the two apart.

Also: a listing fills a page to what a frame holds instead of promising
4096 entries no frame can carry; write and patch resolve the parent with
openat2 on the path as written, so one string cannot mean two places; a
refused request is not recorded, so its id is free, while an action that
ran is recorded whatever came of it; a staged name is random, so a
workspace file is never unlinked to make room; children start from a
declared environment instead of the daemon's; write reports the mode on
disk and keeps the mode a file had; mode means permission bits in stat as
well as read and list; a read past the end is refused; an oversize
refusal names the op it refused; the ledger is bounded by bytes as well
as count; the diff path is the reading both header lines agree on; the
build constraint sits above the package doc; and BUILD embeds a library,
which is how the repo builds a main package with tests.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-18 17:49:38 -07:00
zeekayandHanzo Dev 6208c7df95 Add execd, the execution daemon inside the sandbox
CI / build (push) Canceled after 0s
Go / generate (push) Canceled after 0s
A coding agent is two programs: one reasons, one runs cargo test. execd is the
second half, and it knows nothing about the first — no model, no session, no
plan, no credential. Actions arrive as ZAP frames on FD 3, one end of a
SOCK_SEQPACKET pair the host connected before exec, so there is no listen, no
connect and no path in a filesystem.

Every request carries an action id and every reply names it. A repeated id for
a completed action is answered from the record instead of run again, so losing
an answer to git commit does not commit twice; the table is bounded by count
and an id whose result was evicted is refused rather than replayed. Paths
resolve with openat2 RESOLVE_BENEATH, so the kernel refuses "..", an absolute
path and a symlink that leaves the workspace. write and patch stage a
temporary file and rename over the target, and patch verifies every file in a
diff before renaming any of them.

Nothing fsyncs: the host serves the workspace and owns durability, and an
fsync inside the cell can cost seconds for nothing. Tests need no visor — a
socketpair in-process is the same code path as FD 3 under runsc.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-18 13:48:08 -07:00
zeekayandHanzo Dev a422ca68e5 Install bazel without root
CI / build (push) Failing after 1m5s
Go / generate (push) Failing after 4m37s
Runners execute inside the visor sandbox, where setuid is not honoured: sudo
fails with "nosuid" before the step does anything. Bazel goes into a directory
the job already owns, named on GITHUB_PATH.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 23:23:54 -07:00
zeekayandHanzo Dev b3371ed075 Build with bazel directly, and only where a runner can start
make copy wraps bazel in a container and pushes to an upstream registry, so it
needs a docker socket these runners do not have: it failed on
dial unix /var/run/docker.sock before building anything.

aarch64 is dropped for now rather than left to queue. git-runner:5 is a single
manifest, so an arm64 node cannot start it — the pods StartError and the job
then waits forever, which reads as nothing happening. It comes back in the same
commit that publishes a multi-arch runner image.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 23:21:11 -07:00
zeekayandHanzo Dev e3027ddbc5 Build visor on our own runners and publish verified binaries
publish / binaries (aarch64, hanzo-linux-arm64) (push) Waiting to run
publish / binaries (x86_64, hanzo-linux-amd64) (push) Failing after 22s
A node installed a hand-built runtime nobody could reproduce or attribute to a
commit. Both binaries now come from one bazel invocation, ship beside their
sha512, and refuse to publish unless the runtime reports -visor.

This path, not .hanzo/workflows: the forge has no online runner, and an
unmatched runs-on queues silently instead of failing.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 23:11:57 -07:00
zeekay 2961b80df6 One publish lane, where the runners are
The .hanzo copy could never run: the forge has no online runner, and an
unmatched runs-on queues silently rather than failing.
2026-09-10 23:11:19 -07:00
zeekay ca9e507131 Publish with the token the runner already has
The lane depended on a GitHub PAT nobody had set on the forge, so it could
never have run. It now publishes to the forge release with the token every
runner is given, which is one less credential that can rot.
2026-09-10 23:08:14 -07:00
zeekay 844e091701 Publish verified visor binaries per architecture
Each node installed a hand-built runtime nobody could reproduce or attribute
to a commit. Both binaries now come from one bazel invocation, ship beside
their sha512, and refuse to publish unless the runtime reports -visor.
2026-09-10 19:37:54 -07:00
zeekayandHanzo Dev 04e0b3d153 Delete the packetimpact harness
It was the only thing in the tree importing google.golang.org/grpc, and it
reaches no runsc build path — 71 files of network conformance tests against a
posix_server that is not part of the sandbox. The control plane is urpc, and
now nothing here speaks gRPC at all.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:27:54 -07:00
zeekayandHanzo Dev f4a6d50921 Report -visor so the guest names its own build
A sandbox that answers "-gvisor" is upstream's binary. Telling the two apart
by config is a guess — the RuntimeClass, the containerd entry and the shim
options can all name our binary while the pod runs something else, which is
exactly what happened when an inline BinaryName was accepted by containerd and
never read by the shim. The guest's own uname is the one answer that cannot be
wrong about which kernel is running it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-09 01:48:04 -07:00