Files
stack/agents/darkwing/work/queue-187/build.patch
jason.woltje 158205c5cc lead: commit darkwing's untracked evidence (348 files: queue-137 queue-141 queue-150 queue-162 queue-165 queue-166 queue-167 queue-169 queue-179 queue-180 queue-181 queue-182 queue-185 queue-186 queue-187 queue-188 queue-190 queue-93)
Unclaimed files left in the canonical checkout. Inventory: canonical-cleanup-inventory.md (commit-as-evidence groups). detect-secrets scan clean on 2026-11-02: hits are sha256 pins, git object ids, test sentinels already in tracked tests and unit-file lines.
2026-11-02 15:05:14 -06:00

550 lines
40 KiB
Diff

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/<activation>/ 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<len(b):n+=1;i+=16+struct.unpack_from('iIII',b,i)[3]
+ t=0
+while True:
+ r=select.select([fd,0],[],[])[0]
+ if fd in r:drain(0)
+ if 0 in r and not os.read(0,1):break
+drain(0)
+print(n,flush=True)`;
+// A subtest that fails before noLeak leaves its watches running; they end with the file.
+const WATCHERS=new Set();
+after(()=>{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')<at('backup: ')&&at('backup: ')<at(`installed `),steps);
+ assert.deepEqual(pointer(e).console,args[0]==='--no-proof'?base:[...base,...pair(f)]);
+ }
+ assert.deepEqual(readdirSync(join(e.data,'backups','stack')).length,2);
+ const out=await planOnly(e,0,'activate',C1,'acme');assert.deepEqual(out.filter((l)=>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 ;;