| 1 | /** |
| 2 | * Reading an uploaded skill (docs.g1t.sh/guides/agent-skills/, "Import a |
| 3 | * skill"): a SKILL.md on its own, or a zip of the skill's folder. A zip |
| 4 | * holding one folder (`release-notes/SKILL.md`, as zipping a folder makes |
| 5 | * it) is read as that folder. Stored and deflated entries only, no zip64, |
| 6 | * and never more than the format's 1 MB once unpacked. |
| 7 | */ |
| 8 | import { SKILL_FILES_MAX, SKILL_FOLDER_MAX_BYTES, type SkillFile } from "../../../packages/contracts/src/skill-format.ts"; |
| 9 | |
| 10 | /** The largest upload taken: a zip of a 1 MB folder is smaller than this. */ |
| 11 | export const MAX_UPLOAD_BYTES = 2 * 1024 * 1024; |
| 12 | |
| 13 | export type Unpacked = { ok: true; files: SkillFile[] } | { ok: false; message: string }; |
| 14 | |
| 15 | function base64Bytes(data: string): Uint8Array { |
| 16 | const binary = atob(data.replace(/\s+/g, "")); |
| 17 | const bytes = new Uint8Array(binary.length); |
| 18 | for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); |
| 19 | return bytes; |
| 20 | } |
| 21 | |
| 22 | export function bytesBase64(bytes: Uint8Array): string { |
| 23 | let binary = ""; |
| 24 | for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); |
| 25 | return btoa(binary); |
| 26 | } |
| 27 | |
| 28 | /** A file's content as text when it is UTF-8 without NUL bytes, else as base64. */ |
| 29 | export function asSkillFile(path: string, bytes: Uint8Array): SkillFile { |
| 30 | try { |
| 31 | const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes); |
| 32 | if (!text.includes("\u0000")) return { path, content: text, encoding: "utf8" }; |
| 33 | } catch { |
| 34 | // Not UTF-8: kept as bytes. |
| 35 | } |
| 36 | return { path, content: bytesBase64(bytes), encoding: "base64" }; |
| 37 | } |
| 38 | |
| 39 | async function inflate(data: Uint8Array, size: number): Promise<Uint8Array> { |
| 40 | const stream = new Blob([data as Uint8Array<ArrayBuffer>]).stream().pipeThrough(new DecompressionStream("deflate-raw")); |
| 41 | const reader = stream.getReader(); |
| 42 | const chunks: Uint8Array[] = []; |
| 43 | let total = 0; |
| 44 | for (;;) { |
| 45 | const { done, value } = await reader.read(); |
| 46 | if (done) break; |
| 47 | total += value.length; |
| 48 | if (total > Math.max(size, 0) || total > SKILL_FOLDER_MAX_BYTES) { |
| 49 | await reader.cancel().catch(() => undefined); |
| 50 | throw new Error("too large"); |
| 51 | } |
| 52 | chunks.push(value); |
| 53 | } |
| 54 | const out = new Uint8Array(total); |
| 55 | let at = 0; |
| 56 | for (const chunk of chunks) { |
| 57 | out.set(chunk, at); |
| 58 | at += chunk.length; |
| 59 | } |
| 60 | return out; |
| 61 | } |
| 62 | |
| 63 | /** The files in a zip archive, by path; directories and macOS's `__MACOSX` copies left out. */ |
| 64 | export async function unzip(bytes: Uint8Array): Promise<Unpacked> { |
| 65 | const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); |
| 66 | // The end of central directory record: the last 22 bytes, or before a comment. |
| 67 | let end = -1; |
| 68 | for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 22 - 0xffff); i--) { |
| 69 | if (view.getUint32(i, true) === 0x06054b50) { |
| 70 | end = i; |
| 71 | break; |
| 72 | } |
| 73 | } |
| 74 | if (end < 0) return { ok: false, message: "That file isn't a zip archive." }; |
| 75 | const count = view.getUint16(end + 10, true); |
| 76 | let at = view.getUint32(end + 16, true); |
| 77 | if (count > SKILL_FILES_MAX * 2) return { ok: false, message: `A skill holds at most ${SKILL_FILES_MAX} files.` }; |
| 78 | const files: SkillFile[] = []; |
| 79 | let unpacked = 0; |
| 80 | for (let n = 0; n < count; n++) { |
| 81 | if (at + 46 > bytes.length || view.getUint32(at, true) !== 0x02014b50) return { ok: false, message: "That zip archive is damaged." }; |
| 82 | const flags = view.getUint16(at + 8, true); |
| 83 | const method = view.getUint16(at + 10, true); |
| 84 | const compressed = view.getUint32(at + 20, true); |
| 85 | const size = view.getUint32(at + 24, true); |
| 86 | const nameLength = view.getUint16(at + 28, true); |
| 87 | const extraLength = view.getUint16(at + 30, true); |
| 88 | const commentLength = view.getUint16(at + 32, true); |
| 89 | const local = view.getUint32(at + 42, true); |
| 90 | const name = new TextDecoder().decode(bytes.subarray(at + 46, at + 46 + nameLength)); |
| 91 | at += 46 + nameLength + extraLength + commentLength; |
| 92 | if (name.endsWith("/") || name.startsWith("__MACOSX/") || /(^|\/)\.DS_Store$/.test(name)) continue; |
| 93 | if (flags & 1) return { ok: false, message: `${name} is encrypted. Upload a zip without a password.` }; |
| 94 | if (compressed === 0xffffffff || size === 0xffffffff) return { ok: false, message: "That zip archive is too large for a skill." }; |
| 95 | unpacked += size; |
| 96 | if (unpacked > SKILL_FOLDER_MAX_BYTES) return { ok: false, message: "A skill's folder is at most 1 MB once unpacked." }; |
| 97 | if (local + 30 > bytes.length || view.getUint32(local, true) !== 0x04034b50) return { ok: false, message: "That zip archive is damaged." }; |
| 98 | const start = local + 30 + view.getUint16(local + 26, true) + view.getUint16(local + 28, true); |
| 99 | const data = bytes.subarray(start, start + compressed); |
| 100 | let content: Uint8Array; |
| 101 | if (method === 0) content = data; |
| 102 | else if (method === 8) { |
| 103 | try { |
| 104 | content = await inflate(data, size); |
| 105 | } catch { |
| 106 | return { ok: false, message: `${name} couldn't be unpacked.` }; |
| 107 | } |
| 108 | } else return { ok: false, message: `${name} is packed in a way g1t can't read. Zip it again with standard compression.` }; |
| 109 | files.push(asSkillFile(name, content)); |
| 110 | } |
| 111 | if (!files.length) return { ok: false, message: "That zip archive is empty." }; |
| 112 | // One folder holding everything: read as that folder. |
| 113 | const tops = new Set(files.map((f) => (f.path.includes("/") ? f.path.slice(0, f.path.indexOf("/")) : ""))); |
| 114 | if (tops.size === 1 && !tops.has("")) { |
| 115 | const top = [...tops][0]!; |
| 116 | return { ok: true, files: files.map((f) => ({ ...f, path: f.path.slice(top.length + 1) })) }; |
| 117 | } |
| 118 | return { ok: true, files }; |
| 119 | } |
| 120 | |
| 121 | /** An upload: a zip by its content, else a SKILL.md as text. */ |
| 122 | export async function readUpload(filename: string, dataBase64: string): Promise<Unpacked> { |
| 123 | let bytes: Uint8Array; |
| 124 | try { |
| 125 | bytes = base64Bytes(dataBase64); |
| 126 | } catch { |
| 127 | return { ok: false, message: "That upload couldn't be read." }; |
| 128 | } |
| 129 | if (!bytes.length) return { ok: false, message: "That file is empty." }; |
| 130 | if (bytes.length > MAX_UPLOAD_BYTES) return { ok: false, message: "Upload at most 2 MB: a SKILL.md, or a zip of the skill's folder." }; |
| 131 | const isZip = bytes.length > 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && (bytes[2] === 3 || bytes[2] === 5); |
| 132 | if (isZip) return unzip(bytes); |
| 133 | if (!/\.(md|markdown|txt)$/i.test(filename) && filename) return { ok: false, message: "Upload a SKILL.md, or a zip of the skill's folder." }; |
| 134 | const file = asSkillFile("SKILL.md", bytes); |
| 135 | if (file.encoding !== "utf8") return { ok: false, message: "SKILL.md must be text (UTF-8)." }; |
| 136 | return { ok: true, files: [file] }; |
| 137 | } |