mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-14 18:11:01 +00:00
65ba0b3922
* Add Java Modernization Studio canvas extension A canvas extension that drives the GitHub Copilot App Modernization for Java workflow from an interactive dashboard: environment readiness checks, repo assessment, prioritized plan/progress, validation gates (CVE scan, test generation), and one-click predefined-task runs — all grounded in the repo's real artifacts (.appmod/assessment.json, plan.md, progress.md). Includes a node:test suite (109 tests) for the grounding logic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com> * Secure cockpit loopback server with a per-instance token Address Copilot review feedback on the Java Modernization Studio canvas: - Mint a per-instance secret in createInstanceServer, embed it in the iframe URL, and validate it on every loopback request (/, /state, /events, /action). Other local processes can no longer read repo state or dispatch agent actions just by guessing the random port. Mirrors the existing diagram-viewer token pattern; the client echoes the token from its boot payload. The guard is a no-op when no token is set, so direct makeHandler unit tests are unaffected. - Handle async request-handler rejections in createServer with a .catch that returns 500 and logs, instead of leaking an unhandled rejection that could destabilize the extension process. - Tests: token guard 403s unauthenticated /, /state, /events and /action and allows valid-token requests; the end-to-end test asserts the tokenized URL and a tokenless 403. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CI: add preview screenshot and clear codespell hits Add the required assets/preview.png screenshot for the canvas-extension validator, and resolve two codespell findings in the cockpit: rename the planSim helper's `nd` variable to `notDone` and reword a catalog comment to avoid the `invokable`/`invocable` flag. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Harden cockpit loopback server per Copilot review Address the second-round Copilot review comments on the loopback server: - broadcast(): drop an SSE client whose write() throws instead of keeping it in the Set, so a uncleanly-disconnected client can't cause repeated exceptions or leak dead entries on every subsequent broadcast. - POST /action: treat a malformed JSON body or a missing/invalid "kind" as a 400 client error (with an application/json body) instead of a 200 carrying { ok:false, error:"Unknown action: undefined" }. - Handler catch: set Content-Type: application/json on the 500 error response so it is consistent with the other JSON routes. Adds four tests covering dead-client eviction, the two 400 paths, and the JSON-typed 500. Full suite: 115 passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com> * Add canvas.json gallery metadata for Java Modernization Studio Aaron requested a canvas.json so the awesome-copilot website/gallery can list this extension. Generated via `npm run website:data` (writePerExtensionCanvasManifests), which derives id/name/description/ version/keywords/screenshots from package.json + the createCanvas source + assets, and carries the author through. Output committed verbatim so a CI regeneration produces no diff. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com> * Add author tag to package.json Declare the author in the package manifest (object form, matching the author already carried in canvas.json so it includes the profile URL). The published gallery sources author from canvas.json; this adds the conventional npm author tag to package.json for completeness. Verified `npm run website:data` still emits the author on the extensions record and leaves canvas.json byte-identical. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com> --------- Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
335 lines
14 KiB
JavaScript
335 lines
14 KiB
JavaScript
// server.mjs — Per-instance loopback HTTP server + action dispatch for the cockpit.
|
|
// Decoupled from the SDK: agent messaging is injected as `sendPrompt`, so this
|
|
// module can be exercised by tests without a live session.
|
|
|
|
import { createServer } from "node:http";
|
|
import { randomBytes } from "node:crypto";
|
|
import { scanRepo } from "./scan.mjs";
|
|
import { renderHtml } from "./renderer.mjs";
|
|
import { buildPrompt, ACTION_LABELS } from "./prompts.mjs";
|
|
import { runDoctor } from "./doctor.mjs";
|
|
import { makeRun, runAutopilot, AUTOPILOT_MAX_STEPS } from "./autopilot.mjs";
|
|
|
|
/** Resolve which repo path an open/action context refers to, by precedence. */
|
|
export function resolveRepoPath(ctx, lastWorkingDir) {
|
|
const fromInput =
|
|
ctx && ctx.input && typeof ctx.input.repoPath === "string" ? ctx.input.repoPath : null;
|
|
const fromSession =
|
|
ctx && ctx.session && ctx.session.workingDirectory ? ctx.session.workingDirectory : null;
|
|
// No process.cwd() fallback: that resolves to the extension's own directory,
|
|
// not the user's repo, and would make the cockpit scan unrelated files. When
|
|
// nothing resolves we return null and the UI shows a "repo not available"
|
|
// state instead of plausible-but-wrong data.
|
|
return fromInput || fromSession || lastWorkingDir || null;
|
|
}
|
|
|
|
/**
|
|
* Scan the repo and attach the environment readiness report. Tool probing is
|
|
* expensive (spawns several processes), so the doctor result is cached on the
|
|
* instance record and only recomputed on first build or an explicit recheck —
|
|
* not on every per-turn refresh. Probing only runs when `rec.runDoctor` is wired
|
|
* (real instances), so unit tests stay fast and deterministic.
|
|
*/
|
|
export async function buildState(rec, { recheckEnv = false } = {}) {
|
|
const state = await scanRepo(rec.repoPath);
|
|
if (state && state.ok && typeof rec.runDoctor === "function") {
|
|
if (recheckEnv || rec.doctor == null) {
|
|
try {
|
|
rec.doctor = await rec.runDoctor(state);
|
|
} catch {
|
|
rec.doctor = rec.doctor || null;
|
|
}
|
|
}
|
|
state.doctor = rec.doctor;
|
|
} else if (state && state.ok) {
|
|
state.doctor = rec.doctor || null;
|
|
}
|
|
if (state && state.ok) state.autopilot = rec.autopilot || null;
|
|
return state;
|
|
}
|
|
|
|
/** Write an SSE payload to every connected client of an instance. */
|
|
export function broadcast(rec, payload) {
|
|
const data = "data: " + JSON.stringify(payload) + "\n\n";
|
|
for (const res of rec.sseClients) {
|
|
try {
|
|
res.write(data);
|
|
} catch {
|
|
// Client disconnected uncleanly: drop it so we don't keep throwing on
|
|
// every future broadcast (dead clients would otherwise leak in the Set).
|
|
rec.sseClients.delete(res);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Re-scan the repo and push the fresh snapshot to connected clients. */
|
|
export async function pushState(rec, log, opts) {
|
|
let state;
|
|
try {
|
|
state = await buildState(rec, opts);
|
|
} catch (e) {
|
|
if (log) log("appmod-cockpit scan failed: " + e.message, { level: "error" });
|
|
return null;
|
|
}
|
|
broadcast(rec, { type: "state", state });
|
|
return state;
|
|
}
|
|
|
|
/** Clamp a requested step budget into a sane range. */
|
|
function clampSteps(n) {
|
|
const v = Number(n);
|
|
if (!Number.isFinite(v)) return AUTOPILOT_MAX_STEPS;
|
|
return Math.max(1, Math.min(50, Math.round(v)));
|
|
}
|
|
|
|
/**
|
|
* Kick off an Autopilot run for this instance. Returns immediately; the run loop
|
|
* streams progress to connected clients over SSE. The run promise is parked on
|
|
* the record so it is not garbage-collected mid-flight.
|
|
* @param {object} rec instance record
|
|
* @param {object} payload { scope?: "phase"|"all", maxSteps?, force? }
|
|
* @param {{ runTurn:(p:string)=>Promise<any>, log?:Function }} deps
|
|
*/
|
|
export async function startAutopilot(rec, payload, deps) {
|
|
if (rec.autopilot && rec.autopilot.running) {
|
|
return { ok: false, error: "Autopilot is already running." };
|
|
}
|
|
if (!deps || typeof deps.runTurn !== "function") {
|
|
return { ok: false, error: "This session can't drive Autopilot." };
|
|
}
|
|
const p = payload || {};
|
|
const start = await buildState(rec);
|
|
if (!start || !start.ok) {
|
|
return { ok: false, error: "Can't read the repo to start Autopilot." };
|
|
}
|
|
if (start.doctor && start.doctor.overall === "blocked" && !p.force) {
|
|
return { ok: false, error: "Your environment isn't ready. Open Readiness and install the missing tools first." };
|
|
}
|
|
const scope = p.scope === "all" ? "all" : "phase";
|
|
const startRank = start.ordering ? start.ordering.activeRank : null;
|
|
const run = makeRun({ scope, maxSteps: clampSteps(p.maxSteps), startRank });
|
|
rec.autopilot = run;
|
|
broadcast(rec, { type: "autopilot", autopilot: run });
|
|
|
|
const runDeps = {
|
|
snapshot: () => buildState(rec),
|
|
runTurn: deps.runTurn,
|
|
buildStepPrompt: (step) => buildPrompt("auto_step", { title: step.title, section: step.section }, rec.repoPath),
|
|
onProgress: (r, state) => {
|
|
if (state) broadcast(rec, { type: "state", state });
|
|
else broadcast(rec, { type: "autopilot", autopilot: r });
|
|
},
|
|
log: deps.log,
|
|
};
|
|
rec.autopilotPromise = runAutopilot(run, runDeps)
|
|
.then(() => pushState(rec, deps.log))
|
|
.catch((e) => {
|
|
if (deps.log) deps.log("Autopilot run failed: " + e.message, { level: "error" });
|
|
});
|
|
return { ok: true, message: "Autopilot started", autopilot: run };
|
|
}
|
|
|
|
/**
|
|
* Handle a cockpit action.
|
|
* @param {object} rec instance record ({ repoPath, sseClients, ... })
|
|
* @param {string} kind action kind
|
|
* @param {object} payload action payload
|
|
* @param {{ sendPrompt:(p:string)=>Promise<void>, runTurn?:Function, log?:Function }} deps
|
|
*/
|
|
export async function dispatchAction(rec, kind, payload, deps) {
|
|
if (kind === "refresh") {
|
|
const state = await pushState(rec, deps && deps.log);
|
|
return { ok: true, message: "Refreshed", state };
|
|
}
|
|
if (kind === "recheck_env") {
|
|
const state = await pushState(rec, deps && deps.log, { recheckEnv: true });
|
|
return { ok: true, message: "Re-checked environment", state };
|
|
}
|
|
if (kind === "autopilot_start") {
|
|
return await startAutopilot(rec, payload, deps);
|
|
}
|
|
if (kind === "autopilot_stop") {
|
|
if (rec.autopilot && rec.autopilot.running) {
|
|
rec.autopilot.cancelled = true;
|
|
broadcast(rec, { type: "autopilot", autopilot: rec.autopilot });
|
|
return { ok: true, message: "Stopping after the current step finishes…" };
|
|
}
|
|
return { ok: false, error: "Autopilot isn't running." };
|
|
}
|
|
if (kind === "autopilot_dismiss") {
|
|
if (rec.autopilot && !rec.autopilot.running) {
|
|
rec.autopilot = null;
|
|
await pushState(rec, deps && deps.log);
|
|
}
|
|
return { ok: true, message: "Dismissed" };
|
|
}
|
|
const prompt = buildPrompt(kind, payload, rec.repoPath);
|
|
if (!prompt) return { ok: false, error: "Unknown action: " + kind };
|
|
if (!deps || typeof deps.sendPrompt !== "function") {
|
|
return { ok: false, error: "Session not ready" };
|
|
}
|
|
const label = ACTION_LABELS[kind] || kind;
|
|
try {
|
|
await deps.sendPrompt(prompt);
|
|
if (deps.log) deps.log("Java Modernization Studio → " + label, { ephemeral: true });
|
|
return { ok: true, label, message: "Sent to the agent: " + label, prompt };
|
|
} catch (e) {
|
|
return { ok: false, error: e.message };
|
|
}
|
|
}
|
|
|
|
// Cap the request body the /action endpoint will buffer. Actions carry tiny JSON
|
|
// payloads; anything large is a bug or abuse, so we reject rather than buffer it.
|
|
const MAX_BODY_BYTES = 256 * 1024;
|
|
|
|
function readBody(req, max = MAX_BODY_BYTES) {
|
|
return new Promise((resolve) => {
|
|
let data = "";
|
|
let size = 0;
|
|
let done = false;
|
|
req.on("data", (c) => {
|
|
if (done) return;
|
|
size += c.length;
|
|
if (size > max) {
|
|
done = true;
|
|
resolve({ body: "", tooLarge: true });
|
|
if (typeof req.destroy === "function") req.destroy();
|
|
return;
|
|
}
|
|
data += c;
|
|
});
|
|
req.on("end", () => {
|
|
if (!done) {
|
|
done = true;
|
|
resolve({ body: data, tooLarge: false });
|
|
}
|
|
});
|
|
req.on("error", () => {
|
|
if (!done) {
|
|
done = true;
|
|
resolve({ body: "", tooLarge: false });
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Build the request handler for an instance. Exposed for tests so routes can be
|
|
* exercised without binding a socket.
|
|
*/
|
|
export function makeHandler(rec, { instanceId, initialTab, sendPrompt, runTurn, log }) {
|
|
return async function handler(req, res) {
|
|
const url = new URL(req.url, "http://127.0.0.1");
|
|
try {
|
|
// Per-instance secret: real instances (createInstanceServer) mint a token
|
|
// that the host embeds in the iframe URL. Reject any loopback request that
|
|
// doesn't present it, so other local processes can't read repo state or
|
|
// dispatch agent actions just by guessing the random port. When no token is
|
|
// set (direct makeHandler unit tests) the guard is a no-op.
|
|
if (rec.token) {
|
|
const provided = url.searchParams.get("token");
|
|
if (provided !== rec.token) {
|
|
res.statusCode = 403;
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.end(JSON.stringify({ ok: false, error: "forbidden" }));
|
|
return;
|
|
}
|
|
}
|
|
if (req.method === "GET" && url.pathname === "/") {
|
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
res.end(renderHtml({ instanceId, initialTab, token: rec.token }));
|
|
return;
|
|
}
|
|
if (req.method === "GET" && url.pathname === "/state") {
|
|
const state = await buildState(rec);
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.end(JSON.stringify(state));
|
|
return;
|
|
}
|
|
if (req.method === "GET" && url.pathname === "/events") {
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
Connection: "keep-alive",
|
|
});
|
|
res.write("retry: 3000\n\n");
|
|
rec.sseClients.add(res);
|
|
req.on("close", () => rec.sseClients.delete(res));
|
|
return;
|
|
}
|
|
if (req.method === "POST" && url.pathname === "/action") {
|
|
const { body, tooLarge } = await readBody(req);
|
|
if (tooLarge) {
|
|
res.statusCode = 413;
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.end(JSON.stringify({ ok: false, error: "request body too large" }));
|
|
return;
|
|
}
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(body || "{}");
|
|
} catch {
|
|
res.statusCode = 400;
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.end(JSON.stringify({ ok: false, error: "invalid JSON body" }));
|
|
return;
|
|
}
|
|
if (!parsed || typeof parsed.kind !== "string" || !parsed.kind) {
|
|
res.statusCode = 400;
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.end(JSON.stringify({ ok: false, error: "missing or invalid 'kind'" }));
|
|
return;
|
|
}
|
|
const result = await dispatchAction(rec, parsed.kind, parsed.payload, { sendPrompt, runTurn, log });
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.end(JSON.stringify(result));
|
|
return;
|
|
}
|
|
res.statusCode = 404;
|
|
res.end("not found");
|
|
} catch (e) {
|
|
if (!res.headersSent) {
|
|
res.statusCode = 500;
|
|
res.setHeader("Content-Type", "application/json");
|
|
}
|
|
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Start a loopback server for one canvas instance.
|
|
* @returns {Promise<object>} rec with { server, url, repoPath, sseClients }
|
|
*/
|
|
export async function createInstanceServer({ instanceId, repoPath, initialTab, sendPrompt, runTurn, log, runDoctor: runDoctorImpl }) {
|
|
const rec = { server: null, url: "", repoPath, sseClients: new Set(), doctor: null, autopilot: null, token: randomBytes(16).toString("hex") };
|
|
// Wire the environment doctor (injectable for tests); real instances probe the
|
|
// local toolchain, cached and only recomputed on explicit recheck.
|
|
rec.runDoctor = typeof runDoctorImpl === "function" ? runDoctorImpl : (scan) => runDoctor(scan, {});
|
|
const handler = makeHandler(rec, { instanceId, initialTab, sendPrompt, runTurn, log });
|
|
const server = createServer((req, res) => {
|
|
// The handler is async: if it rejects, surface a 500 and log it rather than
|
|
// letting it become an unhandled rejection that can crash the extension.
|
|
handler(req, res).catch((err) => {
|
|
if (log) log("Java Modernization Studio server error: " + (err && err.message ? err.message : err), { level: "error" });
|
|
try {
|
|
if (!res.headersSent) {
|
|
res.statusCode = 500;
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.end(JSON.stringify({ ok: false, error: "internal error" }));
|
|
} else {
|
|
res.end();
|
|
}
|
|
} catch {
|
|
/* response already torn down */
|
|
}
|
|
});
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const addr = server.address();
|
|
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
rec.server = server;
|
|
rec.url = "http://127.0.0.1:" + port + "/?token=" + rec.token;
|
|
return rec;
|
|
}
|