# 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 # `-c` 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. Scripts # piped on stdin do not cross that fallback and must choose their interpreter explicitly. 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; \ # The base bakes naros-keyring's hosted-repo entry (naros.sources) — the runtime # apt channel. Set it aside for the whole build (here, and under playwright # --with-deps below) so THIS stage installs only from the local pool + Node source # above: the freshly built naros-tier-agent must resolve from this run's pool, not a # same-version deb already published to the hosted channel. It also keeps the image # build independent of the hosted repo — the same reason the base rootfs smoke test # scopes sources.list.d away. Restored in the final stage so the image ships it. mv /etc/apt/sources.list.d/naros.sources /tmp/naros.sources.disabled 2>/dev/null || true; \ 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; \ # nash's parse fallback applies to `-c`, not scripts supplied on stdin. # Upstream installers are outside the dogfood surface, so run them with the # preserved Debian bash instead of feeding them to the diverted sh/bash. curl -fsSL https://sh.rustup.rs | /usr/bin/bash.real -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). As with rustup, the # stdin-fed upstream script needs the preserved real bash rather than diverted `bash`. RUN curl -fsSL https://x.ai/cli/install.sh | /usr/bin/bash.real \ && 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; \ # Restore the hosted runtime apt channel set aside at the start of the build. mv /tmp/naros.sources.disabled /etc/apt/sources.list.d/naros.sources 2>/dev/null || true; \ 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