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/ repo/
publish.sh # dist/pool → apt tree (dists/<channel>/…), signs when a key is present 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) 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 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) — Without `--pool`, the build produces a plain identity-only base (no Nucleic packages) —
useful for validating the mmdebstrap/snapshot/identity plumbing in isolation. 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 ## Versioning
`VERSION` + `SNAPSHOT` define a release (NAROS.md §8). Channels: `edge` (weekly CI, `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 set -eu
R="$1" 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" rm -f "$R/etc/os-release"
cat > "$R/etc/os-release" <<EOF cat > "$R/etc/os-release" <<EOF
NAME="narOS" 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]> Maintainer: Nucleic <[email protected]>
Section: metapackages Section: metapackages
Priority: optional 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) 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 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, 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"