import { createHash } from "node:crypto"; import { isAbsolute, relative, sep } from "node:path"; import type { Context } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { getOrThrow } from "@earendil-works/pi-durable/env"; import type { Environment } from "@berry/api"; import { MAX_SKILL_BYTES } from "./catalog.ts"; import { userDirectory } from "./paths.ts"; export const MAX_BUNDLE_BYTES = 32 * 1024 * 1024; const MAX_ENTRIES = 512; const MAX_DEPTH = 16; type Entry = { path: string; kind: "directory" } | { path: string; kind: "file"; executable: boolean; data: Uint8Array; }; type ManifestEntry = | Omit, "data"> | Extract; type Manifest = { id: string; entries: ManifestEntry[] }; function portablePath(path: string): boolean { return !/[\\\0]/.test(path) && path.split("/").every((part) => part && part !== "." && part !== ".."); } function contained(root: string, path: string): boolean { const fromRoot = relative(root, path); return fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot); } function digest(entries: Entry[]): string { const hash = createHash("sha256"); for (const entry of entries) { hash.update( JSON.stringify( entry.kind === "file" ? [entry.path, entry.kind, entry.executable, entry.data.length] : [entry.path, entry.kind], ) + "\n", ); if (entry.kind === "file") hash.update(entry.data); } return hash.digest("hex"); } async function readBytes(env: Environment, path: string, limit: number, context: Context) { const reader = getOrThrow(await env.openBinaryReader(path, { noFollow: true }, context)); try { const { size } = getOrThrow(await reader.info(context)); if (size > limit) throw new Error("Skill bundle exceeds its size limit"); const bytes = getOrThrow(await reader.read(0, size + 1, context)); if (bytes.length !== size) { throw new Error("Skill resource changed while being copied; reload it again"); } return bytes; } finally { await reader.close(context); } } async function ready( env: Environment, version: string, id: string, context: Context, ): Promise { const path = getOrThrow(await env.joinPath([version, "manifest.json"], context)); const text = await env.readTextFile(path, context); if (!text.ok) { if (text.error.code === "not_found") return false; throw text.error; } if (JSON.parse(text.value).id !== id) throw new Error(`Invalid skill bundle cache at ${version}`); return true; } /** Publish only complete versions, so interrupted or concurrent transfers cannot expose partial bundles. */ async function publish( env: Environment, version: string, id: string, entries: Entry[], context: Context, ) { const directory = getOrThrow(await env.joinPath([version, "skill"], context)); if (await ready(env, version, id, context)) return directory; const stage = `${version}.${crypto.randomUUID()}`; try { const root = getOrThrow(await env.joinPath([stage, "skill"], context)); getOrThrow(await env.createDir(root, { recursive: true }, context)); if (env.platform !== "win32") { const result = getOrThrow( await env.exec(["chmod", "700", "--", stage], { timeout: 10 }, context), ); if (result.exitCode !== 0) throw new Error("Could not make the skill bundle private"); } const executable: string[] = []; const manifest: Manifest = { id, entries: [] }; for (const entry of entries) { const path = getOrThrow(await env.joinPath([root, ...entry.path.split("/")], context)); if (entry.kind === "directory") { getOrThrow(await env.createDir(path, { recursive: true }, context)); manifest.entries.push(entry); } else { getOrThrow(await env.writeFile(path, entry.data, context)); manifest.entries.push({ path: entry.path, kind: entry.kind, executable: entry.executable }); if (entry.executable) executable.push(path); } } if (env.platform !== "win32") { for (let i = 0; i < executable.length; i += 64) { const result = getOrThrow( await env.exec( ["chmod", "a+x", "--", ...executable.slice(i, i + 64)], { timeout: 10 }, context, ), ); if (result.exitCode !== 0) { throw new Error("Could not preserve skill script executable permissions"); } } } getOrThrow( await env.writeFile( getOrThrow(await env.joinPath([stage, "manifest.json"], context)), JSON.stringify(manifest), context, ), ); const renamed = await env.renameFile(stage, version, context); if (!renamed.ok && !await ready(env, version, id, context)) throw renamed.error; return directory; } finally { await env.remove(stage, { recursive: true, force: true }, BACKGROUND_CONTEXT); } } /** Host-side bundle snapshots keep resource versions available to historical conversations and new targets. */ export class Bundles { constructor(private readonly host: Environment) {} private async version(id: string, context: Context): Promise { if (!/^[a-f0-9]{64}$/.test(id)) throw new Error("Invalid skill bundle identity"); const data = await userDirectory(this.host, "XDG_DATA_HOME", [".local", "share"], context); return getOrThrow(await this.host.joinPath([data, "berry", "skills", "bundles", id], context)); } async capture(directory: string, context: Context): Promise<{ id: string; content: string }> { const root = await Deno.realPath(directory); const host = this.host; const entries: Entry[] = []; let total = 0; async function visit(path: string, prefix: string, ancestors: Set) { context.abortSignal?.throwIfAborted(); const canonical = await Deno.realPath(path); if (!contained(root, canonical)) { throw new Error(`Skill symlink escapes its directory: ${prefix}`); } const info = await Deno.stat(canonical); if (prefix) { if (!portablePath(prefix)) { throw new Error(`Skill resource path is not portable: ${prefix}`); } if (entries.length === MAX_ENTRIES) throw new Error("Skill bundle has too many entries"); } if (info.isDirectory) { if (prefix) entries.push({ path: prefix, kind: "directory" }); if (ancestors.has(canonical)) throw new Error(`Skill symlink cycle: ${prefix}`); if (ancestors.size > MAX_DEPTH) throw new Error("Skill bundle is too deeply nested"); const next = new Set(ancestors).add(canonical); const children: string[] = []; for await (const entry of Deno.readDir(canonical)) { if (children.length + entries.length >= MAX_ENTRIES) { throw new Error("Skill bundle has too many entries"); } children.push(entry.name); } for (const name of children.sort()) { const child = prefix ? `${prefix}/${name}` : name; await visit(getOrThrow(await host.joinPath([canonical, name], context)), child, next); } } else if (info.isFile) { const limit = prefix === "SKILL.md" ? Math.min(MAX_SKILL_BYTES, MAX_BUNDLE_BYTES - total) : MAX_BUNDLE_BYTES - total; const data = await readBytes(host, canonical, limit, context); total += data.length; entries.push({ path: prefix, kind: "file", executable: Boolean((info.mode ?? 0) & 0o111), data, }); } else { throw new Error(`Skill resource is not a regular file or directory: ${prefix}`); } } await visit(root, "", new Set()); const main = entries.find((entry) => entry.path === "SKILL.md"); if (main?.kind !== "file") throw new Error("Skill bundle has no regular SKILL.md"); const content = new TextDecoder("utf-8", { fatal: true }).decode(main.data); const id = digest(entries); await publish(this.host, await this.version(id, context), id, entries, context); return { id, content }; } async materialize(id: string, env: Environment, context: Context): Promise { const source = await this.version(id, context); const sourceRoot = getOrThrow(await this.host.joinPath([source, "skill"], context)); if (env.id === this.host.id) { if (!await ready(this.host, source, id, context)) { throw new Error("Host skill bundle snapshot is missing; reload the skill"); } return sourceRoot; } const cache = await userDirectory(env, "XDG_CACHE_HOME", [".cache"], context); const version = getOrThrow(await env.joinPath([cache, "berry", "skills", id], context)); if (await ready(env, version, id, context)) { return getOrThrow(await env.joinPath([version, "skill"], context)); } const text = getOrThrow( await this.host.readTextFile( getOrThrow(await this.host.joinPath([source, "manifest.json"], context)), context, ), ); const manifest: Manifest = JSON.parse(text); if ( manifest.id !== id || !Array.isArray(manifest.entries) || manifest.entries.length > MAX_ENTRIES ) { throw new Error("Invalid host skill bundle manifest"); } const root = await Deno.realPath(sourceRoot); const entries: Entry[] = []; let total = 0; for (const entry of manifest.entries) { if ( typeof entry?.path !== "string" || !portablePath(entry.path) || (entry.kind !== "directory" && (entry.kind !== "file" || typeof entry.executable !== "boolean")) ) { throw new Error("Invalid host skill bundle entry"); } if (entry.kind === "directory") entries.push(entry); else { const path = getOrThrow( await this.host.joinPath([sourceRoot, ...entry.path.split("/")], context), ); const canonical = await Deno.realPath(path); if (!contained(root, canonical)) { throw new Error("Host skill bundle cache contains an escaping symlink"); } const data = await readBytes(this.host, canonical, MAX_BUNDLE_BYTES - total, context); total += data.length; entries.push({ ...entry, data }); } } if (digest(entries) !== id) { throw new Error("Host skill bundle snapshot was modified; reload the skill"); } return await publish(env, version, id, entries, context); } }