upgrade-unified-workshop-skill.mjs 15 KB

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