update-workshop-research.mjs 16 KB

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