update-workshop-quality.mjs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  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/research-library/operations.md','references/workshop-analysis.md',
  15. 'references/workshop-autonomous-research.md','references/workshop-category-scan.md',
  16. 'references/workshop-competitor-landscape.md','references/workshop-course-flow.md',
  17. 'references/workshop-delivery.md','references/workshop-feedback.md',
  18. 'references/workshop-research-loop.md','references/workshop-research-tasking.md',
  19. 'references/workshop-tasks.md','references/workshop-verification.md',
  20. 'references/workshop-value-proposition.md','references/workshop-voc-clustering.md',
  21. 'references/workshop-innovation-reasoning.md','SKILL.md','FILES.sha256',
  22. ];
  23. const pins={
  24. r2:{count:38,skill:'31875ed7d93bd6cb559e5fdf32aa117234b9cd514a35638a4d7f827eb3ce815d',manifest:'4995e7e5a78184ca619debab347f1f46377688353a1d6baf2b0abb1275f43b14'},
  25. r3:{count:39,skill:'d8f3c5bd3ba8cd24a40aad37a19f9e2b3404201726070abc9fef9ade552e06e0',manifest:'a4da4aa14f1e83cf853cb75594d84f6474e27edc1cdc5ca2aa61d1c59764fc60'},
  26. r4:{count:40,skill:'85e44939bdbb3b55d512a4f5eb6daf9eac216634d1165ac3b576e9cc470176e8',manifest:'f7993111efcea575e432dcdf9ff53ca91c6695574a7d31628f9290a769daf461'},
  27. };
  28. const deltaNames=(baseline,candidate)=>allowed.filter(n=>baseline[n]!==candidate[n]);
  29. const allowedFor=id=>id==='r2'?allowed:id==='r3'?allowed.filter(n=>n!=='references/workshop-verification.md'):allowed.filter(n=>['SKILL.md','FILES.sha256','references/workshop-analysis.md','references/workshop-competitor-landscape.md','references/workshop-delivery.md','references/workshop-feedback.md','references/workshop-innovation-reasoning.md','references/workshop-value-proposition.md','references/workshop-voc-clustering.md'].includes(n));
  30. function validateBaseline(id,baseline,candidate){
  31. validMap(baseline);check(pins[id]&&Object.keys(baseline).length===pins[id].count,'UNEXPECTED_SKILL_LAYOUT');
  32. check(baseline['SKILL.md']===pins[id].skill&&baseline['FILES.sha256']===pins[id].manifest,'UNKNOWN_RELEASE_BASELINE');
  33. check(Object.keys(baseline).every(n=>Object.hasOwn(candidate,n)),'BASELINE_DELETION_FORBIDDEN');
  34. const delta=Object.keys(candidate).filter(n=>candidate[n]!==baseline[n]);
  35. check(namesEqual(delta,allowedFor(id)),'UNEXPECTED_WRITE_SET');
  36. for(const[n,h]of Object.entries(candidate))if(runtime(n))check(baseline[n]===h,'RUNTIME_UPDATE_FORBIDDEN');
  37. return delta;
  38. }
  39. const runtime=n=>n==='runtime-lock.json'||n.startsWith('scripts/');
  40. function exists(p){try{fs.lstatSync(p);return true;}catch(e){if(e.code==='ENOENT')return false;throw e;}}
  41. function plain(p,kind='file'){
  42. const abs=path.resolve(p);let cur=path.parse(abs).root;
  43. for(const bit of abs.slice(cur.length).split(path.sep).filter(Boolean)){
  44. cur=path.join(cur,bit);if(!exists(cur))break;
  45. check(!fs.lstatSync(cur).isSymbolicLink(),'SYMLINK_PATH');
  46. }
  47. const s=fs.lstatSync(abs);check(kind==='dir'?s.isDirectory():s.isFile(),'NOT_PLAIN_'+kind.toUpperCase());return abs;
  48. }
  49. const read=p=>fs.readFileSync(plain(p));
  50. const digest=p=>hash(read(p));
  51. function manifest(bytes){
  52. const out={};for(const line of bytes.toString('utf8').trim().split(/\r?\n/)){
  53. const m=/^([a-f0-9]{64}) (.+)$/.exec(line);
  54. check(m&&safeName(m[2])&&m[2]!=='FILES.sha256'&&!Object.hasOwn(out,m[2]),'INVALID_MANIFEST');out[m[2]]=m[1];
  55. }return out;
  56. }
  57. function validMap(map){check(map&&typeof map==='object'&&!Array.isArray(map)&&Object.entries(map).every(([n,h])=>safeName(n)&&sha(h)),'INVALID_FILE_MAP');}
  58. 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);}
  59. function packageInfo(dir){
  60. dir=plain(dir,'dir');const outer=manifest(read(path.join(dir,'FILES.sha256')));
  61. for(const[n,h]of Object.entries(outer))check(digest(path.join(dir,n))===h,'PACKAGE_HASH_MISMATCH');
  62. check(outer['release.json']&&outer['update-skill.mjs'],'PACKAGE_FILES_MISSING');
  63. const release=JSON.parse(read(path.join(dir,'release.json')));
  64. check(release.schema==='ksf.workshop-research-update.v1'&&release.releaseId==='ksf-workshop-skill-update-20261109-r5','INVALID_RELEASE');
  65. const {baselines,candidate}=release;validMap(candidate);
  66. check(Object.keys(candidate).length===41&&baselines&&namesEqual(Object.keys(baselines),['r2','r3','r4']),'UNEXPECTED_SKILL_LAYOUT');
  67. check(candidate['SKILL.md']==='16b428f4f4fa0819a764505269116f3f91e503342d9813d3855bc5c2cf80feb6'&&candidate['FILES.sha256']==='21ae0a245de46e9acbf97ed31718aeb1cdb4ea8ff1ca38259a2b3a9b0c9795e1','UNKNOWN_RELEASE_TARGET');
  68. for(const[id,baseline]of Object.entries(baselines))validateBaseline(id,baseline,candidate);
  69. check(Object.keys(candidate).filter(runtime).length===7,'RUNTIME_LAYOUT_MISMATCH');
  70. const delta=allowed;
  71. const payloadRoot=path.join(dir,'payload');
  72. for(const n of delta)check(outer['payload/'+n]===candidate[n]&&digest(path.join(payloadRoot,n))===candidate[n],'PAYLOAD_HASH_MISMATCH');
  73. check(namesEqual(Object.keys(outer).filter(n=>n.startsWith('payload/')),delta.map(n=>'payload/'+n)),'UNEXPECTED_PAYLOAD');
  74. const inner=manifest(read(path.join(payloadRoot,'FILES.sha256')));inner['FILES.sha256']=candidate['FILES.sha256'];
  75. check(namesEqual(Object.keys(inner),Object.keys(candidate))&&Object.entries(candidate).every(([n,h])=>inner[n]===h),'CANDIDATE_MANIFEST_MISMATCH');
  76. check(Array.isArray(release.researchEntryNames)&&release.researchEntryNames.length===18&&release.researchEntryNames.every(safeName)&&!release.researchEntryNames.includes('SKILL.md'),'INVALID_RESEARCH_LAYOUT');
  77. validMap(release.researchRuntimePins);check(Object.keys(release.researchRuntimePins).length===4&&Object.keys(release.researchRuntimePins).every(n=>n.startsWith('scripts/')),'INVALID_RESEARCH_PINS');
  78. return{release,dir,payloadRoot};
  79. }
  80. function librarySnapshot(dir,release){
  81. plain(dir,'dir');check(!exists(path.join(dir,'SKILL.md')),'LEGACY_ENTRY_REACTIVATED');
  82. const entries=manifest(read(path.join(dir,'FILES.sha256')));
  83. check(namesEqual(Object.keys(entries),release.researchEntryNames),'RESEARCH_MANIFEST_LAYOUT_MISMATCH');
  84. for(const[n,h]of Object.entries(release.researchRuntimePins))check(entries[n]===h,'RESEARCH_RUNTIME_MISMATCH');
  85. verifyMap(dir,entries,'RESEARCH_MANIFEST_MISMATCH');entries['FILES.sha256']=digest(path.join(dir,'FILES.sha256'));
  86. return entries;
  87. }
  88. function bindingSnapshot(targetDir){
  89. const p=path.join(targetDir,'credential-binding.json');plain(p);
  90. const mode=fs.statSync(p).mode&0o777;
  91. if(process.platform!=='win32')check(mode===0o600,'PRIVATE_BINDING_PERMISSIONS');
  92. return{sha256:digest(p),mode}; // hash only; never parse or export credential contents.
  93. }
  94. function writeableParent(file){let p=path.dirname(file);while(!exists(p))p=path.dirname(p);plain(p,'dir');}
  95. function atomic(file,bytes,mode=0o600){
  96. writeableParent(file);fs.mkdirSync(path.dirname(file),{recursive:true,mode:0o700});plain(path.dirname(file),'dir');
  97. if(exists(file))plain(file);
  98. const tmp=file+'.docs-update-'+crypto.randomUUID();
  99. try{fs.writeFileSync(tmp,bytes,{flag:'wx',mode});fs.renameSync(tmp,file);}finally{if(exists(tmp))fs.unlinkSync(tmp);}
  100. }
  101. export function planUpdate({packageDir,targetDir}){
  102. const info=packageInfo(path.resolve(packageDir));targetDir=plain(targetDir,'dir');
  103. check(path.basename(targetDir)==='ksf-workshop-runtime'&&path.basename(path.dirname(targetDir))==='skills','TARGET_PATH_MISMATCH');
  104. const libraryDir=path.join(path.dirname(targetDir),'ksf-voc-library');
  105. const {release,payloadRoot}=info,{baselines,candidate}=release;
  106. const binding=bindingSnapshot(targetDir),research=librarySnapshot(libraryDir,release);
  107. for(const[n,h]of Object.entries(candidate))if(runtime(n))check(digest(path.join(targetDir,n))===h,'RUNTIME_BASELINE_MISMATCH');
  108. const installed=Object.entries(candidate).every(([n,h])=>exists(path.join(targetDir,n))&&digest(path.join(targetDir,n))===h);
  109. const snapshot={targetDir,libraryDir,binding,research};
  110. if(installed)return{ok:true,readOnly:true,alreadyApplied:true,releaseId:release.releaseId,baselineId:'target',changes:[],...snapshot,candidate,baseline:baselines.r4};
  111. const installedSkill=digest(path.join(targetDir,'SKILL.md'));
  112. const baselineId=Object.keys(baselines).find(id=>baselines[id]['SKILL.md']===installedSkill);
  113. check(baselineId,'UNKNOWN_TARGET_SKILL');const baseline=baselines[baselineId];
  114. verifyMap(targetDir,baseline,'TARGET_BASELINE_MISMATCH');
  115. const changes=deltaNames(baseline,candidate).map(n=>{
  116. const p=path.join(targetDir,n),before=baseline[n]??null;
  117. if(before===null){check(!exists(p),'UNEXPECTED_EXISTING_FILE');writeableParent(p);}
  118. return{name:n,path:p,source:path.join(payloadRoot,n),beforeSha256:before,afterSha256:candidate[n]};
  119. });
  120. return{ok:true,readOnly:true,alreadyApplied:false,releaseId:release.releaseId,changes,...snapshot,candidate,baseline,baselineId};
  121. }
  122. function assertPreserved(p){
  123. check(!exists(path.join(p.libraryDir,'SKILL.md')),'LEGACY_ENTRY_REACTIVATED');
  124. verifyMap(p.libraryDir,p.research,'PRESERVED_RESEARCH_CHANGED');
  125. const b=bindingSnapshot(p.targetDir);check(b.sha256===p.binding.sha256&&b.mode===p.binding.mode,'BINDING_CHANGED');
  126. for(const[n,h]of Object.entries(p.candidate))if(runtime(n))check(digest(path.join(p.targetDir,n))===h,'PRESERVED_RUNTIME_CHANGED');
  127. }
  128. function assertSnapshot(p,{after=false}={}){
  129. assertPreserved(p);verifyMap(p.targetDir,after?p.candidate:p.baseline,'CONCURRENT_FILE_CHANGE');
  130. if(!after)for(const e of p.changes)if(e.beforeSha256===null)check(!exists(e.path),'CONCURRENT_FILE_CHANGE');
  131. }
  132. export function publicPlan(p){return{
  133. ok:true,releaseId:p.releaseId,baselineId:p.baselineId,readOnly:p.readOnly,alreadyApplied:p.alreadyApplied,targetDir:p.targetDir,
  134. skillSha256:p.candidate['SKILL.md'],manifestSha256:p.candidate['FILES.sha256'],
  135. changes:p.changes.map(e=>({path:e.path,action:e.beforeSha256?'update':'create',beforeSha256:e.beforeSha256,afterSha256:e.afterSha256})),
  136. changedFiles:p.changes.length,legacyEntrypointRetired:true,runtimeFilesModified:0,
  137. researchDatabaseWrites:0,businessRecordWrites:0,paidCalls:0,serviceRestarted:false,credentialsExported:false,
  138. };}
  139. export function applyUpdate(options,{afterMutation=()=>{}}={}){
  140. const plan=planUpdate(options);if(plan.alreadyApplied)return publicPlan(plan);
  141. assertSnapshot(plan);const parent=plain(path.resolve(plan.targetDir,'../..'),'dir');
  142. const backup=fs.mkdtempSync(path.join(parent,'ksf-workshop-research-backup-'));fs.chmodSync(backup,0o700);
  143. 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'}));
  144. 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});}
  145. const journal={schema:'ksf.workshop-research-journal.v1',releaseId:plan.releaseId,baselineId:plan.baselineId,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:[]};
  146. const save=()=>atomic(path.join(backup,'journal.json'),Buffer.from(JSON.stringify(journal,null,2)+'\n'));
  147. save();let current=null;
  148. try{
  149. assertSnapshot(plan);journal.status='APPLYING';save();
  150. for(const e of rows){
  151. current=e;assertPreserved(plan);
  152. check(e.beforeSha256===null?!exists(e.path):digest(e.path)===e.beforeSha256,'CONCURRENT_FILE_CHANGE');
  153. const bytes=read(e.source);check(hash(bytes)===e.afterSha256,'PACKAGE_CHANGED_DURING_APPLY');
  154. atomic(e.path,bytes,e.mode);journal.applied.push(e.index);save();current=null;afterMutation(e.index);
  155. }
  156. assertSnapshot(plan,{after:true});journal.status='APPLIED';save();
  157. return{...publicPlan({...plan,readOnly:false}),backup,runtimeAndBindingPreserved:true,researchFilesUnchanged:true,agentLoaded:false,currentReadVerified:false,actualBusinessWriteVerified:false,formalReportSubmitted:false};
  158. }catch(error){
  159. const indexes=new Set(journal.applied);
  160. if(current&&exists(current.path)&&digest(current.path)===current.afterSha256)indexes.add(current.index);
  161. try{
  162. for(const i of [...indexes].reverse()){
  163. const e=rows[i];check(exists(e.path)&&digest(e.path)===e.afterSha256,'ROLLBACK_CONCURRENT_FILE_CHANGE');
  164. 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);
  165. }
  166. assertSnapshot(plan);journal.status='FAILED_RESTORED';
  167. }catch{journal.status='FAILED_REQUIRES_REVIEW';}
  168. journal.errorCode=/^[A-Z_]+$/.test(error.code||'')?error.code:'APPLY_FAILED';save();
  169. throw Object.assign(Error(journal.errorCode),{code:journal.errorCode,backup,recoveryStatus:journal.status});
  170. }
  171. }
  172. function readJournal(backup){
  173. backup=plain(backup,'dir');const j=JSON.parse(read(path.join(backup,'journal.json'))),root=path.dirname(backup);
  174. check(j.schema==='ksf.workshop-research-journal.v1'&&j.releaseId==='ksf-workshop-skill-update-20261109-r5'&&Array.isArray(j.rows),'INVALID_JOURNAL');
  175. 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');
  176. validMap(j.baseline);validMap(j.candidate);validMap(j.research);
  177. check(j.binding&&sha(j.binding.sha256)&&Number.isInteger(j.binding.mode),'INVALID_JOURNAL');
  178. check(Object.keys(j.candidate).length===41,'INVALID_JOURNAL');
  179. const expectedDelta=validateBaseline(j.baselineId,j.baseline,j.candidate);
  180. const ordered=deltaNames(j.baseline,j.candidate);
  181. check(j.rows.length===expectedDelta.length&&namesEqual(j.rows.map(e=>e.name),expectedDelta),'JOURNAL_WRITE_SET_MISMATCH');
  182. for(const[i,e]of j.rows.entries()){
  183. check(e.index===i&&ordered[i]===e.name&&e.path===path.join(j.targetDir,e.name),'JOURNAL_PATH_ESCAPE');
  184. check(e.beforeSha256===(j.baseline[e.name]??null)&&e.afterSha256===j.candidate[e.name]&&sha(e.afterSha256),'INVALID_JOURNAL');
  185. check(Number.isInteger(e.mode)&&e.mode>=0&&e.mode<=0o777,'INVALID_JOURNAL');
  186. if(e.beforeSha256===null)check(e.backupFile===null,'INVALID_JOURNAL');
  187. else check(e.backupFile===String(i).padStart(3,'0')+'.before'&&digest(path.join(backup,e.backupFile))===e.beforeSha256,'BACKUP_HASH_MISMATCH');
  188. }
  189. return{backup,j};
  190. }
  191. export function verifyUpdate({backup}){
  192. const{j}=readJournal(backup);check(j.status==='APPLIED','UPDATE_NOT_APPLIED');
  193. assertSnapshot({...j,changes:j.rows},{after:true});
  194. 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};
  195. }
  196. export function rollbackUpdate({backup}){
  197. const{j}=readJournal(backup),p={...j,changes:j.rows};
  198. if(j.status==='ROLLED_BACK'){assertSnapshot(p);return{ok:true,readOnly:true,alreadyRolledBack:true};}
  199. check(j.status==='APPLIED','UPDATE_NOT_APPLIED');assertSnapshot(p,{after:true});
  200. for(const e of [...j.rows].reverse()){
  201. check(digest(e.path)===e.afterSha256,'ROLLBACK_CONCURRENT_FILE_CHANGE');
  202. if(e.backupFile)atomic(e.path,read(path.join(backup,e.backupFile)),e.mode);else fs.unlinkSync(e.path);
  203. }
  204. assertSnapshot(p);j.status='ROLLED_BACK';atomic(path.join(backup,'journal.json'),Buffer.from(JSON.stringify(j,null,2)+'\n'));
  205. return{ok:true,readOnly:false,releaseId:j.releaseId,runtimeAndBindingPreserved:true,legacyEntrypointRetired:true,restoredFiles:j.rows.length};
  206. }
  207. if(process.argv[1]&&path.resolve(process.argv[1])===self){
  208. try{
  209. const args=process.argv.slice(2),mode=args.shift();check(['--dry','--apply','--verify','--rollback'].includes(mode),'USAGE');
  210. check(process.platform==='linux'&&process.getuid()===10000,'HERMES_UID_10000_REQUIRED');
  211. const options={packageDir:path.dirname(self),targetDir:'/opt/data/skills/ksf-workshop-runtime'};
  212. 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');
  213. const result=mode==='--dry'?publicPlan(planUpdate(options)):mode==='--apply'?applyUpdate(options):mode==='--verify'?verifyUpdate(options):rollbackUpdate(options);
  214. console.log(JSON.stringify(result,null,2));
  215. }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;}
  216. }