update-workshop-docs.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. // Incremental instructions-only update. CLI is pinned to the original installation.
  2. // Exported functions are used only by isolated local filesystem rehearsals.
  3. import fs from 'node:fs';
  4. import path from 'node:path';
  5. import crypto from 'node:crypto';
  6. import {fileURLToPath} from 'node:url';
  7. const self=fileURLToPath(import.meta.url);
  8. const hash=b=>crypto.createHash('sha256').update(b).digest('hex');
  9. const check=(ok,code)=>{if(!ok)throw Object.assign(Error(code),{code});};
  10. const sha=h=>typeof h==='string'&&/^[a-f0-9]{64}$/.test(h);
  11. const namesEqual=(a,b)=>JSON.stringify([...a].sort())===JSON.stringify([...b].sort());
  12. const safeName=n=>typeof n==='string'&&/^[A-Za-z0-9_./-]+$/.test(n)&&!path.isAbsolute(n)&&!n.split('/').some(x=>!x||x==='.'||x==='..');
  13. const allowed=[
  14. 'references/workshop-analysis.md','references/workshop-category-scan.md',
  15. 'references/workshop-competitor-landscape.md','references/workshop-course-flow.md',
  16. 'references/workshop-delivery.md','references/workshop-feedback.md',
  17. 'references/workshop-verification.md','SKILL.md','FILES.sha256',
  18. ];
  19. const runtime=n=>n==='runtime-lock.json'||n.startsWith('scripts/');
  20. function exists(p){try{fs.lstatSync(p);return true;}catch(e){if(e.code==='ENOENT')return false;throw e;}}
  21. function plain(p,kind='file'){
  22. const abs=path.resolve(p);let cur=path.parse(abs).root;
  23. for(const bit of abs.slice(cur.length).split(path.sep).filter(Boolean)){
  24. cur=path.join(cur,bit);if(!exists(cur))break;
  25. check(!fs.lstatSync(cur).isSymbolicLink(),'SYMLINK_PATH');
  26. }
  27. const s=fs.lstatSync(abs);check(kind==='dir'?s.isDirectory():s.isFile(),'NOT_PLAIN_'+kind.toUpperCase());return abs;
  28. }
  29. const read=p=>fs.readFileSync(plain(p));
  30. const digest=p=>hash(read(p));
  31. function manifest(bytes){
  32. const out={};for(const line of bytes.toString('utf8').trim().split(/\r?\n/)){
  33. const m=/^([a-f0-9]{64}) (.+)$/.exec(line);
  34. check(m&&safeName(m[2])&&m[2]!=='FILES.sha256'&&!Object.hasOwn(out,m[2]),'INVALID_MANIFEST');out[m[2]]=m[1];
  35. }return out;
  36. }
  37. function validMap(map){check(map&&typeof map==='object'&&!Array.isArray(map)&&Object.entries(map).every(([n,h])=>safeName(n)&&sha(h)),'INVALID_FILE_MAP');}
  38. function verifyMap(root,map,code){for(const[n,h]of Object.entries(map))check(exists(path.join(root,n))&&digest(path.join(root,n))===h,code);}
  39. function packageInfo(dir){
  40. dir=plain(dir,'dir');const outer=manifest(read(path.join(dir,'FILES.sha256')));
  41. for(const[n,h]of Object.entries(outer))check(digest(path.join(dir,n))===h,'PACKAGE_HASH_MISMATCH');
  42. check(outer['release.json']&&outer['update-skill.mjs'],'PACKAGE_FILES_MISSING');
  43. const release=JSON.parse(read(path.join(dir,'release.json')));
  44. check(release.schema==='ksf.workshop-docs-update.v1'&&release.releaseId==='ksf-workshop-skill-update-20261109-r3','INVALID_RELEASE');
  45. const {baseline,candidate}=release;validMap(baseline);validMap(candidate);
  46. check(Object.keys(baseline).length===38&&Object.keys(candidate).length===39,'UNEXPECTED_SKILL_LAYOUT');
  47. check(Object.keys(baseline).every(n=>Object.hasOwn(candidate,n)),'BASELINE_DELETION_FORBIDDEN');
  48. const delta=Object.keys(candidate).filter(n=>candidate[n]!==baseline[n]);
  49. check(namesEqual(delta,allowed)&&!baseline['references/workshop-feedback.md'],'UNEXPECTED_WRITE_SET');
  50. for(const[n,h]of Object.entries(candidate))if(runtime(n))check(baseline[n]===h,'RUNTIME_UPDATE_FORBIDDEN');
  51. check(Object.keys(candidate).filter(runtime).length===7,'RUNTIME_LAYOUT_MISMATCH');
  52. const payloadRoot=path.join(dir,'payload');
  53. for(const n of delta)check(outer['payload/'+n]===candidate[n]&&digest(path.join(payloadRoot,n))===candidate[n],'PAYLOAD_HASH_MISMATCH');
  54. check(namesEqual(Object.keys(outer).filter(n=>n.startsWith('payload/')),delta.map(n=>'payload/'+n)),'UNEXPECTED_PAYLOAD');
  55. const inner=manifest(read(path.join(payloadRoot,'FILES.sha256')));inner['FILES.sha256']=candidate['FILES.sha256'];
  56. check(namesEqual(Object.keys(inner),Object.keys(candidate))&&Object.entries(candidate).every(([n,h])=>inner[n]===h),'CANDIDATE_MANIFEST_MISMATCH');
  57. check(Array.isArray(release.researchEntryNames)&&release.researchEntryNames.length===18&&release.researchEntryNames.every(safeName)&&!release.researchEntryNames.includes('SKILL.md'),'INVALID_RESEARCH_LAYOUT');
  58. validMap(release.researchRuntimePins);check(Object.keys(release.researchRuntimePins).length===4&&Object.keys(release.researchRuntimePins).every(n=>n.startsWith('scripts/')),'INVALID_RESEARCH_PINS');
  59. return{release,dir,payloadRoot};
  60. }
  61. function librarySnapshot(dir,release){
  62. plain(dir,'dir');check(!exists(path.join(dir,'SKILL.md')),'LEGACY_ENTRY_REACTIVATED');
  63. const entries=manifest(read(path.join(dir,'FILES.sha256')));
  64. check(namesEqual(Object.keys(entries),release.researchEntryNames),'RESEARCH_MANIFEST_LAYOUT_MISMATCH');
  65. for(const[n,h]of Object.entries(release.researchRuntimePins))check(entries[n]===h,'RESEARCH_RUNTIME_MISMATCH');
  66. verifyMap(dir,entries,'RESEARCH_MANIFEST_MISMATCH');entries['FILES.sha256']=digest(path.join(dir,'FILES.sha256'));
  67. return entries;
  68. }
  69. function bindingSnapshot(targetDir){
  70. const p=path.join(targetDir,'credential-binding.json');plain(p);
  71. const mode=fs.statSync(p).mode&0o777;
  72. if(process.platform!=='win32')check(mode===0o600,'PRIVATE_BINDING_PERMISSIONS');
  73. return{sha256:digest(p),mode}; // hash only; never parse or export credential contents.
  74. }
  75. function writeableParent(file){let p=path.dirname(file);while(!exists(p))p=path.dirname(p);plain(p,'dir');}
  76. function atomic(file,bytes,mode=0o600){
  77. writeableParent(file);fs.mkdirSync(path.dirname(file),{recursive:true,mode:0o700});plain(path.dirname(file),'dir');
  78. if(exists(file))plain(file);
  79. const tmp=file+'.docs-update-'+crypto.randomUUID();
  80. try{fs.writeFileSync(tmp,bytes,{flag:'wx',mode});fs.renameSync(tmp,file);}finally{if(exists(tmp))fs.unlinkSync(tmp);}
  81. }
  82. export function planUpdate({packageDir,targetDir}){
  83. const info=packageInfo(path.resolve(packageDir));targetDir=plain(targetDir,'dir');
  84. check(path.basename(targetDir)==='ksf-workshop-runtime'&&path.basename(path.dirname(targetDir))==='skills','TARGET_PATH_MISMATCH');
  85. const libraryDir=path.join(path.dirname(targetDir),'ksf-voc-library');
  86. const {release,payloadRoot}=info,{baseline,candidate}=release;
  87. const binding=bindingSnapshot(targetDir),research=librarySnapshot(libraryDir,release);
  88. for(const[n,h]of Object.entries(candidate))if(runtime(n))check(digest(path.join(targetDir,n))===h,'RUNTIME_BASELINE_MISMATCH');
  89. const installed=Object.entries(candidate).every(([n,h])=>exists(path.join(targetDir,n))&&digest(path.join(targetDir,n))===h);
  90. const snapshot={targetDir,libraryDir,binding,research};
  91. if(installed)return{ok:true,readOnly:true,alreadyApplied:true,releaseId:release.releaseId,changes:[],...snapshot,candidate,baseline};
  92. check(digest(path.join(targetDir,'SKILL.md'))===baseline['SKILL.md'],'UNKNOWN_TARGET_SKILL');
  93. verifyMap(targetDir,baseline,'TARGET_BASELINE_MISMATCH');
  94. const changes=allowed.map(n=>{
  95. const p=path.join(targetDir,n),before=baseline[n]??null;
  96. if(before===null){check(!exists(p),'UNEXPECTED_EXISTING_FILE');writeableParent(p);}
  97. return{name:n,path:p,source:path.join(payloadRoot,n),beforeSha256:before,afterSha256:candidate[n]};
  98. });
  99. return{ok:true,readOnly:true,alreadyApplied:false,releaseId:release.releaseId,changes,...snapshot,candidate,baseline};
  100. }
  101. function assertPreserved(p){
  102. check(!exists(path.join(p.libraryDir,'SKILL.md')),'LEGACY_ENTRY_REACTIVATED');
  103. verifyMap(p.libraryDir,p.research,'PRESERVED_RESEARCH_CHANGED');
  104. const b=bindingSnapshot(p.targetDir);check(b.sha256===p.binding.sha256&&b.mode===p.binding.mode,'BINDING_CHANGED');
  105. for(const[n,h]of Object.entries(p.candidate))if(runtime(n))check(digest(path.join(p.targetDir,n))===h,'PRESERVED_RUNTIME_CHANGED');
  106. }
  107. function assertSnapshot(p,{after=false}={}){
  108. assertPreserved(p);verifyMap(p.targetDir,after?p.candidate:p.baseline,'CONCURRENT_FILE_CHANGE');
  109. if(!after)for(const e of p.changes)if(e.beforeSha256===null)check(!exists(e.path),'CONCURRENT_FILE_CHANGE');
  110. }
  111. export function publicPlan(p){return{
  112. ok:true,releaseId:p.releaseId,readOnly:p.readOnly,alreadyApplied:p.alreadyApplied,targetDir:p.targetDir,
  113. skillSha256:p.candidate['SKILL.md'],manifestSha256:p.candidate['FILES.sha256'],
  114. changes:p.changes.map(e=>({path:e.path,action:e.beforeSha256?'update':'create',beforeSha256:e.beforeSha256,afterSha256:e.afterSha256})),
  115. changedFiles:p.changes.length,legacyEntrypointRetired:true,runtimeFilesModified:0,
  116. researchDatabaseWrites:0,businessRecordWrites:0,paidCalls:0,serviceRestarted:false,credentialsExported:false,
  117. };}
  118. export function applyUpdate(options,{afterMutation=()=>{}}={}){
  119. const plan=planUpdate(options);if(plan.alreadyApplied)return publicPlan(plan);
  120. assertSnapshot(plan);const parent=plain(path.resolve(plan.targetDir,'../..'),'dir');
  121. const backup=fs.mkdtempSync(path.join(parent,'ksf-workshop-docs-backup-'));fs.chmodSync(backup,0o700);
  122. const rows=plan.changes.map((e,i)=>({...e,index:i,mode:e.beforeSha256===null?0o600:fs.statSync(e.path).mode&0o777,backupFile:e.beforeSha256===null?null:String(i).padStart(3,'0')+'.before'}));
  123. for(const e of rows)if(e.backupFile){const bytes=read(e.path);check(hash(bytes)===e.beforeSha256,'CONCURRENT_FILE_CHANGE');fs.writeFileSync(path.join(backup,e.backupFile),bytes,{flag:'wx',mode:0o600});}
  124. const journal={schema:'ksf.workshop-docs-journal.v1',releaseId:plan.releaseId,status:'PREPARED',createdAt:new Date().toISOString(),targetDir:plan.targetDir,libraryDir:plan.libraryDir,binding:plan.binding,research:plan.research,baseline:plan.baseline,candidate:plan.candidate,rows:rows.map(({source,...e})=>e),applied:[]};
  125. const save=()=>atomic(path.join(backup,'journal.json'),Buffer.from(JSON.stringify(journal,null,2)+'\n'));
  126. save();let current=null;
  127. try{
  128. assertSnapshot(plan);journal.status='APPLYING';save();
  129. for(const e of rows){
  130. current=e;assertPreserved(plan);
  131. check(e.beforeSha256===null?!exists(e.path):digest(e.path)===e.beforeSha256,'CONCURRENT_FILE_CHANGE');
  132. const bytes=read(e.source);check(hash(bytes)===e.afterSha256,'PACKAGE_CHANGED_DURING_APPLY');
  133. atomic(e.path,bytes,e.mode);journal.applied.push(e.index);save();current=null;afterMutation(e.index);
  134. }
  135. assertSnapshot(plan,{after:true});journal.status='APPLIED';save();
  136. return{...publicPlan({...plan,readOnly:false}),backup,runtimeAndBindingPreserved:true,researchFilesUnchanged:true,agentLoaded:false,currentReadVerified:false,actualBusinessWriteVerified:false,formalReportSubmitted:false};
  137. }catch(error){
  138. const indexes=new Set(journal.applied);
  139. if(current&&exists(current.path)&&digest(current.path)===current.afterSha256)indexes.add(current.index);
  140. try{
  141. for(const i of [...indexes].reverse()){
  142. const e=rows[i];check(exists(e.path)&&digest(e.path)===e.afterSha256,'ROLLBACK_CONCURRENT_FILE_CHANGE');
  143. if(e.backupFile){const bytes=read(path.join(backup,e.backupFile));check(hash(bytes)===e.beforeSha256,'BACKUP_HASH_MISMATCH');atomic(e.path,bytes,e.mode);}else fs.unlinkSync(e.path);
  144. }
  145. assertSnapshot(plan);journal.status='FAILED_RESTORED';
  146. }catch{journal.status='FAILED_REQUIRES_REVIEW';}
  147. journal.errorCode=/^[A-Z_]+$/.test(error.code||'')?error.code:'APPLY_FAILED';save();
  148. throw Object.assign(Error(journal.errorCode),{code:journal.errorCode,backup,recoveryStatus:journal.status});
  149. }
  150. }
  151. function readJournal(backup){
  152. backup=plain(backup,'dir');const j=JSON.parse(read(path.join(backup,'journal.json'))),root=path.dirname(backup);
  153. check(j.schema==='ksf.workshop-docs-journal.v1'&&j.releaseId==='ksf-workshop-skill-update-20261109-r3'&&Array.isArray(j.rows),'INVALID_JOURNAL');
  154. check(path.resolve(j.targetDir)===path.join(root,'skills','ksf-workshop-runtime')&&path.resolve(j.libraryDir)===path.join(root,'skills','ksf-voc-library'),'JOURNAL_TARGET_MISMATCH');
  155. validMap(j.baseline);validMap(j.candidate);validMap(j.research);
  156. check(j.binding&&sha(j.binding.sha256)&&Number.isInteger(j.binding.mode),'INVALID_JOURNAL');
  157. check(j.rows.length===9&&namesEqual(j.rows.map(e=>e.name),allowed),'JOURNAL_WRITE_SET_MISMATCH');
  158. check(Object.keys(j.baseline).length===38&&Object.keys(j.candidate).length===39,'INVALID_JOURNAL');
  159. check(namesEqual(Object.keys(j.candidate).filter(n=>j.baseline[n]!==j.candidate[n]),allowed),'JOURNAL_WRITE_SET_MISMATCH');
  160. check(Object.keys(j.baseline).every(n=>Object.hasOwn(j.candidate,n)),'INVALID_JOURNAL');
  161. for(const[n,h]of Object.entries(j.candidate))if(runtime(n))check(j.baseline[n]===h,'JOURNAL_RUNTIME_WRITE_FORBIDDEN');
  162. for(const[i,e]of j.rows.entries()){
  163. check(e.index===i&&allowed[i]===e.name&&e.path===path.join(j.targetDir,e.name),'JOURNAL_PATH_ESCAPE');
  164. check(e.beforeSha256===(j.baseline[e.name]??null)&&e.afterSha256===j.candidate[e.name]&&sha(e.afterSha256),'INVALID_JOURNAL');
  165. check(Number.isInteger(e.mode)&&e.mode>=0&&e.mode<=0o777,'INVALID_JOURNAL');
  166. if(e.beforeSha256===null)check(e.backupFile===null,'INVALID_JOURNAL');
  167. else check(e.backupFile===String(i).padStart(3,'0')+'.before'&&digest(path.join(backup,e.backupFile))===e.beforeSha256,'BACKUP_HASH_MISMATCH');
  168. }
  169. return{backup,j};
  170. }
  171. export function verifyUpdate({backup}){
  172. const{j}=readJournal(backup);check(j.status==='APPLIED','UPDATE_NOT_APPLIED');
  173. assertSnapshot({...j,changes:j.rows},{after:true});
  174. return{ok:true,readOnly:true,releaseId:j.releaseId,installedFilesVerified:Object.keys(j.candidate).length,changedFilesVerified:j.rows.length,skillSha256:j.candidate['SKILL.md'],manifestSha256:j.candidate['FILES.sha256'],runtimeAndBindingPreserved:true,researchFilesUnchanged:true,legacyEntrypointRetired:true,agentLoaded:false,currentReadVerified:false,actualBusinessWriteVerified:false,formalReportSubmitted:false,databaseWrites:0,paidCalls:0,serviceRestarted:false};
  175. }
  176. export function rollbackUpdate({backup}){
  177. const{j}=readJournal(backup),p={...j,changes:j.rows};
  178. if(j.status==='ROLLED_BACK'){assertSnapshot(p);return{ok:true,readOnly:true,alreadyRolledBack:true};}
  179. check(j.status==='APPLIED','UPDATE_NOT_APPLIED');assertSnapshot(p,{after:true});
  180. for(const e of [...j.rows].reverse()){
  181. check(digest(e.path)===e.afterSha256,'ROLLBACK_CONCURRENT_FILE_CHANGE');
  182. if(e.backupFile)atomic(e.path,read(path.join(backup,e.backupFile)),e.mode);else fs.unlinkSync(e.path);
  183. }
  184. assertSnapshot(p);j.status='ROLLED_BACK';atomic(path.join(backup,'journal.json'),Buffer.from(JSON.stringify(j,null,2)+'\n'));
  185. return{ok:true,readOnly:false,releaseId:j.releaseId,runtimeAndBindingPreserved:true,legacyEntrypointRetired:true,restoredFiles:9};
  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(planUpdate(options)):mode==='--apply'?applyUpdate(options):mode==='--verify'?verifyUpdate(options):rollbackUpdate(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:'UPDATE_FAILED'},...(e.backup?{backup:e.backup,recoveryStatus:e.recoveryStatus}:{}),credentialsExported:false}));process.exitCode=1;}
  196. }