/** * @file Proof gallery (C): the screenshots a QC agent captures live at * `/.playwright-mcp/proof///*.png` (plus a * `ticket/REPORT.html`), grouped by feature slug — the same slug Task B's * `lane_features` already tracks. Ported from Shipyard's * `dashboard/server/services/proof.js`; the "current feature" special-casing * that file needed (it has no DB, so it parsed a branch regex) is dropped — * CCAM's `lane_features` row already IS that source of truth. * * Path containment is the entire security surface here: every path this * module touches is resolved, realpath'd, and checked to be inside the * lane's own proof root before any read/write/delete reaches `fs`. Ported * near-verbatim from Shipyard rather than "cleaned up" — this is the one part * of C worth reviewing line-by-line, not abstracting. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const lanesLib = require("./lanes"); const laneFeatures = require("./lane-features"); const IMAGE_RE = /\.(png|jpg|jpeg)$/i; const SERVEABLE_RE = /\.(png|jpg|jpeg|html)$/i; /** Where a lane's proof lives — inside its OWN cwd, not LANES_ROOT (an * adopted lane's cwd need not be under LANES_ROOT at all). */ function proofRoot(lane) { return path.join(lane.cwd, ".playwright-mcp", "proof"); } const badName = (s) => !s || typeof s !== "string" || s.includes("/") || s.includes("\\") || s.includes(".."); function countFiles(dir) { let c = 0; for (const e of fs.readdirSync(dir, { withFileTypes: true })) { if (e.isDirectory()) c += countFiles(path.join(dir, e.name)); else c++; } return c; } function latestMtime(dir) { let mt = 0; const walk = (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const full = path.join(d, e.name); if (e.isDirectory()) walk(full); else { const s = fs.statSync(full); if (s.mtimeMs > mt) mt = s.mtimeMs; } } }; try { walk(dir); } catch { /* best effort */ } return mt / 1000; } /** * Every feature slug with proof (or an activated-but-not-yet-shot feature * from `lane_features`), most recently touched first. * * @param {number} laneId * @returns {Array<{slug: string, groups: object, ticket_report: string, mtime: number}>} */ function listProof(laneId) { const lane = lanesLib.getLane(laneId); if (!lane) return []; const base = proofRoot(lane); const feats = []; if (fs.existsSync(base) && fs.statSync(base).isDirectory()) { for (const slug of fs.readdirSync(base).sort()) { const d = path.join(base, slug); try { if (!fs.statSync(d).isDirectory()) continue; } catch { continue; } const groups = {}; for (const grp of fs.readdirSync(d).sort()) { if (grp === "ticket") continue; const g = path.join(d, grp); try { if (!fs.statSync(g).isDirectory()) continue; } catch { continue; } const imgs = fs .readdirSync(g) .filter((f) => IMAGE_RE.test(f)) .sort(); if (imgs.length) groups[grp] = imgs; } let ticketReport = ""; if (fs.existsSync(path.join(d, "ticket", "REPORT.html"))) { ticketReport = `${slug}/ticket/REPORT.html`; } feats.push({ slug, groups, ticket_report: ticketReport, mtime: latestMtime(d) }); } } // Surface an activated feature with no proof dir yet. const have = new Set(feats.map((f) => f.slug)); for (const f of laneFeatures.listFeatures(laneId)) { if (!have.has(f.slug)) { feats.push({ slug: f.slug, groups: {}, ticket_report: "", mtime: 0 }); } } feats.sort((a, b) => (b.mtime || 0) - (a.mtime || 0)); return feats; } /** * Resolve one proof file, containment-checked. Returns the absolute realpath * or null on any failure — missing lane, missing file, traversal, symlink * escape, or wrong extension. */ function proofFile(laneId, slug, group, file) { const lane = lanesLib.getLane(laneId); if (!lane) return null; let base; try { base = fs.realpathSync(proofRoot(lane)); } catch { return null; } let p; try { p = fs.realpathSync(path.join(base, slug, group, file)); } catch { return null; } if ( (p === base || p.startsWith(base + path.sep)) && fs.existsSync(p) && fs.statSync(p).isFile() && SERVEABLE_RE.test(p) ) { return p; } return null; } /** * Delete proof screenshots at one of three granularities: * { slug, group, images: [...] } -> unlink those files, prune the group dir if now empty * { slug, group } -> remove the whole group dir * { slug } -> remove every group dir EXCEPT ticket/ * Never touches `ticket/`, never reaches outside the lane's proof root. * * @returns {{deleted: number}} */ function deleteProof(laneId, { slug, group, images } = {}) { const lane = lanesLib.getLane(laneId); if (!lane) throw Object.assign(new Error(`no lane ${laneId}`), { code: "ENOLANE" }); if (badName(slug)) throw Object.assign(new Error("invalid slug"), { code: "EBADPATH" }); let base; try { base = fs.realpathSync(proofRoot(lane)); } catch { throw Object.assign(new Error("no proof for this lane"), { code: "ENOFEATURE" }); } const inBase = (p) => p === base || p.startsWith(base + path.sep); let featDir; try { featDir = fs.realpathSync(path.join(base, slug)); } catch { throw Object.assign(new Error("feature not found"), { code: "ENOFEATURE" }); } if (!inBase(featDir) || !fs.statSync(featDir).isDirectory()) { throw Object.assign(new Error("invalid feature dir"), { code: "EBADPATH" }); } let deleted = 0; if (group !== undefined) { if (badName(group) || group === "ticket") { throw Object.assign(new Error("invalid group"), { code: "EBADPATH" }); } let grpDir; try { grpDir = fs.realpathSync(path.join(featDir, group)); } catch { throw Object.assign(new Error("group not found"), { code: "ENOFEATURE" }); } if (!inBase(grpDir) || !fs.statSync(grpDir).isDirectory()) { throw Object.assign(new Error("invalid group dir"), { code: "EBADPATH" }); } if (Array.isArray(images) && images.length) { for (const img of images) { if (badName(img) || !IMAGE_RE.test(img)) { throw Object.assign(new Error("invalid image"), { code: "EBADPATH" }); } let f; try { f = fs.realpathSync(path.join(grpDir, img)); } catch { continue; } if (inBase(f) && fs.statSync(f).isFile() && IMAGE_RE.test(f)) { fs.unlinkSync(f); deleted++; } } try { if (!fs.readdirSync(grpDir).length) fs.rmdirSync(grpDir); } catch { /* ok */ } } else { deleted = countFiles(grpDir); fs.rmSync(grpDir, { recursive: true, force: true }); } } else { for (const e of fs.readdirSync(featDir, { withFileTypes: true })) { if (!e.isDirectory() || e.name === "ticket") continue; const g = path.join(featDir, e.name); deleted += countFiles(g); fs.rmSync(g, { recursive: true, force: true }); } } return { deleted }; } /** * Port of Shipyard's `ensure_proof_link`: converge the clone-root `proof/` * onto `.playwright-mcp/proof` so every proof write lands in one place * regardless of an MCP's `--output-dir` convention. A pre-existing REAL * `proof/` dir is merged into the canonical root first (no clobber), then * replaced with a relative symlink. Idempotent — a no-op when the symlink * already points at the right place. * * Never auto-invoked by this codebase; exposed only as the explicit * `ccam lanes proof-link` primitive. * * @param {object} lane - A hydrated lane row (must have `cwd`). * @returns {{linked: boolean}} `linked: false` when nothing changed. */ function ensureProofLink(lane) { const canon = proofRoot(lane); const stray = path.join(lane.cwd, "proof"); const relTarget = path.join(".playwright-mcp", "proof"); let strayStat = null; try { strayStat = fs.lstatSync(stray); } catch { /* doesn't exist yet */ } if (strayStat && strayStat.isSymbolicLink()) { if (fs.readlinkSync(stray) === relTarget) return { linked: false }; fs.rmSync(stray, { force: true }); } else if (strayStat && strayStat.isDirectory()) { fs.mkdirSync(canon, { recursive: true }); mergeDirInto(stray, canon); fs.rmSync(stray, { recursive: true, force: true }); } else if (strayStat) { // A stray file named `proof` — refuse to clobber; leave it alone. return { linked: false }; } fs.mkdirSync(canon, { recursive: true }); fs.symlinkSync(relTarget, stray); return { linked: true }; } /** Recursively copy `src`'s contents into `dest`, never overwriting an * existing file at the destination (no-clobber merge). */ function mergeDirInto(src, dest) { for (const e of fs.readdirSync(src, { withFileTypes: true })) { const s = path.join(src, e.name); const d = path.join(dest, e.name); if (e.isDirectory()) { fs.mkdirSync(d, { recursive: true }); mergeDirInto(s, d); } else if (!fs.existsSync(d)) { fs.copyFileSync(s, d); } } } module.exports = { proofRoot, listProof, proofFile, deleteProof, ensureProofLink, };