char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
a4e772a
main
1import { createHash } from "node:crypto" ; 2import { isAbsolute , relative , sep } from "node:path" ; 3import type { Context } from "@earendil-works/chord" ; 4import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context" ; 5import { getOrThrow } from "@earendil-works/pi-durable/env" ; 6import type { Environment } from "@berry/api" ; 7import { MAX_SKILL_BYTES } from "./catalog.ts" ; 8import { userDirectory } from "./paths.ts" ; 9 10export const MAX_BUNDLE_BYTES = 32 * 1024 * 1024 ; 11const MAX_ENTRIES = 512 ; 12const MAX_DEPTH = 16 ; 13 14type Entry = { path :string ; kind :"directory" } | { 15path :string ; 16kind :"file" ; 17executable :boolean ; 18data :Uint8Array ; 19}; 20type ManifestEntry = 21| Omit < Extract < Entry , { kind :"file" }>, "data" > 22| Extract < Entry , { kind :"directory" }>; 23type Manifest = { id :string ; entries :ManifestEntry [] }; 24 25function portablePath ( path :string ) :boolean { 26return ! /[\\\0] / . test ( path ) && 27path . split ( "/" ). every (( part ) => part && part !== "." && part !== ".." ); 28} 29 30function contained ( root :string , path :string ) :boolean { 31const fromRoot = relative ( root , path ); 32return fromRoot !== ".." && ! fromRoot . startsWith ( `.. ${ sep } ` ) && ! isAbsolute ( fromRoot ); 33} 34 35function digest ( entries :Entry []) :string { 36const hash = createHash ( "sha256" ); 37for ( const entry of entries ) { 38hash . update ( 39JSON . stringify ( 40entry . kind === "file" 41 ?[ entry . path , entry . kind , entry . executable , entry . data . length ] 42 :[ entry . path , entry . kind ], 43) + "\n" , 44); 45if ( entry . kind === "file" ) hash . update ( entry . data ); 46} 47return hash . digest ( "hex" ); 48} 49 50async function readBytes ( env :Environment , path :string , limit :number , context :Context ) { 51const reader = getOrThrow ( await env . openBinaryReader ( path , { noFollow :true }, context )); 52try { 53const { size} = getOrThrow ( await reader . info ( context )); 54if ( size > limit ) throw new Error ( "Skill bundle exceeds its size limit" ); 55const bytes = getOrThrow ( await reader . read ( 0 , size + 1 , context )); 56if ( bytes . length !== size ) { 57throw new Error ( "Skill resource changed while being copied; reload it again" ); 58} 59return bytes ; 60} finally { 61await reader . close ( context ); 62} 63} 64 65async function ready ( 66env :Environment , 67version :string , 68id :string , 69context :Context , 70) :Promise < boolean > { 71const path = getOrThrow ( await env . joinPath ([ version , "manifest.json" ], context )); 72const text = await env . readTextFile ( path , context ); 73if ( ! text . ok ) { 74if ( text . error . code === "not_found" ) return false ; 75throw text . error ; 76} 77if ( JSON . parse ( text . value ). id !== id ) throw new Error ( `Invalid skill bundle cache at ${ version } ` ); 78return true ; 79} 80 81/** Publish only complete versions, so interrupted or concurrent transfers cannot expose partial bundles. */ 82async function publish ( 83env :Environment , 84version :string , 85id :string , 86entries :Entry [], 87context :Context , 88) { 89const directory = getOrThrow ( await env . joinPath ([ version , "skill" ], context )); 90if ( await ready ( env , version , id , context )) return directory ; 91const stage = ` ${ version } . ${ crypto . randomUUID ()} ` ; 92try { 93const root = getOrThrow ( await env . joinPath ([ stage , "skill" ], context )); 94getOrThrow ( await env . createDir ( root , { recursive :true }, context )); 95if ( env . platform !== "win32" ) { 96const result = getOrThrow ( 97await env . exec ([ "chmod" , "700" , "--" , stage ], { timeout :10 }, context ), 98); 99if ( result . exitCode !== 0 ) throw new Error ( "Could not make the skill bundle private" ); 100} 101const executable :string [] = []; 102const manifest :Manifest = { id, entries :[] }; 103for ( const entry of entries ) { 104const path = getOrThrow ( await env . joinPath ([ root , ...entry . path . split ( "/" )], context )); 105if ( entry . kind === "directory" ) { 106getOrThrow ( await env . createDir ( path , { recursive :true }, context )); 107manifest . entries . push ( entry ); 108} else { 109getOrThrow ( await env . writeFile ( path , entry . data , context )); 110manifest . entries . push ({ path :entry . path , kind :entry . kind , executable :entry . executable }); 111if ( entry . executable ) executable . push ( path ); 112} 113} 114if ( env . platform !== "win32" ) { 115for ( let i = 0 ; i < executable . length ; i += 64 ) { 116const result = getOrThrow ( 117await env . exec ( 118[ "chmod" , "a+x" , "--" , ...executable . slice ( i , i + 64 )], 119{ timeout :10 }, 120context , 121), 122); 123if ( result . exitCode !== 0 ) { 124throw new Error ( "Could not preserve skill script executable permissions" ); 125} 126} 127} 128getOrThrow ( 129await env . writeFile ( 130getOrThrow ( await env . joinPath ([ stage , "manifest.json" ], context )), 131JSON . stringify ( manifest ), 132context , 133), 134); 135const renamed = await env . renameFile ( stage , version , context ); 136if ( ! renamed . ok && ! await ready ( env , version , id , context )) throw renamed . error ; 137return directory ; 138} finally { 139await env . remove ( stage , { recursive :true , force :true }, BACKGROUND_CONTEXT ); 140} 141} 142 143/** Host-side bundle snapshots keep resource versions available to historical conversations and new targets. */ 144export class Bundles { 145constructor ( private readonly host :Environment ) {} 146 147private async version ( id :string , context :Context ) :Promise < string > { 148if ( ! /^[a-f0-9]{64}$ / . test ( id )) throw new Error ( "Invalid skill bundle identity" ); 149const data = await userDirectory ( this . host , "XDG_DATA_HOME" , [ ".local" , "share" ], context ); 150return getOrThrow ( await this . host . joinPath ([ data , "berry" , "skills" , "bundles" , id ], context )); 151} 152 153async capture ( directory :string , context :Context ) :Promise <{ id :string ; content :string }> { 154const root = await Deno . realPath ( directory ); 155const host = this . host ; 156const entries :Entry [] = []; 157let total = 0 ; 158async function visit ( path :string , prefix :string , ancestors :Set < string >) { 159context . abortSignal ?. throwIfAborted (); 160const canonical = await Deno . realPath ( path ); 161if ( ! contained ( root , canonical )) { 162throw new Error ( `Skill symlink escapes its directory: ${ prefix } ` ); 163} 164const info = await Deno . stat ( canonical ); 165if ( prefix ) { 166if ( ! portablePath ( prefix )) { 167throw new Error ( `Skill resource path is not portable: ${ prefix } ` ); 168} 169if ( entries . length === MAX_ENTRIES ) throw new Error ( "Skill bundle has too many entries" ); 170} 171if ( info . isDirectory ) { 172if ( prefix ) entries . push ({ path :prefix , kind :"directory" }); 173if ( ancestors . has ( canonical )) throw new Error ( `Skill symlink cycle: ${ prefix } ` ); 174if ( ancestors . size > MAX_DEPTH ) throw new Error ( "Skill bundle is too deeply nested" ); 175const next = new Set ( ancestors ). add ( canonical ); 176const children :string [] = []; 177for await ( const entry of Deno . readDir ( canonical )) { 178if ( children . length + entries . length >= MAX_ENTRIES ) { 179throw new Error ( "Skill bundle has too many entries" ); 180} 181children . push ( entry . name ); 182} 183for ( const name of children . sort ()) { 184const child = prefix ?` ${ prefix } / ${ name } ` :name ; 185await visit ( getOrThrow ( await host . joinPath ([ canonical , name ], context )), child , next ); 186} 187} else if ( info . isFile ) { 188const limit = prefix === "SKILL.md" 189 ?Math . min ( MAX_SKILL_BYTES , MAX_BUNDLE_BYTES - total ) 190 :MAX_BUNDLE_BYTES - total ; 191const data = await readBytes ( host , canonical , limit , context ); 192total += data . length ; 193entries . push ({ 194path :prefix , 195kind :"file" , 196executable :Boolean (( info . mode ?? 0 ) & 0o111 ), 197 data, 198}); 199} else { 200throw new Error ( `Skill resource is not a regular file or directory: ${ prefix } ` ); 201} 202} 203await visit ( root , "" , new Set ()); 204const main = entries . find (( entry ) => entry . path === "SKILL.md" ); 205if ( main ?. kind !== "file" ) throw new Error ( "Skill bundle has no regular SKILL.md" ); 206const content = new TextDecoder ( "utf-8" , { fatal :true }). decode ( main . data ); 207const id = digest ( entries ); 208await publish ( this . host , await this . version ( id , context ), id , entries , context ); 209return { id, content}; 210} 211 212async materialize ( id :string , env :Environment , context :Context ) :Promise < string > { 213const source = await this . version ( id , context ); 214const sourceRoot = getOrThrow ( await this . host . joinPath ([ source , "skill" ], context )); 215if ( env . id === this . host . id ) { 216if ( ! await ready ( this . host , source , id , context )) { 217throw new Error ( "Host skill bundle snapshot is missing; reload the skill" ); 218} 219return sourceRoot ; 220} 221const cache = await userDirectory ( env , "XDG_CACHE_HOME" , [ ".cache" ], context ); 222const version = getOrThrow ( await env . joinPath ([ cache , "berry" , "skills" , id ], context )); 223if ( await ready ( env , version , id , context )) { 224return getOrThrow ( await env . joinPath ([ version , "skill" ], context )); 225} 226const text = getOrThrow ( 227await this . host . readTextFile ( 228getOrThrow ( await this . host . joinPath ([ source , "manifest.json" ], context )), 229context , 230), 231); 232const manifest :Manifest = JSON . parse ( text ); 233if ( 234manifest . id !== id || ! Array . isArray ( manifest . entries ) || 235manifest . entries . length > MAX_ENTRIES 236) { 237throw new Error ( "Invalid host skill bundle manifest" ); 238} 239const root = await Deno . realPath ( sourceRoot ); 240const entries :Entry [] = []; 241let total = 0 ; 242for ( const entry of manifest . entries ) { 243if ( 244typeof entry ?. path !== "string" || ! portablePath ( entry . path ) || 245( entry . kind !== "directory" && 246( entry . kind !== "file" || typeof entry . executable !== "boolean" )) 247) { 248throw new Error ( "Invalid host skill bundle entry" ); 249} 250if ( entry . kind === "directory" ) entries . push ( entry ); 251else { 252const path = getOrThrow ( 253await this . host . joinPath ([ sourceRoot , ...entry . path . split ( "/" )], context ), 254); 255const canonical = await Deno . realPath ( path ); 256if ( ! contained ( root , canonical )) { 257throw new Error ( "Host skill bundle cache contains an escaping symlink" ); 258} 259const data = await readBytes ( this . host , canonical , MAX_BUNDLE_BYTES - total , context ); 260total += data . length ; 261entries . push ({ ...entry , data}); 262} 263} 264if ( digest ( entries ) !== id ) { 265throw new Error ( "Host skill bundle snapshot was modified; reload the skill" ); 266} 267return await publish ( env , version , id , entries , context ); 268} 269}