update-workshop-intake.mjs 16 KB

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