/** * @file pets.js * @description Custom pets for the dashboard mascot (Sagi). A pet is a folder * `/pets//` holding `meta.json` (written here) and either a * Codex pet's `spritesheet.webp` — the `.codex-pet.zip` format: an 8×9 grid of * 192×208 frames, one row per animation state — or one uploaded image. Pets * come from exactly three places: an uploaded `.codex-pet.zip`, an uploaded * image, or one of the FEATURED pets, downloaded from codex-pets.net by a fixed * id — never a URL or a path from a request. None is bundled with AgentHub: * the featured pets are other people's uploads with no redistribution * licence, so each install fetches its own copy from the site that hosts * them. An id is always made here; SVG is refused, because an SVG served from * the dashboard's origin runs script when opened. * @author Nguyễn Ngọc Trí Vĩ */ "use strict"; const fs = require("node:fs"); const path = require("node:path"); const crypto = require("node:crypto"); const AdmZip = require("adm-zip"); const { getDataDir } = require("./claude-home"); const MAX_BUNDLE_BYTES = 8 * 1024 * 1024; const MAX_SHEET_BYTES = 6 * 1024 * 1024; const MAX_IMAGE_BYTES = 4 * 1024 * 1024; const MAX_MANIFEST_BYTES = 16 * 1024; const MAX_PETS = 50; /** The Codex atlas: 8 columns × 9 rows of 192×208 frames. */ const SHEET = { width: 1536, height: 1872, cellWidth: 192, cellHeight: 208 }; const FEATURED_HOST = "https://codex-pets.net"; /** Offered in the pet menu, installed only on a click. `id` is codex-pets.net's. */ const FEATURED = [ { id: "goku", displayName: "Goku", by: "hackminit" }, { id: "border-collie", displayName: "Border Collie", by: "dev-minsoo" }, { id: "11", displayName: "11", by: "ct" }, ]; const ID_RE = /^(?:p-[0-9a-f]{12}|cp-[a-z0-9-]{1,40})$/; const IMAGE_TYPES = { png: "image/png", jpg: "image/jpeg", gif: "image/gif", webp: "image/webp", }; let fetchImpl = (...args) => fetch(...args); /** Test seam: replace the HTTP client the featured install uses. */ function __setFetch(fn) { fetchImpl = fn || ((...args) => fetch(...args)); } const fail = (code, message) => Object.assign(new Error(message), { code }); function petsRoot() { return path.join(getDataDir(), "pets"); } function petDir(id) { if (!ID_RE.test(String(id))) throw fail("ENOPET", "pet not found"); return path.join(petsRoot(), id); } /** Width and height from a WebP header (VP8X, VP8 or VP8L), else null. */ function webpSize(buf) { if (buf.length < 30) return null; if (buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WEBP") return null; const chunk = buf.toString("ascii", 12, 16); if (chunk === "VP8X") return { width: 1 + buf.readUIntLE(24, 3), height: 1 + buf.readUIntLE(27, 3) }; if (chunk === "VP8 ") return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff }; if (chunk === "VP8L") { const bits = buf.readUInt32LE(21); return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) }; } return null; } /** The raster type of an uploaded image by its magic bytes, else null. */ function sniffImage(buf) { if ( buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) ) return "png"; if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "jpg"; if (buf.length >= 6 && /^GIF8[79]a$/.test(buf.toString("ascii", 0, 6))) return "gif"; if ( webpSize(buf) || (buf.length >= 12 && buf.toString("ascii", 8, 12) === "WEBP" && buf.toString("ascii", 0, 4) === "RIFF") ) return "webp"; return null; } /** A display string from untrusted input: trimmed, single-line, capped. */ function cleanText(value, max) { return typeof value === "string" ? value.replace(/\s+/g, " ").trim().slice(0, max) : ""; } function checkSheet(buf) { if (buf.length > MAX_SHEET_BYTES) throw fail("ETOOBIG", "the spritesheet is larger than 6 MB"); const size = webpSize(buf); if (!size) throw fail("EBADPET", "the spritesheet is not a WebP image"); if (size.width !== SHEET.width || size.height !== SHEET.height) throw fail( "EBADPET", `the spritesheet is ${size.width}×${size.height}; a Codex pet is ${SHEET.width}×${SHEET.height}` ); } function readMeta(id) { try { return JSON.parse(fs.readFileSync(path.join(petDir(id), "meta.json"), "utf8")); } catch { return null; } } function view(id, meta) { return { id, displayName: meta.displayName, description: meta.description || "", kind: meta.kind, source: meta.source, ...(meta.featuredId ? { featuredId: meta.featuredId } : {}), ...(meta.by ? { by: meta.by } : {}), }; } function listPets() { let names = []; try { names = fs.readdirSync(petsRoot()); } catch { return []; } return names .filter((n) => ID_RE.test(n)) .map((n) => [n, readMeta(n)]) .filter(([, meta]) => meta && (meta.kind === "codex" || meta.kind === "image")) .sort((a, b) => String(a[1].addedAt).localeCompare(String(b[1].addedAt))) .map(([n, meta]) => view(n, meta)); } /** Writes a pet atomically: a temp folder renamed into place. */ function store(id, meta, file, body) { const existing = listPets(); if (!existing.some((p) => p.id === id) && existing.length >= MAX_PETS) throw fail("ETOOMANY", `at most ${MAX_PETS} pets`); const root = petsRoot(); fs.mkdirSync(root, { recursive: true }); const tmp = fs.mkdtempSync(path.join(root, ".new-")); try { fs.writeFileSync(path.join(tmp, file), body); fs.writeFileSync( path.join(tmp, "meta.json"), JSON.stringify({ ...meta, file, addedAt: new Date().toISOString() }) ); fs.rmSync(petDir(id), { recursive: true, force: true }); fs.renameSync(tmp, petDir(id)); } catch (err) { fs.rmSync(tmp, { recursive: true, force: true }); throw err; } return view(id, readMeta(id)); } const newId = () => `p-${crypto.randomBytes(6).toString("hex")}`; /** Imports a `.codex-pet.zip`: `pet.json` + `spritesheet.webp`, at the root * or in one top-level folder. Nothing else in the archive is read. */ function importBundle(buf) { if (!Buffer.isBuffer(buf) || !buf.length) throw fail("EBADPET", "no file"); if (buf.length > MAX_BUNDLE_BYTES) throw fail("ETOOBIG", "the bundle is larger than 8 MB"); let entries; try { entries = new AdmZip(buf).getEntries(); } catch { throw fail("EBADPET", "not a zip archive"); } const pick = (name) => entries.find( (e) => !e.isDirectory && /^(?:[^/]+\/)?[^/]+$/.test(e.entryName) && path.posix.basename(e.entryName) === name ); const manifest = pick("pet.json"); const sheet = pick("spritesheet.webp"); if (!manifest || !sheet) throw fail("EBADPET", "a .codex-pet bundle holds pet.json and spritesheet.webp"); // Sizes from the central directory, checked BEFORE inflating (zip bombs). if (manifest.header.size > MAX_MANIFEST_BYTES) throw fail("EBADPET", "pet.json is too large"); if (sheet.header.size > MAX_SHEET_BYTES) throw fail("ETOOBIG", "the spritesheet is larger than 6 MB"); let pet; try { pet = JSON.parse(manifest.getData().toString("utf8")); } catch { throw fail("EBADPET", "pet.json is not valid JSON"); } const body = sheet.getData(); checkSheet(body); const displayName = cleanText(pet && pet.displayName, 60) || cleanText(pet && pet.id, 60) || "Pet"; return store( newId(), { kind: "codex", source: "upload", displayName, description: cleanText(pet && pet.description, 300), }, "spritesheet.webp", body ); } /** Adds one uploaded image (PNG, JPEG, GIF or WebP) as a still pet. */ function addImage(buf, name) { if (!Buffer.isBuffer(buf) || !buf.length) throw fail("EBADPET", "no file"); if (buf.length > MAX_IMAGE_BYTES) throw fail("ETOOBIG", "the image is larger than 4 MB"); const ext = sniffImage(buf); if (!ext) throw fail("EBADPET", "only PNG, JPEG, GIF or WebP images"); const displayName = cleanText(String(name || "").replace(/\.[^.]+$/, ""), 60) || "Pet"; return store( newId(), { kind: "image", source: "upload", displayName, type: ext }, `image.${ext}`, buf ); } async function fetchCapped(url, max) { const res = await fetchImpl(url, { signal: AbortSignal.timeout(20_000), redirect: "error" }); if (!res.ok) throw fail("EFETCH", `codex-pets.net answered ${res.status}`); const len = Number(res.headers?.get?.("content-length")); if (len > max) throw fail("ETOOBIG", "the download is too large"); const body = Buffer.from(await res.arrayBuffer()); if (body.length > max) throw fail("ETOOBIG", "the download is too large"); return body; } /** Downloads one FEATURED pet from codex-pets.net into this install. */ async function installFeatured(featuredId) { const f = FEATURED.find((x) => x.id === featuredId); if (!f) throw fail("ENOPET", "not a featured pet"); const base = `${FEATURED_HOST}/api/pets/${encodeURIComponent(f.id)}`; let description = ""; try { const meta = JSON.parse((await fetchCapped(base, MAX_MANIFEST_BYTES * 4)).toString("utf8")); description = cleanText(meta && meta.pet && meta.pet.description, 300); } catch (err) { if (err.code === "ETOOBIG") throw err; /* the description is optional; the spritesheet is not */ } let body; try { body = await fetchCapped(`${base}/spritesheet`, MAX_SHEET_BYTES); } catch (err) { if (err.code) throw err; throw fail("EFETCH", `could not reach codex-pets.net: ${err.message}`); } checkSheet(body); return store( `cp-${f.id}`, { kind: "codex", source: "featured", featuredId: f.id, by: f.by, displayName: f.displayName, description, }, "spritesheet.webp", body ); } function removePet(id) { const dir = petDir(id); if (!readMeta(id)) throw fail("ENOPET", "pet not found"); fs.rmSync(dir, { recursive: true, force: true }); } /** The file to serve for a pet and its content type. */ function spriteFile(id) { const meta = readMeta(id); if (!meta || (meta.file !== "spritesheet.webp" && !/^image\.(png|jpg|gif|webp)$/.test(meta.file))) throw fail("ENOPET", "pet not found"); const ext = meta.file.split(".").pop(); return { path: path.join(petDir(id), meta.file), type: IMAGE_TYPES[ext] }; } function featured() { const installed = new Set( listPets() .map((p) => p.featuredId) .filter(Boolean) ); return FEATURED.map((f) => ({ ...f, installed: installed.has(f.id), page: `${FEATURED_HOST}/#/pets/${f.id}`, })); } module.exports = { SHEET, FEATURED, MAX_BUNDLE_BYTES, MAX_IMAGE_BYTES, listPets, featured, importBundle, addImage, installFeatured, removePet, spriteFile, webpSize, __setFetch, };