From 018da28322047fc15aaa03579911c518f59bea29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?eW=C9=98yn?= <5607939+Llewellynvdm@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:37:14 +0200 Subject: [PATCH 1/2] Harden runtime security and portability --- .env.example | 5 + .github/workflows/validate.yaml | 14 +- CHANGELOG.md | 15 ++ Makefile | 7 +- README.md | 13 ++ docs/gitea-packages.md | 10 ++ docs/host-and-network-safety.md | 31 ++++ docs/known-limitations.md | 10 +- docs/quick-start.md | 19 ++- host/systemd/vdm-opencode-reaper.service.in | 29 +++- .../usr/local/libexec/vdm-opencode-session | 5 +- image/provision/browser.sh | 6 +- image/provision/common.sh | 41 +++++- image/provision/finalize.sh | 2 +- image/provision/joomla-mcp.sh | 2 +- image/provision/python.sh | 22 +-- manifest/mcp-catalog.yaml | 2 + manifest/platform.env | 2 +- manifest/providers.yaml | 10 ++ scripts/bootstrap-host.sh | 23 +++ scripts/build-image.sh | 6 +- scripts/configure-models.sh | 9 +- scripts/install-host-units.sh | 51 ++++++- scripts/launch-vm.sh | 59 ++++++-- scripts/lib/common.sh | 66 ++++++++- scripts/lib/joomla-mcp.sh | 2 +- scripts/list-vms.sh | 34 +++-- scripts/mcp-toggle.sh | 13 +- scripts/publish-gitea-package.sh | 139 +++++++++++++++--- scripts/reap-expired-vms.sh | 7 +- scripts/start-session.sh | 1 + scripts/stop-session.sh | 8 +- scripts/test-joomla-mcp.sh | 1 + scripts/verify-image.sh | 39 +++-- tests/build-resources.sh | 15 +- tests/fixtures/gitea/cosign | 34 +++++ tests/fixtures/gitea/curl | 85 +++++++++++ tests/fixtures/gitea/zstd | 24 +++ tests/fixtures/session/incus | 16 +- tests/guest-smoke.sh | 9 -- tests/publish-idempotency.sh | 54 +++++++ tests/runtime-regressions.sh | 81 ++++++++++ tests/security-image.sh | 13 -- tests/security-static.py | 23 +++ tests/systemd-units.sh | 26 ++++ tests/test_authorities.py | 92 ++++++++++++ tests/validate-repository.sh | 6 +- 47 files changed, 1035 insertions(+), 146 deletions(-) create mode 100755 tests/fixtures/gitea/cosign create mode 100755 tests/fixtures/gitea/curl create mode 100755 tests/fixtures/gitea/zstd delete mode 100755 tests/guest-smoke.sh create mode 100755 tests/publish-idempotency.sh create mode 100755 tests/runtime-regressions.sh delete mode 100755 tests/security-image.sh create mode 100755 tests/systemd-units.sh create mode 100644 tests/test_authorities.py diff --git a/.env.example b/.env.example index c46995f..33aa969 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,11 @@ GITHUB_BASE_URL=https://github.com # Leave empty to use the Incus default storage pool. INCUS_STORAGE_POOL= +# Guest account identity. Keep the defaults unless every image will be rebuilt. +# Custom homes must be absolute paths below /home or /srv. +AGENT_USER=opencode +AGENT_HOME=/home/opencode + # Optional build disk floor. Zero uses the variant-specific manifest requirement. MIN_BUILD_FREE_GIB=0 diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index fb7ae75..e39b4ef 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -16,7 +16,11 @@ concurrency: jobs: validate: - runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, ubuntu-26.04] + runs-on: ${{ matrix.os }} timeout-minutes: 10 steps: - name: Check out repository @@ -36,9 +40,9 @@ jobs: - name: Upload failed validation log if: failure() && steps.validate.outcome == 'failure' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v4.6.2 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: - name: validation-log + name: validation-log-${{ matrix.os }} path: validation.log if-no-files-found: error retention-days: 7 @@ -52,9 +56,9 @@ jobs: - name: Upload failed package round-trip log if: failure() && steps.package-roundtrip.outcome == 'failure' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v4.6.2 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: - name: package-roundtrip-log + name: package-roundtrip-log-${{ matrix.os }} path: package-roundtrip.log if-no-files-found: error retention-days: 7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 76f4f63..5060660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## Unreleased + +- Made VM launch metadata atomic, safe when `USER` is absent, and self-cleaning + when a newly created instance cannot start. +- Replaced the checkout-based root timer target with a root-owned installed + runtime and tightened the systemd service sandbox. +- Replaced implicit Incus sudo fallback with explicit privilege selection and + bootstrap-managed `incus-admin` onboarding. +- Made Gitea publication retries validate an existing semantic completion + marker and derive release time from the immutable package manifest. +- Fixed guest runtime cleanup, minimal-host VM listing, configurable agent + home/user support, and browser-MCP image invariants. +- Added executing runtime and publication regressions, semantic authority + validation, and a local `make ci` target. + ## 0.3.0-rc.3 - 2026-07-29 - Separated adaptive image-build resources from launch-time runtime sizes, diff --git a/Makefile b/Makefile index f354b62..e3567c9 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,11 @@ SHELL := /usr/bin/env bash -.PHONY: help validate generate bootstrap apply-incus build build-all local-release +.PHONY: help validate ci generate bootstrap apply-incus build build-all local-release help: @printf '%s\n' \ 'make validate Validate repository syntax, generated state and security contracts' \ + 'make ci Run the same repository and package checks as pull-request CI' \ 'make generate Regenerate all derived platform files from manifest/images.yaml' \ 'make bootstrap Install host prerequisites, Incus and the TTL reaper' \ 'make apply-incus Apply restricted project, networks, ACLs and profiles' \ @@ -15,6 +16,10 @@ help: validate: ./tests/validate-repository.sh +ci: + ./tests/validate-repository.sh + ./tests/package-roundtrip.sh + generate: python3 ./scripts/generate-platform.py --write diff --git a/README.md b/README.md index 292219e..a16b058 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,13 @@ changes the coreutils provider, or alters Docker firewall rules. An Incus upgrade is refused while instances are running unless the operator explicitly schedules and acknowledges that downtime. +The host workflow targets Ubuntu 24.04 LTS and Ubuntu 26.04 LTS on both server +and desktop installations. Minimal installations are supported: cosmetic +formatters such as `column` are optional. The guest images intentionally remain +based on Ubuntu 24.04 LTS so host upgrades do not silently change the generated +runtime. Run `make ci` before bootstrap to execute repository, runtime-mock and +package round-trip checks. + Image builds use manifest-owned build limits, not the larger launch-time VM sizes. The preflight reserves at least 20% of host memory, ignores swap as a capacity source, leaves a host CPU available, and creates nothing when current @@ -41,6 +48,12 @@ checkpoints with diagnostics under `build/diagnostics/`. Verified APT, npm, pip and Playwright downloads are reused from a managed cache that is detached before an image is published. +Bootstrap installs the expiry reaper beneath `/usr/local/libexec` as root-owned +code. The root service never executes scripts from the Git checkout. Normal +commands require direct Incus access; bootstrap adds the invoking operator to +`incus-admin` when necessary and requires a new login before unprivileged use. +Treat that group as root-equivalent. + ## Configure Joomla MCP Configure one fixed public HTTPS Joomla origin. The default profile is read-only: diff --git a/docs/gitea-packages.md b/docs/gitea-packages.md index cafe46d..20d1e65 100644 --- a/docs/gitea-packages.md +++ b/docs/gitea-packages.md @@ -22,6 +22,16 @@ export GITEA_PACKAGE_TOKEN='runtime-only-token' The publisher token needs package write permission only. Colleagues should receive read-only package access. +Publication is safe to retry. The release entry uses `manifest.json`'s +`packaged_at` value, the bundle is verified by downloading it again, and an +existing completion marker is extracted with a strict member allow-list before +its release entry is compared. A valid existing marker completes the retry +without regenerating a potentially non-deterministic signature bundle. + +In release mode, retries of an existing signed marker also require +`COSIGN_PUBLIC_KEY` so the downloaded marker can be verified +cryptographically. First publication continues to require `COSIGN_KEY`. + Tagged GitHub builds can publish the same package set to Gitea when repository variable `GITEA_PUBLISH_ENABLED=true`, variable `GITEA_BASE_URL`, owner/user variables and secret `GITEA_PACKAGE_TOKEN` are configured. Gitea tag builds publish directly through `.gitea/workflows/build-images.yaml`. diff --git a/docs/host-and-network-safety.md b/docs/host-and-network-safety.md index c60b7e8..86c096f 100644 --- a/docs/host-and-network-safety.md +++ b/docs/host-and-network-safety.md @@ -18,6 +18,11 @@ packages; Zabbly also provides supported current packages for listed distributions. Selecting or changing that source is an operator-controlled host policy, not something this repository automates. +The acceptance baseline is Ubuntu 24.04 LTS and Ubuntu 26.04 LTS, for server and +desktop installations. Debian-family detection remains available, but every +production promotion must still pass the repository's real KVM/Incus acceptance +matrix on the exact host release. + Before APT changes anything, bootstrap verifies: ```bash @@ -37,6 +42,32 @@ The platform never installs or removes `gnu-coreutils`, `coreutils-from-uutils`, or an alternative `dd`; it never creates a systemd `PATH` override for Incus. +## Incus authority + +Incus administration is root-equivalent. Bootstrap adds the invoking operator +to `incus-admin` only when direct access is absent, completes that one bootstrap +through its explicit privileged path, and asks the operator to log out and back +in. All other commands require direct access by default. They do not reinterpret +a daemon, socket or configuration failure as permission to run `sudo incus`. + +For a deliberately privileged one-off invocation, set +`VDM_INCUS_USE_SUDO=true`. Non-interactive use requires an already configured, +narrowly scoped non-interactive sudo policy; the scripts never wait for a +password prompt in CI or a system service. + +## Expiry service trust boundary + +`install-host-units.sh` copies only the reaper, its shared library and required +non-secret manifests into `/usr/local/libexec/vdm-opencode`, owned by root. It +also writes the selected project name to +`/etc/vdm-opencode-platform/reaper.env` and validates the installed units with +`systemd-analyze verify`. + +The timer therefore never gives a user-writable Git checkout a root execution +path. `ProtectHome=true` remains enabled. The service is restricted to the +Incus Unix socket family, has no Linux capabilities or writable home path, and +receives only the Incus runtime and private state paths it needs. + ## Network ownership The workload project has: diff --git a/docs/known-limitations.md b/docs/known-limitations.md index e2bfdba..e9bc57f 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -1,7 +1,8 @@ # Known limitations -- Static and mocked validation is available, but the six real Incus images have - not yet been built in this delivery environment. +- Static validation plus executing mocked launch, cleanup, listing, reaper, + credential and publication-retry tests are available, but the six real Incus + images have not yet been built in this delivery environment. - Build-resource selection, resumable checkpoints and cache detachment are covered by repository tests, but release promotion still requires a real clean `--no-cache` build on two independent hardware-virtualisation hosts. @@ -35,3 +36,8 @@ independent clean host is mandatory for redistribution evidence. - OpenCode configuration compatibility must be revalidated whenever OpenCode is upgraded. +- Ubuntu 24.04/26.04 host portability is covered by fail-closed package, + privilege, systemd-unit and command-path contracts, and repository CI runs on + both GitHub-hosted OS images. Real KVM acceptance on both releases remains a + promotion gate; hosted CI cannot prove kernel virtualization, bridge egress + enforcement or a live systemd timer. diff --git a/docs/quick-start.md b/docs/quick-start.md index 8ab6bf8..c2e0838 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -6,26 +6,36 @@ git clone https://github.com/vast-development-method/opencode-platform.git cd opencode-platform cp .env.example .env -./tests/validate-repository.sh +make ci ``` Use `GITEA_BASE_URL` and `INCUS_STORAGE_POOL` in `.env` when the defaults do not match the host. Do not place secrets in `.env`. +The host path supports Ubuntu 24.04 LTS and 26.04 LTS, on server and desktop +installations. The built VM remains Ubuntu 24.04 LTS on either host. + ## 2. Prepare Incus ```bash ./scripts/bootstrap-host.sh -./scripts/apply-incus.sh ./scripts/ci/check-incus-runner.sh php ``` +Bootstrap already applies the Incus resources and installs the expiry timer; +running `apply-incus.sh` again is safe but unnecessary. + The bootstrap fails before package installation when APT/dpkg is inconsistent or essential core utilities are missing. It upgrades Incus only to the newest candidate in repositories already configured by the operator. It does not add a third-party repository, replace core utilities, install OVN/Open vSwitch, or change Docker/firewall configuration. +When needed, bootstrap adds the invoking account to `incus-admin`. This group is +root-equivalent. Log out and back in after the command reports that membership +was added; normal platform commands deliberately do not fall back to `sudo` +after an arbitrary Incus failure. + ## 3. Build one image For Joomla/JCB work: @@ -66,6 +76,11 @@ Existing instances do not acquire newly installed packages merely because the re The verified stopped build VM is published directly. No temporary full-disk snapshot is created. +The expiry timer executes a root-owned copy under `/usr/local/libexec`; it does +not execute the checkout under the operator's home directory. The unit keeps +home directories hidden and grants write access only to the Incus runtime path +and its private state directory. + ## 4. Launch a personal VM ```bash diff --git a/host/systemd/vdm-opencode-reaper.service.in b/host/systemd/vdm-opencode-reaper.service.in index 2486e9a..e1afd82 100644 --- a/host/systemd/vdm-opencode-reaper.service.in +++ b/host/systemd/vdm-opencode-reaper.service.in @@ -2,11 +2,38 @@ Description=Stop expired VDM OpenCode agent VMs After=incus.service Requires=incus.service +ConditionFileIsExecutable=/usr/local/libexec/vdm-opencode/scripts/reap-expired-vms.sh [Service] Type=oneshot -ExecStart=@ROOT_DIR@/scripts/reap-expired-vms.sh +User=root +Group=root +ExecStart=/usr/local/libexec/vdm-opencode/scripts/reap-expired-vms.sh +EnvironmentFile=-/etc/vdm-opencode-platform/reaper.env +Environment=INCUS_CONF=%S/vdm-opencode-reaper/incus +StateDirectory=vdm-opencode-reaper +StateDirectoryMode=0700 +UMask=0077 NoNewPrivileges=true PrivateTmp=true +PrivateDevices=true ProtectSystem=strict ProtectHome=true +ProtectClock=true +ProtectControlGroups=true +ProtectHostname=true +ProtectKernelLogs=true +ProtectKernelModules=true +ProtectKernelTunables=true +ProtectProc=invisible +ProcSubset=pid +RestrictAddressFamilies=AF_UNIX +IPAddressDeny=any +RestrictNamespaces=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true +CapabilityBoundingSet= +SystemCallArchitectures=native +ReadWritePaths=-/var/lib/incus diff --git a/image/files/usr/local/libexec/vdm-opencode-session b/image/files/usr/local/libexec/vdm-opencode-session index 331beaa..7780999 100755 --- a/image/files/usr/local/libexec/vdm-opencode-session +++ b/image/files/usr/local/libexec/vdm-opencode-session @@ -5,6 +5,7 @@ umask 077 : "${CREDENTIALS_DIRECTORY:?systemd credential directory is required}" : "${VDM_SESSION_ID:?session id is required}" : "${VDM_SESSION_MODE:=opencode}" +: "${VDM_AGENT_USER:=opencode}" : "${VDM_AGENT_HOME:=/home/opencode}" : "${VDM_WORKSPACE_ROOT:=/workspace}" @@ -55,8 +56,8 @@ while IFS= read -r line || [ -n "$line" ]; do done < "$credential_file" export HOME="$VDM_AGENT_HOME" -export USER=opencode -export LOGNAME=opencode +export USER="$VDM_AGENT_USER" +export LOGNAME="$VDM_AGENT_USER" export PATH="$VDM_AGENT_HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" export XDG_DATA_HOME="/run/vdm-opencode-$VDM_SESSION_ID/data" install -d -m 0700 "$XDG_DATA_HOME" diff --git a/image/provision/browser.sh b/image/provision/browser.sh index 6c7a0f1..18efdaa 100755 --- a/image/provision/browser.sh +++ b/image/provision/browser.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash set -Eeuo pipefail +AGENT_USER="${AGENT_USER:-opencode}" +AGENT_HOME="${AGENT_HOME:-/home/${AGENT_USER}}" npm install --global \ "${PLAYWRIGHT_MCP_PACKAGE:?PLAYWRIGHT_MCP_PACKAGE is required}" \ @@ -37,8 +39,8 @@ installed_mcp="$(npm list --global --json --depth=0 | jq -r '.dependencies["@pla } test -x /usr/local/bin/vdm-playwright-mcp -CONFIG=/home/opencode/.config/opencode/opencode.json +CONFIG="$AGENT_HOME/.config/opencode/opencode.json" tmp="$(mktemp)" jq '.mcp.playwright.enabled = true' "$CONFIG" > "$tmp" -install -o opencode -g opencode -m 0640 "$tmp" "$CONFIG" +install -o "$AGENT_USER" -g "$AGENT_USER" -m 0640 "$tmp" "$CONFIG" rm -f "$tmp" diff --git a/image/provision/common.sh b/image/provision/common.sh index f9e9cce..6de6fc9 100755 --- a/image/provision/common.sh +++ b/image/provision/common.sh @@ -2,10 +2,22 @@ set -Eeuo pipefail AGENT_USER="${AGENT_USER:-opencode}" -AGENT_HOME="/home/${AGENT_USER}" +AGENT_HOME="${AGENT_HOME:-/home/${AGENT_USER}}" BUILD_CACHE_DIR="${VDM_BUILD_CACHE_DIR:-}" export DEBIAN_FRONTEND=noninteractive +[[ "$AGENT_USER" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]] || { + printf 'Unsafe agent user: %s\n' "$AGENT_USER" >&2 + exit 1 +} +if [[ ! "$AGENT_HOME" =~ ^/(home|srv)/[A-Za-z0-9._/-]+$ ]] || + [[ "/${AGENT_HOME#/}/" == *'/../'* ]] || + [[ "/${AGENT_HOME#/}/" == *'/./'* ]] || + [[ "$AGENT_HOME" == *'//'* ]]; then + printf 'Unsafe agent home: %s\n' "$AGENT_HOME" >&2 + exit 1 +fi + if [ -n "$BUILD_CACHE_DIR" ]; then install -d -m 0755 \ "$BUILD_CACHE_DIR/apt/archives/partial" \ @@ -42,7 +54,24 @@ apt-get install -y --no-install-recommends \ zip if ! id "$AGENT_USER" >/dev/null 2>&1; then - useradd --create-home --shell /bin/bash "$AGENT_USER" + useradd --create-home --home-dir "$AGENT_HOME" --shell /bin/bash "$AGENT_USER" +else + account_home="$(getent passwd "$AGENT_USER" | cut -d: -f6)" + [ "$account_home" = "$AGENT_HOME" ] || { + printf 'Existing user %s has home %s, expected %s\n' \ + "$AGENT_USER" "$account_home" "$AGENT_HOME" >&2 + exit 1 + } +fi + +template_home=/home/opencode +if [ "$AGENT_HOME" != "$template_home" ] && [ -d "$template_home" ]; then + [[ "$AGENT_HOME" != "$template_home/"* ]] || { + printf 'Agent home cannot be nested below the template home: %s\n' "$AGENT_HOME" >&2 + exit 1 + } + cp -a "$template_home/." "$AGENT_HOME/" + rm -rf "$template_home" fi if [ -n "$BUILD_CACHE_DIR" ]; then chown -R "$AGENT_USER:$AGENT_USER" \ @@ -56,6 +85,14 @@ install -d -o "$AGENT_USER" -g "$AGENT_USER" -m 0750 "$AGENT_HOME/.config/openco install -d -o "$AGENT_USER" -g "$AGENT_USER" -m 0700 "$AGENT_HOME/.local/share/opencode" install -d -m 0755 /etc/opencode /etc/vdm-opencode-platform +config="$AGENT_HOME/.config/opencode/opencode.json" +tmp="$(mktemp)" +jq --arg agent_home "$AGENT_HOME" \ + '.mcp.git.command[0] = ($agent_home + "/.local/bin/mcp-server-git")' \ + "$config" > "$tmp" +install -o "$AGENT_USER" -g "$AGENT_USER" -m 0640 "$tmp" "$config" +rm -f "$tmp" + if ! command -v node >/dev/null 2>&1 || [ "$(node --version | sed 's/^v//' | cut -d. -f1)" -lt 20 ]; then nodesource_setup="$(mktemp)" trap 'rm -f "$nodesource_setup"' EXIT diff --git a/image/provision/finalize.sh b/image/provision/finalize.sh index 2272e07..37b2a39 100755 --- a/image/provision/finalize.sh +++ b/image/provision/finalize.sh @@ -4,7 +4,7 @@ set -Eeuo pipefail VARIANT="${1:?variant required}" PLATFORM_VERSION="${PLATFORM_VERSION:?platform version required}" AGENT_USER="${AGENT_USER:-opencode}" -AGENT_HOME="/home/${AGENT_USER}" +AGENT_HOME="${AGENT_HOME:-/home/${AGENT_USER}}" install -d -m 0755 /etc/vdm-opencode-platform diff --git a/image/provision/joomla-mcp.sh b/image/provision/joomla-mcp.sh index 6bbe158..42e860c 100755 --- a/image/provision/joomla-mcp.sh +++ b/image/provision/joomla-mcp.sh @@ -2,7 +2,7 @@ set -Eeuo pipefail AGENT_USER="${AGENT_USER:-opencode}" -AGENT_HOME="/home/${AGENT_USER}" +AGENT_HOME="${AGENT_HOME:-/home/${AGENT_USER}}" # The single-quoted program is JavaScript evaluated by Node, not shell text. # shellcheck disable=SC2016 diff --git a/image/provision/python.sh b/image/provision/python.sh index f513191..59628e2 100755 --- a/image/provision/python.sh +++ b/image/provision/python.sh @@ -1,29 +1,31 @@ #!/usr/bin/env bash set -Eeuo pipefail export DEBIAN_FRONTEND=noninteractive +AGENT_USER="${AGENT_USER:-opencode}" +AGENT_HOME="${AGENT_HOME:-/home/${AGENT_USER}}" apt-get update apt-get install -y --no-install-recommends \ python3-dev \ python3-pytest -sudo -u opencode -H env \ - PIPX_HOME=/home/opencode/.local/share/pipx \ - PIPX_BIN_DIR=/home/opencode/.local/bin \ - PIP_CACHE_DIR="${PIP_CACHE_DIR:-/home/opencode/.cache/pip}" \ +sudo -u "$AGENT_USER" -H env \ + PIPX_HOME="$AGENT_HOME/.local/share/pipx" \ + PIPX_BIN_DIR="$AGENT_HOME/.local/bin" \ + PIP_CACHE_DIR="${PIP_CACHE_DIR:-$AGENT_HOME/.cache/pip}" \ pipx install --force "${RUFF_PACKAGE:?RUFF_PACKAGE is required}" -sudo -u opencode -H env \ - PIPX_HOME=/home/opencode/.local/share/pipx \ - PIPX_BIN_DIR=/home/opencode/.local/bin \ - PIP_CACHE_DIR="${PIP_CACHE_DIR:-/home/opencode/.cache/pip}" \ +sudo -u "$AGENT_USER" -H env \ + PIPX_HOME="$AGENT_HOME/.local/share/pipx" \ + PIPX_BIN_DIR="$AGENT_HOME/.local/bin" \ + PIP_CACHE_DIR="${PIP_CACHE_DIR:-$AGENT_HOME/.cache/pip}" \ pipx install --force "${MYPY_PACKAGE:?MYPY_PACKAGE is required}" installed_ruff="$( - sudo -u opencode -H /home/opencode/.local/bin/ruff --version | + sudo -u "$AGENT_USER" -H "$AGENT_HOME/.local/bin/ruff" --version | awk 'NR == 1 {print $2}' )" installed_mypy="$( - sudo -u opencode -H /home/opencode/.local/bin/mypy --version | + sudo -u "$AGENT_USER" -H "$AGENT_HOME/.local/bin/mypy" --version | awk 'NR == 1 {print $2}' )" [ "$installed_ruff" = "$RUFF_EXPECTED_VERSION" ] || { diff --git a/manifest/mcp-catalog.yaml b/manifest/mcp-catalog.yaml index 04e311f..0720957 100644 --- a/manifest/mcp-catalog.yaml +++ b/manifest/mcp-catalog.yaml @@ -7,6 +7,7 @@ servers: transport: stdio source: https://github.com/modelcontextprotocol/servers/tree/main/src/git package: mcp-server-git + version: 2026.7.10 note: Early development; restrict repository path to /workspace. joomla: status: installed-disabled-on-php-images @@ -43,6 +44,7 @@ servers: transport: stdio source: https://github.com/microsoft/playwright-mcp package: "@playwright/mcp" + version: 0.0.78 note: Prefer deterministic Playwright tests for suites; keep MCP for exploratory QA. speech_to_text: status: optional diff --git a/manifest/platform.env b/manifest/platform.env index 3709359..777bcbe 100644 --- a/manifest/platform.env +++ b/manifest/platform.env @@ -6,7 +6,7 @@ INCUS_STORAGE_POOL="${INCUS_STORAGE_POOL:-}" INCUS_DEFAULT_ROOT_SIZE="${INCUS_DEFAULT_ROOT_SIZE:-80GiB}" AGENT_USER="${AGENT_USER:-opencode}" -AGENT_HOME="${AGENT_HOME:-/home/opencode}" +AGENT_HOME="${AGENT_HOME:-/home/${AGENT_USER}}" WORKSPACE_ROOT="${WORKSPACE_ROOT:-/workspace}" GITEA_BASE_URL="${GITEA_BASE_URL:-https://git.vdm.dev}" diff --git a/manifest/providers.yaml b/manifest/providers.yaml index 955d7c9..09826c1 100644 --- a/manifest/providers.yaml +++ b/manifest/providers.yaml @@ -2,22 +2,32 @@ schema: 1 providers: chatgpt: requested_service: ChatGPT Plus + route: openai-default recommended_path: OpenCode-supported ChatGPT login or company gateway credential_location: guest tmpfs session only persistent_in_image: false anthropic: requested_service: Claude + route: anthropic-default recommended_path: Anthropic API or approved company gateway credential_location: external broker persistent_in_image: false warning: Do not rely on unofficial Claude Pro/Max subscription plugins. xai: requested_service: Grok + route: xai-default recommended_path: xAI API or approved company gateway credential_location: external broker persistent_in_image: false local_llama: requested_service: Local Llama + route: local-default recommended_path: OpenAI-compatible endpoint on trusted LAN credential_location: none or short-lived gateway token persistent_in_image: false + gemini: + requested_service: Gemini + route: gemini-default + recommended_path: Google Gemini API or approved company gateway + credential_location: external broker + persistent_in_image: false diff --git a/scripts/bootstrap-host.sh b/scripts/bootstrap-host.sh index 4c774aa..05a8ce9 100755 --- a/scripts/bootstrap-host.sh +++ b/scripts/bootstrap-host.sh @@ -12,6 +12,18 @@ case "${ID:-}:${ID_LIKE:-}" in *) die "Supported hosts are Ubuntu and Debian." ;; esac +operator_user="${SUDO_USER:-${USER:-$(id -un)}}" +[[ "$operator_user" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]] || + die "Unable to identify a safe Incus operator account" +id "$operator_user" >/dev/null 2>&1 || + die "Incus operator account does not exist: $operator_user" + +# Bootstrap is the only workflow that opts into privileged Incus fallback +# automatically. Normal operator commands require direct Incus access. +if [ "${EUID:-$(id -u)}" -ne 0 ]; then + export VDM_INCUS_USE_SUDO=true +fi + for utility in /usr/bin/cp /usr/bin/mv /usr/bin/rm; do [ -x "$utility" ] || die "Host package state is unsafe: $utility is missing or not executable. Repair dpkg before running bootstrap." done @@ -73,6 +85,14 @@ sudo systemctl enable --now incus.service sudo systemctl is-active --quiet incus.service || die "Incus did not become active after package installation." +membership_added=false +if [ "$operator_user" != root ] && + ! id -nG "$operator_user" | tr ' ' '\n' | grep -qx incus-admin; then + log "Granting $operator_user direct Incus administration access" + sudo usermod --append --groups incus-admin "$operator_user" + membership_added=true +fi + server_version="$( incus_cmd version | awk -F': ' '/^Server version:/ {print $2; exit}' @@ -93,4 +113,7 @@ fi "$SCRIPT_DIR/apply-incus.sh" "$SCRIPT_DIR/install-host-units.sh" +if [ "$membership_added" = true ]; then + warn "$operator_user was added to incus-admin. Log out and back in before running Incus or platform commands without sudo. Membership in incus-admin is root-equivalent." +fi log "Host bootstrap complete" diff --git a/scripts/build-image.sh b/scripts/build-image.sh index 6131eb9..77d249c 100755 --- a/scripts/build-image.sh +++ b/scripts/build-image.sh @@ -69,7 +69,9 @@ build_fingerprint="$( "$PLATFORM_VERSION" \ "$VARIANT" \ "${PLATFORM_IMAGE_COMPONENTS[$VARIANT]}" \ - "$BUILD_SELECTED_DISK" + "$BUILD_SELECTED_DISK" \ + "$AGENT_USER" \ + "$AGENT_HOME" while IFS= read -r -d '' path; do sha256sum "$path" done < <( @@ -308,6 +310,7 @@ wait_for_vm "$BUILD_NAME" || die "VM agent did not become ready: $BUILD_NAME" COMMON_ENV=( "AGENT_USER=$AGENT_USER" + "AGENT_HOME=$AGENT_HOME" "NODE_MAJOR=$NODE_MAJOR" "NODESOURCE_SETUP_SHA256=$NODESOURCE_SETUP_SHA256" "OPENCODE_PACKAGE=$OPENCODE_PACKAGE" @@ -389,6 +392,7 @@ if [ "$build_state" = provisioning ]; then project_cmd exec "$BUILD_NAME" \ --env "PLATFORM_VERSION=$PLATFORM_VERSION" \ --env "AGENT_USER=$AGENT_USER" \ + --env "AGENT_HOME=$AGENT_HOME" \ -- bash /opt/vdm-build/finalize.sh "$VARIANT" project_cmd config set "$BUILD_NAME" user.vdm.build.state finalized fi diff --git a/scripts/configure-models.sh b/scripts/configure-models.sh index 3d8b674..2679fa8 100755 --- a/scripts/configure-models.sh +++ b/scripts/configure-models.sh @@ -17,9 +17,14 @@ for agent in "${agents[@]}"; do # The single-quoted program is evaluated inside the guest, where the injected variables exist. # shellcheck disable=SC2016 - project_cmd exec "$NAME" --env "AGENT_NAME=$agent" --env "AGENT_MODEL=$model" -- bash -c ' + project_cmd exec "$NAME" \ + --env "AGENT_NAME=$agent" \ + --env "AGENT_MODEL=$model" \ + --env "AGENT_HOME=$AGENT_HOME" \ + -- bash -c ' set -Eeuo pipefail -file="/home/opencode/.config/opencode/agents/${AGENT_NAME}.md" +: "${AGENT_HOME:?agent home is required}" +file="$AGENT_HOME/.config/opencode/agents/${AGENT_NAME}.md" if grep -q "^model:" "$file"; then if [ -n "$AGENT_MODEL" ]; then sed -i "s|^model:.*|model: ${AGENT_MODEL}|" "$file" diff --git a/scripts/install-host-units.sh b/scripts/install-host-units.sh index 05823e8..863c99e 100755 --- a/scripts/install-host-units.sh +++ b/scripts/install-host-units.sh @@ -4,15 +4,52 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/common.sh" -escaped_root="$(printf '%s' "$ROOT_DIR" | sed 's/[&|]/\\&/g')" -tmp="$(mktemp)" -trap 'rm -f "$tmp"' EXIT -sed "s|@ROOT_DIR@|$escaped_root|g" \ - "$ROOT_DIR/host/systemd/vdm-opencode-reaper.service.in" > "$tmp" -sudo install -o root -g root -m 0644 "$tmp" /etc/systemd/system/vdm-opencode-reaper.service +for command in install sudo systemctl systemd-analyze; do + require_command "$command" +done + +RUNTIME_ROOT=/usr/local/libexec/vdm-opencode +CONFIG_ROOT=/etc/vdm-opencode-platform +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +[[ "$INCUS_PROJECT" =~ ^[A-Za-z0-9_.-]+$ ]] || + die "Unsafe Incus project name: $INCUS_PROJECT" +printf 'INCUS_PROJECT=%s\n' "$INCUS_PROJECT" > "$tmp_dir/reaper.env" + +sudo systemctl stop \ + vdm-opencode-reaper.timer \ + vdm-opencode-reaper.service >/dev/null 2>&1 || + true +sudo install -d -o root -g root -m 0755 \ + "$RUNTIME_ROOT/scripts/lib" \ + "$RUNTIME_ROOT/manifest/generated" \ + "$CONFIG_ROOT" +sudo install -o root -g root -m 0755 \ + "$ROOT_DIR/scripts/reap-expired-vms.sh" \ + "$RUNTIME_ROOT/scripts/reap-expired-vms.sh" +sudo install -o root -g root -m 0644 \ + "$ROOT_DIR/scripts/lib/common.sh" \ + "$RUNTIME_ROOT/scripts/lib/common.sh" +sudo install -o root -g root -m 0644 \ + "$ROOT_DIR/manifest/generated/platform.env" \ + "$RUNTIME_ROOT/manifest/generated/platform.env" +sudo install -o root -g root -m 0644 \ + "$ROOT_DIR/manifest/platform.env" \ + "$ROOT_DIR/manifest/toolchain.env" \ + "$RUNTIME_ROOT/manifest/" +sudo install -o root -g root -m 0644 \ + "$tmp_dir/reaper.env" \ + "$CONFIG_ROOT/reaper.env" +sudo install -o root -g root -m 0644 \ + "$ROOT_DIR/host/systemd/vdm-opencode-reaper.service.in" \ + /etc/systemd/system/vdm-opencode-reaper.service sudo install -o root -g root -m 0644 \ "$ROOT_DIR/host/systemd/vdm-opencode-reaper.timer" \ /etc/systemd/system/vdm-opencode-reaper.timer +sudo systemd-analyze verify \ + /etc/systemd/system/vdm-opencode-reaper.service \ + /etc/systemd/system/vdm-opencode-reaper.timer sudo systemctl daemon-reload sudo systemctl enable --now vdm-opencode-reaper.timer -log "Installed the agent VM expiry reaper" +log "Installed the root-owned agent VM expiry reaper runtime" diff --git a/scripts/launch-vm.sh b/scripts/launch-vm.sh index 0ceddb5..111012a 100755 --- a/scripts/launch-vm.sh +++ b/scripts/launch-vm.sh @@ -25,20 +25,57 @@ ALIAS="$(image_alias "$VARIANT")" project_cmd image show "$ALIAS" >/dev/null 2>&1 || die "Image not found: $ALIAS. Build or import it first." project_cmd info "$NAME" >/dev/null 2>&1 && die "Instance already exists: $NAME" +OWNER="${VDM_SESSION_OWNER:-${USER:-unknown}}" +TEAM="${VDM_SESSION_TEAM:-individual}" +REPOSITORY_TRUST="${VDM_REPOSITORY_TRUST:-trusted}" +REPOSITORY_ALLOWLIST="${VDM_REPOSITORY_ALLOWLIST:-none}" +PROVIDER_ALLOWLIST="${VDM_PROVIDER_ALLOWLIST:-local-default}" +SESSION_BUDGET="${VDM_SESSION_BUDGET_USD:-0}" +SESSION_TTL="${VDM_SESSION_TTL:-$DEFAULT_SESSION_TTL}" +WORKSPACE_LIFECYCLE="${VDM_WORKSPACE_LIFECYCLE:-disposable}" + +[[ "$NAME" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ ]] || die "Unsafe instance name: $NAME" +[ -n "$OWNER" ] || die "Session owner cannot be empty" +[[ "$SESSION_BUDGET" =~ ^[0-9]+([.][0-9]{1,2})?$ ]] || + die "Session budget must be a non-negative number with at most two decimal places" +TTL_SECONDS="$(duration_to_seconds "$SESSION_TTL")" +((TTL_SECONDS >= 60 && TTL_SECONDS <= 86400)) || + die "Session TTL must be between 60 seconds and 24 hours" +case "$REPOSITORY_TRUST" in trusted|untrusted) ;; *) die "Repository trust must be trusted or untrusted" ;; esac +case "$WORKSPACE_LIFECYCLE" in disposable|retained) ;; *) die "Workspace lifecycle must be disposable or retained" ;; esac + +created=false +cleanup_failed_launch() { + local status=$? + trap - EXIT + set +e + if [ "$status" -ne 0 ] && [ "$created" = true ]; then + warn "Launch failed; removing the incomplete instance created by this invocation: $NAME" + project_cmd delete "$NAME" --force >/dev/null 2>&1 || + warn "Automatic cleanup failed; inspect and remove $NAME before retrying" + fi + exit "$status" +} +trap cleanup_failed_launch EXIT + project_cmd init "$ALIAS" "$NAME" --vm \ --profile "vdm-opencode-${VARIANT}" \ --profile "vdm-size-$SIZE" \ - --profile "vdm-policy-$POLICY" -project_cmd config set "$NAME" user.vdm.owner "${VDM_SESSION_OWNER:-$USER}" -project_cmd config set "$NAME" user.vdm.team "${VDM_SESSION_TEAM:-individual}" -project_cmd config set "$NAME" user.vdm.policy "$POLICY" -project_cmd config set "$NAME" user.vdm.size "$SIZE" -project_cmd config set "$NAME" user.vdm.repository.trust "${VDM_REPOSITORY_TRUST:-trusted}" -project_cmd config set "$NAME" user.vdm.repository.allowlist "${VDM_REPOSITORY_ALLOWLIST:-none}" -project_cmd config set "$NAME" user.vdm.providers.allowlist "${VDM_PROVIDER_ALLOWLIST:-local-default}" -project_cmd config set "$NAME" user.vdm.session.budget_usd "${VDM_SESSION_BUDGET_USD:-0}" -project_cmd config set "$NAME" user.vdm.session.ttl "${VDM_SESSION_TTL:-$DEFAULT_SESSION_TTL}" -project_cmd config set "$NAME" user.vdm.workspace.lifecycle "${VDM_WORKSPACE_LIFECYCLE:-disposable}" + --profile "vdm-policy-$POLICY" \ + --config "user.vdm.owner=$OWNER" \ + --config "user.vdm.team=$TEAM" \ + --config "user.vdm.variant=$VARIANT" \ + --config "user.vdm.policy=$POLICY" \ + --config "user.vdm.size=$SIZE" \ + --config "user.vdm.repository.trust=$REPOSITORY_TRUST" \ + --config "user.vdm.repository.allowlist=$REPOSITORY_ALLOWLIST" \ + --config "user.vdm.providers.allowlist=$PROVIDER_ALLOWLIST" \ + --config "user.vdm.session.budget_usd=$SESSION_BUDGET" \ + --config "user.vdm.session.ttl=$SESSION_TTL" \ + --config "user.vdm.workspace.lifecycle=$WORKSPACE_LIFECYCLE" +created=true project_cmd start "$NAME" wait_for_vm "$NAME" || die "VM agent did not become ready: $NAME" +created=false +trap - EXIT log "Launched $NAME from $ALIAS" diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index bd74b0f..d28fa65 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -50,14 +50,48 @@ duration_to_seconds() { esac } -incus_cmd() { - if incus info >/dev/null 2>&1; then - incus "$@" +declare -ag VDM_INCUS_COMMAND=() + +resolve_incus_command() { + local direct_error + local sudo_mode="${VDM_INCUS_USE_SUDO:-false}" + + (("${#VDM_INCUS_COMMAND[@]}" == 0)) || return 0 + require_command incus + case "$sudo_mode" in + true|false) ;; + *) die "VDM_INCUS_USE_SUDO must be true or false" ;; + esac + + if direct_error="$(incus info 2>&1)"; then + VDM_INCUS_COMMAND=(incus) + return 0 + fi + + if [ "$sudo_mode" != true ]; then + if [ "${EUID:-$(id -u)}" -eq 0 ]; then + die "Incus is installed but its daemon is unavailable: ${direct_error:-no diagnostic returned}" + fi + die "Direct Incus access failed. Ensure the daemon is running and the operator has active incus-admin membership (log out and back in after bootstrap). Set VDM_INCUS_USE_SUDO=true only for an explicitly privileged invocation. Incus reported: ${direct_error:-no diagnostic returned}" + fi + + require_command sudo + if [ -t 0 ] && [ -t 1 ]; then + sudo incus info >/dev/null || + die "Unable to contact Incus through the explicitly enabled sudo path" + VDM_INCUS_COMMAND=(sudo incus) else - sudo incus "$@" + sudo -n incus info >/dev/null || + die "Non-interactive sudo access to Incus is unavailable; grant direct incus-admin access or configure a narrowly scoped non-interactive policy" + VDM_INCUS_COMMAND=(sudo -n incus) fi } +incus_cmd() { + resolve_incus_command + "${VDM_INCUS_COMMAND[@]}" "$@" +} + project_cmd() { incus_cmd --project "$INCUS_PROJECT" "$@" } @@ -85,6 +119,30 @@ wait_for_vm() { return 1 } +stop_guest_sessions() { + local name="${1:?instance name required}" + + # The single-quoted command is intentionally evaluated inside the guest. + # shellcheck disable=SC2016 + project_cmd exec "$name" -- bash -c \ + 'systemctl list-units --type=service --all --no-legend "vdm-opencode-*.service" | + awk "{print \$1}" | xargs -r systemctl stop' >/dev/null 2>&1 || + true +} + +cleanup_guest_runtime() { + local name="${1:?instance name required}" + + # `find` receives the pattern literally and evaluates it in the guest. + project_cmd exec "$name" -- find /run \ + -mindepth 1 \ + -maxdepth 1 \ + -type d \ + \( -name vdm-opencode-input -o -name 'vdm-opencode-*' \) \ + -exec rm -rf -- {} + >/dev/null 2>&1 || + true +} + variant_exists() { [[ -n "${1:-}" && -v "PLATFORM_IMAGE_COMPONENTS[$1]" ]] } diff --git a/scripts/lib/joomla-mcp.sh b/scripts/lib/joomla-mcp.sh index b88d9c1..727e0e2 100755 --- a/scripts/lib/joomla-mcp.sh +++ b/scripts/lib/joomla-mcp.sh @@ -44,7 +44,7 @@ validate_joomla_mcp_runtime_credentials() { for key in "${required_keys[@]}"; do allowed_runtime_key "$key" || - die "Jomla MCP configuration requests an unsupported credential: $key" + die "Joomla MCP configuration requests an unsupported credential: $key" runtime_value_present "$runtime_file" "$key" || die "Set $key in the protected runtime credential file before starting Joomla MCP" if [ "$key" = JOOMLA_MCP_APPROVAL_SECRET ]; then diff --git a/scripts/list-vms.sh b/scripts/list-vms.sh index 6f60e52..11a2b74 100755 --- a/scripts/list-vms.sh +++ b/scripts/list-vms.sh @@ -4,16 +4,24 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/common.sh" -project_cmd list --format json | jq -r ' - ["NAME","STATE","OWNER","TEAM","IMAGE","POLICY","SIZE","TRUST","EXPIRES"], - (.[] | [ - .name, - .status, - (.config["user.vdm.owner"] // "-"), - (.config["user.vdm.team"] // "-"), - (.config["user.vdm.variant"] // "-"), - (.config["user.vdm.policy"] // "-"), - (.config["user.vdm.size"] // "-"), - (.config["user.vdm.repository.trust"] // "-"), - (.config["user.vdm.session.expires_epoch"] // "-") - ]) | @tsv' | column -t -s $'\t' +list_tsv() { + project_cmd list --format json | jq -r ' + ["NAME","STATE","OWNER","TEAM","IMAGE","POLICY","SIZE","TRUST","EXPIRES"], + (.[] | [ + .name, + .status, + (.config["user.vdm.owner"] // "-"), + (.config["user.vdm.team"] // "-"), + (.config["user.vdm.variant"] // "-"), + (.config["user.vdm.policy"] // "-"), + (.config["user.vdm.size"] // "-"), + (.config["user.vdm.repository.trust"] // "-"), + (.config["user.vdm.session.expires_epoch"] // "-") + ]) | @tsv' +} + +if command -v column >/dev/null 2>&1; then + list_tsv | column -t -s $'\t' +else + list_tsv +fi diff --git a/scripts/mcp-toggle.sh b/scripts/mcp-toggle.sh index 6f20b92..bd13f50 100755 --- a/scripts/mcp-toggle.sh +++ b/scripts/mcp-toggle.sh @@ -14,9 +14,16 @@ case "$STATE" in true|false) ;; *) die "State must be true or false" ;; esac # The single-quoted program is evaluated inside the guest, where the injected variables exist. # shellcheck disable=SC2016 -project_cmd exec "$NAME" --env "MCP_SERVER=$SERVER" --env "MCP_STATE=$STATE" -- bash -c ' +project_cmd exec "$NAME" \ + --env "MCP_SERVER=$SERVER" \ + --env "MCP_STATE=$STATE" \ + --env "AGENT_USER=$AGENT_USER" \ + --env "AGENT_HOME=$AGENT_HOME" \ + -- bash -c ' set -Eeuo pipefail -config="/home/opencode/.config/opencode/opencode.json" +: "${AGENT_USER:?agent user is required}" +: "${AGENT_HOME:?agent home is required}" +config="$AGENT_HOME/.config/opencode/opencode.json" jq -e --arg server "$MCP_SERVER" ".mcp[\$server] != null" "$config" >/dev/null || { printf "Unknown MCP server: %s\n" "$MCP_SERVER" >&2 exit 1 @@ -44,6 +51,6 @@ tmp="$(mktemp)" jq --arg server "$MCP_SERVER" --argjson state "$MCP_STATE" \ ".mcp[\$server].enabled = \$state" \ "$config" > "$tmp" -install -o opencode -g opencode -m 0640 "$tmp" "$config" +install -o "$AGENT_USER" -g "$AGENT_USER" -m 0640 "$tmp" "$config" rm -f "$tmp" ' diff --git a/scripts/publish-gitea-package.sh b/scripts/publish-gitea-package.sh index 67f36f1..9468c0b 100755 --- a/scripts/publish-gitea-package.sh +++ b/scripts/publish-gitea-package.sh @@ -13,11 +13,17 @@ variant_exists "$VARIANT" || die "Unknown variant: $VARIANT" : "${GITEA_PACKAGE_OWNER:?Set GITEA_PACKAGE_OWNER}" : "${GITEA_PACKAGE_TOKEN:?Set GITEA_PACKAGE_TOKEN in the CI runtime only}" +: "${GITEA_BASE_URL:?Set GITEA_BASE_URL}" GITEA_PACKAGE_USER="${GITEA_PACKAGE_USER:-package-publisher}" [[ "$GITEA_PACKAGE_USER" =~ ^[A-Za-z0-9_.@+-]+$ ]] || die "Unsafe Gitea package user" -[[ "$GITEA_PACKAGE_TOKEN" != *$'\n'* && "$GITEA_PACKAGE_TOKEN" != *'"'* ]] || die "Unsafe Gitea token encoding" +[[ "$GITEA_PACKAGE_OWNER" =~ ^[A-Za-z0-9_.-]+$ ]] || die "Unsafe Gitea package owner" +[[ "$GITEA_PACKAGE_TOKEN" != *$'\n'* && + "$GITEA_PACKAGE_TOKEN" != *$'\r'* && + "$GITEA_PACKAGE_TOKEN" != *'"'* ]] || + die "Unsafe Gitea token encoding" PACKAGE_NAME="${GITEA_PACKAGE_NAME:-vdm-opencode-${VARIANT}-${ARCHITECTURE}}" +[[ "$PACKAGE_NAME" =~ ^[A-Za-z0-9_.-]+$ ]] || die "Unsafe Gitea package name" ARTIFACT_ID="$(artifact_id "$VARIANT" "$ARCHITECTURE")" STAGING="$(mktemp -d)" trap 'rm -rf "$STAGING"' EXIT @@ -27,6 +33,15 @@ tar --sort=name --mtime='UTC 1970-01-01' --owner=0 --group=0 --numeric-owner \ -C "$PACKAGE_DIR" -cf - . | zstd --quiet --threads=0 -19 -o "$BUNDLE" BUNDLE_SHA="$(sha256sum "$BUNDLE" | awk '{print $1}')" BUNDLE_SIZE="$(stat -c '%s' "$BUNDLE")" +PACKAGED_AT="$( + jq -er ' + .packaged_at + | select( + type == "string" + and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$") + ) + ' "$PACKAGE_DIR/manifest.json" +)" || die "manifest.json has no valid UTC packaged_at timestamp" jq -n \ --arg schema "1" \ @@ -37,35 +52,39 @@ jq -n \ --arg bundle "$(basename "$BUNDLE")" \ --arg sha256 "$BUNDLE_SHA" \ --argjson bytes "$BUNDLE_SIZE" \ - --arg published_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg published_at "$PACKAGED_AT" \ '{schema:($schema|tonumber),artifact_id:$artifact_id,version:$version,variant:$variant, architecture:$architecture,bundle:$bundle,sha256:$sha256,bytes:$bytes,published_at:$published_at}' \ > "$STAGING/release-entry.json" -if [ -n "${COSIGN_KEY:-}" ]; then - cosign sign-blob --yes --tlog-upload=false --key "$COSIGN_KEY" \ - --bundle "$STAGING/release-entry.sigstore.json" "$STAGING/release-entry.json" -elif [ "${VDM_RELEASE_MODE:-false}" = true ]; then - die "COSIGN_KEY is required for tagged release publication" -fi - -MARKER="$STAGING/${ARTIFACT_ID}.complete.tar" -marker_files=(release-entry.json) -if [ -f "$STAGING/release-entry.sigstore.json" ]; then - marker_files+=(release-entry.sigstore.json) -fi -tar --sort=name --mtime='UTC 1970-01-01' --owner=0 --group=0 --numeric-owner \ - -C "$STAGING" -cf "$MARKER" "${marker_files[@]}" - gitea_curl() { local method="${1:?method required}" local url="${2:?url required}" + local escaped_token shift 2 - printf 'user = "%s:%s"\n' "$GITEA_PACKAGE_USER" "$GITEA_PACKAGE_TOKEN" | + escaped_token="${GITEA_PACKAGE_TOKEN//\\/\\\\}" + printf 'user = "%s:%s"\n' "$GITEA_PACKAGE_USER" "$escaped_token" | curl --config - --request "$method" --silent --show-error \ --retry 4 --retry-all-errors --connect-timeout 15 "$@" "$url" } +object_url() { + local name="${1:?object name required}" + printf '%s/api/packages/%s/generic/%s/%s/%s' \ + "$GITEA_BASE_URL" \ + "$GITEA_PACKAGE_OWNER" \ + "$PACKAGE_NAME" \ + "$PLATFORM_VERSION" \ + "$name" +} + +remote_status() { + local name="${1:?object name required}" + gitea_curl HEAD "$(object_url "$name")" \ + --output /dev/null \ + --write-out '%{http_code}' +} + publish_immutable() { local file="${1:?file required}" local name @@ -75,8 +94,8 @@ publish_immutable() { local local_sha local remote_sha name="$(basename "$file")" - url="${GITEA_BASE_URL}/api/packages/${GITEA_PACKAGE_OWNER}/generic/${PACKAGE_NAME}/${PLATFORM_VERSION}/${name}" - status="$(gitea_curl HEAD "$url" --output /dev/null --write-out '%{http_code}')" + url="$(object_url "$name")" + status="$(remote_status "$name")" case "$status" in 404) gitea_curl PUT "$url" --upload-file "$file" --fail-with-body >/dev/null @@ -93,8 +112,88 @@ publish_immutable() { die "Published object differs from local object: $name" } +validate_remote_marker() { + local marker_name="${1:?marker name required}" + local remote_marker="$STAGING/remote-marker.tar" + local remote_entry="$STAGING/remote-release-entry.json" + local remote_signature="$STAGING/remote-release-entry.sigstore.json" + local name + local release_entries=0 + local signature_entries=0 + local -a entries=() + + gitea_curl GET "$(object_url "$marker_name")" \ + --fail-with-body \ + --output "$remote_marker" + mapfile -t entries < <(tar -tf "$remote_marker") + (("${#entries[@]}" > 0)) || die "Remote completion marker is empty: $marker_name" + for name in "${entries[@]}"; do + case "$name" in + release-entry.json) release_entries=$((release_entries + 1)) ;; + release-entry.sigstore.json) signature_entries=$((signature_entries + 1)) ;; + *) die "Remote completion marker contains an unsafe or unexpected member: $name" ;; + esac + done + [ "$release_entries" -eq 1 ] || + die "Remote completion marker must contain exactly one release-entry.json" + [ "$signature_entries" -le 1 ] || + die "Remote completion marker contains duplicate signature entries" + + tar --extract --to-stdout \ + --file "$remote_marker" \ + release-entry.json > "$remote_entry" || + die "Unable to read release-entry.json from remote marker" + cmp -s "$STAGING/release-entry.json" "$remote_entry" || + die "Published completion marker describes a different release: $marker_name" + + if [ "${VDM_RELEASE_MODE:-false}" = true ] && [ "$signature_entries" -ne 1 ]; then + die "Published release marker is unsigned: $marker_name" + fi + if [ "$signature_entries" -eq 1 ] && [ -n "${COSIGN_PUBLIC_KEY:-}" ]; then + require_command cosign + tar --extract --to-stdout \ + --file "$remote_marker" \ + release-entry.sigstore.json > "$remote_signature" || + die "Unable to read the signature from remote marker" + cosign verify-blob \ + --key "$COSIGN_PUBLIC_KEY" \ + --bundle "$remote_signature" \ + "$remote_entry" >/dev/null + elif [ "${VDM_RELEASE_MODE:-false}" = true ]; then + die "COSIGN_PUBLIC_KEY is required to validate an existing signed release marker" + fi +} + # The completion marker is deliberately uploaded last. Consumers must ignore # an artifact unless this immutable marker exists and verifies. publish_immutable "$BUNDLE" + +MARKER="$STAGING/${ARTIFACT_ID}.complete.tar" +marker_status="$(remote_status "$(basename "$MARKER")")" +case "$marker_status" in + 200) + validate_remote_marker "$(basename "$MARKER")" + log "Existing atomic package marker is valid for ${PACKAGE_NAME}/${PLATFORM_VERSION}" + exit 0 + ;; + 404) ;; + *) die "Unexpected Gitea response for $(basename "$MARKER"): HTTP $marker_status" ;; +esac + +if [ -n "${COSIGN_KEY:-}" ]; then + require_command cosign + cosign sign-blob --yes --tlog-upload=false --key "$COSIGN_KEY" \ + --bundle "$STAGING/release-entry.sigstore.json" "$STAGING/release-entry.json" +elif [ "${VDM_RELEASE_MODE:-false}" = true ]; then + die "COSIGN_KEY is required for tagged release publication" +fi + +marker_files=(release-entry.json) +if [ -f "$STAGING/release-entry.sigstore.json" ]; then + marker_files+=(release-entry.sigstore.json) +fi +tar --sort=name --mtime='UTC 1970-01-01' --owner=0 --group=0 --numeric-owner \ + -C "$STAGING" -cf "$MARKER" "${marker_files[@]}" + publish_immutable "$MARKER" log "Published atomic package marker for ${PACKAGE_NAME}/${PLATFORM_VERSION}" diff --git a/scripts/reap-expired-vms.sh b/scripts/reap-expired-vms.sh index a204b5d..9d3dc68 100755 --- a/scripts/reap-expired-vms.sh +++ b/scripts/reap-expired-vms.sh @@ -22,11 +22,8 @@ for row in "${rows[@]}"; do fi if ((expires <= NOW)); then log "Stopping expired agent VM $name" - # The single-quoted command is intentionally evaluated inside the guest. - # shellcheck disable=SC2016 - project_cmd exec "$name" -- bash -c \ - 'systemctl list-units --type=service --all --no-legend "vdm-opencode-*.service" | - awk "{print \$1}" | xargs -r systemctl stop' >/dev/null 2>&1 || true + stop_guest_sessions "$name" + cleanup_guest_runtime "$name" project_cmd stop "$name" --timeout 30 >/dev/null 2>&1 || project_cmd stop "$name" --force project_cmd config unset "$name" user.vdm.session.id >/dev/null 2>&1 || true project_cmd config unset "$name" user.vdm.session.expires_epoch >/dev/null 2>&1 || true diff --git a/scripts/start-session.sh b/scripts/start-session.sh index 2b50bbe..f3e7821 100755 --- a/scripts/start-session.sh +++ b/scripts/start-session.sh @@ -81,6 +81,7 @@ project_cmd exec "$NAME" -- systemd-run \ --property="LoadCredential=session.env:$GUEST_CREDENTIAL" \ --setenv="VDM_SESSION_ID=$SESSION_ID" \ --setenv="VDM_SESSION_MODE=opencode" \ + --setenv="VDM_AGENT_USER=$AGENT_USER" \ --setenv="VDM_AGENT_HOME=$AGENT_HOME" \ --setenv="VDM_WORKSPACE_ROOT=$WORKSPACE_ROOT" \ --setenv="VDM_GITEA_BASE_URL=$GITEA_BASE_URL" \ diff --git a/scripts/stop-session.sh b/scripts/stop-session.sh index 26d0243..064dbb9 100755 --- a/scripts/stop-session.sh +++ b/scripts/stop-session.sh @@ -7,12 +7,8 @@ source "$SCRIPT_DIR/lib/common.sh" NAME="${1:-}" [ -n "$NAME" ] || die "Usage: $0 INSTANCE_NAME" -# The single-quoted command is intentionally evaluated inside the guest. -# shellcheck disable=SC2016 -project_cmd exec "$NAME" -- bash -c \ - 'systemctl list-units --type=service --all --no-legend "vdm-opencode-*.service" | - awk "{print \$1}" | xargs -r systemctl stop' >/dev/null 2>&1 || true -project_cmd exec "$NAME" -- rm -rf /run/vdm-opencode-input /run/vdm-opencode-* >/dev/null 2>&1 || true +stop_guest_sessions "$NAME" +cleanup_guest_runtime "$NAME" project_cmd config unset "$NAME" user.vdm.session.id >/dev/null 2>&1 || true project_cmd config unset "$NAME" user.vdm.session.expires_epoch >/dev/null 2>&1 || true project_cmd stop "$NAME" --timeout 60 diff --git a/scripts/test-joomla-mcp.sh b/scripts/test-joomla-mcp.sh index b45fc13..804f726 100755 --- a/scripts/test-joomla-mcp.sh +++ b/scripts/test-joomla-mcp.sh @@ -78,6 +78,7 @@ project_cmd exec "$NAME" -- systemd-run \ --property="LoadCredential=session.env:$GUEST_CREDENTIAL" \ --setenv="VDM_SESSION_ID=$SESSION_ID" \ --setenv="VDM_SESSION_MODE=joomla-read-test" \ + --setenv="VDM_AGENT_USER=$AGENT_USER" \ --setenv="VDM_AGENT_HOME=$AGENT_HOME" \ --setenv="VDM_WORKSPACE_ROOT=$WORKSPACE_ROOT" \ --setenv="VDM_JOOMLA_SITE_ALIAS=$SITE_ALIAS" \ diff --git a/scripts/verify-image.sh b/scripts/verify-image.sh index 86032b0..acac329 100755 --- a/scripts/verify-image.sh +++ b/scripts/verify-image.sh @@ -18,29 +18,35 @@ assert_version() { [ "$actual" = "$expected" ] || die "$label version mismatch: expected $expected, got $actual" } -project_cmd exec "$INSTANCE" -- test -x "/home/$AGENT_USER/.local/bin/opencode" +project_cmd exec "$INSTANCE" -- test -x "$AGENT_HOME/.local/bin/opencode" +project_cmd exec "$INSTANCE" -- test -d "$WORKSPACE_ROOT" project_cmd exec "$INSTANCE" -- test -f /etc/opencode/opencode.json project_cmd exec "$INSTANCE" -- test -f /etc/vdm-opencode-platform/build.json +project_cmd exec "$INSTANCE" -- jq empty /etc/opencode/opencode.json # The jq program must reach the guest unchanged. # shellcheck disable=SC2016 project_cmd exec "$INSTANCE" -- jq -e --arg variant "$VARIANT" '.variant == $variant' /etc/vdm-opencode-platform/build.json >/dev/null -project_cmd exec "$INSTANCE" -- test ! -e "/home/$AGENT_USER/.local/share/opencode/auth.json" -project_cmd exec "$INSTANCE" -- test ! -e "/home/$AGENT_USER/.local/share/opencode/mcp-auth.json" +project_cmd exec "$INSTANCE" -- test ! -e "$AGENT_HOME/.local/share/opencode/auth.json" +project_cmd exec "$INSTANCE" -- test ! -e "$AGENT_HOME/.local/share/opencode/mcp-auth.json" project_cmd exec "$INSTANCE" -- test ! -e /opt/vdm-build project_cmd exec "$INSTANCE" -- test ! -s /etc/machine-id project_cmd exec "$INSTANCE" -- bash -c '! compgen -G "/etc/ssh/ssh_host_*" >/dev/null' -project_cmd exec "$INSTANCE" -- bash -c \ - '! find /home/opencode -xdev -type f \( -name "id_rsa*" -o -name "id_ed25519*" -o -name ".git-credentials" \) -print -quit | grep -q .' +project_cmd exec "$INSTANCE" --env "AGENT_HOME=$AGENT_HOME" -- bash -c \ + '! find "$AGENT_HOME" -xdev -type f \ + \( -name "id_rsa*" -o -name "id_ed25519*" -o -name ".git-credentials" \ + -o -name "*.token" -o -name "*.secret" -o -name "*.key" \) \ + -print -quit | grep -q .' +project_cmd exec "$INSTANCE" -- jq empty "$AGENT_HOME/.config/opencode/opencode.json" assert_version \ OpenCode \ "$OPENCODE_EXPECTED_VERSION" \ - "$(guest_output "/home/$AGENT_USER/.local/bin/opencode" --version | tr -d '[:space:]')" + "$(guest_output "$AGENT_HOME/.local/bin/opencode" --version | tr -d '[:space:]')" assert_version \ "Git MCP" \ "$GIT_MCP_EXPECTED_VERSION" \ - "$(guest_output "/home/$AGENT_USER/.local/share/pipx/venvs/mcp-server-git/bin/python" -c 'import importlib.metadata; print(importlib.metadata.version("mcp-server-git"))' | tr -d '[:space:]')" + "$(guest_output "$AGENT_HOME/.local/share/pipx/venvs/mcp-server-git/bin/python" -c 'import importlib.metadata; print(importlib.metadata.version("mcp-server-git"))' | tr -d '[:space:]')" if image_has_component "$VARIANT" browser; then assert_version \ @@ -53,6 +59,17 @@ if image_has_component "$VARIANT" browser; then "$(guest_output npm list --global --json --depth=0 | jq -r '.dependencies["@playwright/mcp"].version')" project_cmd exec "$INSTANCE" -- test -x /usr/local/bin/vdm-playwright-mcp project_cmd exec "$INSTANCE" -- test -d /opt/ms-playwright + # shellcheck disable=SC2016 + project_cmd exec "$INSTANCE" -- jq -e \ + '.mcp.playwright.enabled == true + and .mcp.playwright.command[0] == "/usr/local/bin/vdm-playwright-mcp"' \ + "$AGENT_HOME/.config/opencode/opencode.json" >/dev/null +else + project_cmd exec "$INSTANCE" -- test ! -x /usr/local/bin/vdm-playwright-mcp + # shellcheck disable=SC2016 + project_cmd exec "$INSTANCE" -- jq -e \ + '.mcp.playwright.enabled == false' \ + "$AGENT_HOME/.config/opencode/opencode.json" >/dev/null fi if image_has_component "$VARIANT" joomla-mcp; then @@ -72,12 +89,12 @@ if image_has_component "$VARIANT" joomla-mcp; then and .mcp.joomla.enabled == false and .mcp.joomla.command[0] == "/usr/local/bin/vdm-joomla-mcp" and .mcp.joomla.environment.JOOMLA_MCP_CONFIG == "/etc/joomla-mcp/sites.json"' \ - "/home/$AGENT_USER/.config/opencode/opencode.json" >/dev/null + "$AGENT_HOME/.config/opencode/opencode.json" >/dev/null else project_cmd exec "$INSTANCE" -- test ! -x /usr/local/bin/vdm-joomla-mcp # shellcheck disable=SC2016 project_cmd exec "$INSTANCE" -- jq -e '.mcp.joomla == null' \ - "/home/$AGENT_USER/.config/opencode/opencode.json" >/dev/null + "$AGENT_HOME/.config/opencode/opencode.json" >/dev/null fi if image_has_component "$VARIANT" typescript; then @@ -100,8 +117,8 @@ if image_has_component "$VARIANT" typescript; then fi if image_has_component "$VARIANT" python; then - assert_version Ruff "$RUFF_EXPECTED_VERSION" "$(guest_output "/home/$AGENT_USER/.local/bin/ruff" --version | awk '{print $2}')" - assert_version mypy "$MYPY_EXPECTED_VERSION" "$(guest_output "/home/$AGENT_USER/.local/bin/mypy" --version | awk '{print $2}')" + assert_version Ruff "$RUFF_EXPECTED_VERSION" "$(guest_output "$AGENT_HOME/.local/bin/ruff" --version | awk '{print $2}')" + assert_version mypy "$MYPY_EXPECTED_VERSION" "$(guest_output "$AGENT_HOME/.local/bin/mypy" --version | awk '{print $2}')" fi log "Image verification passed for $VARIANT" diff --git a/tests/build-resources.sh b/tests/build-resources.sh index 26eaa2b..bb56bc7 100755 --- a/tests/build-resources.sh +++ b/tests/build-resources.sh @@ -6,6 +6,9 @@ source "$ROOT_DIR/scripts/lib/common.sh" # shellcheck disable=SC1091 source "$ROOT_DIR/scripts/lib/build-resources.sh" +test_output="$(mktemp)" +trap 'rm -f "$test_output"' EXIT + export VDM_TEST_MEM_TOTAL_MIB=14336 export VDM_TEST_MEM_AVAILABLE_MIB=12288 export VDM_TEST_CPU_COUNT=8 @@ -21,26 +24,24 @@ select_build_resources typescript default true export VDM_TEST_MEM_AVAILABLE_MIB=3482 if ( select_build_resources typescript default true -) >"$ROOT_DIR/build-resource-test.out" 2>&1; then +) >"$test_output" 2>&1; then printf 'The TypeScript resource plan accepted unsafe available memory.\n' >&2 exit 1 fi grep -F 'Insufficient currently available memory for typescript' \ - "$ROOT_DIR/build-resource-test.out" >/dev/null + "$test_output" >/dev/null grep -F 'Swap is not required and is not counted.' \ - "$ROOT_DIR/build-resource-test.out" >/dev/null -rm -f "$ROOT_DIR/build-resource-test.out" + "$test_output" >/dev/null export VDM_TEST_MEM_AVAILABLE_MIB=12288 export VDM_TEST_DISK_AVAILABLE_GIB=119 if ( select_build_resources typescript default true -) >"$ROOT_DIR/build-resource-test.out" 2>&1; then +) >"$test_output" 2>&1; then printf 'The TypeScript resource plan accepted unsafe free storage.\n' >&2 exit 1 fi grep -F 'Insufficient Incus storage for typescript' \ - "$ROOT_DIR/build-resource-test.out" >/dev/null -rm -f "$ROOT_DIR/build-resource-test.out" + "$test_output" >/dev/null printf 'Build resource tests passed.\n' diff --git a/tests/fixtures/gitea/cosign b/tests/fixtures/gitea/cosign new file mode 100755 index 0000000..15bd3d2 --- /dev/null +++ b/tests/fixtures/gitea/cosign @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +[ "${1:-}" = sign-blob ] || { + printf 'Mock cosign supports sign-blob only.\n' >&2 + exit 2 +} +shift +bundle= +input= +while (($# > 0)); do + case "$1" in + --yes|--tlog-upload=false) + shift + ;; + --key) + shift 2 + ;; + --bundle) + bundle="${2:?bundle path required}" + shift 2 + ;; + *) + input="$1" + shift + ;; + esac +done +[ -n "$bundle" ] && [ -f "$input" ] || { + printf 'Mock cosign did not receive its bundle and input.\n' >&2 + exit 2 +} +printf '{"mock_signature_nonce":"%s-%s"}\n' "$$" "${RANDOM:-0}" > "$bundle" +printf 'signed\n' >> "${MOCK_COSIGN_LOG:?MOCK_COSIGN_LOG is required}" diff --git a/tests/fixtures/gitea/curl b/tests/fixtures/gitea/curl new file mode 100755 index 0000000..9a31a01 --- /dev/null +++ b/tests/fixtures/gitea/curl @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +cat >/dev/null +method=GET +output= +write_out= +upload= +url= +while (($# > 0)); do + case "$1" in + --config|--retry|--connect-timeout) + shift 2 + ;; + --request) + method="${2:?request method required}" + shift 2 + ;; + --output) + output="${2:?output path required}" + shift 2 + ;; + --write-out) + write_out="${2:?write-out value required}" + shift 2 + ;; + --upload-file) + upload="${2:?upload path required}" + shift 2 + ;; + --silent|--show-error|--retry-all-errors|--fail-with-body) + shift + ;; + http://*|https://*) + url="$1" + shift + ;; + *) + printf 'Unexpected mock curl argument: %s\n' "$1" >&2 + exit 2 + ;; + esac +done + +[ -n "$url" ] || { + printf 'Mock curl did not receive a URL.\n' >&2 + exit 2 +} +remote="${MOCK_GITEA_ROOT:?MOCK_GITEA_ROOT is required}/${url##*/}" +status=200 +case "$method" in + HEAD) + [ -f "$remote" ] || status=404 + ;; + PUT) + [ -n "$upload" ] || { + printf 'Mock PUT did not receive --upload-file.\n' >&2 + exit 2 + } + install -D -m 0600 "$upload" "$remote" + status=201 + ;; + GET) + [ -f "$remote" ] || exit 22 + [ -n "$output" ] || { + printf 'Mock GET did not receive --output.\n' >&2 + exit 2 + } + if [ "$output" != /dev/null ]; then + install -m 0600 "$remote" "$output" + fi + ;; + *) + printf 'Unsupported mock curl method: %s\n' "$method" >&2 + exit 2 + ;; +esac + +if [ -n "$write_out" ]; then + [ "$write_out" = '%{http_code}' ] || { + printf 'Unsupported mock curl format: %s\n' "$write_out" >&2 + exit 2 + } + printf '%s' "$status" +fi diff --git a/tests/fixtures/gitea/zstd b/tests/fixtures/gitea/zstd new file mode 100755 index 0000000..d38f00f --- /dev/null +++ b/tests/fixtures/gitea/zstd @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +output= +while (($# > 0)); do + case "$1" in + -o) + output="${2:?output path required}" + shift 2 + ;; + --quiet|--threads=0|-19) + shift + ;; + *) + printf 'Unexpected mock zstd argument: %s\n' "$1" >&2 + exit 2 + ;; + esac +done +[ -n "$output" ] || { + printf 'Mock zstd did not receive an output path.\n' >&2 + exit 2 +} +cat > "$output" diff --git a/tests/fixtures/session/incus b/tests/fixtures/session/incus index 556b619..f038288 100755 --- a/tests/fixtures/session/incus +++ b/tests/fixtures/session/incus @@ -2,7 +2,8 @@ set -Eeuo pipefail if [ "${1:-}" = info ]; then - exit 0 + [ "${MOCK_INCUS_DIRECT_ACCESS:-true}" = true ] + exit fi if [ "${1:-}" = --project ]; then shift 2 @@ -17,10 +18,21 @@ if [ "${1:-}" = exec ] && [[ "$*" == *'.mcp.joomla.enabled == true'* ]]; then fi case "${1:-}:${2:-}" in + image:show|init:*) + exit 0 + ;; + info:*) + [ "${MOCK_INCUS_INSTANCE_EXISTS:-true}" = true ] + exit + ;; + start:*) + [ "${MOCK_INCUS_START_FAIL:-false}" != true ] + exit + ;; list:--format) cat "${MOCK_INCUS_LIST:?MOCK_INCUS_LIST is required for list}" ;; - info:*|start:*|stop:*|config:set|config:unset|file:push|exec:*) + stop:*|delete:*|config:set|config:unset|file:push|exec:*) exit 0 ;; *) diff --git a/tests/guest-smoke.sh b/tests/guest-smoke.sh deleted file mode 100755 index 463db44..0000000 --- a/tests/guest-smoke.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -test -x /home/opencode/.local/bin/opencode -test -d /workspace -test -f /etc/opencode/opencode.json -test -f /etc/vdm-opencode-platform/build.json -jq empty /etc/opencode/opencode.json -jq empty /home/opencode/.config/opencode/opencode.json diff --git a/tests/publish-idempotency.sh b/tests/publish-idempotency.sh new file mode 100755 index 0000000..86b6ad8 --- /dev/null +++ b/tests/publish-idempotency.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +export PATH="$ROOT_DIR/tests/fixtures/gitea:$ROOT_DIR/tests/fixtures:$PATH" +export MOCK_INCUS_LOG="$tmp_dir/incus.log" +export MOCK_GITEA_ROOT="$tmp_dir/gitea" +export MOCK_COSIGN_LOG="$tmp_dir/cosign.log" +install -d -m 0700 "$MOCK_GITEA_ROOT" +credential_name=GITEA_PACKAGE_TOKEN +printf -v "$credential_name" '%s' test-token +export "$credential_name" + +package_dir="$tmp_dir/package" +"$ROOT_DIR/scripts/package-image.sh" base "$package_dir" >/dev/null +platform_version="$(<"$ROOT_DIR/VERSION")" +artifact_id="vdm-opencode-base-${platform_version}-amd64" + +publish() { + GITEA_BASE_URL=https://gitea.invalid \ + GITEA_PACKAGE_OWNER=platform \ + GITEA_PACKAGE_USER=publisher \ + COSIGN_KEY=test-signing-key \ + "$ROOT_DIR/scripts/publish-gitea-package.sh" base "$package_dir" +} + +publish > "$tmp_dir/first.out" +marker="$MOCK_GITEA_ROOT/${artifact_id}.complete.tar" +[ -f "$marker" ] +first_sha="$(sha256sum "$marker" | awk '{print $1}')" +sleep 1 +publish > "$tmp_dir/second.out" +second_sha="$(sha256sum "$marker" | awk '{print $1}')" +[ "$first_sha" = "$second_sha" ] || { + printf 'Completion marker changed during an idempotent retry.\n' >&2 + exit 1 +} +grep -F 'Existing atomic package marker is valid' "$tmp_dir/second.out" >/dev/null +[ "$(wc -l < "$MOCK_COSIGN_LOG")" -eq 1 ] || { + printf 'A valid existing marker was signed again during retry.\n' >&2 + exit 1 +} + +install -d "$tmp_dir/marker" +tar -xf "$marker" -C "$tmp_dir/marker" +packaged_at="$(jq -r '.packaged_at' "$package_dir/manifest.json")" +jq -e --arg packaged_at "$packaged_at" \ + '.published_at == $packaged_at' \ + "$tmp_dir/marker/release-entry.json" >/dev/null +[ -f "$tmp_dir/marker/release-entry.sigstore.json" ] + +printf 'Gitea publication idempotency tests passed.\n' diff --git a/tests/runtime-regressions.sh b/tests/runtime-regressions.sh new file mode 100755 index 0000000..7fa1b0f --- /dev/null +++ b/tests/runtime-regressions.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +export PATH="$ROOT_DIR/tests/fixtures/session:$PATH" +export MOCK_INCUS_LOG="$tmp_dir/incus.log" +export MOCK_INCUS_LIST="$tmp_dir/list.json" +printf '[]\n' > "$MOCK_INCUS_LIST" + +env -u USER \ + PATH="$PATH" \ + MOCK_INCUS_LOG="$MOCK_INCUS_LOG" \ + MOCK_INCUS_LIST="$MOCK_INCUS_LIST" \ + MOCK_INCUS_INSTANCE_EXISTS=false \ + "$ROOT_DIR/scripts/launch-vm.sh" base test-unset-user connected small +grep -F 'user.vdm.owner=unknown' "$MOCK_INCUS_LOG" >/dev/null +grep -F 'user.vdm.variant=base' "$MOCK_INCUS_LOG" >/dev/null + +: > "$MOCK_INCUS_LOG" +if env -u USER \ + PATH="$PATH" \ + MOCK_INCUS_LOG="$MOCK_INCUS_LOG" \ + MOCK_INCUS_LIST="$MOCK_INCUS_LIST" \ + MOCK_INCUS_INSTANCE_EXISTS=false \ + MOCK_INCUS_START_FAIL=true \ + "$ROOT_DIR/scripts/launch-vm.sh" base test-failed-launch connected small \ + >"$tmp_dir/failed-launch.out" 2>&1; then + printf 'Launch unexpectedly succeeded after the mocked start failure.\n' >&2 + exit 1 +fi +grep -E '^delete test-failed-launch --force ' "$MOCK_INCUS_LOG" >/dev/null + +: > "$MOCK_INCUS_LOG" +"$ROOT_DIR/scripts/stop-session.sh" test-cleanup +grep -F 'find /run -mindepth 1 -maxdepth 1 -type d' "$MOCK_INCUS_LOG" >/dev/null +grep -F -- "-name vdm-opencode-\\*" "$MOCK_INCUS_LOG" >/dev/null + +minimal_bin="$tmp_dir/minimal-bin" +install -d "$minimal_bin" +for command in bash cat dirname incus jq; do + command_path="$(command -v "$command")" + ln -s "$command_path" "$minimal_bin/$command" +done +cat > "$MOCK_INCUS_LIST" <<'JSON' +[ + { + "name": "portable-list", + "status": "Running", + "config": { + "user.vdm.owner": "tester", + "user.vdm.variant": "base" + } + } +] +JSON +PATH="$minimal_bin" \ + MOCK_INCUS_LOG="$MOCK_INCUS_LOG" \ + MOCK_INCUS_LIST="$MOCK_INCUS_LIST" \ + "$ROOT_DIR/scripts/list-vms.sh" > "$tmp_dir/list.out" +grep -F $'NAME\tSTATE\tOWNER' "$tmp_dir/list.out" >/dev/null +grep -F $'portable-list\tRunning\ttester' "$tmp_dir/list.out" >/dev/null + +if MOCK_INCUS_DIRECT_ACCESS=false \ + "$ROOT_DIR/scripts/list-vms.sh" > "$tmp_dir/incus-access.out" 2>&1; then + printf 'A normal command unexpectedly used privileged Incus fallback.\n' >&2 + exit 1 +fi +grep -E \ + 'Direct Incus access failed|Incus is installed but its daemon is unavailable' \ + "$tmp_dir/incus-access.out" >/dev/null + +: > "$MOCK_INCUS_LOG" +AGENT_USER=agenttest \ +AGENT_HOME=/srv/agenttest \ + "$ROOT_DIR/scripts/mcp-toggle.sh" test-mcp git false +grep -F -- '--env AGENT_USER=agenttest' "$MOCK_INCUS_LOG" >/dev/null +grep -F -- '--env AGENT_HOME=/srv/agenttest' "$MOCK_INCUS_LOG" >/dev/null + +printf 'Runtime regression tests passed.\n' diff --git a/tests/security-image.sh b/tests/security-image.sh deleted file mode 100755 index 56ae8b6..0000000 --- a/tests/security-image.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -for forbidden in \ - /home/opencode/.local/share/opencode/auth.json \ - /home/opencode/.local/share/opencode/mcp-auth.json \ - /home/opencode/.git-credentials \ - /home/opencode/.ssh/id_rsa \ - /home/opencode/.ssh/id_ed25519; do - test ! -e "$forbidden" -done - -! find /home/opencode -xdev -type f \( -name '*.token' -o -name '*.secret' -o -name '*.key' \) -print -quit | grep -q . diff --git a/tests/security-static.py b/tests/security-static.py index 728fa8a..5447840 100644 --- a/tests/security-static.py +++ b/tests/security-static.py @@ -108,6 +108,29 @@ def test_bootstrap_never_changes_package_sources_or_coreutils_provider(self) -> self.assertIn("dpkg --audit", bootstrap) self.assertIn("apt-get check", bootstrap) + def test_reaper_executes_only_the_root_owned_runtime(self) -> None: + installer = (ROOT / "scripts/install-host-units.sh").read_text() + service = ( + ROOT / "host/systemd/vdm-opencode-reaper.service.in" + ).read_text() + runtime = "/usr/local/libexec/vdm-opencode/scripts/reap-expired-vms.sh" + self.assertIn("RUNTIME_ROOT=/usr/local/libexec/vdm-opencode", installer) + self.assertIn('"$RUNTIME_ROOT/scripts/reap-expired-vms.sh"', installer) + self.assertIn(f"ExecStart={runtime}", service) + self.assertNotIn("@ROOT_DIR@", service) + self.assertIn("ProtectHome=true", service) + self.assertIn("ProtectSystem=strict", service) + self.assertIn("CapabilityBoundingSet=", service) + + def test_agent_identity_changes_invalidate_build_checkpoints(self) -> None: + build = (ROOT / "scripts/build-image.sh").read_text() + fingerprint_block = build[ + build.index('build_fingerprint="$(') : build.index('BUILD_NAME=') + ] + self.assertIn('"$AGENT_USER"', fingerprint_block) + self.assertIn('"$AGENT_HOME"', fingerprint_block) + self.assertIn('"AGENT_HOME=$AGENT_HOME"', build) + def test_joomla_defaults_are_local_read_only_and_credential_free(self) -> None: example = json.loads( ( diff --git a/tests/systemd-units.sh b/tests/systemd-units.sh new file mode 100755 index 0000000..7564c84 --- /dev/null +++ b/tests/systemd-units.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" + +command -v systemd-analyze >/dev/null 2>&1 || { + printf 'systemd-analyze is required to validate host units.\n' >&2 + exit 1 +} + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT +sed \ + -e '/^After=incus\.service$/d' \ + -e '/^Requires=incus\.service$/d' \ + -e 's|^ConditionFileIsExecutable=.*|ConditionFileIsExecutable=/bin/true|' \ + -e 's|^ExecStart=.*|ExecStart=/bin/true|' \ + "$ROOT_DIR/host/systemd/vdm-opencode-reaper.service.in" \ + > "$tmp_dir/vdm-opencode-reaper.service" +cp "$ROOT_DIR/host/systemd/vdm-opencode-reaper.timer" \ + "$tmp_dir/vdm-opencode-reaper.timer" + +systemd-analyze verify \ + "$tmp_dir/vdm-opencode-reaper.service" \ + "$tmp_dir/vdm-opencode-reaper.timer" + +printf 'Systemd unit validation passed.\n' diff --git a/tests/test_authorities.py b/tests/test_authorities.py new file mode 100644 index 0000000..213c92a --- /dev/null +++ b/tests/test_authorities.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import re +import unittest +from pathlib import Path + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] + + +def load_yaml(path: str) -> dict: + value = yaml.safe_load((ROOT / path).read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise AssertionError(f"{path} must contain a mapping") + return value + + +def load_toolchain() -> dict[str, str]: + values: dict[str, str] = {} + for line in (ROOT / "manifest/toolchain.env").read_text(encoding="utf-8").splitlines(): + if not line or line.startswith("#"): + continue + key, separator, value = line.partition("=") + if separator: + values[key] = value + return values + + +class AuthorityContractTest(unittest.TestCase): + def test_provider_routes_match_the_broker(self) -> None: + providers = load_yaml("manifest/providers.yaml") + broker = load_yaml("broker/config/litellm.yaml") + self.assertEqual(1, providers["schema"]) + declared_routes = { + provider["route"] for provider in providers["providers"].values() + } + broker_routes = {entry["model_name"] for entry in broker["model_list"]} + self.assertEqual(broker_routes, declared_routes) + for provider in providers["providers"].values(): + self.assertFalse(provider["persistent_in_image"]) + self.assertNotEqual("image", provider["credential_location"]) + + def test_mcp_catalog_matches_the_shipped_configuration(self) -> None: + catalog = load_yaml("manifest/mcp-catalog.yaml") + config = json.loads( + ( + ROOT + / "image/files/home/opencode/.config/opencode/opencode.json" + ).read_text(encoding="utf-8") + ) + servers = catalog["servers"] + self.assertEqual(1, catalog["schema"]) + self.assertEqual(set(servers), set(config["mcp"]) | {"joomla"}) + + toolchain = load_toolchain() + expected_versions = { + "git": toolchain["GIT_MCP_EXPECTED_VERSION"], + "joomla": toolchain["JOOMLA_MCP_EXPECTED_VERSION"], + "playwright": toolchain["PLAYWRIGHT_MCP_EXPECTED_VERSION"], + } + for server, version in expected_versions.items(): + self.assertEqual(version, str(servers[server]["version"])) + + self.assertTrue(config["mcp"]["git"]["enabled"]) + self.assertFalse(config["mcp"]["playwright"]["enabled"]) + + def test_session_policy_schema_preserves_fail_closed_controls(self) -> None: + schema = json.loads( + (ROOT / "schemas/session-policy.schema.json").read_text(encoding="utf-8") + ) + self.assertEqual("object", schema["type"]) + self.assertFalse(schema["additionalProperties"]) + self.assertEqual( + {"session", "expires_at", "models", "repositories", "mcp"}, + set(schema["required"]), + ) + self.assertFalse(schema["properties"]["allow_protected_branches"]["const"]) + self.assertGreaterEqual(schema["properties"]["session"]["minLength"], 16) + self.assertTrue( + re.fullmatch( + r"https://json-schema\.org/draft/2020-12/schema", + schema["$schema"], + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/validate-repository.sh b/tests/validate-repository.sh index a356caf..40a40a3 100755 --- a/tests/validate-repository.sh +++ b/tests/validate-repository.sh @@ -30,6 +30,7 @@ bash "$ROOT_DIR/tests/variant-selection.sh" || fail=1 bash "$ROOT_DIR/tests/build-resources.sh" || fail=1 python3 "$ROOT_DIR/scripts/generate-platform.py" --check || fail=1 python3 -m unittest "$ROOT_DIR/tests/test_platform_manifest.py" || fail=1 +python3 -m unittest "$ROOT_DIR/tests/test_authorities.py" || fail=1 python3 -m unittest "$ROOT_DIR/tests/security-static.py" || fail=1 bash "$ROOT_DIR/tests/generated-platform.sh" || fail=1 bash "$ROOT_DIR/tests/joomla-mcp-integration.sh" || fail=1 @@ -38,9 +39,12 @@ bash "$ROOT_DIR/tests/guest-exec.sh" || fail=1 bash "$ROOT_DIR/tests/runtime-credentials.sh" || fail=1 bash "$ROOT_DIR/tests/session-secret-transport.sh" || fail=1 bash "$ROOT_DIR/tests/reaper.sh" || fail=1 +bash "$ROOT_DIR/tests/runtime-regressions.sh" || fail=1 +bash "$ROOT_DIR/tests/publish-idempotency.sh" || fail=1 +bash "$ROOT_DIR/tests/systemd-units.sh" || fail=1 if command -v make >/dev/null 2>&1; then - make -C "$ROOT_DIR" --dry-run help local-release >/dev/null || fail=1 + make -C "$ROOT_DIR" --dry-run help ci local-release >/dev/null || fail=1 fi while IFS= read -r -d '' json; do From 0eb23236b036a4725b10c36f7360b97e71922f6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?eW=C9=98yn?= <5607939+Llewellynvdm@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:40:25 +0200 Subject: [PATCH 2/2] Fix CI shellcheck validation --- scripts/verify-image.sh | 2 ++ tests/publish-idempotency.sh | 5 ++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/verify-image.sh b/scripts/verify-image.sh index acac329..3e9457e 100755 --- a/scripts/verify-image.sh +++ b/scripts/verify-image.sh @@ -31,6 +31,8 @@ project_cmd exec "$INSTANCE" -- test ! -e "$AGENT_HOME/.local/share/opencode/mcp project_cmd exec "$INSTANCE" -- test ! -e /opt/vdm-build project_cmd exec "$INSTANCE" -- test ! -s /etc/machine-id project_cmd exec "$INSTANCE" -- bash -c '! compgen -G "/etc/ssh/ssh_host_*" >/dev/null' +# This expression is intentionally expanded by the guest shell. +# shellcheck disable=SC2016 project_cmd exec "$INSTANCE" --env "AGENT_HOME=$AGENT_HOME" -- bash -c \ '! find "$AGENT_HOME" -xdev -type f \ \( -name "id_rsa*" -o -name "id_ed25519*" -o -name ".git-credentials" \ diff --git a/tests/publish-idempotency.sh b/tests/publish-idempotency.sh index 86b6ad8..3485cb4 100755 --- a/tests/publish-idempotency.sh +++ b/tests/publish-idempotency.sh @@ -9,9 +9,8 @@ export MOCK_INCUS_LOG="$tmp_dir/incus.log" export MOCK_GITEA_ROOT="$tmp_dir/gitea" export MOCK_COSIGN_LOG="$tmp_dir/cosign.log" install -d -m 0700 "$MOCK_GITEA_ROOT" -credential_name=GITEA_PACKAGE_TOKEN -printf -v "$credential_name" '%s' test-token -export "$credential_name" +printf -v GITEA_PACKAGE_TOKEN '%s' test-token +export GITEA_PACKAGE_TOKEN package_dir="$tmp_dir/package" "$ROOT_DIR/scripts/package-image.sh" base "$package_dir" >/dev/null