Everything the lab does is driven by a single manifest — a JSONC file that
declares which plugins to test and how to run them. The default is
lab.config.json; optional profiles live under
profiles/. The machine-readable schema is
schema/lab.config.schema.json.
Validate any manifest with:
node scripts/lab-manifest.mjs validate --manifest path/to/lab.config.jsonvalidate prints actionable errors (unknown plugin referenced by a config,
missing source.type, a path that references an unset variable, a tests module
that does not exist, contradictions between expectTools and
capabilities.tools, …).
versionmust be1.opencode.versionpins@opencode/cli;sdkVersionpins@opencode/pluginand defaults toversion.make build OPENCODE_VERSION=…overrides for a one-off evaluation; the exact installed version is recorded in every report.defaultConfigis whatmake startand a baremake testrun.
{
"name": "my-plugin", // required, stable lab id
"package": "@me/my-plugin", // optional, informational
"id": "me.my-plugin", // optional runtime plugin id
"source": { "type": "directory", "path": "../my-plugin" },
"build": { "command": "npm run build", "dist": "dist", "entry": "index.js" },
"options": { "greeting": "hi" }, // passed to the plugin at load
"capabilities": { "tools": true },
"expectTools": ["my_tool"],
"setupMarker": "[my-plugin] setup", // optional: asserted in the log
"cleanupMarker": "[my-plugin] cleanup", // optional: asserted after shutdown
"expectedWrites": [".my-plugin-state"], // optional: allowed project writes
"tests": ["tests/my-plugin.mjs"], // optional: plugin-specific suite
"notes": "free text recorded in reports"
}type |
Meaning | Notes |
|---|---|---|
directory |
a local source tree | dependencies installed and the build command run |
package |
an already-built package directory | used as-is (set build.rootEntry: true) |
tarball |
a local .tgz |
extracted; built unless rootEntry |
npm |
an npm reference (name@1.2.3) |
never fetched unless source.allowRemote: true |
Paths are resolved relative to the manifest file and support ~, $HOME,
${VAR} and ${VAR:-default}. Public manifests should not contain absolute
home paths — use a variable with a default:
"source": { "type": "directory", "path": "${MY_PLUGIN_DIR:-$HOME/Projects/my-plugin}" }If a source is not a git worktree (an exported tree, a tarball), record the commit it corresponds to so provenance is complete:
"source": { "type": "directory", "path": "/tmp/pin/my-plugin", "commit": "65bf6981eee5…" }- Compiled (default):
build.commandruns (or the package's ownbuildscript), anddist/<entry>is packaged behind anindex.tsshim. - Prebuilt: set
build.rootEntry: trueand pointentryat the file at the package root (e.g.index.js). No build runs.
install.scripts (default false) controls whether npm lifecycle scripts run
during dependency installation. Enable it only for plugins that need it; it is a
supply-chain risk.
The harness only asserts what a plugin declares, and reports the rest as skipped:
"capabilities": { "tools": false, "hooks": false, "storage": false, "redaction": false, "network": false }tools— registers tools;expectToolslists their ids.redaction— transforms model requests; the offline model-path suite then asserts a synthetic secret is absent from the transmitted request.network— makes outbound calls; only exercised in the gated online tier.storage— keeps its own state; the generic write-boundary check covers it.
{
"name": "multi-plugin",
"description": "two plugins together",
"plugins": ["my-plugin", "another-plugin"], // load order
"options": { "my-plugin": { "greeting": "hi" } }, // per-plugin overrides
"settings": { "compaction": { "auto": false } }, // extra opencode keys
"template": "configs/base.opencode.jsonc" // optional JSONC base
}The plugin list is always generated from the manifest. settings and template
let you add non-plugin runtime keys (providers, compaction, …). A template may
use the __REPORTS__ placeholder; the generated plugin list and the lab
inspector are applied on top of it.
make test-plugin PLUGIN=/path/to/my-pluginor, as a committed manifest:
{
"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"] }]
}{
"version": 1,
"plugins": [
{ "name": "alpha", "source": { "type": "package", "path": "./prebuilt/alpha" }, "build": { "rootEntry": true, "entry": "index.js" }, "capabilities": { "tools": true }, "expectTools": ["alpha_run"] },
{ "name": "beta", "source": { "type": "directory", "path": "../beta" }, "build": { "command": "npm run build", "entry": "index.js" }, "capabilities": { "tools": false } }
],
"configs": [
{ "name": "alpha", "plugins": ["alpha"] },
{ "name": "beta", "plugins": ["beta"] },
{ "name": "both", "plugins": ["alpha", "beta"] }
]
}The load-order global suite permutes the plugins in the widest configuration
and detects tool-name collisions and duplicate entries automatically.
"source": { "type": "tarball", "path": "./artifacts/my-plugin-1.2.3.tgz" },
"build": { "rootEntry": true, "entry": "dist/index.js" }"source": { "type": "npm", "ref": "@me/my-plugin@1.2.3", "allowRemote": true }Without allowRemote: true the manifest validates but the build refuses to fetch
— remote packages are never pulled silently.
Per-plugin suite ("tests") — runs once, gated on the plugin being built:
// tests/my-plugin.mjs
import { Suite, assert } from "../../../harness/lib/suite.mjs";
import { PLUGINS } from "../../../harness/lib/env.mjs";
import { join } from "node:path";
export async function run({ tier = "validate", plugin }) {
const suite = new Suite(`plugin/${plugin}`, { tier });
await suite.test("my-plugin.smoke", () => {
assert(PLUGINS[plugin].tools.length > 0, "declares tools");
return { packageDir: PLUGINS[plugin].packageDir };
});
return suite.summary();
}Manifest-wide suite ("globalSuites"):
"globalSuites": [{ "name": "my-suite", "module": "tests/my-suite.mjs" }]Both shapes export run({ tier, plugin, name }) and return a Suite#summary().
Extensions run only when their manifest is active, so a fresh clone never
runs them.
A profile is just a manifest under profiles/<name>/. Activate it with
PROFILE=<name>:
make build PROFILE=gitdamnit
make suite PROFILE=gitdamnitor point at any manifest file with LAB_MANIFEST_FILE=/abs/path make build.
{ "$schema": "./schema/lab.config.schema.json", "version": 1, "name": "my lab", "opencode": { "version": "2.0.21", "sdkVersion": "2.0.21" }, "defaultConfig": "baseline", "plugins": [ /* see below */ ], "configs": [ /* see below */ ], "globalSuites": [ /* optional, see Test extensions */ ] }