A disposable, isolated Docker test lab for OpenCode V2 plugins. Point it at
any plugin and it builds the plugin, loads it in the real opencode runtime,
and checks the things plugin authors actually care about: does it load, do its
tools register, does it collide with other plugins, does it survive running next
to a broken plugin, does it clean up, and what exactly gets installed.
It does not touch your OpenCode installation, never mounts a development repository into a container, and runs entirely offline after the image is built.
- Default manifest works out of the box — no external plugins required; it tests two tiny bundled example plugins.
- Bring your own plugin — a local source directory, a packed tarball, or an already-built package, configurable by name and path.
- Test several together — load-order permutations, tool-name collisions and failure isolation are generic.
- Optional regression profile — reproduce the original three-plugin compatibility matrix without making those plugins a dependency of the lab.
The lab is a standalone developer tool, not a loadable OpenCode plugin. See Ecosystem & distribution.
git clone https://github.com/gitdamnit/opencode-plugin-lab
cd opencode-plugin-lab
make build # stage the bundled example plugins + build the Docker image
make start # smoke-check the default configuration
make suite # full generic suite across every configuration
make report # print the latest reportmake discover lists the plugins and configurations in the active manifest.
make test-plugin PLUGIN=/path/to/your-pluginThat generates a temporary single-plugin manifest, stages and builds your plugin,
and runs the full generic suite. Nothing is committed. Add NAME=...,
TYPE=directory|package|tarball or ENTRY=index.js to override the guesses.
- Docker with Compose v2 (BuildKit)
- A POSIX host with
bash,make,git,node(the scripts are bash + Node ESM; macOS and Linux are supported, Windows via WSL2) - Network access during
make buildonly (npm installs, base image, OpenCode CLI). Once built, every test runs withnetwork_mode: "none".
| Version | Status |
|---|---|
| 2.0.21 | Reproducible tested baseline. The pinned version in every committed manifest and in the historical evidence. |
| 2.0.32 | Verified by evaluation (make build OPENCODE_VERSION=2.0.32 && make suite): 76 pass / 0 fail / 5 skip, identical to 2.0.21. Not the pinned default. |
The OpenCode V2 line moves quickly. The lab never silently follows latest: a
manifest pins an exact version, make build OPENCODE_VERSION=… selects another
for a one-off evaluation, and the exact installed version is recorded in every
report. See docs/OPENCODE_COMPATIBILITY.md.
host container (opencode-plugin-lab:local, no network)
lab.config.json ──► scripts/stage.mjs ──► /opt/lab/plugins/<name> (built lab packages)
(manifest) staging/<name>/ /opt/lab/harness/run.mjs (orchestrator)
/opt/lab/lab.config.json (active manifest)
reports/ ◄──────────── reports ◄───────── /lab/reports (bind mount, output only)
/lab/{home,xdg,work} (tmpfs, disposable)
A manifest declares plugins and configurations. Staging copies each plugin's
source (allowlisted, credential-scanned) into the build context; the builder
installs dependencies and runs the plugin's build; the runtime image loads the
built packages by absolute path through OpenCode's official plugins
configuration. See docs/ARCHITECTURE.md.
lab.config.json ships four configurations and two example plugins:
| Config | Plugins | What it proves |
|---|---|---|
baseline |
none | the runtime boots cleanly with no external plugins |
example-plugin |
example-plugin |
a compiled plugin registers a tool and cleans up |
single-plugin |
example-plugin (+ option override) |
a single plugin with explicit options |
multi-plugin |
example-plugin, example-plugin-2 |
a tool registerer and a no-tool plugin coexist |
Test one: make test CONFIG=multi-plugin TIER=validate.
| Tier | What runs |
|---|---|
smoke |
server boots; plugins load; package inspection |
functional |
smoke + package inspection |
model |
offline model-request path via a local mock provider |
integration |
smoke + load-order permutations |
security |
configuration validation & error handling |
validate / all |
everything: per-config runtime + model, plus every global suite |
| Suite | What it proves |
|---|---|
package-inspection |
package metadata, SDK resolution, entry point, build output, provenance |
load-order |
every load-order permutation + duplicate entry; tool ids unique, no conflicts |
failure-isolation |
a plugin that throws in setup, and one with an unresolvable import, loaded first and last — everything else stays healthy |
process-cleanup |
graceful shutdown exits the process, releases the port, leaves no orphans |
install-artifact |
the npm packed artifact (not just the build tree) loads in the runtime |
config-validation |
bad configuration input is rejected with a useful error, not a crash |
Capabilities are respected: a plugin that registers no tools is not failed for "missing tools" — the assertion is skipped and reported as such.
The full JSON Schema is schema/lab.config.schema.json;
a walkthrough with copy-paste examples is docs/CONFIGURATION.md.
node scripts/lab-manifest.mjs validate # lint the default manifest
node scripts/lab-manifest.mjs validate --manifest my-lab.config.json
node scripts/lab-manifest.mjs discover # show sources/versions/commitsMinimal single-plugin manifest:
Plugin inputs supported: directory (built), package (prebuilt, used as-is),
tarball (a local .tgz), and npm (opt-in only via allowRemote; never fetched
silently). No plugin is assumed to be in a particular npm scope, and public
manifests contain no hardcoded home paths.
The lab originally validated three independent plugins. That work is preserved as an optional profile — not a dependency of the default lab:
make build PROFILE=gitdamnit # stage + build the three plugins
make suite PROFILE=gitdamnit # full suite incl. their specialized assertions
make test-checkpoint PROFILE=gitdamnitSource paths default to $HOME/Projects/... and are overridable without editing
the profile:
CHECKPOINT_ORCHESTRATOR_DIR=... GIT_THAT_SHIT_DIR=... STRANGER_DANGER_DIR=... \
make build PROFILE=gitdamnitSee profiles/gitdamnit/README.md. The original
145 passed / 0 failed run and its release-candidate evidence are committed,
unchanged, and clearly labelled as historical under
reports/historical/. They are not
relabelled as validation of this generalized lab.
A manifest can add its own suites for a plugin-specific capability:
- per-plugin:
"tests": ["tests/my-plugin.mjs"] - manifest-wide:
"globalSuites": [{ "name": "my-suite", "module": "tests/suite.mjs" }]
Each module exports run({ tier, plugin }). See
docs/CONFIGURATION.md and the profile
tests for complete examples. Extensions run only when their manifest is
active — a fresh user never runs the three-plugin assertions.
Every run writes a dated, self-contained report and leaves the previous ones untouched:
reports/runs/<run-id>/test-report.json machine-readable (schema v1)
reports/runs/<run-id>/test-report.md human-readable
reports/test-report.{json,md} convenience pointer to the latest run
Reports record: the manifest (name, path, digest), OpenCode and Node versions, container image id, per-plugin version/commit/branch/dirty/source-type, the exact requested configuration, PASS/FAIL/SKIP counts by area, failures with logs, security warnings, and a reproduction command. Credentials are never written.
make rc-evidence additionally produces reports/rc-evidence.{json,md}:
source → staged → packed tarball (+ sha256) → installed-in-image, with a
consistency check.
The offline lab service: network_mode: "none", read_only rootfs with small
tmpfs scratch mounts, all capabilities dropped, no-new-privileges, runs as your
host uid/gid, no published ports, resource limits, restart: "no". Only
./reports is bind-mounted out; the plugin repositories are never mounted into a
container. The only networked service is lab-online, behind the online
profile, used exclusively by make test-online.
Never mounted: host SSH/npm/cloud credentials, your real OpenCode configuration, entire home directories, or the Docker daemon socket.
Rootless Docker reduces risk but is not an absolute boundary against malicious
plugin code. Plugin source is built (dependencies + build scripts run) and then
loaded by the runtime; review untrusted plugins before pointing the lab at them.
Install-time lifecycle scripts are disabled by default (install.scripts: false);
a plugin that needs them must opt in. See
docs/SECURITY.md.
make help Show all commands
make build Stage sources + build the image
make stage Stage manifest sources only
make discover Show plugin sources, versions, commits, configs
make start Build if needed + smoke-check the default config
make stop Stop lab containers
make logs Container logs + latest report
make test CONFIG=x TIER=y Run one configuration/tier
make suite Full suite across every configuration
make validate Full validation incl. all global suites
make rc-evidence Source/packed/installed version evidence
make test-plugin PLUGIN=… Build and test an arbitrary local plugin
make profile PROFILE=… Build + full suite for an optional profile
make test-online Optional online model test (.env required)
make refresh Re-stage sources and rebuild
make reset Reset disposable test data
make report Print the latest report
make shell Interactive shell in the lab container
make clean Remove lab image, staged sources and reports
Select a manifest with PROFILE=<name> (see profiles/) or LAB_MANIFEST_FILE=<path>.
- Rootless Docker maps container UIDs into a subuid range, so a host-owned
directory is not writable by a non-root container.
scripts/lib.shchmods./reportsto0777for this reason; if a fresh run cannot write reports, checkdocker inforeportsrootless. configured plugin path must be a directory— plugins are loaded as package directories, not file paths.Cannot find package '@opencode/plugin'— a built package is missing its SDK dependency. Re-runmake build; the builder installs it next to every plugin.- Plugin shows
failedin/api/plugin— the report embeds the server log for failing suites; the plugin'ssetupmost likely threw. - A plugin build fails — the image build fails loudly on purpose rather than
shipping a stale artifact. Run
make discoverand check the staged source.
This lab is a standalone developer/testing tool. It is not a loadable
OpenCode runtime plugin, and it is not published to any plugin registry. It is
distributed as source on GitHub and run with Docker. See
docs/RELEASE_CHECKLIST.md for the publication
procedure and the suggested repository description and topics.
MIT — see LICENSE and THIRD_PARTY_NOTICES.md.
Change history: CHANGELOG.md.
{ "$schema": "./schema/lab.config.schema.json", "version": 1, "plugins": [ { "name": "my-plugin", "source": { "type": "directory", "path": "../my-plugin" }, "build": { "command": "npm run build", "dist": "dist", "entry": "index.js" }, "capabilities": { "tools": true }, "expectTools": ["my_tool"] } ], "configs": [ { "name": "my-plugin", "plugins": ["my-plugin"] } ] }