diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 3a9de2aa7..ba59079d9 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -367,7 +367,8 @@ reports checkout or copy identity and refactor drift, and writes nothing. ``` scripts/stack-release.sh adopt BUSINESS -scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--timeout S] [--plan] +scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N] + [--proof-header NAME --proof-file PATH | --no-proof] [--timeout S] [--plan] scripts/stack-release.sh pm-on BUSINESS [--timeout S] scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan] scripts/stack-release.sh status BUSINESS @@ -457,6 +458,28 @@ refuses `capture-failed` before it stops anything. Adopt uses the same rule. 8. Jason's plain `mosaic talk pm "..."` must print sent and a reply. Keep the `status` output with the receipts. +The Console's row 178 proof pair gets into the pointer only through `activate` +(row 187). `--proof-header NAME --proof-file PATH` puts the pair in the +pointer's Console flags and the unit after the preview flags and before `--send` +and `--tester-port`, where `webui-service.sh` renders it, replacing any pair +already there. `--no-proof` drops it. Either may name the live copy, which a +plain activate refuses with `already-active`, so the proof goes on or off +without changing copies; `--no-proof` with no pair in the pointer still refuses +there. Later activates, `pm-on` and rollbacks carry the pair unchanged. A +rollback to a copy whose Console lacks `--proof-header` drops the pair and says +so in the steps log. An activate to such a copy refuses `proof-target` unless +`--no-proof` is given, so the proof goes off only when asked. Before anything +stops, the run also refuses `proof-needs-preview` when the flags it would +install lack `--public-host`, `--auth-header` or `--allow-user`, +`proof-header-invalid` on the Console's own header rules, and +`proof-file-invalid` for a path that isn't absolute, holds a character outside +`[A-Za-z0-9/._+-]`, is a symlink or not a regular file, isn't mode 600 or 400, +isn't owned by the uid the script runs as, or sits inside the repository or the +copies. It checks the file with `stat` and `realpath` and never reads the value. +`--plan` prints one `proof:` line: `on`, `carried`, `dropped` with the reason, +or `off`. On the live copy it also notes that the run re-pins the pointer's copy +and only the Console flags change. + #### A target past c216f47db: the PM after the harness kind From c216f47db on, the bus launches the PM only with a `harness` kind in the diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs index a2c0e720c..1a2d5c5ba 100644 --- a/packages/cli/tests/stack-release.test.mjs +++ b/packages/cli/tests/stack-release.test.mjs @@ -4,7 +4,7 @@ import test, {after} from 'node:test'; import assert from 'node:assert/strict'; import {execFileSync,spawn,spawnSync} from 'node:child_process'; -import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,appendFileSync,cpSync,rmSync,chmodSync,existsSync,statSync,readdirSync,realpathSync} from 'node:fs'; +import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,appendFileSync,cpSync,rmSync,chmodSync,existsSync,statSync,lstatSync,symlinkSync,readdirSync,realpathSync} from 'node:fs'; import {createServer} from 'node:http'; import {createHash} from 'node:crypto'; import {DatabaseSync} from 'node:sqlite'; @@ -262,7 +262,7 @@ const KINDS_OLD=KINDS_NEW.replace('\n harness: Object.freeze({','\n "harness-b assert.notEqual(KINDS_OLD,KINDS_NEW); for(const [id,store] of [[F,'// a copy from before SCHEMA_VERSION\n'],[A,storeText('3g',['3f'],'3x')],[B,storeText('3g',['3f'])],[C1,storeText('3g',['3f'],false)],[C,storeText('3h',['3f','3g'])],[S,storeText('3g',['3f'])],[U,storeText('3g',['3f'])]]){ const d=join(COPIES,id); - for(const [f,text,mode] of [['scripts/mosaic',script(mosaicStub),0o755],['packages/webui/src/cli.mjs',[B,C1,C].includes(id)?"// fixture Console, never run\n// serve accepts '--tester-port'\n":'// fixture Console before tester listener, never run\n',0o644],['packages/bus/src/store.mjs',store,0o644],['packages/bus/src/migrate-schema.mjs',`(${migrateStub})();\n`,0o644],['packages/plugins/src/kinds.mjs',[C1,C].includes(id)?KINDS_NEW:KINDS_OLD,0o644]]){ + for(const [f,text,mode] of [['scripts/mosaic',script(mosaicStub),0o755],['packages/webui/src/cli.mjs',[B,C1,C].includes(id)?"// fixture Console, never run\n// serve accepts '--tester-port'\n// serve accepts '--proof-header' (row 178)\n":'// fixture Console before tester listener, never run\n',0o644],['packages/bus/src/store.mjs',store,0o644],['packages/bus/src/migrate-schema.mjs',`(${migrateStub})();\n`,0o644],['packages/plugins/src/kinds.mjs',[C1,C].includes(id)?KINDS_NEW:KINDS_OLD,0o644]]){ mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text,{mode}); } for(const p of ['packages/business/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true}); @@ -381,7 +381,7 @@ async function seed(e,{copy=B,pm=false,store='3g',pointer:how='adopt'}={}){ const rolledBack=(code,to=B)=>`stack-release: ${code}; rolled back to ${to}, which passed the gate\n`; const planBackup=(e)=>`backup: the run backs up the stopped store to ${e.data}/backups/stack// before any migration`; const planNoBackup='backup: none; the run rolls back before the backup'; -const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,`Console: scripts/webui-service.sh install --copy ${to} --copies ${COPIES} --port ${e.port} --business acme`,`CLI: scripts/stack-copy.sh wrapper ${to} --copies ${COPIES} --bin ${e.bin}`]; +const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,'proof: off',`Console: scripts/webui-service.sh install --copy ${to} --copies ${COPIES} --port ${e.port} --business acme`,`CLI: scripts/stack-copy.sh wrapper ${to} --copies ${COPIES} --bin ${e.bin}`]; // --- The T table --- @@ -880,7 +880,7 @@ async function pending(e){ // A refusal before anything stops: exit 3, the message, no file changed, no start or stop, one log line. async function refused(e,args,code,msg){ const before=fp(e),n=calls(e).length,logs=logLines(e).length; - const r=await run(e,...args);assert.equal(r.code,3,r.out+r.err); + const r=await run(e,...args);assert.equal(r.code,3,r.out+r.err);(e.outs??=[]).push(r.out,r.err); assert.equal(r.err,`stack-release: refusing ${code}: ${msg}\n`); assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]); assert.equal(logLines(e).length,logs+1);assert.deepEqual([logLines(e).at(-1).result,logLines(e).at(-1).code],['refused',code]); @@ -1030,17 +1030,17 @@ test('T36 --plan shows the pending state and lists the row 169 refusals it can s return r.out.split('\n'); }; let out=await quiet(0,'activate',C1,'acme','--pm-after-harness','--plan'); - assert.deepEqual(out.slice(-5),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,C1),'']); + assert.deepEqual(out.slice(-6),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,C1),'']); biz(e,HARNESS); out=await quiet(3,'activate',A,'acme','--pm-after-harness','--plan'); - assert.deepEqual(out.slice(-6),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,A),`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`,'']); + assert.deepEqual(out.slice(-7),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,A),`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`,'']); out=await quiet(3,'activate',C1,'acme','--pm-after-harness','--plan'); assert.equal(out.at(-2),'refuses pm-after-harness-set: the business file already sets harness ["claude-code"]; use --pm'); biz(e);assert.equal((await run(e,'activate',C1,'acme','--pm-after-harness')).code,0); out=await quiet(3,'activate',C,'acme','--plan'); - assert.deepEqual(out.slice(-6),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,C,true),`refuses pm-pending: ${PENDING_MSG}`,'']); + assert.deepEqual(out.slice(-7),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,C,true),`refuses pm-pending: ${PENDING_MSG}`,'']); out=await quiet(0,'rollback','acme','--plan'); - assert.deepEqual(out.slice(-5),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,B,true),'']); + assert.deepEqual(out.slice(-6),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,B,true),'']); }); // Row 176 test 11: supported and older Console copies deliberately differ from the checkout. @@ -1421,3 +1421,232 @@ test('T178 proxy proof pair survives a tester-port replacement as well',async t= assert.deepEqual(pointer(e).console,[...expected,'--tester-port','7333']); assert.ok(consoleText(e).includes([...expected,'--tester-port','7333'].join(' '))); }); + +// Row 187 (#1685): activate sets, carries and drops the proof pair. Every proof file here holds +// SENTINEL, which no output, unit, pointer, log or steps log may hold (brief test 8), and an +// inotify watch counts each open or read of it, which must stay 0: the script checks the file +// with lstat and stat only. Python's ctypes reaches inotify(7); node's fs.watch has no IN_ACCESS. +const SENTINEL='row187_sentinel_proof_value_0123456789abcdef'; +const PREVIEW=['--public-host','console.example','--auth-header','x-user','--allow-user','jason']; +const WATCH=`import ctypes,os,select,struct,sys +libc=ctypes.CDLL(None,use_errno=True) +fd=libc.inotify_init1(0) +if fd<0 or libc.inotify_add_watch(fd,os.fsencode(sys.argv[1]),0x21)<0:sys.exit(2) +print('ready',flush=True) +n=0 +def drain(t): + global n + while select.select([fd],[],[],t)[0]: + b=os.read(fd,65536);i=0 + while i{for(const ch of WATCHERS)ch.kill('SIGKILL')}); +// A proof file under e.root, outside the data root, with its read watch running. +async function proofFile(e,name='proof.187',mode=0o600){ + const f=join(e.root,name);writeFileSync(f,SENTINEL+'\n',{mode});chmodSync(f,mode); + const ch=spawn('python3',['-I','-c',WATCH,f],{stdio:['pipe','pipe','inherit']}); + let out='';ch.stdout.on('data',(d)=>{out+=d}); + await new Promise((res,rej)=>{ch.stdout.on('data',()=>{if(out.startsWith('ready\n'))res()});ch.on('error',rej);ch.on('close',(c)=>rej(new Error(`inotify watch exited ${c}`)))}); + WATCHERS.add(ch);ch.on('close',()=>WATCHERS.delete(ch)); + (e.watches??=[]).push(async()=>{ch.stdin.end();if(ch.exitCode===null)await once(ch,'close');return Number(out.split('\n')[1])}); + return f; +} +async function prun(e,...args){const r=await run(e,...args);(e.outs??=[]).push(r.out,r.err);return r} +// Brief test 8 and the read mutant, at the end of each case. +async function noLeak(e){ + const hits=[]; + const walk=(q)=>{let st;try{st=lstatSync(q)}catch{return}if(st.isDirectory()){for(const n of readdirSync(q))walk(join(q,n))}else if(st.isFile()&&readFileSync(q).includes(SENTINEL))hits.push(q)}; + for(const d of [e.data,e.units,e.sd,e.bin])walk(d); + (e.outs??[]).forEach((o,i)=>{if(o.includes(SENTINEL))hits.push(`run output ${i}`)}); + assert.deepEqual(hits,[]); + for(const stop of e.watches??[])assert.equal(await stop(),0,'the run opened or read the proof file'); +} +// The seeded stack with the preview flags, and any flags in more, in the pointer and the Console unit. +async function seedPreview(e,opts,more=[]){ + await seed(e,opts); + const p=pointer(e);p.console.push(...PREVIEW,...more);writeFileSync(e.pointer,JSON.stringify(p)+'\n'); + edit(join(e.units,CON),' --business acme\n',` --business acme ${[...PREVIEW,...more].join(' ')}\n`); + return p.console; +} +const pair=(f,h='x-console-proof')=>['--proof-header',h,'--proof-file',f]; +const conExec=(e)=>consoleText(e).split('\n').find((l)=>l.startsWith('ExecStart=')); +// --plan: exit code, nothing written, nothing started or stopped, no log line; the output lines. +async function planOnly(e,code,...args){ + const before=fp(e),data=tree(e.data),n=calls(e).length,logs=logLines(e).length; + const r=await prun(e,...args,'--plan');assert.equal(r.code,code,r.out+r.err);assert.equal(r.err,''); + assert.deepEqual(fp(e),before);assert.deepEqual(tree(e.data),data);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs); + return r.out.split('\n'); +} +const proofLines=(out)=>out.filter((l)=>l.startsWith('proof:')); +const CARRY_MSG=(to)=>`${to}'s Console does not support --proof-header, which the pointer's Console flags carry; give --no-proof to drop the pair`; +const HEADER_MSG=(h)=>`--proof-header ${h} must be a header name of a-z, 0-9 and - (64 at most, lowercased) other than host, origin, x-mosaic-tester and the --auth-header value`; + +test('T53 row 187: activate sets, carries and drops the proof pair',async(t)=>{ + await t.test('tests 1 and 9: the pair goes after the preview flags, before --send and the kept --tester-port, as the unit renders it; a new pair replaces it',async(t)=>{ + const e=await mk(t),base=await seedPreview(e,undefined,['--send']),f=await proofFile(e),g=await proofFile(e,'proof2.187',0o400); + const pre=base.slice(0,-1); + let r=await prun(e,'activate',C1,'acme','--tester-port','7333');assert.equal(r.code,0,r.out+r.err); + const out=await planOnly(e,0,'activate',C1,'acme',...pair(f)); + assert.deepEqual(proofLines(out),[`proof: on, header x-console-proof, file ${f}`]); + assert.ok(out.find((l)=>l.startsWith('Console: ')).endsWith(` ${PREVIEW.join(' ')} ${pair(f).join(' ')} --send --tester-port 7333`)); + r=await prun(e,'activate',C1,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err); + assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--send','--tester-port','7333']); + assert.ok(conExec(e).endsWith(` --checkout ${repo} ${[...pre,...pair(f),'--send','--tester-port','7333'].join(' ')}`),conExec(e)); + r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/); + r=await prun(e,'activate',C1,'acme',...pair(g,'X-Proof-Two'));assert.equal(r.code,0,r.out+r.err); + assert.deepEqual(pointer(e).console,[...pre,...pair(g,'X-Proof-Two'),'--send','--tester-port','7333']); + r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/); + assert.equal(conExec(e).match(/--proof-header/g).length,1);assert.ok(!conExec(e).includes(f)); + await noLeak(e); + }); + await t.test('tests 2 and 3: an activate of the live copy writes the pair; a later activate and a rollback carry it',async(t)=>{ + const e=await mk(t),base=await seedPreview(e),f=await proofFile(e); + let r=await prun(e,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err); + assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,[...base,...pair(f)]);assert.ok(conExec(e).endsWith(pair(f).join(' '))); + assert.deepEqual(proofLines(await planOnly(e,0,'activate',C1,'acme')),['proof: carried, header x-console-proof']); + r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err); + assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,[...base,...pair(f)]);assert.ok(conExec(e).endsWith(pair(f).join(' '))); + assert.deepEqual(proofLines(await planOnly(e,0,'rollback','acme')),['proof: carried, header x-console-proof']); + r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err); + assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,[...base,...pair(f)]);assert.ok(conExec(e).endsWith(pair(f).join(' '))); + await noLeak(e); + }); + await t.test('test 2: only a pair change may name the live copy; the log keeps from and to, and rollback still goes to the earlier copy',async(t)=>{ + const e=await mk(t),base=await seedPreview(e),f=await proofFile(e); + await refused(e,['activate',B,'acme'],'already-active',`${B} is the pointer's copy`); + await refused(e,['activate',B,'acme','--no-proof'],'already-active',`${B} is the pointer's copy`); + await refused(e,['activate',B,'acme','--tester-port','7333'],'already-active',`${B} is the pointer's copy`); + let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err); + r=await prun(e,'activate',C1,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err); + let l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']);assert.ok(l.backup); + assert.deepEqual(pointer(e).console,[...base,...pair(f)]); + await refused(e,['activate',C1,'acme'],'already-active',`${C1} is the pointer's copy`); + const out=await planOnly(e,0,'activate',C1,'acme','--no-proof'); + assert.deepEqual(proofLines(out),['proof: dropped; --no-proof drops header x-console-proof']); + assert.ok(out.includes('commits: the run adds 0 (newest first, at most 200):'),out.join('\n')); + r=await prun(e,'activate',C1,'acme','--no-proof');assert.equal(r.code,0,r.out+r.err); + l=logLines(e).at(-1);assert.deepEqual([l.from,l.to,l.result],[C1,C1,'ok']); + assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,base);assert.doesNotMatch(conExec(e),/--proof-/); + r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B); + await noLeak(e); + }); + await t.test('test 2: a live-copy activate says only the Console flags change, and backs up the stopped store before it installs',async(t)=>{ + const e=await mk(t),base=await seedPreview(e),f=await proofFile(e); + const NOTE=`note: ${B} is the pointer's copy; the run re-pins it and only the Console flags change`; + for(const args of [pair(f),['--no-proof']]){ + const out=await planOnly(e,0,'activate',B,'acme',...args); + assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[NOTE]);assert.equal(out[out.indexOf(`to: ${B}`)+1],NOTE); + assert.deepEqual(out.filter((l)=>l.startsWith('backup')),[planBackup(e)]); + const sum=createHash('sha256').update(readFileSync(e.store)).digest('hex'),n=calls(e).length; + const r=await prun(e,'activate',B,'acme',...args);assert.equal(r.code,0,r.out+r.err); + const act=pointer(e).activation,l=logLines(e).at(-1); + assert.deepEqual([l.from,l.to,l.result,l.backup],[B,B,'ok',act]);assert.equal(backupSum(e,act),sum); + assert.deepEqual(startsStops(e,n),[`stop ${CON}`,`stop ${BUS}`,`start ${BUS}`,`start ${CON}`]); + const steps=stepText(e),at=(x)=>steps.indexOf(x); + assert.ok(at('stopped both units')>=0&&at('stopped both units')l.startsWith('note: ')),[]); + await noLeak(e); + }); + await t.test('test 3: pm-on keeps the pair',async(t)=>{ + const e=await mk(t),base=await seedPreview(e,{pm:true}),f=await proofFile(e); + let r=await prun(e,'activate',C1,'acme','--pm-after-harness',...pair(f));assert.equal(r.code,0,r.out+r.err); + biz(e,HARNESS); + r=await prun(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err); + assert.equal(pointer(e).pm,true);assert.deepEqual(pointer(e).console,[...base,...pair(f)]);assert.ok(conExec(e).endsWith(pair(f).join(' '))); + await noLeak(e); + }); + await t.test('test 4: --no-proof drops the pair from the pointer and the unit and says so; with no pair it says so and passes',async(t)=>{ + const e=await mk(t),base=await seedPreview(e),f=await proofFile(e); + let r=await prun(e,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err); + assert.deepEqual(proofLines(await planOnly(e,0,'activate',C1,'acme','--no-proof')),['proof: dropped; --no-proof drops header x-console-proof']); + r=await prun(e,'activate',C1,'acme','--no-proof');assert.equal(r.code,0,r.out+r.err); + assert.deepEqual(pointer(e).console,base);assert.doesNotMatch(conExec(e),/--proof-/); + assert.match(stepText(e),new RegExp(`drop --proof-header x-console-proof --proof-file ${f}; --no-proof was given\\n`)); + r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/); + assert.deepEqual(proofLines(await planOnly(e,0,'activate',B,'acme','--no-proof')),['proof: off; --no-proof was given and the pointer holds no pair']); + r=await prun(e,'activate',B,'acme','--no-proof');assert.equal(r.code,0,r.out+r.err); + assert.deepEqual(pointer(e).console,base);assert.doesNotMatch(conExec(e),/--proof-/); + assert.match(stepText(e),/--no-proof was given and the pointer holds no proof pair, so there is nothing to drop\n/); + assert.deepEqual(proofLines(await planOnly(e,0,'activate',C1,'acme')),['proof: off']); + await noLeak(e); + }); + await t.test('test 5: each refusal exits 3 before anything stops',async(t)=>{ + const e=await mk(t);await seedPreview(e); + const f=await proofFile(e),open=await proofFile(e,'open.187',0o644),loose=await proofFile(e,'loose.187',0o640); + const link=join(e.root,'link.187');symlinkSync(f,link); + const dir=join(e.root,'dir.187');mkdirSync(dir,{mode:0o700}); + const odd=join(e.root,'odd 187');writeFileSync(odd,SENTINEL+'\n',{mode:0o600}); + await refused(e,['activate',A,'acme',...pair(f)],'proof-target',`${A}'s Console does not support --proof-header`); + for(const h of ['Host','origin','x-mosaic-tester','X-User','-lead','under_score','a'.repeat(65)]){ + await refused(e,['activate',C1,'acme',...pair(f,h)],'proof-header-invalid',HEADER_MSG(h)); + } + const inCopies=join(COPIES,'proof-r187.187');writeFileSync(inCopies,SENTINEL+'\n',{mode:0o600});t.after(()=>rmSync(inCopies,{force:true})); + await refused(e,['activate',C1,'acme',...pair(inCopies)],'proof-file-invalid',`--proof-file ${inCopies} is inside the repository or the copies`); + rmSync(inCopies); + for(const [p,why] of [['proof.187','is not an absolute path'],[odd,'holds a character outside [A-Za-z0-9/._+-]'],[link,'is a symlink'],[dir,'is not a regular file'],[join(e.root,'none.187'),'cannot be found'],[open,'has mode 644, not 600 or 400'],[loose,'has mode 640, not 600 or 400']]){ + await refused(e,['activate',C1,'acme',...pair(p)],'proof-file-invalid',`--proof-file ${p} ${why}`); + } + const g=await mk(t);await seed(g);const h=await proofFile(g); + await refused(g,['activate',C1,'acme',...pair(h)],'proof-needs-preview','the proof pair needs --public-host, --auth-header and --allow-user in the Console flags; the run\'s flags lack --public-host --auth-header --allow-user'); + for(const x of [e,g])await noLeak(x); + }); + await t.test('tests 5 and 9: --plan lists every proof refusal with the others and writes nothing',async(t)=>{ + const e=await mk(t);await seed(e);biz(e,HARNESS);const open=await proofFile(e,'open.187',0o644); + const out=await planOnly(e,3,'activate',A,'acme',...pair(open,'host')); + assert.deepEqual(proofLines(out),[`proof: on, header host, file ${open}`]); + assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[ + `refuses proof-target: ${A}'s Console does not support --proof-header`, + 'refuses proof-needs-preview: the proof pair needs --public-host, --auth-header and --allow-user in the Console flags; the run\'s flags lack --public-host --auth-header --allow-user', + `refuses proof-header-invalid: ${HEADER_MSG('host')}`, + `refuses proof-file-invalid: --proof-file ${open} has mode 644, not 600 or 400`, + `refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`]); + await noLeak(e); + }); + await t.test('test 6: usage errors',async(t)=>{ + const e=await mk(t);await seedPreview(e);const f=await proofFile(e); + const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e); + for(const args of [['activate',C1,'acme','--proof-header','x-console-proof'],['activate',C1,'acme','--proof-file',f],['activate',C1,'acme','--proof-header','','--proof-file',f], + ['activate',C1,'acme','--no-proof',...pair(f)],['activate',C1,'acme',...pair(f),'--no-proof'],['activate',C1,'acme','--no-proof','--proof-header','x-console-proof'], + ['rollback','acme',...pair(f)],['rollback','acme','--no-proof'],['rollback','acme','--proof-header','x-console-proof'],['pm-on','acme',...pair(f)],['pm-on','acme','--no-proof'],['pm-on','acme','--proof-file',f], + ['activate',C1,'acme','--plan','--proof-file',f]]){ + const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText); + } + assert.match(usageText,/\[--proof-header NAME --proof-file PATH \| --no-proof\]/); + assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs); + await noLeak(e); + }); + await t.test('test 7: a rollback to a copy without proof support drops the pair and says so',async(t)=>{ + const e=await mk(t),base=await seedPreview(e,{copy:A}),f=await proofFile(e); + let r=await prun(e,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err); + const out=await planOnly(e,0,'rollback','acme'); + assert.deepEqual(proofLines(out),[`proof: dropped; ${A}'s Console does not support --proof-header, so the rollback drops header x-console-proof`]); + assert.doesNotMatch(out.find((l)=>l.startsWith('Console: ')),/--proof-/); + r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err); + assert.equal(pointer(e).copy,A);assert.deepEqual(pointer(e).console,base);assert.doesNotMatch(conExec(e),/--proof-/); + assert.match(stepText(e),new RegExp(`drop --proof-header x-console-proof --proof-file ${f}; the target Console does not support it\\n`)); + await noLeak(e); + }); + await t.test('test 7: an activate to a copy without proof support refuses proof-target without --no-proof and passes with it',async(t)=>{ + const e=await mk(t),base=await seedPreview(e),f=await proofFile(e); + let r=await prun(e,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err); + const out=await planOnly(e,3,'activate',A,'acme'); + assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses proof-target: ${CARRY_MSG(A)}`]); + await refused(e,['activate',A,'acme'],'proof-target',CARRY_MSG(A)); + assert.deepEqual(proofLines(await planOnly(e,0,'activate',A,'acme','--no-proof')),['proof: dropped; --no-proof drops header x-console-proof']); + r=await prun(e,'activate',A,'acme','--no-proof');assert.equal(r.code,0,r.out+r.err); + assert.equal(pointer(e).copy,A);assert.deepEqual(pointer(e).console,base);assert.doesNotMatch(conExec(e),/--proof-/); + assert.match(stepText(e),/drop --proof-header x-console-proof [^\n]*; --no-proof was given\n/); + await noLeak(e); + }); +}); diff --git a/scripts/stack-release.sh b/scripts/stack-release.sh index 5779b64f7..2586a2a37 100755 --- a/scripts/stack-release.sh +++ b/scripts/stack-release.sh @@ -2,7 +2,8 @@ # Stack activate and rollback, health-gated (D1, #1659; docs/plans/2026-10-18_stack-release.md). # Moves the bus, the Console and the human CLI from the pointer's copy to another copy together, # behind a health gate, and puts the old stack back inside the same run when the gate fails. -# Console tester listener flags follow target support (row 176, #1674). +# Console tester listener flags follow target support (row 176, #1674); so does the row 178 proof +# pair, which activate sets or drops (row 187, #1685). # Design and tests: agents/darkwing/work/queue-162/BUILD.md. # # Who runs it. Decision 96 (Mos, mos-0716) lets this script stop and start the live units under @@ -27,11 +28,13 @@ BIN="$HOME/.npm-global/bin" UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" CONSOLE_UNIT=mosaic-webui.service TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" TESTER_PORT="" +PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 usage() { cat >&2 <<'USAGE' usage: stack-release.sh adopt BUSINESS - stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N] [--timeout S] [--plan] + stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N] + [--proof-header NAME --proof-file PATH | --no-proof] [--timeout S] [--plan] stack-release.sh pm-on BUSINESS [--timeout S] stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan] stack-release.sh status BUSINESS @@ -76,6 +79,9 @@ parse_opts() { --pm) [ "$OP" = activate ] || usage; WANT_PM=1; shift ;; --pm-after-harness) [ "$OP" = activate ] || usage; AFTER_HARNESS=1; shift ;; --tester-port) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; TESTER_PORT="$2"; shift 2 ;; + --proof-header) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; PROOF_HEADER="$2"; shift 2 ;; + --proof-file) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; PROOF_FILE="$2"; shift 2 ;; + --no-proof) [ "$OP" = activate ] || usage; NO_PROOF=1; shift ;; --migrate) [ "$OP" = activate ] || usage; MIGRATE=1; shift ;; --restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || usage; RESTORE="$2"; shift 2 ;; --events) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^(0|[1-9][0-9]*)$ ]] || usage; EVENTS="$2"; shift 2 ;; @@ -87,6 +93,9 @@ parse_opts() { [ -z "$TESTER_PORT" ] || [ "$PLAN" = 1 ] || valid_tester_port "$TESTER_PORT" 7332 || usage # --pm-after-harness replaces --pm (row 169 item 1), so the two never go together. [ "$WANT_PM" = 0 ] || [ "$AFTER_HARNESS" = 0 ] || usage + # The proof pair goes together, and --no-proof goes alone (row 187). + [ "${PROOF_HEADER:+1}" = "${PROOF_FILE:+1}" ] || usage + [ "$NO_PROOF" = 0 ] || [ -z "$PROOF_HEADER" ] || usage local d for d in "$COPIES" "$BIN"; do case "$d" in /*) ;; *) usage ;; esac @@ -228,12 +237,16 @@ target_schema() { in_chain() { local v; for v in "${T_CHAIN[@]}"; do [ "$v" != "$1" ] || return 0; done; return 1; } -# The item 3 checks on the target copy, in order: copy, schema, already-active. +# The item 3 checks on the target copy, in order: copy, schema, already-active. An activate that +# sets a proof pair, or drops the pointer's pair with --no-proof, may name the pointer's copy: it +# re-pins the live copy with new Console flags (row 187, Part B's B3 and B-R1). check_target() { copy_exists "$TO" 2>/dev/null || refuse copy-missing "no copy $TO in $COPIES" copy_ok "$TO" 2>/dev/null || refuse copy-unreachable "$TO is not reachable from refactor and origin/refactor" target_schema "$TO" || refuse copy-schema-unknown "cannot read SCHEMA_VERSION and its chain from $COPIES/$TO/packages/bus/src/store.mjs" - [ "$TO" != "$P_COPY" ] || refuse already-active "$TO is the pointer's copy" + [ "$TO" != "$P_COPY" ] || [ -n "$PROOF_HEADER" ] || + { [ "$NO_PROOF" = 1 ] && [[ " ${P_CONSOLE[*]} " == *" --proof-header "* ]]; } || + refuse already-active "$TO is the pointer's copy" } # --- Plug-in kinds (row 169) --- @@ -637,8 +650,8 @@ flags_drift() { [ -z "$d" ] || refuse unit-flags-drift "the units differ from the pointer; settle it first:$d" } -# Row 178: --proof-header and --proof-file are an opaque pair in P_CONSOLE. -# Carry them unchanged on re-pin and rollback; never read the proof value. +# Row 178: --proof-header and --proof-file are a pair in P_CONSOLE, which proof_for_target (row +# 187) carries unchanged, replaces or drops; never read the proof value. # Resolve desired flags independently of the pointer and effective units, so a hand-edited # unit still refuses drift and a failed attempt can restore exactly the captured stack. # Read the target's own Console, because old copies cannot start with the new flag. @@ -670,6 +683,98 @@ console_for_target() { RUN_CONSOLE=("${without_tester[@]}") DROP_TESTER="--tester-port $inherited" fi + proof_for_target +} + +# Row 187: the row 178 proof pair in RUN_CONSOLE. A new pair replaces the pointer's and goes last; +# --no-proof drops it. With neither, a target Console that takes --proof-header carries the pair +# unchanged. One that doesn't drops it on rollback, the way back, and refuses proof-target on +# activate, so only --no-proof turns the proof off. Sets PROOF_PLAN (the plan's proof: line) and +# PROOF_STEP (a steps log line, or empty). +proof_for_target() { + local supported=0 had=0 header="" file="" i + local -a without_proof=() + grep -q -- '--proof-header' "$COPIES/$TO/packages/webui/src/cli.mjs" && supported=1 + for ((i = 0; i < ${#RUN_CONSOLE[@]}; i++)); do + case "${RUN_CONSOLE[i]}" in + --proof-header) had=1; i=$((i + 1)); header="${RUN_CONSOLE[i]:-}" ;; + --proof-file) had=1; i=$((i + 1)); file="${RUN_CONSOLE[i]:-}" ;; + *) without_proof+=("${RUN_CONSOLE[i]}") ;; + esac + done + PROOF_PLAN="proof: off" PROOF_STEP="" + if [ "$NO_PROOF" = 1 ]; then + RUN_CONSOLE=("${without_proof[@]}") + if [ "$had" = 1 ]; then + PROOF_PLAN="proof: dropped; --no-proof drops header $header" + PROOF_STEP="drop --proof-header $header --proof-file $file; --no-proof was given" + else + PROOF_PLAN="proof: off; --no-proof was given and the pointer holds no pair" + PROOF_STEP="--no-proof was given and the pointer holds no proof pair, so there is nothing to drop" + fi + return 0 + fi + if [ -n "$PROOF_HEADER" ]; then + # webui-service.sh renders the pair after the preview flags and before --send and + # --tester-port, and flags_drift compares the pointer with that argv as it stands. + header="$PROOF_HEADER" file="$PROOF_FILE" RUN_CONSOLE=() + for ((i = 0; i < ${#without_proof[@]}; i++)); do + case "${without_proof[i]}" in --send | --tester-port) break ;; esac + RUN_CONSOLE+=("${without_proof[i]}") + done + RUN_CONSOLE+=(--proof-header "$header" --proof-file "$file" "${without_proof[@]:i}") + PROOF_PLAN="proof: on, header $header, file $file" + [ "$supported" = 1 ] || check_refuse proof-target "$TO's Console does not support --proof-header" + elif [ "$had" = 0 ]; then + return 0 + elif [ "$supported" = 0 ] && [ "$OP" = rollback ]; then + RUN_CONSOLE=("${without_proof[@]}") + PROOF_PLAN="proof: dropped; $TO's Console does not support --proof-header, so the rollback drops header $header" + PROOF_STEP="drop --proof-header $header --proof-file $file; the target Console does not support it" + return 0 + else + PROOF_PLAN="proof: carried, header $header" + [ "$supported" = 1 ] || check_refuse proof-target "$TO's Console does not support --proof-header, which the pointer's Console flags carry; give --no-proof to drop the pair" + fi + proof_checks "$header" "$file" +} + +# Row 187: proofOptions' own rules (packages/webui/src/serve.mjs), checked before anything stops so +# the Console can't fail its start on them. The file gets stat only: this script never opens it, +# and no line it writes holds the value. +proof_checks() { + local header="${1,,}" auth="" seen=" " missing="" f i why + for ((i = 0; i < ${#RUN_CONSOLE[@]}; i++)); do + seen+="${RUN_CONSOLE[i]} " + [ "${RUN_CONSOLE[i]}" != --auth-header ] || auth="${RUN_CONSOLE[i + 1]:-}" + done + for f in --public-host --auth-header --allow-user; do + [[ "$seen" == *" $f "* ]] || missing+=" $f" + done + [ -z "$missing" ] || check_refuse proof-needs-preview "the proof pair needs --public-host, --auth-header and --allow-user in the Console flags; the run's flags lack$missing" + if ! [[ "$header" =~ ^[a-z0-9][a-z0-9-]{0,63}$ ]] || [ "$header" = host ] || [ "$header" = origin ] || + [ "$header" = x-mosaic-tester ] || [ "$header" = "${auth,,}" ]; then + check_refuse proof-header-invalid "--proof-header $1 must be a header name of a-z, 0-9 and - (64 at most, lowercased) other than host, origin, x-mosaic-tester and the --auth-header value" + fi + why="$(proof_file_problem "$2")" + [ -z "$why" ] || check_refuse proof-file-invalid "--proof-file $2 $why" +} + +# Why the proof file can't serve, or nothing. lstat, stat and realpath only; the order follows +# webui-service.sh render, which refuses the same files. +proof_file_problem() { + local st uid mode type real copies + case "$1" in /*) ;; *) echo "is not an absolute path"; return ;; esac + case "$1" in *[!A-Za-z0-9/._+-]*) echo "holds a character outside [A-Za-z0-9/._+-]"; return ;; esac + [ ! -L "$1" ] || { echo "is a symlink"; return; } + st="$(stat -c '%u:%a:%F' -- "$1" 2>/dev/null)" || { echo "cannot be found"; return; } + uid="${st%%:*}" st="${st#*:}" + mode="${st%%:*}" type="${st#*:}" + case "$type" in "regular file" | "regular empty file") ;; *) echo "is not a regular file"; return ;; esac + real="$(realpath -e -- "$1")" && copies="$(realpath -m -- "$COPIES")" || { echo "cannot be resolved"; return; } + case "$real" in "$REPO" | "$REPO"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac + [ "$mode" = 600 ] || [ "$mode" = 400 ] || { echo "has mode $mode, not 600 or 400"; return; } + [ "$uid" = "$(id -u)" ] || echo "is owned by uid $uid, not $(id -u), the uid this script runs as" } # The item 3 checks after the pointer, shared by activate and rollback, for target $TO. @@ -794,6 +899,7 @@ plan() { echo "plan: $OP for $BUSINESS" echo "from: $P_COPY" echo "to: $TO" + [ "$TO" != "$P_COPY" ] || echo "note: $TO is the pointer's copy; the run re-pins it and only the Console flags change" echo "store schema: $S_SCHEMA ($S_FROM)" echo "target schema: $T_SCHEMA" [ "$S_SCHEMA" = "$P_SCHEMA" ] || [ -n "$S_CHANGED" ] || echo "note: the pointer says $P_SCHEMA; the run reads the stopped store and goes by that" @@ -830,6 +936,7 @@ plan() { [ "$RUN_PENDING" = 0 ] || echo "pm: the bus starts without --pm and the PM stays pending; next: Jason's plugins set of harness, then pm-on" echo "bus: scripts/bus-service.sh install --copy $TO --copies $COPIES$([ "$RUN_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE" [ -z "$DROP_TESTER" ] || echo "drop $DROP_TESTER; the target Console does not support it" + echo "$PROOF_PLAN" echo "Console: scripts/webui-service.sh install --copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}" echo "CLI: scripts/stack-copy.sh wrapper $TO --copies $COPIES --bin $BIN" local r @@ -842,6 +949,7 @@ run_steps() { log_line result started capture || refuse capture-failed "could not write the rollback set $RS; nothing was stopped" [ -z "$DROP_TESTER" ] || say "drop $DROP_TESTER; the target Console does not support it" + [ -z "$PROOF_STEP" ] || say "$PROOF_STEP" trap 'after_fail signal' INT TERM HUP stop_both || rollback_in_run stop-timeout if [ -e "$STORE" ]; then @@ -1070,7 +1178,7 @@ STORE="$DATA/bus/bus.sqlite" BUS_UNIT="mosaic-bus@$BUSINESS.service" FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT="" UNCLEAN="" FAIL="" STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED="" -P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PLAN_REFUSE=() +P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" PLAN_REFUSE=() case "$OP" in adopt) do_adopt ;; activate) do_activate ;;