upgrade-unified-workshop-skill.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. // Documentation-only Skill upgrade. Importable for isolated filesystem tests.
  2. import fs from 'node:fs';
  3. import path from 'node:path';
  4. import crypto from 'node:crypto';
  5. import {fileURLToPath} from 'node:url';
  6. const self=fileURLToPath(import.meta.url);
  7. const hash=b=>crypto.createHash('sha256').update(b).digest('hex');
  8. const fail=code=>{throw Object.assign(Error(code),{code});};
  9. const check=(ok,code)=>{if(!ok)fail(code);};
  10. const runtime=n=>n==='runtime-lock.json'||n.startsWith('scripts/');
  11. const safeName=n=>typeof n==='string'&&/^[A-Za-z0-9_./-]+$/.test(n)&&!path.isAbsolute(n)&&!n.split('/').some(x=>!x||x==='.'||x==='..');
  12. function plain(p,kind='file'){
  13. const abs=path.resolve(p);let cur=path.parse(abs).root;
  14. for(const bit of abs.slice(cur.length).split(path.sep).filter(Boolean)){
  15. 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;}
  16. check(!fs.lstatSync(cur).isSymbolicLink(),'SYMLINK_PATH');
  17. }
  18. const s=fs.lstatSync(abs);check(kind==='dir'?s.isDirectory():s.isFile(),'NOT_PLAIN_'+kind.toUpperCase());return abs;
  19. }
  20. function read(p){return fs.readFileSync(plain(p));}
  21. const digest=p=>hash(read(p));
  22. function manifest(bytes){
  23. const out={};for(const line of bytes.toString('utf8').trim().split(/\r?\n/)){
  24. 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];
  25. }return out;
  26. }
  27. function inspectLibrary(dir,expectedSkill,{retired=false}={}){
  28. plain(dir,'dir');const bytes=read(path.join(dir,'FILES.sha256')),entries=manifest(bytes);
  29. if(retired)check(!fs.existsSync(path.join(dir,'SKILL.md'))&&!Object.hasOwn(entries,'SKILL.md'),'LEGACY_ENTRY_STILL_ACTIVE');
  30. else check(digest(path.join(dir,'SKILL.md'))===expectedSkill&&entries['SKILL.md']===expectedSkill,'UNKNOWN_LEGACY_SKILL');
  31. check(['query_voc.py','voc_db.py','library_channel.py'].every(n=>entries['scripts/'+n]),'RESEARCH_RUNTIME_MISSING');
  32. for(const [n,h]of Object.entries(entries))check(digest(path.join(dir,n))===h,'RESEARCH_MANIFEST_MISMATCH');
  33. return{bytes,entries};
  34. }
  35. function packageInfo(packageDir){
  36. plain(packageDir,'dir');const release=JSON.parse(read(path.join(packageDir,'release.json')));
  37. check(release.schema==='ksf.unified-workshop-release.v1'&&typeof release.releaseId==='string'&&/^[a-z0-9-]+$/.test(release.releaseId),'INVALID_RELEASE');
  38. const outer=manifest(read(path.join(packageDir,'FILES.sha256')));
  39. for(const[n,h]of Object.entries(outer))check(digest(path.join(packageDir,n))===h,'PACKAGE_HASH_MISMATCH');
  40. check(outer['release.json']&&outer['upgrade-skill.mjs'],'PACKAGE_FILES_MISSING');
  41. const candidateRoot=path.join(packageDir,'skill'),candidate=manifest(read(path.join(candidateRoot,'FILES.sha256')));
  42. candidate['FILES.sha256']=digest(path.join(candidateRoot,'FILES.sha256'));
  43. check(JSON.stringify(candidate)===JSON.stringify(release.candidate),'RELEASE_MANIFEST_MISMATCH');
  44. for(const[n,h]of Object.entries(candidate)){
  45. check(safeName(n)&&(n==='SKILL.md'||n==='FILES.sha256'||n==='runtime-lock.json'||n.startsWith('references/')||n.startsWith('scripts/')),'UNEXPECTED_CANDIDATE_FILE');
  46. check(outer['skill/'+n]===h&&digest(path.join(candidateRoot,n))===h,'PACKAGE_HASH_MISMATCH');
  47. }
  48. check(release.baseline&&Object.hasOwn(release.baseline,'SKILL.md')&&Object.hasOwn(release.baseline,'FILES.sha256'),'BASELINE_MISSING');
  49. for(const[n,h]of Object.entries(release.baseline))check(safeName(n)&&/^[a-f0-9]{64}$/.test(h),'INVALID_BASELINE');
  50. return{release,candidate,candidateRoot};
  51. }
  52. function writeableParent(file){
  53. let p=path.dirname(file);while(!fs.existsSync(p))p=path.dirname(p);plain(p,'dir');
  54. }
  55. function atomic(file,bytes,mode){
  56. writeableParent(file);fs.mkdirSync(path.dirname(file),{recursive:true,mode:0o700});plain(path.dirname(file),'dir');
  57. const tmp=file+'.unified-'+crypto.randomUUID();
  58. try{fs.writeFileSync(tmp,bytes,{flag:'wx',mode:mode??0o600});fs.renameSync(tmp,file);}finally{if(fs.existsSync(tmp))fs.unlinkSync(tmp);}
  59. }
  60. export function planUpgrade({packageDir,targetDir}){
  61. const info=packageInfo(path.resolve(packageDir));targetDir=plain(targetDir,'dir');
  62. check(path.basename(targetDir)==='ksf-workshop-runtime','TARGET_NAME_MISMATCH');
  63. const libraryDir=path.join(path.dirname(targetDir),'ksf-voc-library');
  64. const {release,candidate,candidateRoot}=info;
  65. const binding=path.join(targetDir,'credential-binding.json');plain(binding);
  66. if(process.platform!=='win32')check((fs.statSync(binding).mode&0o777)===0o600,'PRIVATE_BINDING_PERMISSIONS');
  67. const baseline=release.baseline;
  68. for(const[n,h]of Object.entries(candidate))if(runtime(n)){
  69. check(baseline[n]===h&&digest(path.join(targetDir,n))===h,'RUNTIME_BASELINE_MISMATCH');
  70. }
  71. const installed=Object.entries(candidate).every(([n,h])=>fs.existsSync(path.join(targetDir,n))&&digest(path.join(targetDir,n))===h);
  72. if(installed){
  73. inspectLibrary(libraryDir,release.legacySkillSha256,{retired:true});
  74. return{ok:true,alreadyApplied:true,readOnly:true,releaseId:release.releaseId,targetDir,libraryDir,changes:[],runtimeFilesModified:0};
  75. }
  76. check(digest(path.join(targetDir,'SKILL.md'))===baseline['SKILL.md'],'UNKNOWN_TARGET_SKILL');
  77. for(const[n,h]of Object.entries(baseline))check(digest(path.join(targetDir,n))===h,'TARGET_BASELINE_MISMATCH');
  78. const library=inspectLibrary(libraryDir,release.legacySkillSha256);
  79. const changes=[];
  80. for(const[n,h]of Object.entries(candidate)){
  81. if(runtime(n))continue;
  82. const target=path.join(targetDir,n),exists=fs.existsSync(target);
  83. if(exists){plain(target);check(baseline[n]&&digest(target)===baseline[n],'UNEXPECTED_EXISTING_FILE');}
  84. else{check(!baseline[n],'BASELINE_FILE_MISSING');writeableParent(target);}
  85. if(!exists||digest(target)!==h)changes.push({path:target,source:path.join(candidateRoot,n),beforeSha256:exists?digest(target):null,afterSha256:h,remove:false});
  86. }
  87. const legacyManifest=Buffer.from(Object.entries(library.entries).filter(([n])=>n!=='SKILL.md').map(([n,h])=>h+' '+n+'\n').join(''));
  88. changes.sort((a,b)=>Number(path.basename(a.path)==='SKILL.md')-Number(path.basename(b.path)==='SKILL.md'));
  89. changes.push({path:path.join(libraryDir,'SKILL.md'),beforeSha256:release.legacySkillSha256,afterSha256:null,remove:true});
  90. changes.push({path:path.join(libraryDir,'FILES.sha256'),beforeSha256:hash(library.bytes),afterSha256:hash(legacyManifest),bytes:legacyManifest,remove:false});
  91. const preservation=Object.entries(library.entries).filter(([n])=>n!=='SKILL.md').map(([n,h])=>({path:path.join(libraryDir,n),sha256:h}));
  92. preservation.push(...Object.entries(candidate).filter(([n])=>runtime(n)).map(([n,h])=>({path:path.join(targetDir,n),sha256:h})),{path:binding,sha256:digest(binding)});
  93. return{ok:true,alreadyApplied:false,readOnly:true,releaseId:release.releaseId,targetDir,libraryDir,changes,preservation,runtimeFilesModified:0};
  94. }
  95. function assertSnapshot(plan,{after=false}={}){
  96. for(const e of plan.preservation||[])check(digest(e.path)===e.sha256,'PRESERVED_FILE_CHANGED');
  97. for(const e of plan.changes){const h=after?e.afterSha256:e.beforeSha256;
  98. if(h===null)check(!fs.existsSync(e.path),'CONCURRENT_FILE_CHANGE');else check(digest(e.path)===h,'CONCURRENT_FILE_CHANGE');
  99. }
  100. }
  101. export function publicPlan(plan){return{ok:true,releaseId:plan.releaseId,readOnly:plan.readOnly,alreadyApplied:plan.alreadyApplied,targetDir:plan.targetDir,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};}
  102. export function applyUpgrade(options,{afterMutation=()=>{}}={}){
  103. const plan=planUpgrade(options);if(plan.alreadyApplied)return publicPlan(plan);
  104. assertSnapshot(plan);
  105. const parent=path.resolve(plan.targetDir,'../..');plain(parent,'dir');
  106. const backup=fs.mkdtempSync(path.join(parent,'ksf-workshop-unified-backup-'));fs.chmodSync(backup,0o700);
  107. 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'}));
  108. for(const e of rows)if(e.backupFile)fs.writeFileSync(path.join(backup,e.backupFile),read(e.path),{flag:'wx',mode:0o600});
  109. 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()};
  110. const save=()=>atomic(path.join(backup,'journal.json'),Buffer.from(JSON.stringify(journal,null,2)+'\n'));
  111. save();let current=null;
  112. try{
  113. assertSnapshot(plan);journal.status='APPLYING';save();
  114. for(const e of rows){
  115. current=e;check(e.beforeSha256===null?!fs.existsSync(e.path):digest(e.path)===e.beforeSha256,'CONCURRENT_FILE_CHANGE');
  116. 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);}
  117. journal.applied.push(e.index);save();current=null;afterMutation(e.index);
  118. }
  119. assertSnapshot(plan,{after:true});journal.status='APPLIED';save();
  120. return{...publicPlan({...plan,readOnly:false}),backup,legacyEntrypointArchived:true,runtimeAndBindingPreserved:true,agentReloadVerified:false,remoteReadVerified:false,actualBusinessWriteVerified:false};
  121. }catch(error){
  122. // Only restore bytes still owned by this operation; never overwrite unknown concurrent edits.
  123. const indexes=new Set(journal.applied);
  124. if(current&&(current.afterSha256===null?!fs.existsSync(current.path):fs.existsSync(current.path)&&digest(current.path)===current.afterSha256))indexes.add(current.index);
  125. try{
  126. for(const i of [...indexes].reverse()){
  127. const e=rows[i];check(e.afterSha256===null?!fs.existsSync(e.path):digest(e.path)===e.afterSha256,'ROLLBACK_CONCURRENT_FILE_CHANGE');
  128. if(e.backupFile)atomic(e.path,read(path.join(backup,e.backupFile)),e.mode);else fs.unlinkSync(e.path);
  129. }
  130. assertSnapshot(plan);journal.status='FAILED_RESTORED';
  131. }catch{journal.status='FAILED_REQUIRES_REVIEW';}
  132. journal.errorCode=/^[A-Z_]+$/.test(error.code||'')?error.code:'APPLY_FAILED';save();
  133. throw Object.assign(Error(journal.errorCode),{code:journal.errorCode,backup,recoveryStatus:journal.status});
  134. }
  135. }
  136. function readJournal(backup){
  137. backup=plain(backup,'dir');const journal=JSON.parse(read(path.join(backup,'journal.json')));
  138. check(journal.schema==='ksf.unified-workshop-upgrade-journal.v1'&&Array.isArray(journal.rows)&&Array.isArray(journal.preservation),'INVALID_JOURNAL');
  139. 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');
  140. for(const e of journal.rows){
  141. const bases=[journal.targetDir,journal.libraryDir];check(bases.some(b=>e.path.startsWith(b+path.sep)),'JOURNAL_PATH_ESCAPE');
  142. const rel=path.relative(e.path.startsWith(journal.targetDir+path.sep)?journal.targetDir:journal.libraryDir,e.path).split(path.sep).join('/');
  143. check(safeName(rel)&&!runtime(rel),'JOURNAL_RUNTIME_WRITE_FORBIDDEN');
  144. if(e.backupFile){check(/^\d{3}\.before$/.test(e.backupFile)&&digest(path.join(backup,e.backupFile))===e.beforeSha256,'BACKUP_HASH_MISMATCH');}
  145. }
  146. return{backup,journal};
  147. }
  148. export function verifyUpgrade({backup}){
  149. const{journal}=readJournal(backup);check(journal.status==='APPLIED','UPGRADE_NOT_APPLIED');
  150. assertSnapshot({changes:journal.rows,preservation:journal.preservation},{after:true});
  151. return{ok:true,readOnly:true,releaseId:journal.releaseId,installedFilesVerified:journal.rows.length,runtimeAndBindingPreserved:true,legacyEntrypointArchived:true,agentReloadVerified:false,remoteReadVerified:false,actualBusinessWriteVerified:false};
  152. }
  153. export function rollbackUpgrade({backup}){
  154. const state=readJournal(backup),j=state.journal;
  155. if(j.status==='ROLLED_BACK'){assertSnapshot({changes:j.rows,preservation:j.preservation});return{ok:true,readOnly:true,alreadyRolledBack:true};}
  156. check(j.status==='APPLIED','UPGRADE_NOT_APPLIED');assertSnapshot({changes:j.rows,preservation:j.preservation},{after:true});
  157. for(const e of [...j.rows].reverse()){
  158. check(e.afterSha256===null?!fs.existsSync(e.path):digest(e.path)===e.afterSha256,'ROLLBACK_CONCURRENT_FILE_CHANGE');
  159. if(e.backupFile)atomic(e.path,read(path.join(state.backup,e.backupFile)),e.mode);else fs.unlinkSync(e.path);
  160. }
  161. assertSnapshot({changes:j.rows,preservation:j.preservation});j.status='ROLLED_BACK';
  162. atomic(path.join(state.backup,'journal.json'),Buffer.from(JSON.stringify(j,null,2)+'\n'));
  163. return{ok:true,readOnly:false,releaseId:j.releaseId,legacyEntrypointRestored:true,runtimeAndBindingPreserved:true};
  164. }
  165. if(process.argv[1]&&path.resolve(process.argv[1])===self){
  166. try{
  167. const args=process.argv.slice(2),mode=args.shift();check(['--dry','--apply','--verify','--rollback'].includes(mode),'USAGE');
  168. check(process.platform==='linux'&&process.getuid()===10000,'HERMES_UID_10000_REQUIRED');
  169. const options={packageDir:path.dirname(self),targetDir:'/opt/data/skills/ksf-workshop-runtime'};
  170. 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');
  171. const result=mode==='--dry'?publicPlan(planUpgrade(options)):mode==='--apply'?applyUpgrade(options):mode==='--verify'?verifyUpgrade(options):rollbackUpgrade(options);
  172. console.log(JSON.stringify(result,null,2));
  173. }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;}
  174. }