Skip to content

feat(guest-image): pin the agent OCI image the guest rootfs derives from (RIG-3786) - #1275

Closed
rigel-mintaka wants to merge 5 commits into
mainfrom
compass-managed/rig-3787-derive-rootfs
Closed

rigel-mintaka wants to merge 5 commits into
mainfrom
compass-managed/rig-3787-derive-rootfs

Conversation

@rigel-mintaka

@rigel-mintaka rigel-mintaka commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

This PR is part of a stack containing 2 PRs:

  1. main
  2. "feat(guest-image): pin the agent OCI image the guest rootfs derives from (RIG-3786)" (this PR)
  3. feat(guest-image): publish the guest triple as a non-runnable OCI artifact #1268

Correctness-lens findings on the OCI-derived rootfs, each reproduced first:

  • Extraction used --no-same-permissions, so every member took the builder's
    umask instead of its published mode: /bin packed 0755 where the image ships
    0555. Extract with -p and re-open staging write access on directories only,
    then restore the directories' published modes before packing. Directory
    members are selected by tar's type flag, since these layers list directories
    with no trailing slash.
  • The whiteout scan matched the marker prefix anywhere in a path, so an
    ordinary directory whose name contained it became a delete order.
    Reproduced deleting a lower-layer file outright. The match is now anchored
    to a marker-prefixed final component.
  • An opaque marker naming a missing directory was swallowed by a redirect to
    /dev/null plus a true fallback; it now breaks the build, since it means the
    layer stack is not what this build thinks it is.
  • The image root's mode was a chmod side effect (0555 inherited from the boot
    layer's store path until a later pass happened to widen it); it is now set
    outright.
  • The store-path loop word-split an unquoted command substitution under
    nullglob, so a path containing a glob metacharacter was silently dropped,
    yielding an incomplete rootfs. It now reads line-wise.
  • The contract resolver counted path components, not symlink hops, so a deep
    symlink-free path could be reported as a loop.
  • The lock guard called builtins.match on a non-string tag, replacing the
    curated error with a raw nix type error.

Not fixed, measured: mkfs.erofs drops the sticky bit on /tmp whatever the
staged mode, so /tmp packs 0777 rather than 1777. The rootfs boots read-only,
so the lower layer's mode is not what makes /tmp writable.

Refs RIG-3787

rigel-mintaka and others added 5 commits September 16, 2026 16:04
…rom (RIG-3786)

The microVM guest rootfs is about to be derived from the PUBLISHED
compass-agent image rather than re-evaluated from the agent's nix
expressions. That makes the pin the whole provenance story, so this adds
it first, on its own.

guest-image/agent-oci.lock records the repo, an immutable per-commit
tag, the manifest digest they resolve to, and every layer descriptor
digest the rootfs's fixed-output fetches will key on.

tools/guest-image/pin-core.ts enforces provenance rather than
documenting it: the only accepted repo is ghcr.io/rigelbuild/compass-agent,
the only accepted tag shape is the publish lane's immutable git-<sha12>,
and a multi-platform index is refused outright because it would need a
platform choice the lock cannot record. Layer order is preserved
deliberately — the layers stack into the filesystem, so a reordered set
fetches identical bytes and unpacks a different rootfs.

The Renovate lockstep is the non-obvious half. A bare regex manager is
NOT sufficient here, unlike the digest-only postgres pin where the
rewrite completes the update: the lock carries fetch-key fields Renovate
cannot compute, so a digest-only bump would leave the layer descriptors
describing the previous manifest and redden every fixed-output fetch on
every Renovate PR. That is the same stale-paired-field class
refresh-devenv-lock.ts names for a rev-only rewrite, so this takes the
same remedy: a customManager PLUS a branch-mode postUpgradeTask relock,
solo-grouped so it owns the branch's single task slot, with the paired
bot-config allowlist entry.

Detection tracks the moving :latest digest, because the pinned tag is
per-commit immutable and no datasource can order it. The relock resolves
:latest back to whichever immutable tag shares its manifest, which is
sound because the publish lane asserts the pair shares a config digest
and fails closed. Verified live against the registry: git-ec4954bd9400
matches :latest on both manifest and config digest.

Tag discovery walks the tag list newest-first. The registry lists tags
oldest-first and the wanted tag is almost always newest, so in-order
discovery cost a round trip per historical tag — measured at 121s, now
3s — and compares digests without fetching the ~120-layer manifest body.

The project is registered in .moon/workspace.yml AND defines a `ci`
aggregate: ci-matrix emits a target only for a project that defines one,
so without it the guard suite would be registered and still silently
inert.

Co-authored-by: Matt Wilkinson <matt@rigel.build>
Review findings on the agent-image pin, plus one the review prompted me
to measure.

The digest no longer comes from a second registry read. `inspect` made
two skopeo calls against a MUTABLE tag — one for the manifest body, one
for the digest — so a publish landing between them would pair one
manifest's layers with another's digest, and the lock would describe
layers its own digest disowns. A manifest digest is by definition the
sha256 of the manifest bytes, verified here against the registry's own
reported digest on the live image, so one fetch hashed locally gives
both and the digest provably describes the body that was pinned.

The Renovate rule no longer inherits the repo-wide 5-day soak. This
image is built by nix2container, which zeroes timestamps for
reproducibility, so the registry reports Created: 0001-01-01T00:00:00Z
and `internalChecksFilter: "strict"` over an unknown age would hold the
digest permanently pending — zero PRs, the stale-pin-forever outcome the
rule exists to prevent. Nulled with the git-refs siblings' rationale,
NOT the postgres pin's, which keeps its soak precisely because Docker
Hub carries a real timestamp. Two guards added: the cooldown is nulled,
and the dep resolves enabled rather than being swallowed.

Tag discovery filters candidates by the same predicate the lock
validator enforces, so a malformed git-* tag sharing the digest is
skipped rather than selected and then hard-failing the relock.

The render test asserted renderLock against itself, which passes for any
deterministic implementation; I tried three mutations before finding
that canonical key order is structural in renderLock and therefore
unfalsifiable. Replaced with the byte-exact output, which does fail when
the indentation changes.

Co-authored-by: Matt Wilkinson <matt@rigel.build>
…image

The guest userland was a second nix evaluation of the agent image's
expressions under a different nixpkgs pin, so the microVM guest and the
published container could drift silently.

The rootfs now unpacks the published image itself: per-layer
fixed-output fetches keyed by the locked descriptor digests, applied in
manifest order with each layer's whiteouts resolved against the lower
layers before its own content lands. A boot layer is overlaid on top
and wins every conflict: guestd as /sbin/init, modprobe, /lib/modules
from the root-pinned kernel, and a writable /etc/resolv.conf.

Two build-time gates keep it honest. The eval re-checks the lock's
shape, and the userland contract is verified on the assembled tree by
resolving every path component inside it -- the way the kernel will
after switch_root -- so a missing /bin/sh breaks the build instead of
shipping a guest that cannot arm egress.

Refs RIG-3787

Co-authored-by: Matt Wilkinson <matt@rigel.build>
…unpacking

Review findings on the OCI-derived rootfs, each with a reproduced counterexample:

- The lock's manifest digest constrained nothing: only the layer descriptors
  decided what was unpacked, so a lock pairing that digest with another image's
  valid layers built clean. The manifest is now fetched fixed-output against the
  digest (a manifest digest is the sha256 of its body) and its own ordered layer
  list must equal the lock's.
- A lower layer's absolute symlink used as a parent path let tar write, and the
  whiteout `rm -rf` delete, outside the staged root. Such parents are dropped
  before extraction and a `..` member now breaks the build.
- The userland contract passed on a non-executable or a directory; it now
  requires an executable regular file.
- setuid/setgid files are asserted absent rather than assumed.

The packed image is byte-identical: the published layers trip none of these.

Refs RIG-3787
…arkers

Correctness-lens findings on the OCI-derived rootfs, each reproduced first:

- Extraction used --no-same-permissions, so every member took the builder's
  umask instead of its published mode: /bin packed 0755 where the image ships
  0555. Extract with -p and re-open staging write access on directories only,
  then restore the directories' published modes before packing. Directory
  members are selected by tar's type flag, since these layers list directories
  with no trailing slash.
- The whiteout scan matched the marker prefix anywhere in a path, so an
  ordinary directory whose name contained it became a delete order.
  Reproduced deleting a lower-layer file outright. The match is now anchored
  to a marker-prefixed final component.
- An opaque marker naming a missing directory was swallowed by a redirect to
  /dev/null plus a true fallback; it now breaks the build, since it means the
  layer stack is not what this build thinks it is.
- The image root's mode was a chmod side effect (0555 inherited from the boot
  layer's store path until a later pass happened to widen it); it is now set
  outright.
- The store-path loop word-split an unquoted command substitution under
  nullglob, so a path containing a glob metacharacter was silently dropped,
  yielding an incomplete rootfs. It now reads line-wise.
- The contract resolver counted path components, not symlink hops, so a deep
  symlink-free path could be reported as a loop.
- The lock guard called builtins.match on a non-string tag, replacing the
  curated error with a raw nix type error.

Not fixed, measured: mkfs.erofs drops the sticky bit on /tmp whatever the
staged mode, so /tmp packs 0777 rather than 1777. The rootfs boots read-only,
so the lower layer's mode is not what makes /tmp writable.

Refs RIG-3787
@trunk-io

trunk-io Bot commented Sep 17, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@linear-code

linear-code Bot commented Sep 17, 2026

Copy link
Copy Markdown

RIG-3787

RIG-3786

@rigel-mintaka

Copy link
Copy Markdown
Contributor Author

Redundant: this content merged as #1252 (squashed to d8ccbb3 on main). Reopened by my own jj-vine submit of the stale pre-squash bookmark; every T2 file is byte-identical to main. Closing.

@github-actions

Copy link
Copy Markdown

Compass engineering docs preview: https://compass-managed-rig-3787-der.compass-eng-docs.pages.dev

Deployed from compass-managed/rig-3787-derive-rootfs at abc8a29.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant