From 1aba84c391b07ccc2c04890d91e20d9e2cbcf6fe Mon Sep 17 00:00:00 2001 From: Nikita Ivanov Date: Sun, 26 Apr 2026 14:55:24 +0000 Subject: [PATCH 1/2] feat(matrix): add Ubuntu 26.04 (Resolute Raccoon) to v0.3.0 supported targets AGE-11. Wires 26.04 LTS (released 2026-04-23, codename `resolute`) into the v0.3.0 plugin matrix end-to-end: - tests/docker/Dockerfile.ubuntu-26.04 (mirrors 24.04 sibling) - tests/docker/run.sh accepts ubuntu-26.04 - tests/qemu/cloud-images.txt + boot.sh codename map (resolute) - plugin/lib/distro_detect.sh + agentlinux-install --help - CI matrices: test.yml + nightly-qemu.yml + release.yml gates - README, PROJECT, REQUIREMENTS, CLAUDE.md, HARNESS.md copy refresh Empirical "installer green on 26.04" verification deferred to the next test.yml PR run + first nightly-qemu cycle. --- .github/workflows/nightly-qemu.yml | 18 +++--- .github/workflows/release.yml | 13 ++-- .github/workflows/test.yml | 8 ++- .planning/PROJECT.md | 2 +- CLAUDE.md | 2 +- README.md | 10 +-- docs/HARNESS.md | 3 +- plugin/bin/agentlinux-install | 2 +- plugin/lib/distro_detect.sh | 14 ++--- tests/docker/Dockerfile.ubuntu-26.04 | 91 ++++++++++++++++++++++++++++ tests/docker/run.sh | 4 +- tests/qemu/boot.sh | 3 +- tests/qemu/cloud-images.txt | 1 + 13 files changed, 135 insertions(+), 36 deletions(-) create mode 100644 tests/docker/Dockerfile.ubuntu-26.04 diff --git a/.github/workflows/nightly-qemu.yml b/.github/workflows/nightly-qemu.yml index 6c1495c..53a889e 100644 --- a/.github/workflows/nightly-qemu.yml +++ b/.github/workflows/nightly-qemu.yml @@ -1,9 +1,10 @@ name: nightly-qemu # Phase 6 Plan 06-03 Task 3. Nightly QEMU release-gate harness (TST-03). # Replaces the Phase 1 empty-guard scaffold with a real matrix run against -# both supported Ubuntu LTS images. Docker (test.yml) runs every PR; this +# every supported Ubuntu LTS image. Docker (test.yml) runs every PR; this # workflow runs nightly + workflow_dispatch and is the release-gate-adjacent -# "Docker-alone is disqualified" (ADR-007) signal. +# "Docker-alone is disqualified" (ADR-007) signal. AGE-11 added 26.04 +# ("Resolute Raccoon") on 2026-04-26. on: schedule: @@ -11,14 +12,15 @@ on: workflow_dispatch: inputs: ubuntu: - description: 'Ubuntu version(s) to run (22.04 / 24.04 / both)' + description: 'Ubuntu version(s) to run (22.04 / 24.04 / 26.04 / all)' required: false - default: both + default: all type: choice options: - - both + - all - '22.04' - '24.04' + - '26.04' permissions: contents: read @@ -31,17 +33,17 @@ jobs: strategy: fail-fast: false matrix: - ubuntu: ['22.04', '24.04'] + ubuntu: ['22.04', '24.04', '26.04'] steps: - uses: actions/checkout@v5 # Skip the matrix leg when workflow_dispatch targeted a single version. - # Cron runs (github.event_name == 'schedule') always exercise both. + # Cron runs (github.event_name == 'schedule') always exercise all arms. - name: Decide whether to run this matrix leg id: decide run: | dispatched='${{ github.event.inputs.ubuntu }}' - if [[ -z "$dispatched" || "$dispatched" == 'both' ]]; then + if [[ -z "$dispatched" || "$dispatched" == 'all' || "$dispatched" == 'both' ]]; then echo "run=true" >> "$GITHUB_OUTPUT" elif [[ "$dispatched" == '${{ matrix.ubuntu }}' ]]; then echo "run=true" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b46d45b..63c0318 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,11 +118,12 @@ jobs: shell: bash run: pnpm exec node --test dist-test/test/*.test.js - # Gate 2 — Docker matrix. Runs tests/docker/run.sh on both Ubuntu LTS - # images. The Docker harness executes the full tests/bats/ suite INCLUDING + # Gate 2 — Docker matrix. Runs tests/docker/run.sh on every supported Ubuntu + # LTS image. The Docker harness executes the full tests/bats/ suite INCLUDING # tests/bats/51-agt02-release-gate.bats — this satisfies TST-05's - # "AGT-02 inside Docker" half. fail-fast:false so a 22.04 red still shows - # the 24.04 signal (and vice versa) for faster triage. + # "AGT-02 inside Docker" half. fail-fast:false so any arm failing still + # shows the others' signal for faster triage. AGE-11 added 26.04 on + # 2026-04-26. gate-2-docker: needs: gate-1-precommit runs-on: ubuntu-24.04 @@ -130,7 +131,7 @@ jobs: strategy: fail-fast: false matrix: - ubuntu: [ubuntu-22.04, ubuntu-24.04] + ubuntu: [ubuntu-22.04, ubuntu-24.04, ubuntu-26.04] steps: - uses: actions/checkout@v5 - name: Docker bats matrix (incl. 51-*.bats — TST-05 inside Docker) @@ -147,7 +148,7 @@ jobs: strategy: fail-fast: false matrix: - ubuntu: ['22.04', '24.04'] + ubuntu: ['22.04', '24.04', '26.04'] steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a506447..c119002 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -118,14 +118,16 @@ jobs: # systemctl is-system-running wait is the usual long-tail; this caps it. timeout-minutes: 15 strategy: - # Phase 2 acceptance matrix: BOTH supported Ubuntu LTS versions run on - # every PR. fail-fast=false so 22.04 failure still reports 24.04 (and - # vice versa) — Phase 6 release gate needs to see both arms. + # Phase 2 acceptance matrix: every supported Ubuntu LTS version runs on + # every PR. fail-fast=false so a single arm failure still reports the + # others — Phase 6 release gate needs to see all arms. AGE-11 added + # 26.04 ("Resolute Raccoon") on 2026-04-26. fail-fast: false matrix: ubuntu: - ubuntu-22.04 - ubuntu-24.04 + - ubuntu-26.04 steps: - uses: actions/checkout@v5 # Empty-plugin guard from Phase 1: still useful. After Phase 2 lands the diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 612e8f8..52d2cdb 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -123,7 +123,7 @@ Known minor issue: OG image (SVG format) doesn't render on all social platforms ## Constraints -- **Target OS (v0.3.0):** Ubuntu (LTS). Fedora/CentOS/Alma/Arch deferred. +- **Target OS (v0.3.0):** Ubuntu LTS — 22.04 (Jammy), 24.04 (Noble), 26.04 (Resolute Raccoon, added 2026-04-26 per AGE-11). Fedora/CentOS/Alma/Arch deferred. - **Install UX:** one-command from user perspective; internally may chain apt / curl / npm / fpm. - **Node.js ownership:** must be owned by the agent user or use a writable global prefix under the agent user's home — no `sudo npm install -g`. - **Test harness:** containerized or QEMU-based; no real-hardware / cloud-VM deploy step. diff --git a/CLAUDE.md b/CLAUDE.md index ecb4565..d51f9ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ Pivoted from custom distro (v0.2.0) on 2026-04-18. See steps, catalog, registry CLI in `plugin/cli/`) - `tests/bats/` — behavior-contract suite (BHV-XX / RT-XX / AGT-XX / CLI-XX / CAT-XX / INST-XX) - `tests/harness/` — harness meta-tests (Phase 1 acceptance gate) -- `tests/docker/` — fast CI harness (Ubuntu 22.04 + 24.04 matrix, every PR) +- `tests/docker/` — fast CI harness (Ubuntu 22.04 + 24.04 + 26.04 matrix, every PR) - `tests/qemu/` — release-gate harness (fresh cloud images, nightly + release) - `packaging/` — curl-pipe-bash installer + optional fpm .deb wrapper - `docs/` — reference documentation (`HARNESS.md`, `decisions/`, `research/`, `proposals/`, `reviews/`) diff --git a/README.md b/README.md index 772aa4d..692db70 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ sudo fights. Curated stable versions; explicit override with `agentlinux pin`. ## Install -One command on a clean Ubuntu 22.04 or 24.04 host (root/sudo required): +One command on a clean Ubuntu 22.04, 24.04 or 26.04 host (root/sudo required): ```bash curl -fsSL https://agentlinux.org/install.sh | sudo bash @@ -67,8 +67,8 @@ and the `nodejs` package as well. A re-run of the curl installer after ## Stability model AgentLinux ships *curated combos*: every catalog agent is pinned to an exact -version that we test together end-to-end (Docker × {22.04, 24.04} + QEMU × -{22.04, 24.04}) before each release. When you install an agent, you get the +version that we test together end-to-end (Docker × {22.04, 24.04, 26.04} + +QEMU × {22.04, 24.04, 26.04}) before each release. When you install an agent, you get the curated pin; when you want to run ahead of it, you can — `agentlinux upgrade` shows the 3-way divergence between installed, curated, and upstream latest, and `agentlinux pin` sets sticky overrides so power users are not re-nagged. @@ -95,9 +95,9 @@ catalog's curated choice. Precedent: Homebrew's `brew pin`. ## Requirements -- Ubuntu 22.04 LTS or 24.04 LTS (x86_64) +- Ubuntu 22.04 LTS, 24.04 LTS, or 26.04 LTS (x86_64) - root or sudo access for the one-time install -- `curl` preinstalled (stock on both releases) +- `curl` preinstalled (stock on all three releases) Not yet supported in v0.3.0: ARM64, Fedora/Alma/Rocky/Arch. Those are on the v0.4+ roadmap. See [.planning/REQUIREMENTS.md](.planning/REQUIREMENTS.md) for diff --git a/docs/HARNESS.md b/docs/HARNESS.md index f3a7806..caa4de6 100644 --- a/docs/HARNESS.md +++ b/docs/HARNESS.md @@ -66,6 +66,7 @@ agent-linux/ # Workspace root │ ├── docker/ # Fast CI harness — Dockerfile per Ubuntu version │ │ ├── Dockerfile.ubuntu-22.04 │ │ ├── Dockerfile.ubuntu-24.04 +│ │ ├── Dockerfile.ubuntu-26.04 │ │ └── run.sh # Orchestrates: build image → run installer → run bats │ └── qemu/ # Definitive release-gate harness — cloud-image VMs │ ├── boot.sh # Fresh Ubuntu cloud image → SSH → install → bats @@ -282,7 +283,7 @@ External systems that agents interact with during AgentLinux development. Compar | Playwright (browser-access tool for agents) | `npm view playwright` + Playwright docs | Full | — | | GSD npm package | Local GSD install + `npm view get-shit-done-cc` | Full | — | | Ubuntu cloud images (QEMU test harness) | Cloud-images.ubuntu.com download + QEMU local | Partial | **P1:** cache downloaded images, boot helper skill | -| Docker Hub (ubuntu:22.04, ubuntu:24.04) | `docker pull` via GH Actions | Full | — | +| Docker Hub (ubuntu:22.04, ubuntu:24.04, ubuntu:26.04) | `docker pull` via GH Actions | Full | — | | agentlinux.org (website + releases host) | GitHub Pages deploy via Actions | Full | — | | Context7 MCP (library docs lookup) | Configured via `.mcp.json` | Full | — | diff --git a/plugin/bin/agentlinux-install b/plugin/bin/agentlinux-install index 947da2b..bf60c89 100755 --- a/plugin/bin/agentlinux-install +++ b/plugin/bin/agentlinux-install @@ -37,7 +37,7 @@ Options: users may depend on Node.js, so this is NOT the default purge behavior. -The installer must run as root. Supported hosts: Ubuntu 22.04 and 24.04. +The installer must run as root. Supported hosts: Ubuntu 22.04, 24.04 and 26.04. Transcript: ${LOG_FILE} EOF } diff --git a/plugin/lib/distro_detect.sh b/plugin/lib/distro_detect.sh index d7f7855..57f8c79 100644 --- a/plugin/lib/distro_detect.sh +++ b/plugin/lib/distro_detect.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash # SPDX-License-Identifier: MIT -# plugin/lib/distro_detect.sh — Ubuntu 22.04 / 24.04 detection. +# plugin/lib/distro_detect.sh — Ubuntu 22.04 / 24.04 / 26.04 detection. # -# The installer refuses to run on anything other than Ubuntu 22.04 or 24.04 — -# future distros land in v0.4+ per ADR. `detect_distro` exports +# The installer refuses to run on anything other than Ubuntu 22.04, 24.04 or +# 26.04 — future distros land in v0.4+ per ADR. `detect_distro` exports # AGENTLINUX_DISTRO_VERSION for downstream provisioners that need to branch # (e.g. locale-gen no-op on C.UTF-8, Pitfall 5 in 02-RESEARCH.md). # @@ -24,8 +24,8 @@ fi # # Escape hatch: AGENTLINUX_SKIP_DISTRO_CHECK=1 bypasses validation and exports # AGENTLINUX_DISTRO_VERSION=unchecked. Intended ONLY for bats unit sourcing on -# dev hosts that are not themselves Ubuntu 22.04/24.04. Real installer runs -# MUST NOT set this. +# dev hosts that are not themselves Ubuntu 22.04/24.04/26.04. Real installer +# runs MUST NOT set this. detect_distro() { if [[ "${AGENTLINUX_SKIP_DISTRO_CHECK:-0}" == "1" ]]; then export AGENTLINUX_DISTRO_VERSION="unchecked" @@ -49,12 +49,12 @@ detect_distro() { fi case "${VERSION_ID:-}" in - 22.04 | 24.04) + 22.04 | 24.04 | 26.04) export AGENTLINUX_DISTRO_VERSION="$VERSION_ID" log_info "detected ubuntu ${VERSION_ID}" ;; *) - log_error "unsupported ubuntu version: ${VERSION_ID:-unset} (required: 22.04 or 24.04)" + log_error "unsupported ubuntu version: ${VERSION_ID:-unset} (required: 22.04, 24.04 or 26.04)" return 1 ;; esac diff --git a/tests/docker/Dockerfile.ubuntu-26.04 b/tests/docker/Dockerfile.ubuntu-26.04 new file mode 100644 index 0000000..900bc2b --- /dev/null +++ b/tests/docker/Dockerfile.ubuntu-26.04 @@ -0,0 +1,91 @@ +# tests/docker/Dockerfile.ubuntu-26.04 +# Systemd-capable Ubuntu 26.04 base for running the full BHV matrix in CI. +# Source: docs.docker.com, ADR-007, jrei/systemd-ubuntu, 02-RESEARCH.md §Pitfall 3. +# +# Invariants (identical to the 22.04 / 24.04 variants — only FROM differs): +# - PID 1 = systemd (CMD ["/sbin/init"]) so BHV-04 (systemd User=agent) is real, +# not vacuously skipped. Requires `--privileged --cgroupns=host` at runtime. +# - cron + openssh-server installed so BHV-02/BHV-03 helpers have a daemon to +# talk to (systemd units start them once PID 1 is up). +# - bats installed system-wide so `bats tests/bats/` works inside the container +# without a host-side install. +# - locales + C.UTF-8 update-locale seeded so BHV-01 locale checks fire against +# a real system configuration, not Docker's DEBIAN_FRONTEND env override. +# - sudo present so run_sudo_u / run_sudo_u_i helpers can exercise BHV-05. +# - --no-install-recommends keeps the image small (attack surface + pull time). +# +# 26.04 ("Resolute Raccoon") joined the v0.3.0 support matrix per AGE-11. +# Layout mirrors the 24.04 sibling — keep them byte-similar so a single Pitfall +# fix lands on every supported version. + +# --------------------------------------------------------------- +# Stage 1: cli-builder — compile plugin/cli/ via pnpm on node:22-slim. +# Identical to the 22.04 / 24.04 variants — see Dockerfile.ubuntu-24.04 for +# the rationale (Docker-build hermeticity, source-only cache key, prune --prod). +# --------------------------------------------------------------- +FROM node:22-slim AS cli-builder +WORKDIR /build/cli +COPY plugin/cli/package.json plugin/cli/pnpm-lock.yaml ./ +COPY plugin/cli/tsconfig.json ./ +COPY plugin/cli/src ./src +RUN corepack enable \ + && corepack prepare pnpm@latest --activate \ + && pnpm install --frozen-lockfile \ + && pnpm run build \ + && pnpm prune --prod + +# --------------------------------------------------------------- +# Stage 2: final test image — Ubuntu 26.04 + systemd-in-docker. +# --------------------------------------------------------------- +FROM ubuntu:26.04 + +ENV DEBIAN_FRONTEND=noninteractive \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 + +# Same package set as the 24.04 sibling — see Dockerfile.ubuntu-24.04 for the +# per-package rationale (dbus, jq, curl, python3, file, shellcheck). +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + systemd systemd-sysv \ + cron openssh-server \ + bats locales sudo \ + dbus \ + jq \ + curl \ + python3 \ + file \ + ca-certificates bash coreutils util-linux \ + shellcheck && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +# Systemd inside Docker: mask units that fight with containerized PID 1. +RUN rm -f /lib/systemd/system/multi-user.target.wants/* && \ + systemctl mask \ + systemd-logind.service \ + systemd-resolved.service \ + systemd-networkd.service \ + systemd-tmpfiles-setup.service \ + systemd-tmpfiles-clean.service \ + systemd-tmpfiles-clean.timer + +# SSH: generate host keys at build time (daemon is started later by systemd). +RUN mkdir -p /run/sshd && ssh-keygen -A + +# Locale: C.UTF-8 is a glibc built-in on 22.04+; locale-gen is a no-op but the +# update-locale write into /etc/default/locale is the real correctness step. +RUN locale-gen C.UTF-8 || true && update-locale LANG=C.UTF-8 LC_ALL=C.UTF-8 + +# Phase 4 Plan 04-06: copy the pre-built CLI dist/ + production-pruned +# node_modules/ + package.json from the builder stage. See the 24.04 sibling +# for the full rationale (run.sh splices these into the staged source tree). +COPY --from=cli-builder /build/cli/dist /opt/cli-prebuilt/dist +COPY --from=cli-builder /build/cli/node_modules /opt/cli-prebuilt/node_modules +COPY --from=cli-builder /build/cli/package.json /opt/cli-prebuilt/package.json + +# cgroup bind + SIGRTMIN+3 graceful-stop required by systemd-as-PID-1 recipe. +VOLUME /sys/fs/cgroup +STOPSIGNAL SIGRTMIN+3 + +# /sbin/init is systemd — Pitfall 3 mitigation. +CMD ["/sbin/init"] diff --git a/tests/docker/run.sh b/tests/docker/run.sh index 92b32bb..6b6a9d3 100755 --- a/tests/docker/run.sh +++ b/tests/docker/run.sh @@ -23,7 +23,7 @@ set -euo pipefail usage() { cat >&2 <<'EOF' -usage: tests/docker/run.sh +usage: tests/docker/run.sh Builds the matching Docker image, runs agentlinux-install inside, runs the bats suite inside, and exits with the bats exit code. @@ -45,7 +45,7 @@ if [[ -z $UBUNTU_VERSION ]]; then exit 64 fi case "$UBUNTU_VERSION" in - ubuntu-22.04 | ubuntu-24.04) ;; + ubuntu-22.04 | ubuntu-24.04 | ubuntu-26.04) ;; -h | --help) usage exit 0 diff --git a/tests/qemu/boot.sh b/tests/qemu/boot.sh index 2e7a890..b11fffe 100755 --- a/tests/qemu/boot.sh +++ b/tests/qemu/boot.sh @@ -49,7 +49,7 @@ set -euo pipefail # --------------------------------------------------------------------------- usage() { cat <<'EOF' -usage: tests/qemu/boot.sh <22.04|24.04> +usage: tests/qemu/boot.sh <22.04|24.04|26.04> Runs the AgentLinux QEMU release-gate harness against a fresh Ubuntu cloud image. Exits 0 on a fully green run (cloud-init seed → installer → bats), @@ -115,6 +115,7 @@ read -r _UV IMG_URL SHASUMS_URL <<<"$MANIFEST_LINE" case "$UBUNTU_VERSION" in 22.04) RELEASE=jammy ;; 24.04) RELEASE=noble ;; + 26.04) RELEASE=resolute ;; *) printf 'ERROR: unsupported Ubuntu version %q (no release codename mapping)\n' \ "$UBUNTU_ARG" >&2 diff --git a/tests/qemu/cloud-images.txt b/tests/qemu/cloud-images.txt index 4dcc129..9283942 100644 --- a/tests/qemu/cloud-images.txt +++ b/tests/qemu/cloud-images.txt @@ -14,3 +14,4 @@ # workflow + a Docker mirror) when a new LTS becomes a support target. 22.04 https://cloud-images.ubuntu.com/releases/jammy/release/ubuntu-22.04-server-cloudimg-amd64.img https://cloud-images.ubuntu.com/releases/jammy/release/SHA256SUMS 24.04 https://cloud-images.ubuntu.com/releases/noble/release/ubuntu-24.04-server-cloudimg-amd64.img https://cloud-images.ubuntu.com/releases/noble/release/SHA256SUMS +26.04 https://cloud-images.ubuntu.com/releases/resolute/release/ubuntu-26.04-server-cloudimg-amd64.img https://cloud-images.ubuntu.com/releases/resolute/release/SHA256SUMS From 11cfd2644cc3bd796b30113884689d1de6a06c75 Mon Sep 17 00:00:00 2001 From: Nikita Ivanov Date: Sat, 2 May 2026 13:10:56 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(26.04):=20unblock=20CI=20on=20Ubuntu=20?= =?UTF-8?q?26.04=20=E2=80=94=20uutils=20install=20+=20curl-installer=20all?= =?UTF-8?q?owlist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two blockers surfaced by the first 26.04 PR cycle (the third — Playwright's chromium-install rejecting ubuntu26.04 — is moot after master's #7 dogfood fix swapped the catalog from `playwright` (full + chromium) to `playwright-cli` (@playwright/cli, JS-only, no per-OS browser recipe)). 1. INST-02 / BHV-07 byte-stable re-run failed with "install: No such file or directory" only on the second installer pass. Diagnosed via strace: Ubuntu 26.04 ships uutils-coreutils 0.7.0 (Rust rewrite). Its `install` recursively readlink-chases /dev/stdin → /proc/self/fd/0 → "pipe:[NNN]" and ENOENTs whenever the destination already exists. The first run creates the file (succeeds); the idempotent re-run tries to overwrite the existing file (fails). GNU coreutils opens fd 0 directly and never hits this path. Fix: add a portable `write_file_atomic ` helper to plugin/lib/idempotency.sh — same atomic-rename semantics as `install -m /dev/stdin ` but via a same-directory tmpfile so it works on both GNU and uutils. Function-scoped RETURN trap mirrors ensure_marker_block's tmpfile cleanup pattern. Three call sites in plugin/provisioner/40-path-wiring.sh (profile.d, agentlinux.env, cron.d) migrated. The /dev/null source path is unaffected and stays. 2. INST-03 curl-installer fixture rejected 26.04 because packaging/curl-installer/install.sh has its own detect_ubuntu_version allowlist that the AGE-11 patch missed. Extended to 22.04|24.04|26.04 in lockstep with plugin/lib/distro_detect.sh; matching error message updated. Comment makes the lockstep invariant explicit. Verified locally on the rebased branch: tests/docker/run.sh ubuntu-{22.04,24.04,26.04} all PASS. Co-Authored-By: Claude Opus 4.7 (1M context) --- packaging/curl-installer/install.sh | 9 ++++-- plugin/lib/idempotency.sh | 44 ++++++++++++++++++++++++++++ plugin/provisioner/40-path-wiring.sh | 27 ++++++++++------- 3 files changed, 67 insertions(+), 13 deletions(-) diff --git a/packaging/curl-installer/install.sh b/packaging/curl-installer/install.sh index 38a8e40..6156781 100755 --- a/packaging/curl-installer/install.sh +++ b/packaging/curl-installer/install.sh @@ -66,8 +66,11 @@ check_root() { } # ------------------------------------------------------------------------------ -# Parse /etc/os-release; die unless it declares Ubuntu 22.04 or 24.04. +# Parse /etc/os-release; die unless it declares Ubuntu 22.04, 24.04 or 26.04. # Source: 06-RESEARCH.md lines 920-936 (Example: Ubuntu version detection). +# Keep this allowlist in lockstep with plugin/lib/distro_detect.sh — both gate +# the same support matrix and the curl-installer test fixture exercises this +# path before handing off to the staged installer. # ------------------------------------------------------------------------------ detect_ubuntu_version() { local id version @@ -81,9 +84,9 @@ detect_ubuntu_version() { [[ "$id" == "ubuntu" ]] \ || die "unsupported distro: ${id} (AgentLinux v0.3.0 supports Ubuntu only)" case "$version" in - 22.04 | 24.04) ;; + 22.04 | 24.04 | 26.04) ;; *) - die "unsupported Ubuntu version: ${version} (AgentLinux v0.3.0 supports 22.04 and 24.04 only)" + die "unsupported Ubuntu version: ${version} (AgentLinux v0.3.0 supports 22.04, 24.04 and 26.04 only)" ;; esac } diff --git a/plugin/lib/idempotency.sh b/plugin/lib/idempotency.sh index 779a646..1544ea5 100644 --- a/plugin/lib/idempotency.sh +++ b/plugin/lib/idempotency.sh @@ -17,6 +17,50 @@ if ! command -v log_error >/dev/null 2>&1; then return 1 2>/dev/null || exit 1 fi +# write_file_atomic +# Atomic full-file overwrite from stdin: write the heredoc/pipe body to a +# tmpfile in the same directory as , then install(1) it into place. +# Same semantics as `install -m /dev/stdin ` on GNU coreutils, +# but portable to uutils-coreutils 0.7.0 (the Rust rewrite shipped on Ubuntu +# 26.04). uutils' install recursively readlink-chases /dev/stdin → +# /proc/self/fd/0 → "pipe:[NNN]" and then tries to stat the synthetic pipe +# name as a path, ENOENTing with "install: No such file or directory" — but +# only when the destination ALREADY exists (first run succeeds, idempotent +# re-runs fail). Symptom diagnosed via strace on Ubuntu 26.04 during the +# AGE-11 supported-target rollout (see PR #5 / commit fixing INST-02 + BHV-07 +# byte-stability on uutils). Keep this helper in the lib so future calls +# cannot regress to /dev/stdin under set -e. +# +# Same-directory tmpfile placement keeps the install(1) rename atomic — a +# cross-filesystem rename would fall back to copy+unlink and lose atomicity. +# The tmpfile is hidden (leading dot) and unlinked unconditionally; on install +# failure the function returns non-zero so the caller's set -euo pipefail trap +# fires and the operator sees the underlying install diagnostic in the log. +write_file_atomic() { + if [[ $# -lt 2 ]]; then + log_error "write_file_atomic: missing arguments (usage: write_file_atomic )" + return 64 + fi + local mode=$1 dest=$2 + local dir base tmp rc=0 + dir=$(dirname -- "$dest") + base=$(basename -- "$dest") + tmp=$(mktemp -p "$dir" ".${base}.XXXXXX") + # shellcheck disable=SC2064 + # Expand $tmp at trap-install time (function-local var); resolving later + # would re-read a stale binding if the variable were reassigned. + # Symmetric with ensure_marker_block's RETURN trap — guarantees tmpfile + # cleanup on any exit path, including a `cat` that aborts mid-write under + # set -e (ENOSPC, SIGPIPE). + trap "rm -f -- '$tmp'" RETURN + cat >"$tmp" + install -m "$mode" "$tmp" "$dest" || rc=$? + if [[ $rc -ne 0 ]]; then + log_error "write_file_atomic: install -m ${mode} failed for ${dest} (rc=${rc})" + return "$rc" + fi +} + # ensure_line_in_file # Append to only if not already present (fixed-string # whole-line match). `-F` = literal string, `-x` = whole-line, `-q` = quiet, diff --git a/plugin/provisioner/40-path-wiring.sh b/plugin/provisioner/40-path-wiring.sh index a55106b..092f105 100644 --- a/plugin/provisioner/40-path-wiring.sh +++ b/plugin/provisioner/40-path-wiring.sh @@ -33,9 +33,11 @@ # # Invariants enforced by this file (the plan's acceptance greps cross-verify # the forbidden substrings literally do not appear anywhere in this source): -# - State mutation routes through `install -m 0644 /dev/stdin < < /dev/stdin `, but portable to uutils-coreutils +# on Ubuntu 26.04) or `ensure_marker_block` (partial edit on user-owned +# ~agent/.bashrc). No raw-append or in-place editing. # - Zero privilege-escalation configuration shipped: Phase 2 locks the # no-default-drop-in rule; future phases use visudo_validate + 0440. # - Zero wrapper shim pointing at an agent-owned binary from a root-owned @@ -59,12 +61,17 @@ ensure_dir /home/agent/.local/bin 0755 agent:agent # Sourced by /etc/profile on login shells. Re-source guard prevents double # PATH-prepend if the file is sourced twice in the same shell session # (T-02-09 mitigation + INST-02 idempotency contract). -# `install -m 0644 /dev/stdin` is an atomic full-file overwrite: re-runs of -# this provisioner produce byte-identical content (no env-variable -# substitution by the heredoc body since we use the 'PROFILE' single-quoted -# tag — `$PATH` etc. stay literal in the written file). +# `write_file_atomic` is the portable analogue of +# `install -m 0644 /dev/stdin ` — same atomic-rename semantics, but +# routed through a same-directory tmpfile so it works on uutils-coreutils 0.7.0 +# (the Rust `install` shipped on Ubuntu 26.04). uutils' install recursively +# readlink-chases /dev/stdin → /proc/self/fd/0 → "pipe:[NNN]" and ENOENTs +# whenever the destination already exists; the first run succeeds, the +# idempotent re-run fails. See plugin/lib/idempotency.sh `write_file_atomic` +# header for the full strace trail. The single-quoted heredoc tag keeps `$PATH` +# etc. literal in the written file. # --------------------------------------------------------------- -install -m 0644 /dev/stdin /etc/profile.d/agentlinux.sh <<'PROFILE' +write_file_atomic 0644 /etc/profile.d/agentlinux.sh <<'PROFILE' # AgentLinux login environment (generated by agentlinux-install). # Sourced by /etc/profile on interactive login shells AND `sudo -u agent -i`. # Re-source guard: bails on second source in the same shell session so that @@ -143,7 +150,7 @@ log_info "wrote agentlinux-path marker block to /home/agent/.bashrc (--top)" # split-brain avoidance; a future accidental divergence fails the plan's # cross-grep acceptance criterion. # --------------------------------------------------------------- -install -m 0644 /dev/stdin /etc/agentlinux.env <<'ENVFILE' +write_file_atomic 0644 /etc/agentlinux.env <<'ENVFILE' PATH=/home/agent/.npm-global/bin:/home/agent/.local/bin:/usr/local/bin:/usr/bin:/bin NPM_CONFIG_PREFIX=/home/agent/.npm-global LANG=C.UTF-8 @@ -160,7 +167,7 @@ log_info "wrote /etc/agentlinux.env (systemd EnvironmentFile + cron header templ # this PATH byte-identical to /etc/agentlinux.env — the plan's acceptance # criterion cross-greps both for the same literal string. # --------------------------------------------------------------- -install -m 0644 /dev/stdin /etc/cron.d/agentlinux <<'CRON' +write_file_atomic 0644 /etc/cron.d/agentlinux <<'CRON' # AgentLinux cron environment (generated by agentlinux-install). # Any agent cron job placed in this file inherits the PATH/locale below. # Phase 2 ships NO default jobs. Example shape (do NOT uncomment):