unified-workshop-upgrade.test.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. import test from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import fs from 'node:fs';
  4. import path from 'node:path';
  5. import os from 'node:os';
  6. import crypto from 'node:crypto';
  7. import {fileURLToPath} from 'node:url';
  8. import {planUpgrade,publicPlan,applyUpgrade,verifyUpgrade,rollbackUpgrade} from '../scripts/upgrade-unified-workshop-skill.mjs';
  9. const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
  10. const hash=b=>crypto.createHash('sha256').update(b).digest('hex');
  11. const digest=p=>hash(fs.readFileSync(p));
  12. function fileMap(dir){const out={};for(const e of fs.readdirSync(dir,{withFileTypes:true})){const p=path.join(dir,e.name);if(e.isDirectory())for(const[n,h]of Object.entries(fileMap(p)))out[e.name+'/'+n]=h;else out[e.name]=digest(p);}return out;}
  13. function sealPackage(packageDir){fs.writeFileSync(path.join(packageDir,'FILES.sha256'),Object.entries(fileMap(packageDir)).filter(([n])=>n!=='FILES.sha256').map(([n,h])=>h+' '+n+'\n').join(''));}
  14. function fixture(t,{received83=false,stale=true}={}){
  15. const dir=fs.mkdtempSync(path.join(os.tmpdir(),'ksf-unified-upgrade-test-'));
  16. t.after(()=>{const abs=fs.realpathSync(dir);assert.ok(abs.startsWith(fs.realpathSync(os.tmpdir())+path.sep)&&path.basename(abs).startsWith('ksf-unified-upgrade-test-'));fs.rmSync(abs,{recursive:true});});
  17. const dataRoot=path.join(dir,'data'),targetDir=path.join(dataRoot,'skills/ksf-workshop-runtime'),libraryDir=path.join(dataRoot,'skills/ksf-voc-library'),packageDir=path.join(dir,'package');
  18. fs.mkdirSync(path.dirname(targetDir),{recursive:true});
  19. const received=path.join(root,'docs/workshop-skill-review-20261108/received-15ffc90f');
  20. fs.cpSync(path.join(received,'ksf-workshop-runtime'),targetDir,{recursive:true});
  21. fs.cpSync(path.join(root,'skills/ksf-voc-library'),libraryDir,{recursive:true});
  22. // The working tree has pending library documentation edits. Construct a self-consistent
  23. // synthetic installation here; this is never a repair of the real library or its manifest.
  24. fs.writeFileSync(path.join(libraryDir,'FILES.sha256'),Object.entries(fileMap(libraryDir)).filter(([n])=>n!=='FILES.sha256').map(([n,h])=>h+' '+n+'\n').join(''));
  25. let legacyManifestPolicy;
  26. if(received83){
  27. // Exact received SKILL bytes; the other 18 members are isolated synthetic fixture bytes.
  28. // Freeze the historical 19-member layout; later migration scripts are outside this fixture.
  29. for(const name of ['scripts/review_correction.py','scripts/unified_store.py'])fs.rmSync(path.join(libraryDir,name),{force:true});
  30. const incoming=path.join(root,'docs/workshop-skill-release-20261108-r2/received-library-2773fc95');
  31. fs.copyFileSync(path.join(incoming,'ksf-voc-library-SKILL.md'),path.join(libraryDir,'SKILL.md'));
  32. const old='b4266efed78c19c0162aa2200cf0be83b0e7afa2473cef134459eb14c97ce69c';
  33. const members=Object.entries(fileMap(libraryDir)).filter(([n])=>n!=='FILES.sha256');
  34. assert.equal(members.length,19);
  35. fs.writeFileSync(path.join(libraryDir,'FILES.sha256'),members.map(([n,h])=>(n==='SKILL.md'&&stale?old:h)+' '+n+'\n').join(''));
  36. const runtimeNames=['query_voc.py','library_channel.py','voc_db.py','retrieval.py'];
  37. legacyManifestPolicy={entryNames:members.map(([n])=>n),verifiedRuntimeEntries:Object.fromEntries(runtimeNames.map(n=>['scripts/'+n,digest(path.join(libraryDir,'scripts',n))])),knownStaleSkill:{actualSha256:digest(path.join(libraryDir,'SKILL.md')),declaredSha256:old,evidenceSha256:digest(path.join(incoming,'ksf-voc-library-current-20261108-v1.zip'))}};
  38. }
  39. fs.mkdirSync(packageDir);fs.cpSync(path.join(root,'skills/ksf-workshop-runtime'),path.join(packageDir,'skill'),{recursive:true});
  40. fs.copyFileSync(path.join(root,'scripts/upgrade-unified-workshop-skill.mjs'),path.join(packageDir,'upgrade-skill.mjs'));
  41. const exportReceipt=JSON.parse(fs.readFileSync(path.join(received,'EXPORT.json')));
  42. const baseline=Object.fromEntries(exportReceipt.files.map(e=>[e.path,e.sha256]));
  43. const candidate=Object.fromEntries(fs.readFileSync(path.join(packageDir,'skill/FILES.sha256'),'utf8').trim().split(/\r?\n/).map(l=>{const[h,n]=l.split(' ');return[n,h];}));
  44. candidate['FILES.sha256']=digest(path.join(packageDir,'skill/FILES.sha256'));
  45. fs.writeFileSync(path.join(packageDir,'release.json'),JSON.stringify({schema:'ksf.unified-workshop-release.v1',releaseId:'ksf-unified-test',baseline,candidate,legacySkillSha256:digest(path.join(libraryDir,'SKILL.md')),...(legacyManifestPolicy?{legacyManifestPolicy}:{})}));
  46. sealPackage(packageDir);
  47. fs.writeFileSync(path.join(targetDir,'credential-binding.json'),'{"fixtureOnly":true}',{mode:0o600});
  48. fs.writeFileSync(path.join(targetDir,'private-fixture-envelope.json'),'{"syntheticFixture":true}',{mode:0o600});
  49. fs.mkdirSync(path.join(dataRoot,'ksf-workshop-state/agents'),{recursive:true});
  50. fs.writeFileSync(path.join(dataRoot,'ksf-workshop-state/agents/offline-fixture.json'),'{"fixtureOnly":true}');
  51. return{dir,dataRoot,targetDir,libraryDir,packageDir,before:fileMap(dataRoot)};
  52. }
  53. function unchanged(f){assert.deepEqual(fileMap(f.dataRoot),f.before);}
  54. test('dry does not create backups, write files or retire either existing entry',t=>{
  55. const f=fixture(t),p=planUpgrade(f);assert.equal(p.ok,true);assert.equal(p.readOnly,true);assert.equal(p.alreadyApplied,false);
  56. assert.equal(p.runtimeFilesModified,0);assert.ok(p.changes.length>20);assert.ok(p.changes.some(e=>e.remove));unchanged(f);
  57. });
  58. test('apply, verify, repeat apply, rollback preserve runtimes, binding, envelope and identity state',t=>{
  59. const f=fixture(t),r=applyUpgrade(f);assert.equal(r.ok,true);assert.ok(!fs.existsSync(path.join(f.libraryDir,'SKILL.md')));
  60. assert.ok(!fs.readFileSync(path.join(f.libraryDir,'FILES.sha256'),'utf8').includes(' SKILL.md'));
  61. assert.equal(verifyUpgrade({backup:r.backup}).runtimeAndBindingPreserved,true);
  62. assert.equal(applyUpgrade(f).alreadyApplied,true);
  63. assert.equal(fs.readdirSync(f.dataRoot).filter(n=>n.startsWith('ksf-workshop-unified-backup-')).length,1);
  64. const rolled=rollbackUpgrade({backup:r.backup});assert.equal(rolled.legacyEntrypointRestored,true);
  65. const restored=fileMap(f.dataRoot);for(const[n,h]of Object.entries(f.before))assert.equal(restored[n],h,n);
  66. for(const n of Object.keys(restored).filter(n=>!n.startsWith('ksf-workshop-unified-backup-')))assert.ok(Object.hasOwn(f.before,n),n);
  67. assert.equal(rollbackUpgrade({backup:r.backup}).alreadyRolledBack,true);
  68. });
  69. test('a corrupted package is rejected before touching the installation',t=>{
  70. const f=fixture(t);fs.appendFileSync(path.join(f.packageDir,'skill/SKILL.md'),'tampered');
  71. assert.throws(()=>applyUpgrade(f),{code:'PACKAGE_HASH_MISMATCH'});unchanged(f);
  72. });
  73. test('unknown current workshop entry is rejected with zero writes',t=>{
  74. const f=fixture(t);fs.appendFileSync(path.join(f.targetDir,'SKILL.md'),'another upgrade');const before=fileMap(f.dataRoot);
  75. assert.throws(()=>applyUpgrade(f),{code:'UNKNOWN_TARGET_SKILL'});assert.deepEqual(fileMap(f.dataRoot),before);
  76. });
  77. test('changed installed executable is never overwritten',t=>{
  78. const f=fixture(t);fs.appendFileSync(path.join(f.targetDir,'scripts/ksf-secure.mjs'),'\n// unknown');const before=fileMap(f.dataRoot);
  79. assert.throws(()=>applyUpgrade(f),{code:'RUNTIME_BASELINE_MISMATCH'});assert.deepEqual(fileMap(f.dataRoot),before);
  80. });
  81. test('an unknown file at a newly introduced reference path is preserved and blocks upgrade',t=>{
  82. const f=fixture(t);fs.writeFileSync(path.join(f.targetDir,'references/workshop-category-scan.md'),'another author');const before=fileMap(f.dataRoot);
  83. assert.throws(()=>applyUpgrade(f),{code:'UNEXPECTED_EXISTING_FILE'});assert.deepEqual(fileMap(f.dataRoot),before);
  84. });
  85. test('unknown library Skill entry is not retired',t=>{
  86. const f=fixture(t);fs.appendFileSync(path.join(f.libraryDir,'SKILL.md'),'new library entry');const before=fileMap(f.dataRoot);
  87. assert.throws(()=>applyUpgrade(f),{code:'UNKNOWN_LEGACY_SKILL'});assert.deepEqual(fileMap(f.dataRoot),before);
  88. });
  89. test('an inconsistent library runtime manifest blocks upgrade',t=>{
  90. const f=fixture(t);fs.appendFileSync(path.join(f.libraryDir,'scripts/query_voc.py'),'\n# another version');const before=fileMap(f.dataRoot);
  91. assert.throws(()=>applyUpgrade(f),{code:'RESEARCH_MANIFEST_MISMATCH'});assert.deepEqual(fileMap(f.dataRoot),before);
  92. });
  93. test('injected mid-apply failure restores original bytes and both entry states',t=>{
  94. const f=fixture(t);let error;
  95. try{applyUpgrade(f,{afterMutation:i=>{if(i===5)throw Object.assign(Error('injected'),{code:'INJECTED_FAILURE'});}});}catch(e){error=e;}
  96. assert.equal(error.recoveryStatus,'FAILED_RESTORED');
  97. const restored=fileMap(f.dataRoot);for(const[n,h]of Object.entries(f.before))assert.equal(restored[n],h,n);
  98. assert.equal(fs.existsSync(path.join(f.targetDir,'references/research-library/operations.md')),false);
  99. });
  100. test('concurrent edits block rollback before any files are restored',t=>{
  101. const f=fixture(t),r=applyUpgrade(f);const changed=path.join(f.targetDir,'references/workshop-category-scan.md');fs.appendFileSync(changed,'\nconcurrent edit');
  102. const before=fileMap(f.dataRoot);assert.throws(()=>rollbackUpgrade({backup:r.backup}),{code:'CONCURRENT_FILE_CHANGE'});assert.deepEqual(fileMap(f.dataRoot),before);
  103. });
  104. test('corrupt backup is rejected before rollback mutates anything',t=>{
  105. const f=fixture(t),r=applyUpgrade(f);const j=JSON.parse(fs.readFileSync(path.join(r.backup,'journal.json')));const row=j.rows.find(e=>e.backupFile);
  106. fs.appendFileSync(path.join(r.backup,row.backupFile),'corrupt');const before=fileMap(f.dataRoot);
  107. assert.throws(()=>rollbackUpgrade({backup:r.backup}),{code:'BACKUP_HASH_MISMATCH'});assert.deepEqual(fileMap(f.dataRoot),before);
  108. });
  109. test('a package modified during apply is rejected and earlier writes are restored',t=>{
  110. const f=fixture(t);let error;
  111. try{applyUpgrade(f,{afterMutation:i=>{if(i===0)fs.appendFileSync(path.join(f.packageDir,'skill/references/research-library/operations.md'),'changed during installation');}});}catch(e){error=e;}
  112. assert.equal(error.code,'PACKAGE_CHANGED_DURING_APPLY');assert.equal(error.recoveryStatus,'FAILED_RESTORED');
  113. const after=fileMap(f.dataRoot);for(const[n,h]of Object.entries(f.before))assert.equal(after[n],h,n);
  114. });
  115. test('directory links are rejected before writing into their targets',t=>{
  116. const f=fixture(t),real=path.join(f.dir,'saved-references');fs.renameSync(path.join(f.targetDir,'references'),real);
  117. try{fs.symlinkSync(real,path.join(f.targetDir,'references'),process.platform==='win32'?'junction':'dir');}catch(e){if(['EPERM','EACCES'].includes(e.code)){t.skip('symlinks unavailable');return;}throw e;}
  118. assert.throws(()=>applyUpgrade(f),{code:'SYMLINK_PATH'});assert.ok(fs.lstatSync(path.join(f.targetDir,'references')).isSymbolicLink());
  119. });
  120. test('received 83-line entry with the exact stale declaration passes dry without repairing it',t=>{
  121. const f=fixture(t,{received83:true}),p=planUpgrade(f),r=publicPlan(p);
  122. assert.equal(r.readOnly,true);assert.equal(r.legacyManifestState,'KNOWN_STALE_SKILL_DECLARATION');
  123. assert.equal(r.legacyManifestMismatches.length,1);
  124. assert.equal(r.legacyManifestMismatches[0].actualSha256,'2773fc95fd98fa2367390c72ac5de20435c4a6458cfea45e2b847657af56630a');
  125. assert.equal(r.legacyManifestMismatches[0].declaredSha256,'b4266efed78c19c0162aa2200cf0be83b0e7afa2473cef134459eb14c97ce69c');
  126. assert.equal(r.runtimeFilesModified,0);unchanged(f);
  127. });
  128. test('the same 83-line entry with a self-consistent manifest also passes dry',t=>{
  129. const f=fixture(t,{received83:true,stale:false}),r=publicPlan(planUpgrade(f));
  130. assert.equal(r.legacyManifestState,'CONSISTENT');assert.deepEqual(r.legacyManifestMismatches,[]);unchanged(f);
  131. });
  132. test('apply then rollback restores received 83-line bytes and the original stale manifest exactly',t=>{
  133. const f=fixture(t,{received83:true}),entry=fs.readFileSync(path.join(f.libraryDir,'SKILL.md')),manifest=fs.readFileSync(path.join(f.libraryDir,'FILES.sha256'));
  134. const r=applyUpgrade(f);assert.equal(verifyUpgrade({backup:r.backup}).ok,true);assert.equal(applyUpgrade(f).alreadyApplied,true);
  135. rollbackUpgrade({backup:r.backup});
  136. assert.deepEqual(fs.readFileSync(path.join(f.libraryDir,'SKILL.md')),entry);
  137. assert.deepEqual(fs.readFileSync(path.join(f.libraryDir,'FILES.sha256')),manifest);
  138. for(const[n,h]of Object.entries(f.before))assert.equal(fileMap(f.dataRoot)[n],h,n);
  139. });
  140. test('a stale declaration is rejected without the explicit evidence-bound policy',t=>{
  141. const f=fixture(t,{received83:true}),p=path.join(f.packageDir,'release.json'),r=JSON.parse(fs.readFileSync(p));delete r.legacyManifestPolicy;
  142. fs.writeFileSync(p,JSON.stringify(r));sealPackage(f.packageDir);
  143. assert.throws(()=>planUpgrade(f),{code:'UNKNOWN_LEGACY_SKILL'});unchanged(f);
  144. });
  145. test('a different unknown manifest declaration is not covered by the stale exception',t=>{
  146. const f=fixture(t,{received83:true}),p=path.join(f.libraryDir,'FILES.sha256');fs.writeFileSync(p,fs.readFileSync(p,'utf8').replace('b4266efed78c19c0162aa2200cf0be83b0e7afa2473cef134459eb14c97ce69c','0'.repeat(64)));
  147. const before=fileMap(f.dataRoot);assert.throws(()=>planUpgrade(f),{code:'UNKNOWN_LEGACY_SKILL'});assert.deepEqual(fileMap(f.dataRoot),before);
  148. });
  149. test('unknown edits to the received entry, another member or manifest layout remain blocking',t=>{
  150. for(const scenario of ['skill','reference','layout']){
  151. const f=fixture(t,{received83:true});
  152. if(scenario==='skill')fs.appendFileSync(path.join(f.libraryDir,'SKILL.md'),'\nunknown');
  153. if(scenario==='reference')fs.appendFileSync(path.join(f.libraryDir,'references/query-rules.md'),'\nunknown');
  154. if(scenario==='layout'){const p=path.join(f.libraryDir,'FILES.sha256');fs.writeFileSync(p,fs.readFileSync(p,'utf8').split('\n').filter(l=>!l.endsWith(' references/query-rules.md')).join('\n'));}
  155. const before=fileMap(f.dataRoot),code=scenario==='skill'?'UNKNOWN_LEGACY_SKILL':scenario==='reference'?'RESEARCH_MANIFEST_MISMATCH':'RESEARCH_MANIFEST_LAYOUT_MISMATCH';
  156. assert.throws(()=>planUpgrade(f),{code});assert.deepEqual(fileMap(f.dataRoot),before);
  157. }
  158. });
  159. test('rewriting a verified runtime and its manifest together cannot bypass the pinned baseline',t=>{
  160. const f=fixture(t,{received83:true}),p=path.join(f.libraryDir,'scripts/query_voc.py');fs.appendFileSync(p,'\n# unknown executable');
  161. const m=path.join(f.libraryDir,'FILES.sha256');fs.writeFileSync(m,fs.readFileSync(m,'utf8').replace(/^([a-f0-9]{64})( scripts\/query_voc.py)$/m,()=>digest(p)+' scripts/query_voc.py'));
  162. const before=fileMap(f.dataRoot);assert.throws(()=>planUpgrade(f),{code:'RESEARCH_BASELINE_MISMATCH'});assert.deepEqual(fileMap(f.dataRoot),before);
  163. });