| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197 |
- // Documentation-only Skill upgrade. Importable for isolated filesystem tests.
- import fs from 'node:fs';
- import path from 'node:path';
- import crypto from 'node:crypto';
- import {fileURLToPath} from 'node:url';
- const self=fileURLToPath(import.meta.url);
- const hash=b=>crypto.createHash('sha256').update(b).digest('hex');
- const fail=code=>{throw Object.assign(Error(code),{code});};
- const check=(ok,code)=>{if(!ok)fail(code);};
- const runtime=n=>n==='runtime-lock.json'||n.startsWith('scripts/');
- const safeName=n=>typeof n==='string'&&/^[A-Za-z0-9_./-]+$/.test(n)&&!path.isAbsolute(n)&&!n.split('/').some(x=>!x||x==='.'||x==='..');
- function plain(p,kind='file'){
- const abs=path.resolve(p);let cur=path.parse(abs).root;
- for(const bit of abs.slice(cur.length).split(path.sep).filter(Boolean)){
- cur=path.join(cur,bit);if(!fs.existsSync(cur)){try{fs.lstatSync(cur);fail('SYMLINK_PATH');}catch(e){if(e.code!=='ENOENT')throw e;}break;}
- check(!fs.lstatSync(cur).isSymbolicLink(),'SYMLINK_PATH');
- }
- const s=fs.lstatSync(abs);check(kind==='dir'?s.isDirectory():s.isFile(),'NOT_PLAIN_'+kind.toUpperCase());return abs;
- }
- function read(p){return fs.readFileSync(plain(p));}
- const digest=p=>hash(read(p));
- function manifest(bytes){
- const out={};for(const line of bytes.toString('utf8').trim().split(/\r?\n/)){
- const m=/^([a-f0-9]{64}) (.+)$/.exec(line);check(m&&safeName(m[2])&&!Object.hasOwn(out,m[2]),'INVALID_MANIFEST');out[m[2]]=m[1];
- }return out;
- }
- function inspectLibrary(dir,expectedSkill,{retired=false,policy}={}){
- plain(dir,'dir');const bytes=read(path.join(dir,'FILES.sha256')),entries=manifest(bytes);
- const mismatches=[];
- if(policy){
- check(Array.isArray(policy.entryNames)&&policy.entryNames.length===19&&new Set(policy.entryNames).size===19&&policy.entryNames.includes('SKILL.md')&&policy.entryNames.every(safeName),'INVALID_LEGACY_MANIFEST_POLICY');
- const expectedNames=policy.entryNames.filter(n=>!retired||n!=='SKILL.md').sort();
- check(JSON.stringify(Object.keys(entries).sort())===JSON.stringify(expectedNames),'RESEARCH_MANIFEST_LAYOUT_MISMATCH');
- const stale=policy.knownStaleSkill;
- check(stale&&stale.actualSha256===expectedSkill&&/^[a-f0-9]{64}$/.test(stale.declaredSha256)&&stale.declaredSha256!==expectedSkill&&/^[a-f0-9]{64}$/.test(stale.evidenceSha256),'INVALID_LEGACY_MANIFEST_POLICY');
- check(policy.verifiedRuntimeEntries&&Object.keys(policy.verifiedRuntimeEntries).length===4,'INVALID_LEGACY_MANIFEST_POLICY');
- for(const[n,h]of Object.entries(policy.verifiedRuntimeEntries)){
- check(n.startsWith('scripts/')&&safeName(n)&&/^[a-f0-9]{64}$/.test(h),'INVALID_LEGACY_MANIFEST_POLICY');
- check(entries[n]===h,'RESEARCH_BASELINE_MISMATCH');
- }
- }
- if(retired)check(!fs.existsSync(path.join(dir,'SKILL.md'))&&!Object.hasOwn(entries,'SKILL.md'),'LEGACY_ENTRY_STILL_ACTIVE');
- else{
- check(digest(path.join(dir,'SKILL.md'))===expectedSkill,'UNKNOWN_LEGACY_SKILL');
- if(entries['SKILL.md']!==expectedSkill){
- check(policy&&entries['SKILL.md']===policy.knownStaleSkill.declaredSha256,'UNKNOWN_LEGACY_SKILL');
- mismatches.push({path:'SKILL.md',actualSha256:expectedSkill,declaredSha256:entries['SKILL.md'],acceptedAs:'EXPLICIT_KNOWN_STALE_DECLARATION',evidenceSha256:policy.knownStaleSkill.evidenceSha256});
- }
- }
- check(['query_voc.py','voc_db.py','library_channel.py'].every(n=>entries['scripts/'+n]),'RESEARCH_RUNTIME_MISSING');
- for(const [n,h]of Object.entries(entries)){
- if(n==='SKILL.md'&&mismatches.length)continue;
- check(digest(path.join(dir,n))===h,'RESEARCH_MANIFEST_MISMATCH');
- }
- return{bytes,entries,status:retired?'LEGACY_ENTRY_RETIRED':mismatches.length?'KNOWN_STALE_SKILL_DECLARATION':'CONSISTENT',mismatches};
- }
- function packageInfo(packageDir){
- plain(packageDir,'dir');const release=JSON.parse(read(path.join(packageDir,'release.json')));
- check(release.schema==='ksf.unified-workshop-release.v1'&&typeof release.releaseId==='string'&&/^[a-z0-9-]+$/.test(release.releaseId),'INVALID_RELEASE');
- const outer=manifest(read(path.join(packageDir,'FILES.sha256')));
- for(const[n,h]of Object.entries(outer))check(digest(path.join(packageDir,n))===h,'PACKAGE_HASH_MISMATCH');
- check(outer['release.json']&&outer['upgrade-skill.mjs'],'PACKAGE_FILES_MISSING');
- const candidateRoot=path.join(packageDir,'skill'),candidate=manifest(read(path.join(candidateRoot,'FILES.sha256')));
- candidate['FILES.sha256']=digest(path.join(candidateRoot,'FILES.sha256'));
- check(JSON.stringify(candidate)===JSON.stringify(release.candidate),'RELEASE_MANIFEST_MISMATCH');
- for(const[n,h]of Object.entries(candidate)){
- check(safeName(n)&&(n==='SKILL.md'||n==='FILES.sha256'||n==='runtime-lock.json'||n.startsWith('references/')||n.startsWith('scripts/')),'UNEXPECTED_CANDIDATE_FILE');
- check(outer['skill/'+n]===h&&digest(path.join(candidateRoot,n))===h,'PACKAGE_HASH_MISMATCH');
- }
- check(release.baseline&&Object.hasOwn(release.baseline,'SKILL.md')&&Object.hasOwn(release.baseline,'FILES.sha256'),'BASELINE_MISSING');
- for(const[n,h]of Object.entries(release.baseline))check(safeName(n)&&/^[a-f0-9]{64}$/.test(h),'INVALID_BASELINE');
- return{release,candidate,candidateRoot};
- }
- function writeableParent(file){
- let p=path.dirname(file);while(!fs.existsSync(p))p=path.dirname(p);plain(p,'dir');
- }
- function atomic(file,bytes,mode){
- writeableParent(file);fs.mkdirSync(path.dirname(file),{recursive:true,mode:0o700});plain(path.dirname(file),'dir');
- const tmp=file+'.unified-'+crypto.randomUUID();
- try{fs.writeFileSync(tmp,bytes,{flag:'wx',mode:mode??0o600});fs.renameSync(tmp,file);}finally{if(fs.existsSync(tmp))fs.unlinkSync(tmp);}
- }
- export function planUpgrade({packageDir,targetDir}){
- const info=packageInfo(path.resolve(packageDir));targetDir=plain(targetDir,'dir');
- check(path.basename(targetDir)==='ksf-workshop-runtime','TARGET_NAME_MISMATCH');
- const libraryDir=path.join(path.dirname(targetDir),'ksf-voc-library');
- const {release,candidate,candidateRoot}=info;
- const binding=path.join(targetDir,'credential-binding.json');plain(binding);
- if(process.platform!=='win32')check((fs.statSync(binding).mode&0o777)===0o600,'PRIVATE_BINDING_PERMISSIONS');
- const baseline=release.baseline;
- for(const[n,h]of Object.entries(candidate))if(runtime(n)){
- check(baseline[n]===h&&digest(path.join(targetDir,n))===h,'RUNTIME_BASELINE_MISMATCH');
- }
- const installed=Object.entries(candidate).every(([n,h])=>fs.existsSync(path.join(targetDir,n))&&digest(path.join(targetDir,n))===h);
- if(installed){
- const library=inspectLibrary(libraryDir,release.legacySkillSha256,{retired:true,policy:release.legacyManifestPolicy});
- return{ok:true,alreadyApplied:true,readOnly:true,releaseId:release.releaseId,targetDir,libraryDir,legacyManifestState:library.status,legacyManifestMismatches:library.mismatches,changes:[],runtimeFilesModified:0};
- }
- check(digest(path.join(targetDir,'SKILL.md'))===baseline['SKILL.md'],'UNKNOWN_TARGET_SKILL');
- for(const[n,h]of Object.entries(baseline))check(digest(path.join(targetDir,n))===h,'TARGET_BASELINE_MISMATCH');
- const library=inspectLibrary(libraryDir,release.legacySkillSha256,{policy:release.legacyManifestPolicy});
- const changes=[];
- for(const[n,h]of Object.entries(candidate)){
- if(runtime(n))continue;
- const target=path.join(targetDir,n),exists=fs.existsSync(target);
- if(exists){plain(target);check(baseline[n]&&digest(target)===baseline[n],'UNEXPECTED_EXISTING_FILE');}
- else{check(!baseline[n],'BASELINE_FILE_MISSING');writeableParent(target);}
- if(!exists||digest(target)!==h)changes.push({path:target,source:path.join(candidateRoot,n),beforeSha256:exists?digest(target):null,afterSha256:h,remove:false});
- }
- const legacyManifest=Buffer.from(Object.entries(library.entries).filter(([n])=>n!=='SKILL.md').map(([n,h])=>h+' '+n+'\n').join(''));
- changes.sort((a,b)=>Number(path.basename(a.path)==='SKILL.md')-Number(path.basename(b.path)==='SKILL.md'));
- changes.push({path:path.join(libraryDir,'SKILL.md'),beforeSha256:release.legacySkillSha256,afterSha256:null,remove:true});
- changes.push({path:path.join(libraryDir,'FILES.sha256'),beforeSha256:hash(library.bytes),afterSha256:hash(legacyManifest),bytes:legacyManifest,remove:false});
- const preservation=Object.entries(library.entries).filter(([n])=>n!=='SKILL.md').map(([n,h])=>({path:path.join(libraryDir,n),sha256:h}));
- preservation.push(...Object.entries(candidate).filter(([n])=>runtime(n)).map(([n,h])=>({path:path.join(targetDir,n),sha256:h})),{path:binding,sha256:digest(binding)});
- return{ok:true,alreadyApplied:false,readOnly:true,releaseId:release.releaseId,targetDir,libraryDir,legacyManifestState:library.status,legacyManifestMismatches:library.mismatches,changes,preservation,runtimeFilesModified:0};
- }
- function assertSnapshot(plan,{after=false}={}){
- for(const e of plan.preservation||[])check(digest(e.path)===e.sha256,'PRESERVED_FILE_CHANGED');
- for(const e of plan.changes){const h=after?e.afterSha256:e.beforeSha256;
- if(h===null)check(!fs.existsSync(e.path),'CONCURRENT_FILE_CHANGE');else check(digest(e.path)===h,'CONCURRENT_FILE_CHANGE');
- }
- }
- export function publicPlan(plan){return{ok:true,releaseId:plan.releaseId,readOnly:plan.readOnly,alreadyApplied:plan.alreadyApplied,targetDir:plan.targetDir,legacyManifestState:plan.legacyManifestState,legacyManifestMismatches:plan.legacyManifestMismatches,changedFiles:plan.changes.map(e=>({path:e.path,action:e.remove?'archive-legacy-entry':e.beforeSha256?'update':'create',beforeSha256:e.beforeSha256,afterSha256:e.afterSha256})),runtimeFilesModified:0,researchDatabaseWrites:0,businessRecordWrites:0,paidCalls:0,serviceRestarted:false};}
- export function applyUpgrade(options,{afterMutation=()=>{}}={}){
- const plan=planUpgrade(options);if(plan.alreadyApplied)return publicPlan(plan);
- assertSnapshot(plan);
- const parent=path.resolve(plan.targetDir,'../..');plain(parent,'dir');
- const backup=fs.mkdtempSync(path.join(parent,'ksf-workshop-unified-backup-'));fs.chmodSync(backup,0o700);
- const rows=plan.changes.map((e,i)=>({...e,index:i,mode:e.beforeSha256===null?null:fs.statSync(e.path).mode&0o777,backupFile:e.beforeSha256===null?null:String(i).padStart(3,'0')+'.before'}));
- for(const e of rows)if(e.backupFile)fs.writeFileSync(path.join(backup,e.backupFile),read(e.path),{flag:'wx',mode:0o600});
- const journal={schema:'ksf.unified-workshop-upgrade-journal.v1',releaseId:plan.releaseId,status:'PREPARED',targetDir:plan.targetDir,libraryDir:plan.libraryDir,rows:rows.map(({source,bytes,...e})=>e),preservation:plan.preservation,applied:[],createdAt:new Date().toISOString()};
- const save=()=>atomic(path.join(backup,'journal.json'),Buffer.from(JSON.stringify(journal,null,2)+'\n'));
- save();let current=null;
- try{
- assertSnapshot(plan);journal.status='APPLYING';save();
- for(const e of rows){
- current=e;check(e.beforeSha256===null?!fs.existsSync(e.path):digest(e.path)===e.beforeSha256,'CONCURRENT_FILE_CHANGE');
- if(e.remove)fs.unlinkSync(e.path);else{const bytes=e.bytes||read(e.source);check(hash(bytes)===e.afterSha256,'PACKAGE_CHANGED_DURING_APPLY');atomic(e.path,bytes,e.mode??0o600);}
- journal.applied.push(e.index);save();current=null;afterMutation(e.index);
- }
- assertSnapshot(plan,{after:true});journal.status='APPLIED';save();
- return{...publicPlan({...plan,readOnly:false}),backup,legacyEntrypointArchived:true,runtimeAndBindingPreserved:true,agentReloadVerified:false,remoteReadVerified:false,actualBusinessWriteVerified:false};
- }catch(error){
- // Only restore bytes still owned by this operation; never overwrite unknown concurrent edits.
- const indexes=new Set(journal.applied);
- if(current&&(current.afterSha256===null?!fs.existsSync(current.path):fs.existsSync(current.path)&&digest(current.path)===current.afterSha256))indexes.add(current.index);
- try{
- for(const i of [...indexes].reverse()){
- const e=rows[i];check(e.afterSha256===null?!fs.existsSync(e.path):digest(e.path)===e.afterSha256,'ROLLBACK_CONCURRENT_FILE_CHANGE');
- if(e.backupFile)atomic(e.path,read(path.join(backup,e.backupFile)),e.mode);else fs.unlinkSync(e.path);
- }
- assertSnapshot(plan);journal.status='FAILED_RESTORED';
- }catch{journal.status='FAILED_REQUIRES_REVIEW';}
- journal.errorCode=/^[A-Z_]+$/.test(error.code||'')?error.code:'APPLY_FAILED';save();
- throw Object.assign(Error(journal.errorCode),{code:journal.errorCode,backup,recoveryStatus:journal.status});
- }
- }
- function readJournal(backup){
- backup=plain(backup,'dir');const journal=JSON.parse(read(path.join(backup,'journal.json')));
- check(journal.schema==='ksf.unified-workshop-upgrade-journal.v1'&&Array.isArray(journal.rows)&&Array.isArray(journal.preservation),'INVALID_JOURNAL');
- const root=path.resolve(backup,'..');check(path.resolve(journal.targetDir)===path.join(root,'skills','ksf-workshop-runtime')&&path.resolve(journal.libraryDir)===path.join(root,'skills','ksf-voc-library'),'JOURNAL_TARGET_MISMATCH');
- for(const e of journal.rows){
- const bases=[journal.targetDir,journal.libraryDir];check(bases.some(b=>e.path.startsWith(b+path.sep)),'JOURNAL_PATH_ESCAPE');
- const rel=path.relative(e.path.startsWith(journal.targetDir+path.sep)?journal.targetDir:journal.libraryDir,e.path).split(path.sep).join('/');
- check(safeName(rel)&&!runtime(rel),'JOURNAL_RUNTIME_WRITE_FORBIDDEN');
- if(e.backupFile){check(/^\d{3}\.before$/.test(e.backupFile)&&digest(path.join(backup,e.backupFile))===e.beforeSha256,'BACKUP_HASH_MISMATCH');}
- }
- return{backup,journal};
- }
- export function verifyUpgrade({backup}){
- const{journal}=readJournal(backup);check(journal.status==='APPLIED','UPGRADE_NOT_APPLIED');
- assertSnapshot({changes:journal.rows,preservation:journal.preservation},{after:true});
- return{ok:true,readOnly:true,releaseId:journal.releaseId,installedFilesVerified:journal.rows.length,runtimeAndBindingPreserved:true,legacyEntrypointArchived:true,agentReloadVerified:false,remoteReadVerified:false,actualBusinessWriteVerified:false};
- }
- export function rollbackUpgrade({backup}){
- const state=readJournal(backup),j=state.journal;
- if(j.status==='ROLLED_BACK'){assertSnapshot({changes:j.rows,preservation:j.preservation});return{ok:true,readOnly:true,alreadyRolledBack:true};}
- check(j.status==='APPLIED','UPGRADE_NOT_APPLIED');assertSnapshot({changes:j.rows,preservation:j.preservation},{after:true});
- for(const e of [...j.rows].reverse()){
- check(e.afterSha256===null?!fs.existsSync(e.path):digest(e.path)===e.afterSha256,'ROLLBACK_CONCURRENT_FILE_CHANGE');
- if(e.backupFile)atomic(e.path,read(path.join(state.backup,e.backupFile)),e.mode);else fs.unlinkSync(e.path);
- }
- assertSnapshot({changes:j.rows,preservation:j.preservation});j.status='ROLLED_BACK';
- atomic(path.join(state.backup,'journal.json'),Buffer.from(JSON.stringify(j,null,2)+'\n'));
- return{ok:true,readOnly:false,releaseId:j.releaseId,legacyEntrypointRestored:true,runtimeAndBindingPreserved:true};
- }
- if(process.argv[1]&&path.resolve(process.argv[1])===self){
- try{
- const args=process.argv.slice(2),mode=args.shift();check(['--dry','--apply','--verify','--rollback'].includes(mode),'USAGE');
- check(process.platform==='linux'&&process.getuid()===10000,'HERMES_UID_10000_REQUIRED');
- const options={packageDir:path.dirname(self),targetDir:'/opt/data/skills/ksf-workshop-runtime'};
- if(['--verify','--rollback'].includes(mode)){check(args.length===2&&args[0]==='--backup'&&path.isAbsolute(args[1]),'BACKUP_ARGUMENT_REQUIRED');options.backup=args[1];}else check(args.length===0,'USAGE');
- const result=mode==='--dry'?publicPlan(planUpgrade(options)):mode==='--apply'?applyUpgrade(options):mode==='--verify'?verifyUpgrade(options):rollbackUpgrade(options);
- console.log(JSON.stringify(result,null,2));
- }catch(e){console.error(JSON.stringify({ok:false,error:{code:/^[A-Z_]+$/.test(e.code||'')?e.code:'UPGRADE_FAILED'},...(e.backup?{backup:e.backup,recoveryStatus:e.recoveryStatus}:{}),credentialsExported:false}));process.exitCode=1;}
- }
|