// Runtime smoke test against a real `opencode serve` (v2). No model calls, no tokens spent. // // npm run build && node scripts/smoke.ts [--keep] // // Builds a temp fixture project whose opencode.json loads this repo's dist/ directory as a local // plugin (local plugin entries must be DIRECTORIES with a root index file; file entries are // ignored), installs the agents with our own init, boots a private server, and asserts over the // HTTP API (Basic auth user `opencode`, password printed by serve): // - plugin haylan.pocock reaches state.status "active" // - every manifest skill id is registered and user-invoked ones have autoinvoke=false // - agents pocock (primary), pocock-worker / pocock-reviewer (subagent) are discovered and the // worker's skill deny + allow-list survived frontmatter decoding // Env: OPENCODE_BIN (default "opencode"), SMOKE_PORT (default random). import { spawn, spawnSync } from "node:child_process" import { mkdtempSync, rmSync, writeFileSync, mkdirSync, existsSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" import { fileURLToPath } from "node:url" import { runInit } from "../src/init.ts" import { loadManifest, packageRoot } from "../src/manifest.ts" const BIN = process.env.OPENCODE_BIN ?? "opencode" const PORT = Number(process.env.SMOKE_PORT ?? 40000 + Math.floor(Math.random() * 20000)) const BASE = `http://127.0.0.1:${PORT}` function assert(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(`smoke FAIL: ${message}`) } async function waitReady(timeoutMs = 90_000): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { try { const res = await fetch(BASE + "/") if (res.ok) return } catch { /* not up yet */ } await new Promise((r) => setTimeout(r, 500)) } throw new Error("smoke FAIL: server did not come up in time") } async function main() { if (!existsSync(path.join(packageRoot, "dist", "index.js"))) { throw new Error("smoke FAIL: dist/index.js missing - run `npm run build` first") } const manifest = loadManifest() const fixture = mkdtempSync(path.join(tmpdir(), "pocock-smoke-")) let server: ReturnType | undefined let out = "" try { mkdirSync(path.join(fixture, ".opencode"), { recursive: true }) writeFileSync( path.join(fixture, "opencode.json"), JSON.stringify( { $schema: "https://opencode.ai/config.json", plugins: [path.join(packageRoot, "dist")] }, null, 2, ), ) runInit({ argv: ["init"], cwd: fixture, pkgRoot: packageRoot }) server = spawn(BIN, ["serve", "--hostname", "127.0.0.1", "--port", String(PORT)], { cwd: fixture, stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32", }) const collect = (chunk: Buffer) => (out += chunk.toString()) server.stdout?.on("data", collect) server.stderr?.on("data", collect) await waitReady() const pw = /server password (\S+)/.exec(out)?.[1] assert(pw, `server password not found in output:\n${out.slice(0, 400)}`) const auth = { Authorization: "Basic " + Buffer.from(`opencode:${pw}`).toString("base64") } const get = async (p: string) => { const res = await fetch(BASE + "/api" + p, { headers: auth }) assert(res.ok, `GET /api${p} -> ${res.status}`) return res.json() as Promise<{ data?: unknown } & Record> } // Plugin loads (lazy: poll until it appears) let plugin: { id: string; state?: { status?: string } } | undefined const deadline = Date.now() + 60_000 while (!plugin && Date.now() < deadline) { const body = (await get("/plugin")) as { data: { id: string; state?: { status: string } }[] } plugin = body.data.find((p) => p.id === "haylan.pocock") if (!plugin) await new Promise((r) => setTimeout(r, 1000)) } assert(plugin, "plugin haylan.pocock never appeared in /api/plugin") assert(plugin!.state?.status === "active", `plugin state: ${JSON.stringify(plugin!.state)}`) console.log("ok plugin haylan.pocock active") // Skills registered with correct autoinvoke flags (ids may also exist from global installs) const skillBody = (await get("/skill")) as { data: { id: string; autoinvoke?: boolean }[] } const byId = new Map(skillBody.data.map((s) => [s.id, s])) for (const entry of manifest) { const found = byId.get(entry.id) assert(found, `skill '${entry.id}' not registered`) // the API omits autoinvoke when it defaults to true const actual = found!.autoinvoke ?? true assert( actual === entry.autoinvoke, `skill '${entry.id}' autoinvoke=${actual}, want ${entry.autoinvoke}`, ) } console.log( `ok ${manifest.length} skills registered (${manifest.filter((m) => !m.autoinvoke).length} hidden from model)`, ) // Agents discovered with correct modes and the worker's permission decoding intact const agentBody = (await get("/agent")) as { data: { id: string mode: string permissions: { action: string; resource: string; effect: string }[] }[] } const agents = new Map(agentBody.data.map((a) => [a.id, a])) assert(agents.get("pocock")?.mode === "primary", "pocock is not primary") assert(agents.get("pocock-reviewer")?.mode === "subagent", "pocock-reviewer is not subagent") const worker = agents.get("pocock-worker") assert(worker?.mode === "subagent", "pocock-worker is not subagent") const rules = worker!.permissions const has = (action: string, resource: string, effect: string) => rules.some((r) => r.action === action && r.resource === resource && r.effect === effect) assert(has("skill", "*", "deny"), "worker skill deny * missing") assert(has("skill", "tdd", "allow"), "worker tdd allow missing") assert(has("shell", "git push*", "deny"), "worker git push deny missing") assert(has("subagent", "*", "deny"), "worker subagent deny missing") console.log("ok agents pocock/pocock-worker/pocock-reviewer with permissions decoded") console.log("smoke PASS") } finally { // shell:true on Windows means kill() only reaches cmd.exe; kill the whole tree. if (server?.pid) { if (process.platform === "win32") spawnSync("taskkill", ["/PID", String(server.pid), "/T", "/F"]) else server.kill() } // Windows holds locks briefly after kill: retry, and never let cleanup mask the real error. try { if (process.argv.includes("--keep")) console.log(`fixture kept at ${fixture}`) else rmSync(fixture, { recursive: true, force: true, maxRetries: 10, retryDelay: 300 }) } catch (cleanupErr) { console.log(`warning: could not remove fixture ${fixture}: ${(cleanupErr as Error).message}`) } } } if (process.argv[1] === fileURLToPath(import.meta.url)) { main() .then(() => process.exit(0)) .catch((err) => { console.error(err.message) process.exit(1) }) }