From ad3b89f9a104879531abda381c8a061d59a22d43 Mon Sep 17 00:00:00 2001 From: Nucleic Date: Sat, 18 Jul 2026 19:00:14 -0700 Subject: [PATCH] Merge nucleic/eager-ancient-heron-p5em into dev --- README.md | 15 ++ images/agent/Dockerfile | 192 ++++++++++++++++++ images/agent/build.sh | 51 +++++ images/agent/files/etc/profile.d/naros-env.sh | 26 +++ images/agent/files/etc/sudoers.d/naros-agent | 3 + mkimage/hooks/00-identity.sh | 6 + mkimage/profiles/agent.pkgs | 3 - packages/naros-tier-agent/control | 2 +- tests/parity-allowlist.txt | 13 ++ tests/parity-sweep.sh | 79 +++++++ 10 files changed, 386 insertions(+), 4 deletions(-) create mode 100644 images/agent/Dockerfile create mode 100755 images/agent/build.sh create mode 100644 images/agent/files/etc/profile.d/naros-env.sh create mode 100644 images/agent/files/etc/sudoers.d/naros-agent delete mode 100644 mkimage/profiles/agent.pkgs create mode 100644 tests/parity-allowlist.txt create mode 100755 tests/parity-sweep.sh diff --git a/README.md b/README.md index eb51ef1..a121659 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,12 @@ packages/ repo/ publish.sh # dist/pool → apt tree (dists//…), 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--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, diff --git a/images/agent/Dockerfile b/images/agent/Dockerfile new file mode 100644 index 0000000..44e291f --- /dev/null +++ b/images/agent/Dockerfile @@ -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 diff --git a/images/agent/build.sh b/images/agent/build.sh new file mode 100755 index 0000000..f1bcadf --- /dev/null +++ b/images/agent/build.sh @@ -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 --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--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 --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)" diff --git a/images/agent/files/etc/profile.d/naros-env.sh b/images/agent/files/etc/profile.d/naros-env.sh new file mode 100644 index 0000000..2a4523d --- /dev/null +++ b/images/agent/files/etc/profile.d/naros-env.sh @@ -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 diff --git a/images/agent/files/etc/sudoers.d/naros-agent b/images/agent/files/etc/sudoers.d/naros-agent new file mode 100644 index 0000000..66ab387 --- /dev/null +++ b/images/agent/files/etc/sudoers.d/naros-agent @@ -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 diff --git a/mkimage/hooks/00-identity.sh b/mkimage/hooks/00-identity.sh index 4edde5d..138207d 100755 --- a/mkimage/hooks/00-identity.sh +++ b/mkimage/hooks/00-identity.sh @@ -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" < 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, diff --git a/tests/parity-allowlist.txt b/tests/parity-allowlist.txt new file mode 100644 index 0000000..2ce3fd4 --- /dev/null +++ b/tests/parity-allowlist.txt @@ -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. diff --git a/tests/parity-sweep.sh b/tests/parity-sweep.sh new file mode 100755 index 0000000..b06341c --- /dev/null +++ b/tests/parity-sweep.sh @@ -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 [--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 [--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"