Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 9 additions & 5 deletions .github/workflows/validate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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' \
Expand All @@ -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

Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions docs/gitea-packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
31 changes: 31 additions & 0 deletions docs/host-and-network-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
10 changes: 8 additions & 2 deletions docs/known-limitations.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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.
19 changes: 17 additions & 2 deletions docs/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
29 changes: 28 additions & 1 deletion host/systemd/vdm-opencode-reaper.service.in
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 3 additions & 2 deletions image/files/usr/local/libexec/vdm-opencode-session
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down Expand Up @@ -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"
Expand Down
6 changes: 4 additions & 2 deletions image/provision/browser.sh
Original file line number Diff line number Diff line change
@@ -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}" \
Expand Down Expand Up @@ -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"
41 changes: 39 additions & 2 deletions image/provision/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down Expand Up @@ -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" \
Expand All @@ -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
Expand Down
Loading