diff --git a/plugin/dist/build-manifest.json b/plugin/dist/build-manifest.json index 4a8bdad..0b5d13a 100644 --- a/plugin/dist/build-manifest.json +++ b/plugin/dist/build-manifest.json @@ -1,6 +1,6 @@ { "entrypoint": "skill-creator.ts", "runtimeEntrypoint": "runtime-entry.ts", - "sourceHash": "fe3ce5066e4462246b3abd27a2882e6fe250f1f16eced5b202b0434eaff6e329", - "builtAt": "2026-05-19T16:18:47.130Z" + "sourceHash": "1bc2a2e2b02cfbbff2c6100d940b5c56bbcd9bd3e923c7d30e080b3b509ef164", + "builtAt": "2026-05-24T23:12:56.138Z" } diff --git a/plugin/dist/package.json b/plugin/dist/package.json index bc338e9..e573ebf 100644 --- a/plugin/dist/package.json +++ b/plugin/dist/package.json @@ -1,6 +1,6 @@ { "name": "opencode-skill-creator", - "version": "0.2.15", + "version": "0.2.16", "description": "OpenCode plugin for skill creation — custom tools for validation, evaluation, description optimization, benchmarking, and review serving.", "type": "module", "main": "./dist/skill-creator.js", diff --git a/plugin/dist/skill-creator.js b/plugin/dist/skill-creator.js index 0525af0..4c30c4d 100644 --- a/plugin/dist/skill-creator.js +++ b/plugin/dist/skill-creator.js @@ -13928,6 +13928,7 @@ function generateMarkdown(benchmark) { } // lib/review-server.ts +import { spawn } from "child_process"; import { existsSync as existsSync4, mkdirSync as mkdirSync3, @@ -13936,6 +13937,7 @@ import { statSync as statSync2, writeFileSync as writeFileSync5 } from "fs"; +import { createServer } from "http"; import { basename as basename2, extname, join as join6, relative } from "path"; var METADATA_FILES = new Set(["transcript.md", "user_notes.md", "metrics.json"]); var TEXT_EXTENSIONS = new Set([ @@ -13967,6 +13969,7 @@ var TEXT_EXTENSIONS = new Set([ ".toml" ]); var IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]); +var MAX_FEEDBACK_BODY_BYTES = 1e6; var MIME_OVERRIDES = { ".svg": "image/svg+xml", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", @@ -14205,14 +14208,60 @@ function generateReviewHtml(opts) { const dataJson = JSON.stringify(embedded); return template.replace("/*__EMBEDDED_DATA__*/", `const EMBEDDED_DATA = ${dataJson};`); } -async function killPort(port) { - try { - const proc = Bun.spawn(["lsof", "-ti", `:${port}`], { + +class PayloadTooLargeError extends Error { + constructor() { + super("Payload too large"); + this.name = "PayloadTooLargeError"; + Object.setPrototypeOf(this, PayloadTooLargeError.prototype); + } +} +function readStream(stream, maxBytes) { + return new Promise((resolve, reject) => { + let body = ""; + let bytes = 0; + let rejected = false; + stream.setEncoding("utf-8"); + stream.on("data", (chunk) => { + if (rejected) + return; + const chunkBytes = Buffer.byteLength(chunk, "utf-8"); + if (bytes + chunkBytes > maxBytes) { + rejected = true; + reject(new PayloadTooLargeError); + return; + } + bytes += chunkBytes; + body += chunk; + }); + stream.on("end", () => { + if (!rejected) + resolve(body); + }); + stream.on("error", reject); + }); +} +function runCommand(command, args) { + return new Promise((resolve) => { + const proc = spawn(command, args, { stdout: "pipe", stderr: "ignore" }); - const text = await new Response(proc.stdout).text(); - await proc.exited; + let text = ""; + proc.stdout?.setEncoding("utf-8"); + proc.stdout?.on("data", (chunk) => { + text += chunk; + }); + proc.on("error", (error45) => resolve({ ok: false, stdout: text, error: error45 })); + proc.on("close", (code) => resolve({ ok: code === 0, stdout: text })); + }); +} +async function killPort(port) { + if (!Number.isInteger(port) || port <= 0) + return; + try { + const result = await runCommand("lsof", ["-ti", `:${port}`]); + const text = result.stdout; for (const pidStr of text.trim().split(` `)) { const pid = parseInt(pidStr.trim(), 10); @@ -14227,6 +14276,110 @@ async function killPort(port) { } } catch {} } +function jsonResponse(body, status = 200) { + return { + status, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }; +} +function textResponse(body, status = 200, contentType = "text/plain") { + return { + status, + headers: { "Content-Type": contentType }, + body + }; +} +async function handleReviewRequest(method, requestUrl, requestBody, context) { + const url2 = new URL(requestUrl, "http://localhost"); + if (method === "GET" && (url2.pathname === "/" || url2.pathname === "/index.html")) { + const runs = findRuns(context.workspace); + let benchmark = null; + if (context.benchmarkPath && existsSync4(context.benchmarkPath)) { + try { + benchmark = JSON.parse(readFileSync4(context.benchmarkPath, "utf-8")); + } catch {} + } + const html = generateReviewHtml({ + runs, + skillName: context.skillName, + previous: context.previous, + benchmark, + templatePath: context.templatePath + }); + return textResponse(html, 200, "text/html; charset=utf-8"); + } + if (method === "GET" && url2.pathname === "/api/feedback") { + let data = "{}"; + if (existsSync4(context.feedbackPath)) { + try { + data = readFileSync4(context.feedbackPath, "utf-8"); + } catch {} + } + return textResponse(data, 200, "application/json"); + } + if (method === "POST" && url2.pathname === "/api/feedback") { + let body; + try { + body = JSON.parse(requestBody); + } catch (e) { + return jsonResponse({ error: String(e) }, 400); + } + if (!isValidFeedbackPayload(body)) { + return jsonResponse({ error: "Expected JSON object with a valid 'reviews' array" }, 400); + } + try { + writeFileSync5(context.feedbackPath, JSON.stringify(body, null, 2) + ` +`); + } catch (e) { + return jsonResponse({ error: String(e) }, 500); + } + return jsonResponse({ ok: true }); + } + return textResponse("Not Found", 404); +} +async function handleNodeRequest(req, res, context) { + try { + const body = req.method === "POST" ? await readStream(req, MAX_FEEDBACK_BODY_BYTES) : ""; + const result = await handleReviewRequest(req.method ?? "GET", req.url ?? "/", body, context); + res.writeHead(result.status, result.headers); + res.end(result.body); + } catch (e) { + if (e instanceof PayloadTooLargeError) { + res.writeHead(413, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: e.message })); + return; + } + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: String(e) })); + } +} +function listen(server, port) { + return new Promise((resolve, reject) => { + const onError = (error45) => { + server.off("error", onError); + reject(error45); + }; + server.once("error", onError); + server.listen(port, "127.0.0.1", () => { + server.off("error", onError); + resolve(); + }); + }); +} +function closeServer(server, sockets) { + for (const socket of sockets) { + socket.destroy(); + } + return new Promise((resolve, reject) => { + server.close((error45) => { + if (error45) + reject(error45); + else + resolve(); + }); + }); +} async function serveReview(opts) { const { workspace, @@ -14247,86 +14400,45 @@ async function serveReview(opts) { previous = loadPreviousIteration(previousWorkspace); } await killPort(port); - let actualPort = port; - const server = Bun.serve({ - port, - hostname: "127.0.0.1", - async fetch(req) { - const url2 = new URL(req.url); - if (req.method === "GET" && (url2.pathname === "/" || url2.pathname === "/index.html")) { - const runs = findRuns(workspace); - let benchmark = null; - if (benchmarkPath && existsSync4(benchmarkPath)) { - try { - benchmark = JSON.parse(readFileSync4(benchmarkPath, "utf-8")); - } catch {} - } - const html = generateReviewHtml({ - runs, - skillName, - previous, - benchmark, - templatePath - }); - return new Response(html, { - headers: { "Content-Type": "text/html; charset=utf-8" } - }); - } - if (req.method === "GET" && url2.pathname === "/api/feedback") { - let data = "{}"; - if (existsSync4(feedbackPath)) { - try { - data = readFileSync4(feedbackPath, "utf-8"); - } catch {} - } - return new Response(data, { - headers: { "Content-Type": "application/json" } - }); - } - if (req.method === "POST" && url2.pathname === "/api/feedback") { - let body; - try { - body = await req.json(); - } catch (e) { - return new Response(JSON.stringify({ error: String(e) }), { - status: 400, - headers: { "Content-Type": "application/json" } - }); - } - if (!isValidFeedbackPayload(body)) { - return new Response(JSON.stringify({ error: "Expected JSON object with a valid 'reviews' array" }), { - status: 400, - headers: { "Content-Type": "application/json" } - }); - } - try { - writeFileSync5(feedbackPath, JSON.stringify(body, null, 2) + ` -`); - } catch (e) { - return new Response(JSON.stringify({ error: String(e) }), { - status: 500, - headers: { "Content-Type": "application/json" } - }); - } - return new Response(JSON.stringify({ ok: true }), { - headers: { "Content-Type": "application/json" } - }); - } - return new Response("Not Found", { status: 404 }); - } + const context = { + workspace, + skillName, + feedbackPath, + previous, + benchmarkPath, + templatePath + }; + const server = createServer((req, res) => { + handleNodeRequest(req, res, context); + }); + const sockets = new Set; + server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => { + sockets.delete(socket); + }); }); - actualPort = server.port; + await listen(server, port); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Review server did not bind to a TCP port"); + } + const actualPort = address.port; const serverUrl = `http://localhost:${actualPort}`; if (openBrowser) { try { - Bun.spawn(["open", serverUrl], { stdout: "ignore", stderr: "ignore" }); + const openProc = spawn("open", [serverUrl], { + detached: true, + stdio: "ignore" + }); + openProc.unref(); } catch {} } return { server, url: serverUrl, feedbackPath, - stop: () => server.stop() + stop: () => closeServer(server, sockets) }; } function exportStaticReview(opts) { @@ -15041,7 +15153,7 @@ var SkillCreatorPlugin = async (ctx) => { const prep = prepareReviewLaunch(args); const existing = activeServers.get(args.workspace); if (existing) { - existing.stop(); + await existing.stop(); activeServers.delete(args.workspace); } const templatePath = join10(TEMPLATES_DIR, "viewer.html"); @@ -15079,7 +15191,7 @@ var SkillCreatorPlugin = async (ctx) => { if (args.workspace) { const srv = activeServers.get(args.workspace); if (srv) { - srv.stop(); + await srv.stop(); activeServers.delete(args.workspace); return JSON.stringify({ stopped: args.workspace }); } @@ -15087,7 +15199,7 @@ var SkillCreatorPlugin = async (ctx) => { } const stopped = []; for (const [ws, srv] of activeServers) { - srv.stop(); + await srv.stop(); stopped.push(ws); } activeServers.clear(); diff --git a/plugin/lib/review-server.ts b/plugin/lib/review-server.ts index 1508ae6..e712a11 100644 --- a/plugin/lib/review-server.ts +++ b/plugin/lib/review-server.ts @@ -5,11 +5,12 @@ * * Reads a workspace directory, discovers runs (directories with outputs/), * embeds all output data into a self-contained HTML page, and serves it via - * Bun.serve(). Feedback auto-saves to feedback.json in the workspace. + * a local HTTP server. Feedback auto-saves to feedback.json in the workspace. * - * No external dependencies beyond Bun built-ins are required. + * No external runtime dependencies are required. */ +import { spawn } from "node:child_process" import { existsSync, mkdirSync, @@ -18,6 +19,9 @@ import { statSync, writeFileSync, } from "fs" +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http" +import type { AddressInfo } from "node:net" +import type { Socket } from "node:net" import { basename, extname, join, relative } from "path" // --------------------------------------------------------------------------- @@ -37,6 +41,9 @@ const TEXT_EXTENSIONS = new Set([ /** Extensions rendered as inline images. */ const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]) +/** Maximum accepted feedback request body size. */ +const MAX_FEEDBACK_BODY_BYTES = 1_000_000 + /** MIME type overrides for common types. */ const MIME_OVERRIDES: Record = { ".svg": "image/svg+xml", @@ -382,14 +389,67 @@ export function generateReviewHtml(opts: { // Port-killing utility // --------------------------------------------------------------------------- -async function killPort(port: number): Promise { - try { - const proc = Bun.spawn(["lsof", "-ti", `:${port}`], { +class PayloadTooLargeError extends Error { + constructor() { + super("Payload too large") + this.name = "PayloadTooLargeError" + Object.setPrototypeOf(this, PayloadTooLargeError.prototype) + } +} + +interface CommandResult { + ok: boolean + stdout: string + error?: Error +} + +function readStream(stream: IncomingMessage, maxBytes: number): Promise { + return new Promise((resolve, reject) => { + let body = "" + let bytes = 0 + let rejected = false + stream.setEncoding("utf-8") + stream.on("data", (chunk) => { + if (rejected) return + const chunkBytes = Buffer.byteLength(chunk, "utf-8") + if (bytes + chunkBytes > maxBytes) { + rejected = true + reject(new PayloadTooLargeError()) + return + } + bytes += chunkBytes + body += chunk + }) + stream.on("end", () => { + if (!rejected) resolve(body) + }) + stream.on("error", reject) + }) +} + +function runCommand(command: string, args: string[]): Promise { + return new Promise((resolve) => { + const proc = spawn(command, args, { stdout: "pipe", stderr: "ignore", }) - const text = await new Response(proc.stdout).text() - await proc.exited + let text = "" + + proc.stdout?.setEncoding("utf-8") + proc.stdout?.on("data", (chunk) => { + text += chunk + }) + proc.on("error", (error) => resolve({ ok: false, stdout: text, error })) + proc.on("close", (code) => resolve({ ok: code === 0, stdout: text })) + }) +} + +async function killPort(port: number): Promise { + if (!Number.isInteger(port) || port <= 0) return + + try { + const result = await runCommand("lsof", ["-ti", `:${port}`]) + const text = result.stdout for (const pidStr of text.trim().split("\n")) { const pid = parseInt(pidStr.trim(), 10) @@ -409,6 +469,158 @@ async function killPort(port: number): Promise { } } +interface ReviewRequestContext { + workspace: string + skillName: string + feedbackPath: string + previous: Record | null + benchmarkPath?: string | null + templatePath: string +} + +interface ReviewResponse { + status: number + headers: Record + body: string +} + +function jsonResponse(body: unknown, status = 200): ReviewResponse { + return { + status, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + } +} + +function textResponse(body: string, status = 200, contentType = "text/plain"): ReviewResponse { + return { + status, + headers: { "Content-Type": contentType }, + body, + } +} + +async function handleReviewRequest( + method: string, + requestUrl: string, + requestBody: string, + context: ReviewRequestContext, +): Promise { + const url = new URL(requestUrl, "http://localhost") + + if (method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) { + const runs = findRuns(context.workspace) + + let benchmark: Record | null = null + if (context.benchmarkPath && existsSync(context.benchmarkPath)) { + try { + benchmark = JSON.parse(readFileSync(context.benchmarkPath, "utf-8")) + } catch { + /* ignore */ + } + } + + const html = generateReviewHtml({ + runs, + skillName: context.skillName, + previous: context.previous, + benchmark, + templatePath: context.templatePath, + }) + + return textResponse(html, 200, "text/html; charset=utf-8") + } + + if (method === "GET" && url.pathname === "/api/feedback") { + let data = "{}" + if (existsSync(context.feedbackPath)) { + try { + data = readFileSync(context.feedbackPath, "utf-8") + } catch { + /* ignore */ + } + } + return textResponse(data, 200, "application/json") + } + + if (method === "POST" && url.pathname === "/api/feedback") { + let body: unknown + try { + body = JSON.parse(requestBody) + } catch (e) { + return jsonResponse({ error: String(e) }, 400) + } + + if (!isValidFeedbackPayload(body)) { + return jsonResponse({ error: "Expected JSON object with a valid 'reviews' array" }, 400) + } + + try { + writeFileSync(context.feedbackPath, JSON.stringify(body, null, 2) + "\n") + } catch (e) { + return jsonResponse({ error: String(e) }, 500) + } + + return jsonResponse({ ok: true }) + } + + return textResponse("Not Found", 404) +} + +async function handleNodeRequest( + req: IncomingMessage, + res: ServerResponse, + context: ReviewRequestContext, +): Promise { + try { + const body = req.method === "POST" ? await readStream(req, MAX_FEEDBACK_BODY_BYTES) : "" + const result = await handleReviewRequest( + req.method ?? "GET", + req.url ?? "/", + body, + context, + ) + res.writeHead(result.status, result.headers) + res.end(result.body) + } catch (e) { + if (e instanceof PayloadTooLargeError) { + res.writeHead(413, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: e.message })) + return + } + res.writeHead(500, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: String(e) })) + } +} + +function listen(server: Server, port: number): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("error", onError) + reject(error) + } + + server.once("error", onError) + server.listen(port, "127.0.0.1", () => { + server.off("error", onError) + resolve() + }) + }) +} + +function closeServer(server: Server, sockets: Set): Promise { + for (const socket of sockets) { + socket.destroy() + } + + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error) + else resolve() + }) + }) +} + // --------------------------------------------------------------------------- // Server // --------------------------------------------------------------------------- @@ -427,13 +639,13 @@ export interface ServeReviewOptions { * Start the eval review HTTP server. * * Regenerates HTML on each page load so refreshing picks up new outputs - * without restarting. Returns the Bun server instance. + * without restarting. Returns the server instance. */ export async function serveReview(opts: ServeReviewOptions): Promise<{ - server: ReturnType + server: Server url: string feedbackPath: string - stop: () => void + stop: () => Promise }> { const { workspace, @@ -461,100 +673,41 @@ export async function serveReview(opts: ServeReviewOptions): Promise<{ // Kill any existing process on the target port await killPort(port) - let actualPort = port - - const server = Bun.serve({ - port, - hostname: "127.0.0.1", - async fetch(req) { - const url = new URL(req.url) - - if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) { - // Regenerate HTML on each request (re-scans workspace for new outputs) - const runs = findRuns(workspace) - - let benchmark: Record | null = null - if (benchmarkPath && existsSync(benchmarkPath)) { - try { - benchmark = JSON.parse(readFileSync(benchmarkPath, "utf-8")) - } catch { - /* ignore */ - } - } - - const html = generateReviewHtml({ - runs, - skillName, - previous, - benchmark, - templatePath, - }) - - return new Response(html, { - headers: { "Content-Type": "text/html; charset=utf-8" }, - }) - } - - if (req.method === "GET" && url.pathname === "/api/feedback") { - let data = "{}" - if (existsSync(feedbackPath)) { - try { - data = readFileSync(feedbackPath, "utf-8") - } catch { - /* ignore */ - } - } - return new Response(data, { - headers: { "Content-Type": "application/json" }, - }) - } - - if (req.method === "POST" && url.pathname === "/api/feedback") { - let body: unknown - try { - body = (await req.json()) as unknown - } catch (e) { - return new Response(JSON.stringify({ error: String(e) }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }) - } - - if (!isValidFeedbackPayload(body)) { - return new Response( - JSON.stringify({ error: "Expected JSON object with a valid 'reviews' array" }), - { - status: 400, - headers: { "Content-Type": "application/json" }, - }, - ) - } - - try { - writeFileSync(feedbackPath, JSON.stringify(body, null, 2) + "\n") - } catch (e) { - return new Response(JSON.stringify({ error: String(e) }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }) - } - - return new Response(JSON.stringify({ ok: true }), { - headers: { "Content-Type": "application/json" }, - }) - } - - return new Response("Not Found", { status: 404 }) - }, + const context: ReviewRequestContext = { + workspace, + skillName, + feedbackPath, + previous, + benchmarkPath, + templatePath, + } + const server = createServer((req, res) => { + void handleNodeRequest(req, res, context) }) + const sockets = new Set() + server.on("connection", (socket) => { + sockets.add(socket) + socket.on("close", () => { + sockets.delete(socket) + }) + }) + await listen(server, port) - actualPort = server.port + const address = server.address() + if (!address || typeof address === "string") { + throw new Error("Review server did not bind to a TCP port") + } + const actualPort = (address as AddressInfo).port const serverUrl = `http://localhost:${actualPort}` if (openBrowser) { // Open browser (best-effort, non-blocking) try { - Bun.spawn(["open", serverUrl], { stdout: "ignore", stderr: "ignore" }) + const openProc = spawn("open", [serverUrl], { + detached: true, + stdio: "ignore", + }) + openProc.unref() } catch { /* ignore — headless environment */ } @@ -564,7 +717,7 @@ export async function serveReview(opts: ServeReviewOptions): Promise<{ server, url: serverUrl, feedbackPath, - stop: () => server.stop(), + stop: () => closeServer(server, sockets), } } diff --git a/plugin/skill-creator.ts b/plugin/skill-creator.ts index 1f03cff..5d1e33d 100644 --- a/plugin/skill-creator.ts +++ b/plugin/skill-creator.ts @@ -323,7 +323,7 @@ export async function maybeAutoRefreshPluginCache( // Track running review servers so they can be stopped // --------------------------------------------------------------------------- -const activeServers: Map void; url: string }> = new Map() +const activeServers: Map Promise; url: string }> = new Map() // --------------------------------------------------------------------------- // Plugin export @@ -786,7 +786,7 @@ export const SkillCreatorPlugin: Plugin = async (ctx) => { // Stop any existing server for this workspace const existing = activeServers.get(args.workspace) if (existing) { - existing.stop() + await existing.stop() activeServers.delete(args.workspace) } @@ -835,7 +835,7 @@ export const SkillCreatorPlugin: Plugin = async (ctx) => { if (args.workspace) { const srv = activeServers.get(args.workspace) if (srv) { - srv.stop() + await srv.stop() activeServers.delete(args.workspace) return JSON.stringify({ stopped: args.workspace }) } @@ -845,7 +845,7 @@ export const SkillCreatorPlugin: Plugin = async (ctx) => { // Stop all const stopped: string[] = [] for (const [ws, srv] of activeServers) { - srv.stop() + await srv.stop() stopped.push(ws) } activeServers.clear() diff --git a/plugin/test/package.test.mjs b/plugin/test/package.test.mjs index edaae9c..45dfa03 100644 --- a/plugin/test/package.test.mjs +++ b/plugin/test/package.test.mjs @@ -1,6 +1,8 @@ import assert from "node:assert/strict" import { createHash } from "node:crypto" +import { createConnection } from "node:net" import { + chmodSync, existsSync, mkdirSync, mkdtempSync, @@ -97,6 +99,159 @@ test("compiled entrypoint only exposes plugin functions for legacy OpenCode load assert.equal(typeof mod.default, "function") }) +test("compiled review server runs in Node without a Bun runtime global", async () => { + assert.equal(globalThis.Bun, undefined) + + const tempHome = mkdtempSync(join(tmpdir(), "osc-review-server-")) + const workspace = join(tempHome, "workspace") + const fakeBin = join(tempHome, "bin") + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME + const previousPath = process.env.PATH + + try { + const outputsDir = join(workspace, "eval-0", "with_skill", "outputs") + mkdirSync(outputsDir, { recursive: true }) + mkdirSync(fakeBin, { recursive: true }) + writeFileSync( + join(workspace, "eval-0", "eval_metadata.json"), + `${JSON.stringify({ eval_id: 0, prompt: "Review this output" })}\n`, + ) + const benchmarkPath = join(workspace, "benchmark.json") + writeFileSync( + benchmarkPath, + `${JSON.stringify({ summary: { total_evals: 1, pass_rate: 1 } })}\n`, + ) + writeFileSync(join(outputsDir, "result.txt"), "ok\n") + writeFileSync(join(fakeBin, "open"), "#!/bin/sh\nexit 0\n") + chmodSync(join(fakeBin, "open"), 0o755) + + process.env.XDG_CONFIG_HOME = tempHome + process.env.PATH = previousPath ? `${fakeBin}:${previousPath}` : fakeBin + + const mod = await import(`${distEntryPath}?review-node=${Date.now()}`) + const hooks = await mod.default({}) + const result = JSON.parse( + await hooks.tool.skill_serve_review.execute({ + workspace, + port: 0, + skillName: "test-skill", + benchmarkPath, + allowPartial: true, + }), + ) + + try { + const response = await fetch(result.url) + assert.equal(response.status, 200) + const html = await response.text() + assert.match(html, /test-skill/) + assert.match(html, /ok/) + assert.match(html, /total_evals/) + + const feedback = { + status: "complete", + reviews: [ + { + run_id: "eval-0-with_skill", + feedback: "works", + timestamp: "2026-05-24T00:00:00.000Z", + }, + ], + } + const feedbackResponse = await fetch(`${result.url}/api/feedback`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(feedback), + }) + assert.equal(feedbackResponse.status, 200) + assert.equal(readFileSync(result.feedbackPath, "utf-8"), `${JSON.stringify(feedback, null, 2)}\n`) + + const oversizedResponse = await fetch(`${result.url}/api/feedback`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + reviews: [ + { + run_id: "eval-0-with_skill", + feedback: "x".repeat(1_100_000), + }, + ], + }), + }) + assert.equal(oversizedResponse.status, 413) + } finally { + await hooks.tool.skill_stop_review.execute({ workspace }) + } + } finally { + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome + } + if (previousPath === undefined) { + delete process.env.PATH + } else { + process.env.PATH = previousPath + } + rmSync(tempHome, { recursive: true, force: true }) + } +}) + +test("compiled review server stop closes active browser connections", async () => { + const tempHome = mkdtempSync(join(tmpdir(), "osc-review-stop-")) + const workspace = join(tempHome, "workspace") + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME + let socket + let stopPromise + + try { + const outputsDir = join(workspace, "eval-0", "with_skill", "outputs") + mkdirSync(outputsDir, { recursive: true }) + writeFileSync( + join(workspace, "eval-0", "eval_metadata.json"), + `${JSON.stringify({ eval_id: 0, prompt: "Review this output" })}\n`, + ) + writeFileSync(join(outputsDir, "result.txt"), "ok\n") + + process.env.XDG_CONFIG_HOME = tempHome + + const mod = await import(`${distEntryPath}?review-stop=${Date.now()}`) + const hooks = await mod.default({}) + const result = JSON.parse( + await hooks.tool.skill_serve_review.execute({ + workspace, + port: 0, + skillName: "test-skill", + allowPartial: true, + }), + ) + const url = new URL(result.url) + + socket = await new Promise((resolve, reject) => { + const client = createConnection(Number(url.port), url.hostname, () => resolve(client)) + client.on("error", reject) + }) + socket.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: keep-alive\r\n") + + stopPromise = hooks.tool.skill_stop_review.execute({ workspace }) + await Promise.race([ + stopPromise, + new Promise((_, reject) => { + setTimeout(() => reject(new Error("Timed out waiting for review server to stop")), 500) + }), + ]) + } finally { + socket?.destroy() + if (stopPromise) await stopPromise.catch(() => {}) + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome + } + rmSync(tempHome, { recursive: true, force: true }) + } +}) + test("compiled plugin startup installs renamed skill and archives plugin-owned legacy skill", async () => { const tempHome = mkdtempSync(join(tmpdir(), "osc-compiled-plugin-")) const previousXdgConfigHome = process.env.XDG_CONFIG_HOME