Merge nucleic/eager-ancient-heron-p5em into dev

This commit is contained in:
2026-07-18 19:00:14 -07:00
parent 8cfd71c6f7
commit ad3b89f9a1
10 changed files with 386 additions and 4 deletions
+15
View File
@@ -26,6 +26,12 @@ packages/
repo/
publish.sh # dist/pool → apt tree (dists/<channel>/…), signs when a key is present
r2-sync.sh # pushes the apt tree to Cloudflare R2 (CI; needs credentials)
images/
agent/ # naros-agent OCI layer FROM naros-base (Dockerfile + build.sh):
# naros-tier-agent (apt), Node 22, rustup, Go, mise, warm
# caches, agent user (uid 501), agent CLIs, Playwright
tests/
parity-sweep.sh # tool-inventory diff vs the previous default sandbox image
dist/ # build output (gitignored): bin/, pool/, repo/, rootfs tars
```
@@ -55,6 +61,15 @@ docker run --rm naros-base:test sh -c '. /etc/os-release && echo "$ID $VERSION_I
Without `--pool`, the build produces a plain identity-only base (no Nucleic packages) —
useful for validating the mmdebstrap/snapshot/identity plumbing in isolation.
Agent tier (needs docker; the agent tier is an OCI **layer** on naros-base, not a
separate mmdebstrap run, so pulls dedupe on the shared base):
```sh
docker import os/dist/naros-base-<ver>-arm64.tar naros-base:local
os/images/agent/build.sh arm64 --base naros-base:local
docker run --rm naros-agent:build-arm64 nash -lc 'naros info'
```
## Versioning
`VERSION` + `SNAPSHOT` define a release (NAROS.md §8). Channels: `edge` (weekly CI,
+192
View File
@@ -0,0 +1,192 @@
# naros-agent — the agent-experience tier (NAROS.md §4, §6; milestone N2).
#
# Built FROM the imported naros-base rootfs (mmdebstrap tar → docker import), so the base
# layer — trixie snapshot, narOS identity, nash FORCED via dpkg-divert, naros-init, the
# naros CLI — is exactly the naros-base image, and this file layers only the agent tier:
# the apt half via the naros-tier-agent meta-package (build-essential, python3, the modern
# CLI kit, nucleic-bridge), Node 22 (NodeSource), rustup + stable Rust, Go, mise, warm
# shared caches under /opt/cache, the `agent` user, the agent CLIs, and Playwright with a
# bundled Chromium. It replaces containers/nucleic-sandbox (last tag: v7) as the default
# sandbox image — keep `ProjectSandbox.defaultImage` (Sources/NucleicCore/Project.swift)
# pinned to a published tag of THIS image.
#
# Build via build.sh (assembles the context: this directory + the dist/pool apt pool).
# NOTE: every RUN here executes under nash — /bin/sh is already diverted in naros-base.
# That is deliberate dogfood (this build is part of nash's M3 validation surface); nash's
# parse-failure fallback re-execs the preserved /usr/bin/bash.real, so a nash regression
# degrades loudly in CI rather than silently corrupting the image.
ARG BASE=naros-base:build
FROM ${BASE}
# arm64 | amd64, set by buildx (or build.sh). Toolchain pins live here — bump them
# together with os/VERSION so a release records one coherent toolchain set (§6.2); the
# versions actually installed are recorded in /etc/naros/manifest.json either way.
ARG TARGETARCH
ARG NODE_MAJOR=22
ARG GO_VERSION=1.26.5
ARG GO_SHA256_AMD64=5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053
ARG GO_SHA256_ARM64=fe4789e92b1f33358680864bbe8704289e7bb5fc207d80623c308935bd696d49
ARG MISE_VERSION=v2026.7.7
ARG MISE_SHA256_AMD64=0953810c2785eb4a75159f67f8b5721c4f3c80b8a6a812015d5af7d7fbd1b8a4
ARG MISE_SHA256_ARM64=c4e542b53a15d2ec641e072f7b2d9da8a0554b92fd2c09a51febde32c6080ab8
# Warm shared caches at fixed world-readable paths (§6.3). Exported here so the BUILD's
# own installs warm them; the runtime equivalent for agent shells is
# /etc/profile.d/naros-env.sh (ContainerEngine ignores OCI env — nash -l sources
# profile.d), and `naros info` records them in the manifest.
ENV npm_config_cache=/opt/cache/npm \
PIP_CACHE_DIR=/opt/cache/pip \
UV_CACHE_DIR=/opt/cache/uv \
CARGO_HOME=/opt/cache/cargo \
RUSTUP_HOME=/opt/rustup \
GOMODCACHE=/opt/cache/gomod \
PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers \
PATH=/opt/mise/shims:/opt/cache/cargo/bin:/usr/local/go/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
# The local apt pool (CI's just-built Nucleic packages) rides in only for this stage's
# installs; the hosted signed repo (naros-keyring, already in the base) is the runtime
# channel. The Debian half still resolves from the snapshot mirror already present in
# /etc/apt/sources.list — same pin, same bytes, as the base build.
COPY pool /tmp/naros-pool
# Node 22 first (NodeSource — narOS no longer inherits node from a base image): its
# `nodejs` deb must be the one satisfying nucleic-bridge's Depends before the tier meta
# resolves, or apt would pull trixie's older nodejs alongside it.
RUN set -eu; \
echo "deb [trusted=yes] copy:///tmp/naros-pool ./" > /etc/apt/sources.list.d/naros-pool.list; \
mkdir -p -m 755 /etc/apt/keyrings; \
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
-o /etc/apt/keyrings/nodesource.asc; \
echo "deb [signed-by=/etc/apt/keyrings/nodesource.asc] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list; \
apt-get update; \
apt-get install -y --no-install-recommends nodejs; \
apt-get install -y --no-install-recommends naros-tier-agent; \
rm -rf /var/lib/apt/lists/* /etc/apt/sources.list.d/naros-pool.list /tmp/naros-pool; \
node --version | grep -q "^v${NODE_MAJOR}\."; \
# Debian ships fd as fdfind; agents reach for `fd`.
ln -sf /usr/bin/fdfind /usr/local/bin/fd
# Rust (rustup + stable, minimal profile) and Go (upstream tarball, pinned + checksummed).
# CARGO_HOME doubles as the shared cargo cache/bin dir; RUSTUP_HOME holds toolchains.
# Everything world-readable so the non-root agent uses them in place (§6.1: no /root
# permission hacks — system paths or /opt by convention).
RUN set -eu; \
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal \
--default-toolchain stable --no-modify-path; \
"$CARGO_HOME/bin/rustc" --version; \
case "$TARGETARCH" in \
amd64) go_sha="$GO_SHA256_AMD64";; \
arm64) go_sha="$GO_SHA256_ARM64";; \
*) echo "unsupported TARGETARCH: $TARGETARCH" >&2; exit 1;; \
esac; \
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${TARGETARCH}.tar.gz" -o /tmp/go.tgz; \
echo "$go_sha /tmp/go.tgz" | sha256sum -c -; \
tar -C /usr/local -xzf /tmp/go.tgz; rm /tmp/go.tgz; \
go version
# mise — the on-demand toolchain manager ("get me Java 21" without apt archaeology).
# System-wide: binary in /usr/local/bin, data/shims under /opt/mise (world-writable like
# the caches — per-session rootfs clones make that private copy-on-write state).
RUN set -eu; \
case "$TARGETARCH" in \
amd64) mise_arch=x64; mise_sha="$MISE_SHA256_AMD64";; \
arm64) mise_arch=arm64; mise_sha="$MISE_SHA256_ARM64";; \
esac; \
curl -fsSL "https://github.com/jdx/mise/releases/download/${MISE_VERSION}/mise-${MISE_VERSION}-linux-${mise_arch}.tar.gz" \
-o /tmp/mise.tgz; \
echo "$mise_sha /tmp/mise.tgz" | sha256sum -c -; \
tar -C /tmp -xzf /tmp/mise.tgz; \
install -m 0755 /tmp/mise/bin/mise /usr/local/bin/mise; \
rm -rf /tmp/mise /tmp/mise.tgz; \
mkdir -p /opt/mise/shims; \
MISE_DATA_DIR=/opt/mise mise --version
# Runtime environment for agent shells: cache paths, toolchain PATH entries, mise
# activation. nash -l sources /etc/profile.d, which is how every containerized exec
# (ContainerEngine exec → nash -lc) sees this without OCI env.
COPY files/etc/profile.d/naros-env.sh /etc/profile.d/naros-env.sh
# The `agent` user (§6.1): fixed uid 501 — the uid ContainerEngine execs as on a
# default single-user Mac (getuid() of the first macOS user), so bind-mount ownership
# and the baked passwd entry agree without seeding. Login shell nash; passwordless sudo
# via a drop-in mirroring the Linux VM guest. (Asserted in CI smoke + a Swift test
# against ContainerSpec.narosAgentUID — keep the three in lockstep.)
COPY files/etc/sudoers.d/naros-agent /etc/sudoers.d/naros-agent
RUN set -eu; \
useradd --uid 501 --user-group --create-home --home-dir /home/agent \
--shell /usr/local/bin/nash agent; \
chmod 0440 /etc/sudoers.d/naros-agent; \
visudo -cf /etc/sudoers.d/naros-agent; \
chmod 0755 /home/agent
# Agent CLIs, pinned @latest at build so every rebuild ships the current releases (and
# the models they unlock); they update in place via ContainerManager.updateAgentCLIs
# between rebuilds. Deliberately npm/vendor installs, not debs (NAROS.md §3.1). The npm
# installs run with the shared cache env above, warming /opt/cache/npm as a side effect.
RUN npm install -g @anthropic-ai/claude-code@latest \
&& npm install -g @openai/codex@latest \
# yarn: the node:22 base image shipped it, so agents (and repos' packageManager
# fields) expect it — parity-sweep guards it.
&& npm install -g yarn \
&& claude --version && codex --version && yarn --version
# xAI Grok Build via its official installer, relocated from the 0700 /root to a
# world-traversable /opt and relinked onto PATH (same dance as nucleic-sandbox v4+ —
# narOS keeps agent-reachable installs out of /root by convention).
RUN curl -fsSL https://x.ai/cli/install.sh | bash \
&& rm -f /usr/local/bin/grok /usr/local/bin/agent \
&& mv /root/.grok /opt/grok \
&& chmod -R a+rX /opt/grok \
&& ln -s /opt/grok/bin/grok /usr/local/bin/grok \
&& ln -s /opt/grok/bin/agent /usr/local/bin/agent \
&& /opt/grok/bin/grok --version
# Headless browser tool — Playwright + bundled Chromium at the fixed world-readable
# path, exactly as in nucleic-sandbox v7: any uid can launch it, and an agent's local
# `npm install playwright` / `pip install playwright` reuses the browsers instead of
# re-downloading. `--with-deps` apt-installs Chromium's shared libraries (root here).
RUN npm install -g playwright@latest \
&& mkdir -p /opt/playwright-browsers \
&& playwright install --with-deps chromium \
&& chmod -R a+rX /opt/playwright-browsers \
&& rm -rf /var/lib/apt/lists/* \
&& playwright --version
# Warm the cargo sparse-index + a first crate into the shared cache (network-light
# `cargo add serde` for agents), then open the caches to every uid: world-writable is
# safe here because each session's container gets its own copy-on-write rootfs clone.
RUN set -eu; \
tmp="$(mktemp -d)"; cd "$tmp"; \
cargo init -q --name warmcache .; \
cargo add -q serde >/dev/null 2>&1; \
cargo fetch -q; \
cd /; rm -rf "$tmp"; \
mkdir -p /opt/cache/npm /opt/cache/pip /opt/cache/uv /opt/cache/gomod; \
chmod -R a+rwX /opt/cache /opt/mise; \
chmod -R a+rX /opt/rustup /usr/local/go
# Stamp the tier: os-release VARIANT and the §6.3 capability manifest (tier, every baked
# toolchain + version, cache paths) so `naros info` / the host probe read one file
# instead of probing binary-by-binary.
RUN set -eu; \
sed -i -e 's/^VARIANT=.*/VARIANT="agent"/' -e 's/^VARIANT_ID=.*/VARIANT_ID=agent/' /etc/os-release; \
node_v="$(node --version)"; \
python_v="$(python3 -c 'import platform; print(platform.python_version())')"; \
rust_v="$(rustc --version | awk '{print $2}')"; \
go_v="$(go version | awk '{print $3}')"; \
mise_v="$(mise --version 2>/dev/null | awk '{print $1}')"; \
gcc_v="$(gcc -dumpfullversion)"; \
pw_v="$(playwright --version | awk '{print $2}')"; \
jq --arg node "$node_v" --arg python "$python_v" --arg rust "$rust_v" \
--arg go "$go_v" --arg mise "$mise_v" --arg gcc "$gcc_v" --arg playwright "$pw_v" \
'.tier = "agent"
| .toolchains = {node: $node, python: $python, rust: $rust, go: $go, mise: $mise, gcc: $gcc}
| .caches = {npm: "/opt/cache/npm", pip: "/opt/cache/pip", uv: "/opt/cache/uv",
cargo: "/opt/cache/cargo", gomod: "/opt/cache/gomod"}
| .playwright = {version: $playwright, browsers: "/opt/playwright-browsers", chromium: true}' \
/etc/naros/manifest.json > /etc/naros/manifest.json.new; \
mv /etc/naros/manifest.json.new /etc/naros/manifest.json; \
naros info --json | jq -e '.tier == "agent" and .toolchains.node != null' > /dev/null
WORKDIR /workspace
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Build the naros-agent OCI image FROM an imported naros-base image (NAROS.md §4, N2).
#
# build.sh <arch> --base IMAGE[:TAG] [--tag IMAGE:TAG] [--pool DIR]
#
# Assembles a build context (this directory + the Nucleic apt pool) so the Docker build
# context never drags in os/dist's rootfs tars, then runs a plain `docker build` for the
# requested platform. CI calls this per arch in the rootfs matrix, right after importing
# the base tar it just built; locally any imported naros-base works:
#
# docker import os/dist/naros-base-<ver>-arm64.tar naros-base:local
# os/images/agent/build.sh arm64 --base naros-base:local
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
OS_DIR="$(cd "$HERE/../.." && pwd)"
ARCH="${1:?usage: build.sh <arch> --base IMAGE [--tag IMAGE:TAG] [--pool DIR]}"
shift
BASE="" TAG="naros-agent:build-$ARCH" POOL="$OS_DIR/dist/pool"
while [ $# -gt 0 ]; do
case "$1" in
--base) BASE="$2"; shift 2 ;;
--tag) TAG="$2"; shift 2 ;;
--pool) POOL="$(cd "$2" && pwd)"; shift 2 ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done
[ -n "$BASE" ] || { echo "--base IMAGE required (an imported naros-base)" >&2; exit 2; }
[ -d "$POOL" ] || { echo "no apt pool at $POOL — run packages/build-all.sh first" >&2; exit 2; }
# The pool needs a flat-repo index for the in-build [trusted=yes] copy:// source; keep it
# fresh the same way build-rootfs.sh does.
if [ ! -f "$POOL/Packages" ] || [ -n "$(find "$POOL" -name '*.deb' -newer "$POOL/Packages" 2>/dev/null)" ]; then
(cd "$POOL" && dpkg-scanpackages --multiversion . > Packages)
fi
CTX="$(mktemp -d)"
trap 'rm -rf "$CTX"' EXIT
cp -a "$HERE/Dockerfile" "$HERE/files" "$CTX/"
mkdir -p "$CTX/pool"
cp -a "$POOL/." "$CTX/pool/"
DOCKER_BUILDKIT=1 docker build \
--platform "linux/$ARCH" \
--build-arg "BASE=$BASE" \
--build-arg "TARGETARCH=$ARCH" \
-t "$TAG" \
"$CTX"
echo "built $TAG (linux/$ARCH FROM $BASE)"
@@ -0,0 +1,26 @@
# narOS agent-tier environment (NAROS.md §6.3), sourced by nash -l for every
# containerized exec (ContainerEngine ignores OCI image env, so this file — not the
# Dockerfile ENV — is what agent shells actually see).
#
# Warm shared caches at fixed world-readable paths. Pre-seeded at image build; the
# per-session copy-on-write rootfs clone makes them private per container.
export npm_config_cache=/opt/cache/npm
export PIP_CACHE_DIR=/opt/cache/pip
export UV_CACHE_DIR=/opt/cache/uv
export CARGO_HOME=/opt/cache/cargo
export RUSTUP_HOME=/opt/rustup
export GOMODCACHE=/opt/cache/gomod
export PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers
# mise (§6.3): shims-on-PATH activation — works for non-interactive `nash -lc` scripts,
# where the interactive `mise activate` hook would not run.
export MISE_DATA_DIR=/opt/mise
# Baked toolchains (§6.2): cargo/rustup bins, Go, and the mise shims ahead of them all
# so `mise use` versions win per-project. Idempotent (guarded) — profile.d can be
# sourced more than once per session.
case ":$PATH:" in
*:/opt/mise/shims:*) ;;
*) PATH="/opt/mise/shims:/opt/cache/cargo/bin:/usr/local/go/bin:$PATH" ;;
esac
export PATH
@@ -0,0 +1,3 @@
# narOS agent user (NAROS.md §6.1): frictionless escalation for `apt install` etc.,
# mirroring the Linux VM guest's drop-in. The container is the isolation boundary.
agent ALL=(ALL) NOPASSWD:ALL
+6
View File
@@ -6,6 +6,12 @@
set -eu
R="$1"
# mmdebstrap writes every build source into the target's sources.list — including the
# CI-local [trusted=yes] copy:// pool, which doesn't exist at runtime and would fail
# every `apt update` in a running container. Keep only the real mirrors (the pinned
# snapshot); the hosted Nucleic repo arrives via naros-keyring's sources.list.d entry.
sed -i '\#copy://#d' "$R/etc/apt/sources.list"
rm -f "$R/etc/os-release"
cat > "$R/etc/os-release" <<EOF
NAME="narOS"
-3
View File
@@ -1,3 +0,0 @@
# naros-agent tier (NAROS.md §4, milestone N2 — placeholder until the agent tier lands).
# Will carry: build-essential/pkg-config, python3 + pip/venv, the modern CLI kit, and the
# hooks that add Node (NodeSource), rustup, Go, mise, warm caches, agent CLIs, Playwright.
+1 -1
View File
@@ -4,7 +4,7 @@ Architecture: all
Maintainer: Nucleic <[email protected]>
Section: metapackages
Priority: optional
Depends: naros-tier-base, nucleic-bridge, build-essential, pkg-config, python3, python3-pip, python3-venv, ripgrep, fd-find, jq, sqlite3, htop, tree, zip, unzip, zstd, xz-utils, moreutils, rsync, less, procps, file, bsdextrautils
Depends: naros-tier-base, nucleic-bridge, build-essential, pkg-config, python3, python3-pip, python3-venv, sudo, gh, ripgrep, fd-find, jq, yq, sqlite3, htop, tree, zip, unzip, zstd, xz-utils, moreutils, rsync, less, procps, file, bsdextrautils
Description: narOS agent tier — apt-resolvable half (NAROS.md §4, §6)
The dev toolchain and modern CLI kit that come from Debian, plus the control
bridge. The non-apt half of the agent tier — Node (NodeSource), rustup, Go,
+13
View File
@@ -0,0 +1,13 @@
# Parity-sweep allowlist (os/tests/parity-sweep.sh): names present in the reference
# image (nucleic-sandbox:v7, FROM node:22-bookworm-slim) that naros-agent deliberately
# does not carry. One name per line; every entry needs a "why".
# node:22 image plumbing, not a tool agents use.
docker-entrypoint.sh
# The node:22 image ships node under /usr/local with npx/corepack symlinked there;
# NodeSource's deb provides the same commands at /usr/bin — same names, so only
# image-internal helper names should ever land here, not node/npm/npx/corepack.
# bookworm→trixie package renames/drops surface here as they are DISCOVERED and
# understood in CI — do not pre-seed guesses.
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# Tool-inventory parity sweep (NAROS.md §10.3): nothing agents rely on may silently
# vanish when the default sandbox image moves from nucleic-sandbox:v7 to naros-agent.
#
# parity-sweep.sh <candidate-image> <reference-image> [--allow FILE]
#
# Compares (a) the set of executables on PATH and (b) the npm global package set, in
# each image. Anything present in the REFERENCE but missing from the CANDIDATE fails
# the sweep unless listed in the allowlist (deliberate, understood removals — one name
# per line, `#` comments). Additions are reported informationally. Python module parity
# is not swept: neither image bakes site-packages beyond pip's own.
#
# Exit: 0 parity holds, 1 unexplained removals, 2 usage/docker errors.
set -euo pipefail
CAND="${1:?usage: parity-sweep.sh <candidate-image> <reference-image> [--allow FILE]}"
REF="${2:?missing reference image}"
shift 2
ALLOW="$(cd "$(dirname "$0")" && pwd)/parity-allowlist.txt"
while [ $# -gt 0 ]; do
case "$1" in
--allow) ALLOW="$2"; shift 2 ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done
# Every executable name reachable on the image's default PATH. --entrypoint /bin/sh is
# nash in naros images and dash in the v7 base — the script is portable to both.
path_inventory() {
docker run --rm --entrypoint /bin/sh "$1" -c \
'for d in $(echo "$PATH" | tr : " "); do ls -1 "$d" 2>/dev/null; done | sort -u'
}
npm_inventory() {
docker run --rm --entrypoint /bin/sh "$1" -c \
'npm ls -g --depth=0 --parseable 2>/dev/null | tail -n +2' \
| awk -F/ 'NF { if ($(NF-1) ~ /^@/) print $(NF-1)"/"$NF; else print $NF }' | sort -u
}
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
echo "inventorying candidate $CAND …"
path_inventory "$CAND" > "$tmp/cand.path"
npm_inventory "$CAND" > "$tmp/cand.npm"
echo "inventorying reference $REF …"
path_inventory "$REF" > "$tmp/ref.path"
npm_inventory "$REF" > "$tmp/ref.npm"
allow_filter() {
if [ -f "$ALLOW" ]; then
grep -vE '^\s*(#|$)' "$ALLOW" | grep -vxF -f /dev/stdin "$1" || true
else
cat "$1"
fi
}
comm -23 "$tmp/ref.path" "$tmp/cand.path" > "$tmp/removed.path"
comm -23 "$tmp/ref.npm" "$tmp/cand.npm" > "$tmp/removed.npm"
added_path=$(comm -13 "$tmp/ref.path" "$tmp/cand.path" | wc -l)
cat "$tmp/removed.path" "$tmp/removed.npm" | sort -u > "$tmp/removed.all"
allow_filter "$tmp/removed.all" > "$tmp/unexplained" || true
echo "PATH: $(wc -l < "$tmp/ref.path") reference, $(wc -l < "$tmp/cand.path") candidate," \
"$(wc -l < "$tmp/removed.path") removed, $added_path added"
echo "npm globals removed: $(wc -l < "$tmp/removed.npm")"
if [ -s "$tmp/removed.all" ]; then
echo "--- removed (before allowlist) ---"
cat "$tmp/removed.all"
fi
if [ -s "$tmp/unexplained" ]; then
echo "PARITY FAILURE — present in $REF, missing from $CAND, not allowlisted:" >&2
cat "$tmp/unexplained" >&2
echo "(fix the image, or add to $ALLOW with a comment explaining the removal)" >&2
exit 1
fi
echo "parity OK"