| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210 |
- // Incremental instructions-only update. CLI is pinned to the original installation.
- // Exported functions are used only by isolated local filesystem rehearsals.
- import fs from 'node:fs';
- import path from 'node:path';
- import crypto from 'node:crypto';
- import {fileURLToPath} from 'node:url';
- const self=fileURLToPath(import.meta.url);
- const hash=b=>crypto.createHash('sha256').update(b).digest('hex');
- const check=(ok,code)=>{if(!ok)throw Object.assign(Error(code),{code});};
- const sha=h=>typeof h==='string'&&/^[a-f0-9]{64}$/.test(h);
- const namesEqual=(a,b)=>JSON.stringify([...a].sort())===JSON.stringify([...b].sort());
- const safeName=n=>typeof n==='string'&&/^[A-Za-z0-9_./-]+$/.test(n)&&!path.isAbsolute(n)&&!n.split('/').some(x=>!x||x==='.'||x==='..');
- const allowed=[
- 'SKILL.md','references/workshop-category-scan.md','references/workshop-course-flow.md',
- 'references/workshop-feedback.md','references/workshop-intake.md',
- 'references/workshop-research-loop.md','FILES.sha256',
- ];
- const pins={
- r5:{count:41,skill:'16b428f4f4fa0819a764505269116f3f91e503342d9813d3855bc5c2cf80feb6',manifest:'21ae0a245de46e9acbf97ed31718aeb1cdb4ea8ff1ca38259a2b3a9b0c9795e1'},
- };
- const deltaNames=(baseline,candidate)=>allowed.filter(n=>baseline[n]!==candidate[n]);
- const allowedFor=id=>allowed;
- function validateBaseline(id,baseline,candidate){
- validMap(baseline);check(pins[id]&&Object.keys(baseline).length===pins[id].count,'UNEXPECTED_SKILL_LAYOUT');
- check(baseline['SKILL.md']===pins[id].skill&&baseline['FILES.sha256']===pins[id].manifest,'UNKNOWN_RELEASE_BASELINE');
- check(Object.keys(baseline).every(n=>Object.hasOwn(candidate,n)),'BASELINE_DELETION_FORBIDDEN');
- const delta=Object.keys(candidate).filter(n=>candidate[n]!==baseline[n]);
- check(namesEqual(delta,allowedFor(id)),'UNEXPECTED_WRITE_SET');
- for(const[n,h]of Object.entries(candidate))if(runtime(n))check(baseline[n]===h,'RUNTIME_UPDATE_FORBIDDEN');
- return delta;
- }
- const runtime=n=>n==='runtime-lock.json'||n.startsWith('scripts/');
- function exists(p){try{fs.lstatSync(p);return true;}catch(e){if(e.code==='ENOENT')return false;throw e;}}
- function plain(p,kind='file'){
- const abs=path.resolve(p);let cur=path.parse(abs).root;
- for(const bit of abs.slice(cur.length).split(path.sep).filter(Boolean)){
- cur=path.join(cur,bit);if(!exists(cur))break;
- check(!fs.lstatSync(cur).isSymbolicLink(),'SYMLINK_PATH');
- }
- const s=fs.lstatSync(abs);check(kind==='dir'?s.isDirectory():s.isFile(),'NOT_PLAIN_'+kind.toUpperCase());return abs;
- }
- const read=p=>fs.readFileSync(plain(p));
- const digest=p=>hash(read(p));
- function manifest(bytes){
- const out={};for(const line of bytes.toString('utf8').trim().split(/\r?\n/)){
- const m=/^([a-f0-9]{64}) (.+)$/.exec(line);
- check(m&&safeName(m[2])&&m[2]!=='FILES.sha256'&&!Object.hasOwn(out,m[2]),'INVALID_MANIFEST');out[m[2]]=m[1];
- }return out;
- }
- function validMap(map){check(map&&typeof map==='object'&&!Array.isArray(map)&&Object.entries(map).every(([n,h])=>safeName(n)&&sha(h)),'INVALID_FILE_MAP');}
- 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);}
- function packageInfo(dir){
- dir=plain(dir,'dir');const outer=manifest(read(path.join(dir,'FILES.sha256')));
- for(const[n,h]of Object.entries(outer))check(digest(path.join(dir,n))===h,'PACKAGE_HASH_MISMATCH');
- check(outer['release.json']&&outer['update-skill.mjs'],'PACKAGE_FILES_MISSING');
- const release=JSON.parse(read(path.join(dir,'release.json')));
- check(release.schema==='ksf.workshop-research-update.v1'&&release.releaseId==='ksf-workshop-skill-update-20261109-r6','INVALID_RELEASE');
- const {baselines,candidate}=release;validMap(candidate);
- check(Object.keys(candidate).length===42&&baselines&&namesEqual(Object.keys(baselines),['r5']),'UNEXPECTED_SKILL_LAYOUT');
- check(candidate['SKILL.md']==='3cb64f381937411b0a2b853772bb19d37f44528a2902a89ef475b707472d3473'&&candidate['FILES.sha256']==='d1ba798caa776f1691436db885365927e4d77378327578a6c1f12d3fc8564373','UNKNOWN_RELEASE_TARGET');
- for(const[id,baseline]of Object.entries(baselines))validateBaseline(id,baseline,candidate);
- check(Object.keys(candidate).filter(runtime).length===7,'RUNTIME_LAYOUT_MISMATCH');
- const delta=allowed;
- const payloadRoot=path.join(dir,'payload');
- for(const n of delta)check(outer['payload/'+n]===candidate[n]&&digest(path.join(payloadRoot,n))===candidate[n],'PAYLOAD_HASH_MISMATCH');
- check(namesEqual(Object.keys(outer).filter(n=>n.startsWith('payload/')),delta.map(n=>'payload/'+n)),'UNEXPECTED_PAYLOAD');
- const inner=manifest(read(path.join(payloadRoot,'FILES.sha256')));inner['FILES.sha256']=candidate['FILES.sha256'];
- check(namesEqual(Object.keys(inner),Object.keys(candidate))&&Object.entries(candidate).every(([n,h])=>inner[n]===h),'CANDIDATE_MANIFEST_MISMATCH');
- check(Array.isArray(release.researchEntryNames)&&release.researchEntryNames.length===18&&release.researchEntryNames.every(safeName)&&!release.researchEntryNames.includes('SKILL.md'),'INVALID_RESEARCH_LAYOUT');
- validMap(release.researchRuntimePins);check(Object.keys(release.researchRuntimePins).length===4&&Object.keys(release.researchRuntimePins).every(n=>n.startsWith('scripts/')),'INVALID_RESEARCH_PINS');
- return{release,dir,payloadRoot};
- }
- function librarySnapshot(dir,release){
- plain(dir,'dir');check(!exists(path.join(dir,'SKILL.md')),'LEGACY_ENTRY_REACTIVATED');
- const entries=manifest(read(path.join(dir,'FILES.sha256')));
- check(namesEqual(Object.keys(entries),release.researchEntryNames),'RESEARCH_MANIFEST_LAYOUT_MISMATCH');
- for(const[n,h]of Object.entries(release.researchRuntimePins))check(entries[n]===h,'RESEARCH_RUNTIME_MISMATCH');
- verifyMap(dir,entries,'RESEARCH_MANIFEST_MISMATCH');entries['FILES.sha256']=digest(path.join(dir,'FILES.sha256'));
- return entries;
- }
- function bindingSnapshot(targetDir){
- const p=path.join(targetDir,'credential-binding.json');plain(p);
- const mode=fs.statSync(p).mode&0o777;
- if(process.platform!=='win32')check(mode===0o600,'PRIVATE_BINDING_PERMISSIONS');
- return{sha256:digest(p),mode}; // hash only; never parse or export credential contents.
- }
- function writeableParent(file){let p=path.dirname(file);while(!exists(p))p=path.dirname(p);plain(p,'dir');}
- function atomic(file,bytes,mode=0o600){
- writeableParent(file);fs.mkdirSync(path.dirname(file),{recursive:true,mode:0o700});plain(path.dirname(file),'dir');
- if(exists(file))plain(file);
- const tmp=file+'.docs-update-'+crypto.randomUUID();
- try{fs.writeFileSync(tmp,bytes,{flag:'wx',mode});fs.renameSync(tmp,file);}finally{if(exists(tmp))fs.unlinkSync(tmp);}
- }
- export function planUpdate({packageDir,targetDir}){
- const info=packageInfo(path.resolve(packageDir));targetDir=plain(targetDir,'dir');
- check(path.basename(targetDir)==='ksf-workshop-runtime'&&path.basename(path.dirname(targetDir))==='skills','TARGET_PATH_MISMATCH');
- const libraryDir=path.join(path.dirname(targetDir),'ksf-voc-library');
- const {release,payloadRoot}=info,{baselines,candidate}=release;
- const binding=bindingSnapshot(targetDir),research=librarySnapshot(libraryDir,release);
- for(const[n,h]of Object.entries(candidate))if(runtime(n))check(digest(path.join(targetDir,n))===h,'RUNTIME_BASELINE_MISMATCH');
- const installed=Object.entries(candidate).every(([n,h])=>exists(path.join(targetDir,n))&&digest(path.join(targetDir,n))===h);
- const snapshot={targetDir,libraryDir,binding,research};
- if(installed)return{ok:true,readOnly:true,alreadyApplied:true,releaseId:release.releaseId,baselineId:'target',changes:[],...snapshot,candidate,baseline:baselines.r5};
- const installedSkill=digest(path.join(targetDir,'SKILL.md'));
- const baselineId=Object.keys(baselines).find(id=>baselines[id]['SKILL.md']===installedSkill);
- check(baselineId,'UNKNOWN_TARGET_SKILL');const baseline=baselines[baselineId];
- verifyMap(targetDir,baseline,'TARGET_BASELINE_MISMATCH');
- const changes=deltaNames(baseline,candidate).map(n=>{
- const p=path.join(targetDir,n),before=baseline[n]??null;
- if(before===null){check(!exists(p),'UNEXPECTED_EXISTING_FILE');writeableParent(p);}
- return{name:n,path:p,source:path.join(payloadRoot,n),beforeSha256:before,afterSha256:candidate[n]};
- });
- return{ok:true,readOnly:true,alreadyApplied:false,releaseId:release.releaseId,changes,...snapshot,candidate,baseline,baselineId};
- }
- function assertPreserved(p){
- check(!exists(path.join(p.libraryDir,'SKILL.md')),'LEGACY_ENTRY_REACTIVATED');
- verifyMap(p.libraryDir,p.research,'PRESERVED_RESEARCH_CHANGED');
- const b=bindingSnapshot(p.targetDir);check(b.sha256===p.binding.sha256&&b.mode===p.binding.mode,'BINDING_CHANGED');
- for(const[n,h]of Object.entries(p.candidate))if(runtime(n))check(digest(path.join(p.targetDir,n))===h,'PRESERVED_RUNTIME_CHANGED');
- }
- function assertSnapshot(p,{after=false}={}){
- assertPreserved(p);verifyMap(p.targetDir,after?p.candidate:p.baseline,'CONCURRENT_FILE_CHANGE');
- if(!after)for(const e of p.changes)if(e.beforeSha256===null)check(!exists(e.path),'CONCURRENT_FILE_CHANGE');
- }
- export function publicPlan(p){return{
- ok:true,releaseId:p.releaseId,baselineId:p.baselineId,readOnly:p.readOnly,alreadyApplied:p.alreadyApplied,targetDir:p.targetDir,
- skillSha256:p.candidate['SKILL.md'],manifestSha256:p.candidate['FILES.sha256'],
- changes:p.changes.map(e=>({path:e.path,action:e.beforeSha256?'update':'create',beforeSha256:e.beforeSha256,afterSha256:e.afterSha256})),
- changedFiles:p.changes.length,legacyEntrypointRetired:true,runtimeFilesModified:0,
- researchDatabaseWrites:0,businessRecordWrites:0,paidCalls:0,serviceRestarted:false,credentialsExported:false,
- };}
- export function applyUpdate(options,{afterMutation=()=>{}}={}){
- const plan=planUpdate(options);if(plan.alreadyApplied)return publicPlan(plan);
- assertSnapshot(plan);const parent=plain(path.resolve(plan.targetDir,'../..'),'dir');
- const backup=fs.mkdtempSync(path.join(parent,'ksf-workshop-research-backup-'));fs.chmodSync(backup,0o700);
- 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'}));
- 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});}
- 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:[]};
- const save=()=>atomic(path.join(backup,'journal.json'),Buffer.from(JSON.stringify(journal,null,2)+'\n'));
- save();let current=null;
- try{
- assertSnapshot(plan);journal.status='APPLYING';save();
- for(const e of rows){
- current=e;assertPreserved(plan);
- check(e.beforeSha256===null?!exists(e.path):digest(e.path)===e.beforeSha256,'CONCURRENT_FILE_CHANGE');
- const bytes=read(e.source);check(hash(bytes)===e.afterSha256,'PACKAGE_CHANGED_DURING_APPLY');
- atomic(e.path,bytes,e.mode);journal.applied.push(e.index);save();current=null;afterMutation(e.index);
- }
- assertSnapshot(plan,{after:true});journal.status='APPLIED';save();
- return{...publicPlan({...plan,readOnly:false}),backup,runtimeAndBindingPreserved:true,researchFilesUnchanged:true,agentLoaded:false,currentReadVerified:false,actualBusinessWriteVerified:false,formalReportSubmitted:false};
- }catch(error){
- const indexes=new Set(journal.applied);
- if(current&&exists(current.path)&&digest(current.path)===current.afterSha256)indexes.add(current.index);
- try{
- for(const i of [...indexes].reverse()){
- const e=rows[i];check(exists(e.path)&&digest(e.path)===e.afterSha256,'ROLLBACK_CONCURRENT_FILE_CHANGE');
- 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);
- }
- assertSnapshot(plan);journal.status='FAILED_RESTORED';
- }catch{journal.status='FAILED_REQUIRES_REVIEW';}
- journal.errorCode=/^[A-Z_]+$/.test(error.code||'')?error.code:'APPLY_FAILED';save();
- throw Object.assign(Error(journal.errorCode),{code:journal.errorCode,backup,recoveryStatus:journal.status});
- }
- }
- function readJournal(backup){
- backup=plain(backup,'dir');const j=JSON.parse(read(path.join(backup,'journal.json'))),root=path.dirname(backup);
- check(j.schema==='ksf.workshop-research-journal.v1'&&j.releaseId==='ksf-workshop-skill-update-20261109-r6'&&Array.isArray(j.rows),'INVALID_JOURNAL');
- 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');
- validMap(j.baseline);validMap(j.candidate);validMap(j.research);
- check(j.binding&&sha(j.binding.sha256)&&Number.isInteger(j.binding.mode),'INVALID_JOURNAL');
- check(Object.keys(j.candidate).length===42,'INVALID_JOURNAL');
- const expectedDelta=validateBaseline(j.baselineId,j.baseline,j.candidate);
- const ordered=deltaNames(j.baseline,j.candidate);
- check(j.rows.length===expectedDelta.length&&namesEqual(j.rows.map(e=>e.name),expectedDelta),'JOURNAL_WRITE_SET_MISMATCH');
- for(const[i,e]of j.rows.entries()){
- check(e.index===i&&ordered[i]===e.name&&e.path===path.join(j.targetDir,e.name),'JOURNAL_PATH_ESCAPE');
- check(e.beforeSha256===(j.baseline[e.name]??null)&&e.afterSha256===j.candidate[e.name]&&sha(e.afterSha256),'INVALID_JOURNAL');
- check(Number.isInteger(e.mode)&&e.mode>=0&&e.mode<=0o777,'INVALID_JOURNAL');
- if(e.beforeSha256===null)check(e.backupFile===null,'INVALID_JOURNAL');
- else check(e.backupFile===String(i).padStart(3,'0')+'.before'&&digest(path.join(backup,e.backupFile))===e.beforeSha256,'BACKUP_HASH_MISMATCH');
- }
- return{backup,j};
- }
- export function verifyUpdate({backup}){
- const{j}=readJournal(backup);check(j.status==='APPLIED','UPDATE_NOT_APPLIED');
- assertSnapshot({...j,changes:j.rows},{after:true});
- 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};
- }
- export function rollbackUpdate({backup}){
- const{j}=readJournal(backup),p={...j,changes:j.rows};
- if(j.status==='ROLLED_BACK'){assertSnapshot(p);return{ok:true,readOnly:true,alreadyRolledBack:true};}
- check(j.status==='APPLIED','UPDATE_NOT_APPLIED');assertSnapshot(p,{after:true});
- for(const e of [...j.rows].reverse()){
- check(digest(e.path)===e.afterSha256,'ROLLBACK_CONCURRENT_FILE_CHANGE');
- if(e.backupFile)atomic(e.path,read(path.join(backup,e.backupFile)),e.mode);else fs.unlinkSync(e.path);
- }
- assertSnapshot(p);j.status='ROLLED_BACK';atomic(path.join(backup,'journal.json'),Buffer.from(JSON.stringify(j,null,2)+'\n'));
- return{ok:true,readOnly:false,releaseId:j.releaseId,runtimeAndBindingPreserved:true,legacyEntrypointRetired:true,restoredFiles:j.rows.length};
- }
- if(process.argv[1]&&path.resolve(process.argv[1])===self){
- try{
- const args=process.argv.slice(2),mode=args.shift();check(['--dry','--apply','--verify','--rollback'].includes(mode),'USAGE');
- check(process.platform==='linux'&&process.getuid()===10000,'HERMES_UID_10000_REQUIRED');
- const options={packageDir:path.dirname(self),targetDir:'/opt/data/skills/ksf-workshop-runtime'};
- 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');
- const result=mode==='--dry'?publicPlan(planUpdate(options)):mode==='--apply'?applyUpdate(options):mode==='--verify'?verifyUpdate(options):rollbackUpdate(options);
- console.log(JSON.stringify(result,null,2));
- }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;}
- }
|