Neutral bootstrap document for AI agents working in this repository. Single
source of truth; CLAUDE.md imports this file via @AGENTS.md so Claude
Code, Codex, Aider, and other tools that read AGENTS.md natively all see
the same instructions.
mpd (Moodle Plugin Development) is a local development environment for
Moodle plugin work, built around reproducible runtime containers, local DNS,
and HTTPS endpoints. It has two user-facing modes, distinguished by where
the user sits and where mpd runs:
- Sandbox VM — full GNOME desktop inside the VM,
mpdruns in the VM. User installs Debian Trixie desktop in any hypervisor, snapshots, runssetup/sandbox/take-over-sandbox-vm.shinside the VM. Host stays untouched. - mpd VM — automated Debian Trixie VM driven by a matched-host
bootstrap (Parallels Desktop Pro on macOS — primary; libvirt/KVM on
Ubuntu and Hyper-V on Windows are speculative). Defaults to
headless; GNOME is installed and toggleable on demand via
gnome-start/gnome-stop(persistent across reboots). User stays on their host: host browser visits*.mpd.testdirectly via WireGuard + CA trust; host terminal SSHes into the VM to use thempdCLI.
mpd itself is a single Linux binary that runs inside the VM. The
macOS-host orchestrator (mpd-virt) that drives Parallels lives in a
separate repository.
Implementation note: sandbox and mpd VM share the same
mpd/Action/ + mpd/VM/ code paths; sandbox is PlatformKind.sandbox
and from the user's perspective is a distinct mode, but the codebase
treats it as a Machine-flavored variant.
Read README.md first for the user-facing overview.
mpd has three absolute, VM-wide paths. All owned by the dev user (bootstrap chowns), all enforced at runtime — do not propose alternates.
/opt/mpd/— the git checkout: code, assets, built binary (/opt/mpd/bin/mpd). FHS slot for add-on packages. Bind-mounted RO into every mpd-created container at the same path, so/opt/mpd/assets/...resolves identically on the VM and inside containers./var/lib/mpd/conf/— persistent identity. CA + service cert,platform.env, wireguard private key. PRIVATE — never bind-mounted into containers./var/lib/mpd/env/— user-editable env overrides. Holdsmpd-vm.envonly. Bind-mounted RO into every runtime container at the same path (directory mount, so vim/nano atomic-rename writes propagate)./var/lib/mpd/skel/— user-managed dotfile overrides for new runtime containers. Same idea as/etc/skel/: contents are copied into/home/<user>/at runtime create, layered on top of the shippedassets/runtime-base/skel/. Empty by default; user populates as needed (.gitconfig,.ssh/known_hostsadditions,.ssh/config, etc.). Last-write-wins: VM-host skel overrides shipped skel./var/lib/mpd/state/— mpd-managed operational state.projects.json,databases.json,current-state.json,hooks-state.json,runtimes/<n>/,dnsmasq.d/,fileaccess/hostkeys/,portal/. The portal mounts the whole tree at/mpd-stateRO; dnsmasq mountsstate/dnsmasq.d/at/etc/dnsmasq.d/RO. Wipe to reset./srv/— only exists inside containers (Podman data volume). Holds per-project trees (projects/, data/, meta/), the database state (dbs/), shared dev tools (tools/), and project backups (backups/).
$HOME is not used for anything mpd-owned; per-user concerns (SSH keys,
shell config, NSS DB) stay in $HOME and are not mpd's responsibility.
The Swift binary lives under mpd/. Each subdirectory is a Mpd.<X> namespace:
mpd/CLI/— command handlers, status rendering, completion (Mpd.Completion)mpd/Action/— top-level lifecycle verbs (Mpd.Action.{Setup,Start,Stop,Restart,Status})mpd/VM/— VM-host operations (paths,Mpd.VM.exec/capture, identity, shutdown unit) plus sub-namespaces (DNS,Certificate,DataVolume,Platform,Config)mpd/Runtime/— runtime/project orchestration, sidecar reconciliationmpd/Service/— always-on infra services (dnsmasq, portal, adminer, fileaccess)mpd/Hooks/— typedEventlifecycle hooks + asset-sidehooks/<event>.d/dispatchmpd/TUI/— interactive terminal UImpd/Util/— Podman shell-out gateway (Mpd.Podman.*) and other utilitiesmpd/Mpd.swift— namespace root (open this file to see the full API surface)mpd/main.swift— CLI entry, ArgumentParser dispatch
Runtime/project-type behavior + service container assets live under assets/:
assets/machine/— VM-level assets deployed to the mpd VM itself (e.g.motd— installed to/etc/motdby provisioning scripts)assets/runtimes/<runtime>/...— runtime definitions, project types, toolsassets/services/<n>/...— always-on infra servicesassets/sidecars/<n>/...— per-runtime-pod sidecarsassets/completions/— shell completion shimsassets/templates/— per-developer template (mpd-vm.env); runtime and type defaults live next to their owners underassets/runtimes/<rt>/mpd-defaults.envandassets/runtimes/<rt>/project_types/<type>/mpd-defaults.env
Topic owners — update the owning file for a topic instead of duplicating across docs.
README.md— project overview and entry pointdocs/README.md— documentation indexdocs/CLI_BEHAVIOR.md— CLI behavior contract (both modes)docs/ARCHITECTURE.md— repo architecture, mode split, networking summary, verb/tool contract (§7)docs/HOOKS.md— typedEventlifecycle hooks: events, audiences, asset-sidehooks/<event>.d/scriptsdocs/ROADMAP.md— committed near-term workdocs/proposals/— design docs for parked / exploratory ideas in this repo (mpd binary, in-VM behavior)- (Architecture proposals for the host-side
mpd-virtorchestrator live in the separatempd-virt-macosrepo underdocs/proposals/.) docs/USAGE.md— day-to-day workflow (bootstrap → first project → SSH-into-runtime)docs/NETWORKING.md— networking model (WireGuard via mpd-virt)docs/SECURITY.md— security model- macOS automation (Parallels / UTM) lives in the sibling
mpd-virt-macosrepo: https://github.com/mutms/mpd-virt-macos setup/linux/README.md— Ubuntu host + libvirt/KVM automationsetup/windows/README.txt— Windows host + Hyper-V automationsetup/sandbox/README.md— graphical "live in the VM" Debian sandbox
Mpd.Podman is the single shared gateway for container operations. Direct
host-OS command execution is allowed only inside mpd/VM/Exec.swift.
Other layers (CLI, Runtime, Service, Core) must not shell out directly — they
request via Podman or environment APIs. Full rule + review checklist in
docs/ARCHITECTURE.md §"Mandatory Constraint".
Applies to runtime containers, the mpd VM, and the fileaccess service — anywhere mpd ships shell code for a host with a dev user plus passwordless sudo.
-
Scripts run as the dev user. Every shell asset under
assets/andmpd-virt/is invoked as the dev user. The orchestrator (Swift'spodman exec -u <user>, host-side ssh, etc.) is responsible for setting that identity at exec time. Scripts do not change identity themselves. -
sudois for individual privileged commands only, executed from inside a script that runs as the dev user. Allowed shapes:sudo apt-get …,sudo install -d /opt/mpd,sudo systemctl …,sudo chown -R "$(id -un):$(id -un)" /opt/mpd,sudo tee /etc/profile.d/foo.sh.chownis fine when scoped to dev-user-owned territory (/opt/mpd,/srv/). -
Never wrap a whole script in
sudo. Nosudo bash <whatever>.sh. If a script needs many privileged ops, the script itself runs as the dev user and sudo's each one. The orchestrator never invokes a provisioning-shaped script as root (onlybootstrap.shis, by exception — see below). -
Never identity-switch to a non-root user. All of the following are forbidden — anywhere in mpd shell code, no exceptions:
sudo -u <user>,runuser -u <user>,runuser <user>,su <user>,su - <user>,su <user> -c …. If you find yourself reaching for one, the orchestrator is invoking the script with the wrong identity — fix that, don't switch in-script.Elevation to root via
su -c '<cmd>'orsu -/su -l(no target user — defaults to root) is allowed. The sandbox take-over bootstrap usessu -cto write the NOPASSWD sudoers drop-in on vanilla Debian (where the user isn't in thesudogroup yet); the same one-shot/root-only pattern assudo bash -cfrom inside the script.
Single bootstrap exception. The dev user must exist before
rule (1) can hold. Exactly one root-context script,
assets/runtime-base/bootstrap.sh, runs before the dev user exists
and creates it (along with sudoers, sshd, /etc/mpd identity, /srv
layout). The orchestrator (Mpd.Runtime provisioning step) is the
only caller. After it returns, phase 2 — assets/runtimes/<rt>/build.sh
— runs as the dev user via podman exec -u <user>. Nothing else
may invoke a script as root.
- Keep changes scoped to the requested task. No drive-by refactors.
- Update affected docs when moving/renaming files.
- Prefer additive asset changes for runtime/project-type behavior; reserve Swift edits for control-plane, state, networking, and orchestration.
- Prefer deterministic behavior over convenience fallbacks.
- Avoid cross-file doc duplication; link to canonical owners.
- For shell completion, edit
mpd/CLI/Complete.swift— the shims underassets/completions/are stable forwarders and rarely need to change. - Each
setup/<name>/directory must stay self-contained — it's released as a small standalone bundle (a handful of.sh/.ps1/.cmdplus a README) and dropped onto a fresh host before the mpd repo is cloned. Scripts in there may only reference files inside the same directory plus standard host tooling and what they pull at runtime (git clone). Do not reach into a sibling platform directory or anywhere else in the repo from bootstrap-stage code; duplicate small helpers instead. Seesetup/README.mdfor the full rule.
Verbs (host-side, surfaced as mpd <verb> <project>) and tools
(in-runtime, on PATH) are the extension surface for runtime + project-
type functionality. The contract — what a verb is vs. a tool, naming
conventions, PATH precedence, privilege model, idempotency — lives in
docs/ARCHITECTURE.md §7. Read that first;
this section is the "how to write one" follow-up.
A capability is a verb if and only if it does work that the runtime container can't do for itself. Otherwise it's a tool.
Almost everything is a tool. The verb set is fixed and tiny — create,
configure, start, stop, delete, show — all Swift, all in the
control plane. Project-type-specific functionality (cron, phpunit,
composer, …) is exposed inside the runtime container where SSH sessions
and AI agents run; you reach it via PATH after ssh user@<runtime>.runtime.mpd.test.
If you find yourself writing a verb whose body is essentially
podman exec <container> <tool>, you're writing a redundant verb.
Write only the tool.
Verbs are Swift, in mpd/Runtime/, mpd/CLI/, etc. The dispatch
table is Mpd.Project.dispatch in mpd/Runtime/Project.swift; the
public verb set is projectVerbs in mpd/main.swift.
To add a new verb:
- Add a Swift handler (a static func on
Mpd.Project) in the appropriate file (ProjectLifecycle.swiftfor lifecycle-shaped things,ProjectOperations.swiftotherwise). - Add the verb name to
projectVerbsinmpd/main.swift. - Add a dispatch case in
Mpd.Project.dispatch. - Update
Mpd.Project.showHelpso the per-project--helplists it. - Update
mpd/CLI/Complete.swiftfor shell completion (verb name + any flag suggestions inverbArgCandidates). - Update
docs/CLI_BEHAVIOR.mdand theDay-to-day commandssection indocs/USAGE.mdif it belongs in the daily surface.
Global flags (mpd --foo) live in mpd/main.swift (the
GlobalCommand ParsableCommand) plus a handler under
mpd/CLI/CommandHandlers/.
A tool is a single executable script under one of three locations, chosen by scope:
assets/runtime-base/tools/— works in any Trixie-based runtime (php, node, util, future ones). bootstrap.sh symlinks these into/srv/tools/_base/and adds that dir to PATH for every login shell. Examples:claude-install,node-install.assets/runtimes/<runtime>/tools/— runtime-wide. The runtime'sbuild.shsymlinks these into/srv/tools/<runtime>/and adds that to PATH. Examples (php):composer-install, thephpwrapper.assets/runtimes/<runtime>/project_types/<type>/tools/— only available when a project of that type is in the runtime. Symlinked into/srv/tools/<type>/, which sits highest on PATH so a type tool wins over a runtime or base tool of the same name.
Skeleton (any of the three locations):
#!/bin/bash
set -euo pipefail
# Tool: <one-line description>.
# Runs as the dev user inside the runtime. May use sudo freely
# (see ARCHITECTURE.md §7 "Privilege model").
# Walk up from $PWD to find the project root (presence of mpd.env).
PROJECT_DIR="$PWD"
while [ ! -f "$PROJECT_DIR/mpd.env" ] && [ "$PROJECT_DIR" != "/" ]; do
PROJECT_DIR="$(dirname "$PROJECT_DIR")"
done
if [ ! -f "$PROJECT_DIR/mpd.env" ]; then
echo "Not inside a project tree (no mpd.env found)" >&2
exit 1
fi
# Load the four-layer MPD_* env (runtime defaults → type defaults →
# /var/lib/mpd/env/mpd-vm.env → project mpd.env). source-mpd-env.sh uses a
# whitelist parser, so a malicious project mpd.env cannot inject code.
PROJECT_NAME="$(basename "$PROJECT_DIR")"
. /opt/mpd/assets/runtime-base/lib/source-mpd-env.sh
# Do the work. Idempotent if -install or -init.
...Tools are bind-mounted at /opt/mpd/assets/runtimes/<rt>/..., symlinked
into /srv/tools/<type>/, and added to PATH at runtime provision time.
Edits on the host (or VM) are immediately visible inside the runtime —
no rebuild step.
- Bare name for upstream-known tools (
composer,php,phpunit). mdl-prefix for Moodle-specific operations whose bare name would collide with system commands or be too generic (mdl-cron,mdl-cache-purge,mdl-install).-installsuffix for "fetch the binary itself" (runtime-wide, one-shot, idempotent). Drops the binary into runtime FS — typically/usr/local/bin/— never under/srv/.-initsuffix for "ready a project for the tool" (project-scoped, idempotent, may run many times across projects).
-install tools must no-op cleanly when the target binary already
exists. -init tools must no-op cleanly when the project is already
initialized. Orchestrators call dependencies blindly without
guard-checking, so each tool guards its own work:
# composer-install pattern
if [ -x /usr/local/bin/composer ]; then exit 0; fi
# ... fetch + verify + install ...# phpunit-init pattern
if mysql -u root -e "USE phpu_${PROJECT}_db" 2>/dev/null; then exit 0; fi
# ... drop-and-recreate ...Tools whose bare name overrides a system binary (e.g. tools/php
shadowing /usr/bin/php) must exec the upstream binary by absolute
path. Otherwise the wrapper recurses into itself:
# WRONG — recurses
exec php "$@"
# RIGHT — absolute path
exec /usr/bin/php8.4 "$@"Look up the resolved version (e.g. from
/srv/meta/<project>/project.json) and exec the matching
/usr/bin/phpX.Y directly.
Tools run as the dev user with passwordless sudo available. Use
sudo inside the tool for the operations that need root —
the dev/AI never types sudo toolname:
# correct — internal sudo on the privileged op:
sudo install -m 755 "$TMPDIR/binary" /usr/local/bin/binary
sudo systemctl restart php8.4-fpm
# correct — caller invokes the tool bare:
$ composer-installDon't write tools that self-elevate (if [ uid != 0 ]; then exec sudo "$0"; fi) — running the entire script as root via wholesale
escalation breaks least-privilege. Don't expect the caller to type
sudo toolname either — sudo's secure_path doesn't include
/srv/tools/, so the bare invocation through sudo would fail "command
not found." Internal sudo on specific operations is the right shape.
- Rebuild the runtime:
mpd --runtime-delete <rt>then recreate via project create (ormpd --runtime-create=<rt>). - SSH in:
ssh user@<rt>.runtime.mpd.test. which <new-tool>resolves to the expected path under/srv/tools/.- Run with no project context (negative test) — should fail gracefully with an actionable message.
cd /srv/projects/<project>/and run again — should succeed.- Re-run immediately (the idempotent path). Exits 0, no duplication.
- For
-installtools: confirm the binary lands under/usr/local/bin/or similar runtime FS, never/srv/.
- The tool itself (executable,
chmod +x). - If the tool deserves dev-facing mention: add a one-line entry under
"Tools available inside the runtime" in
docs/USAGE.md. - If it replaces an existing verb (verb→tool migration): delete the obsolete verb files and update any host-side callers that referenced the verb by name.
Build / static checks (run after any code or asset change):
make install(writesbin/mpd)- skim affected docs for stale path / link references when moving or renaming files.
Throw-away-VM smoke checks (rerun freely — all idempotent):
- fresh VM via
setup/sandbox/take-over-sandbox-vm.sh(or the matched-host bootstrap oncempd-virtlands) mpd --setup,mpd --start,mpd --status- optional:
mpd create/start/stop <project>end-to-end including HTTPS hit mpd --stop