cli, webui: bus, Console and mosaic CLI on one pinned copy (#1579)

Row 85 round 2. Built by Rocko, packet agents/rocko/work/queue-85/r2/,
candidate manifest
bd7ea899c7cacef43b415b8e9ecb164e1f36718ffbe64347881a2c5653170c98.
Filbert approved on #1579, comment 28256; Darkwing approved, comment
28258.

stack-copy.sh builds shared read-only copies under
XDG_DATA_HOME/mosaic-stack/copies; the bus and Console services render
and install against a copy with --copy and --pm. No live step is run
by this commit; those need Mos's go.

Sage's gate rerun on 3a1e11b83 with rows 85, 102 and 103 applied,
Docker up: node suites bus 128, business 67, cli 165, control-board
124, conversation 182, discord 178, harness 73, ledger 78, mosaic 69,
queue 155, runs 43, seat 113, tasks 92, webui 108; scripts auth 15,
conductor 17, config 24, discord 66, extension-package 18,
foundation 44, queue 28, release 14, task 98. All pass, 0 fail.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-13 16:34:47 -05:00
co-authored by Claude Opus 5.5
parent 3a1e11b83e
commit 305a012b13
11 changed files with 532 additions and 133 deletions
+21 -2
View File
@@ -384,10 +384,10 @@ template `systemd/mosaic-bus.service.in` as the systemd user unit
The template's file name has no `@` because queue candidate manifests refuse
it. It is modelled on `scripts/discord-service.sh`:
- `install [--dir DIR] [--no-reload] [--claude PATH]` writes the unit through
- `install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH]` writes the unit through
a temp file and a rename, then runs `systemctl --user daemon-reload`. It
prints the pinned claude and its `--version`.
- `render [--claude PATH]` prints the unit without writing it.
- `render [--copy ID [--copies DIR]] [--pm] [--claude PATH]` prints the unit without writing it.
- `uninstall` refuses while an instance is active.
- `status <business>` shows the unit's state and its `Environment`, which
includes the pinned claude.
@@ -431,6 +431,25 @@ The unit has no `network-online.target` ordering, since a user unit cannot
order on that system target. The notifier needs no network at start: a
send that fails is journaled and retried.
### One pinned copy (row 85, #1579)
The bus, Console and mosaic wrapper must run the same copy: the broker's
human proof accepts only its own human-cli entry path. stack-copy.sh
creates a pushed, read-only tree with pinned npm dependencies under
mosaic-stack/copies/<full id>. bus-service.sh render/install take --copy
ID and --pm alongside the existing --claude pin. Without --copy render
keeps today's checkout unit. A PM refusal exits 3 and the unit does not
retry it. Install refuses every busy instance (also --no-reload), failed
state reads and drop-ins overriding ExecStart or WorkingDirectory.
Status reads effective systemd properties, reports the tree/copy, PM flag,
refactor drift and drop-in files; it changes nothing. The temporary PM
ExecStart drop-in must be removed by Mos before installing the pinned unit.
The one update and rollback plan is [TOOLS.md, Pinned stack update](../../docs/TOOLS.md#pinned-stack-update).
Each live switch/unit change/stop/start requires Mos's go; agents build
and test fixtures only. Restarting the bus ends its sessions, so no PM
turn may be in flight. Queue writes stay on the canonical scripts/mosaic.
## Notifier
The notifier is a child of the host. Each poll (every 30 s) it reads the
+1 -1
View File
@@ -27,7 +27,7 @@ EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env
# refusals exit 3, usage 4, invalid input 2: none of those is retried. A
# child that dies takes the host down with exit 1, and the unit restarts
# the pair.
ExecStart=@REPO@/scripts/mosaic bus start %i
ExecStart=@REPO@/scripts/mosaic bus start %i@PM_FLAGS@
Restart=on-failure
RestartSec=15
RestartPreventExitStatus=2 3 4
+9 -3
View File
@@ -491,13 +491,17 @@ test("bus-service.sh renders the unit and installs it into a given directory", (
assert.doesNotMatch(render.stdout, /@REPO@|@PATH@|@CLAUDE_ENV@/);
// A user unit cannot order on a system target (Darkwing F3).
assert.doesNotMatch(render.stdout, /network-online/);
const first = spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8" });
const fakeBin=join(dir,"bin");mkdirSync(fakeBin);
writeFileSync(join(fakeBin,"systemctl"),"#!/bin/sh\ncase \"$*\" in *show*) echo DropInPaths=;; esac\nexit 0\n");chmodSync(join(fakeBin,"systemctl"),0o755);
writeFileSync(join(fakeBin,"systemd-analyze"),`#!/bin/sh\nprintf '%s\\n' '${dir}/unit-search'\n`);chmodSync(join(fakeBin,"systemd-analyze"),0o755);
const unitEnv={...process.env,PATH:`${fakeBin}:${process.env.PATH}`};
const first = spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8",env:unitEnv });
assert.equal(first.status, 0, first.stderr);
assert.match(first.stdout, /written: /);
assert.match(first.stdout, /^ {2}mkdir -m 0700 -p <dataRoot>\/notify\/<business> +the notifier refuses a looser directory$/m);
assert.equal(readFileSync(join(dir, "[email protected]"), "utf8"), render.stdout);
assert.match(spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8" }).stdout, /unchanged: /);
assert.match(spawnSync(script, ["uninstall", "--dir", dir, "--no-reload"], { encoding: "utf8" }).stdout, /removed: /);
assert.match(spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8",env:unitEnv }).stdout, /unchanged: /);
assert.match(spawnSync(script, ["uninstall", "--dir", dir, "--no-reload"], { encoding: "utf8",env:unitEnv }).stdout, /removed: /);
assert.equal(spawnSync(script, ["bogus"], { encoding: "utf8" }).status, 4);
});
@@ -512,6 +516,8 @@ test("bus-service.sh pins the claude binary, refuses one the unit can't carry, a
return p;
};
mkdirSync(join(dir, "bin"));
writeFileSync(join(dir,"bin","systemctl"),'#!/bin/sh\ncase "$*" in *show*) echo DropInPaths=;; esac\nexit 0\n');chmodSync(join(dir,"bin","systemctl"),0o755);
writeFileSync(join(dir,"bin","systemd-analyze"),`#!/bin/sh\nprintf '%s\\n' '${dir}/unit-search'\n`);chmodSync(join(dir,"bin","systemd-analyze"),0o755);
const onPath = stand(join(dir, "bin", "claude"));
const named = stand(join(dir, "claude-named"));
symlinkSync(named, join(dir, "claude-link"));
+178
View File
@@ -0,0 +1,178 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {execFileSync,spawnSync,spawn} from 'node:child_process';
import {symlinkSync,unlinkSync,mkdtempSync,mkdirSync,writeFileSync,readFileSync,cpSync,rmSync,chmodSync,existsSync,statSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join,dirname} from 'node:path';
import {fileURLToPath} from 'node:url';
import {once} from 'node:events';
import {pathToFileURL} from 'node:url';
import {fixture,notifyConfig,BUSINESSES} from './helpers.mjs';
const ROOT=fileURLToPath(new URL('../../..',import.meta.url)).replace(/\/$/,'');
const git=(cwd,...args)=>execFileSync('git',['-c','user.name=fixture','-c','user.email=fixture@invalid','-c','commit.gpgsign=false','-c','core.hooksPath=/dev/null','-C',cwd,...args],{encoding:'utf8'}).trim();
export function setup(t, realPin=false){
const root=mkdtempSync(join(tmpdir(),'stack-copy-')),repo=join(root,'repo'),copies=join(root,'copies'),bin=join(root,'bin');
t.after(()=>{execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true})});
mkdirSync(repo);mkdirSync(bin);
const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
for(const f of tracked)if(existsSync(join(ROOT,f)))cpSync(join(ROOT,f),join(repo,f));
// Lightweight npm-ci fixture; real pin/host/proof integration is tested separately.
const pkg={name:'copy-fixture',version:'1.0.0'};
if(!realPin){writeFileSync(join(repo,'package.json'),JSON.stringify(pkg));
writeFileSync(join(repo,'package-lock.json'),JSON.stringify({name:pkg.name,version:pkg.version,lockfileVersion:3,packages:{'':pkg}}));}
else {for(const f of ['package.json','package-lock.json'])cpSync(join(ROOT,f),join(repo,f));}
git(repo,'init','-q','-b','refactor');git(repo,'config','gc.auto','0');git(repo,'add','.');git(repo,'commit','-qm','fixture');
const id=git(repo,'rev-parse','HEAD');git(repo,'update-ref','refs/remotes/origin/refactor',id);
const env={PATH:`${bin}:${dirname(process.execPath)}:/usr/bin:/bin`,HOME:root,XDG_DATA_HOME:join(root,'data'),XDG_CONFIG_HOME:join(root,'config')};
const search=join(root,'unit-search');mkdirSync(search);
const analyze=body=>{writeFileSync(join(bin,'systemd-analyze'),'#!/bin/sh\n'+body);chmodSync(join(bin,'systemd-analyze'),0o755);};
analyze(`printf '%s\\n' '${search}'\n`);
const run=(script,args=[])=>spawnSync(join(repo,'scripts',script),args,{env,encoding:'utf8',timeout:30000});
const fake=body=>{writeFileSync(join(bin,'systemctl'),'#!/bin/bash\n'+body);chmodSync(join(bin,'systemctl'),0o755)};
return {root,repo,copies,bin,id,env,run,fake,search,analyze};
}
test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',t=>{
const s=setup(t),r=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.equal(r.status,0,r.stderr);
const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555);
for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json'])assert.ok(existsSync(join(copy,f)),f);
const npm=join(s.bin,'npm');writeFileSync(npm,'#!/bin/sh\nexit 99\n');chmodSync(npm,0o755);
const existing=s.run('webui-service.sh',['copy',s.id,'--copies',s.copies]);assert.equal(existing.status,0);assert.equal(existing.stdout,`exists: ${copy}\n`);unlinkSync(npm);
git(s.repo,'update-ref','refs/remotes/origin/refactor',git(s.repo,'hash-object','-w','package.json'));
const denied=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.notEqual(denied.status,0);assert.match(denied.stderr,/origin\/refactor/);
});
test('wrapper preserves Q81 lease and marker, rejects foreign/symlink; check uses effective roots',t=>{
const s=setup(t);assert.equal(s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]).status,0);
const wrapper=join(s.bin,'mosaic'),marker='# mosaic-cli wrapper (Q81, agents/sage/work/q81/mosaic-cli.sh)';
const lease=`[ "\${1:-}" = __lease-capability ] && exec "${s.bin}/mosaic-old" "$@"`;
writeFileSync(wrapper,`#!/usr/bin/env bash\n${marker}\n${lease}\nexec "/old/scripts/mosaic" "$@"\n`);
assert.equal(s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin]).status,0);
assert.match(readFileSync(wrapper,'utf8'),new RegExp(s.id));assert.ok(readFileSync(wrapper,'utf8').includes(lease));
const old=join(s.bin,'mosaic-old');writeFileSync(old,'#!/bin/sh\nprintf \"legacy:%s\\n\" \"$1\"\n');chmodSync(old,0o755);
assert.equal(spawnSync(wrapper,['__lease-capability'],{env:s.env,encoding:'utf8'}).stdout,'legacy:__lease-capability\n');
const copy=join(s.copies,s.id);s.fake(`case "$*" in *mosaic-bus*) echo 'LoadState=loaded'; echo 'WorkingDirectory=${copy}'; echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start mosaic-stack --pm ; }';; *) echo 'LoadState=loaded'; echo 'WorkingDirectory=${copy}'; echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';; esac\n`);
assert.equal(s.run('stack-copy.sh',['check','--copies',s.copies,'--bin',s.bin]).status,0);
s.fake(`echo 'LoadState=loaded'; echo 'WorkingDirectory=${copy}';case \"$*\" in *mosaic-bus*)echo 'ExecStart={ path=/other/scripts/mosaic ; argv[]=/other/scripts/mosaic bus start demo ; }';; *)echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';;esac\n`);
assert.equal(s.run('stack-copy.sh',['check','--copies',s.copies,'--bin',s.bin]).status,1,'effective ExecStart mismatch');
s.fake(`echo 'LoadState=loaded'; echo 'WorkingDirectory=/other';\n`);assert.equal(s.run('stack-copy.sh',['check','--copies',s.copies,'--bin',s.bin]).status,1);
const foreign=`#!/usr/bin/env bash\n# foreign wrapper\n${lease}\nexec \"/old/scripts/mosaic\" \"$@\"\n`;writeFileSync(wrapper,foreign);assert.notEqual(s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin]).status,0);assert.equal(readFileSync(wrapper,'utf8'),foreign);
const backing=join(s.bin,'backing');writeFileSync(backing,`#!/usr/bin/env bash\n${marker}\n${lease}\nexec \"/old/scripts/mosaic\" \"$@\"\n`);unlinkSync(wrapper);symlinkSync(backing,wrapper);assert.notEqual(s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin]).status,0);
});
test('bus unit copy/pm pins, install refuses all busy states and failed reads, drop-in refusal',t=>{
const s=setup(t);assert.equal(s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]).status,0);
const args=['--copy',s.id,'--copies',s.copies,'--pm'];
const render=s.run('bus-service.sh',['render',...args]);assert.equal(render.status,0,render.stderr);assert.match(render.stdout,new RegExp(`WorkingDirectory=${s.copies}/${s.id}`));assert.match(render.stdout,/bus start %i --pm/);
const units=join(s.root,'units');
for(const state of ['active','activating','deactivating','reloading']){s.fake(`echo '[email protected] loaded ${state} running fixture';\n`);const r=s.run('bus-service.sh',['install',...args,'--dir',units,'--no-reload']);assert.notEqual(r.status,0,state);assert.ok(!existsSync(units));}
s.fake('exit 1\n');assert.notEqual(s.run('bus-service.sh',['install',...args,'--dir',units,'--no-reload']).status,0);assert.ok(!existsSync(units));
const dropdir=join(s.search,'[email protected]');mkdirSync(dropdir);const drop=join(dropdir,'pm.conf');writeFileSync(drop,'[Service]\nExecStart=elsewhere\n');
s.fake(`case "$*" in *list-units*) echo '[email protected] loaded inactive dead fixture';; *show*) echo 'LoadState=loaded';echo 'DropInPaths=${drop}';; esac\n`);
const refused=s.run('bus-service.sh',['install',...args,'--dir',units,'--no-reload']);assert.notEqual(refused.status,0);assert.ok(refused.stderr.includes(drop));assert.ok(!existsSync(units));
writeFileSync(drop,'[Service]\nEnvironment=SAFE=1\n');assert.equal(s.run('bus-service.sh',['install',...args,'--dir',units,'--no-reload']).status,0);
});
test('missing package manifest refuses before npm; npm failure removes the unpublished temp copy', t=>{
const s=setup(t);git(s.repo,'rm','package.json');git(s.repo,'commit','-qm','missing package');const id=git(s.repo,'rev-parse','HEAD');git(s.repo,'update-ref','refs/remotes/origin/refactor',id);
const npm=join(s.bin,'npm'),called=join(s.root,'npm-called');writeFileSync(npm,`#!/bin/bash\necho called > '${called}'\nexit 1\n`);chmodSync(npm,0o755);
const r=s.run('stack-copy.sh',['copy',id,'--copies',s.copies]);assert.notEqual(r.status,0);assert.match(r.stderr,/package.json/);assert.ok(!existsSync(called));assert.ok(!existsSync(join(s.copies,id)));
git(s.repo,'reset','--hard',s.id);git(s.repo,'update-ref','refs/remotes/origin/refactor',s.id);
const tar=join(s.bin,'tar');writeFileSync(tar,'#!/bin/sh\n/usr/bin/tar \"$@\" || exit $?\nfor a; do dest=\"$a\"; done\nrm \"$dest/package.json\"\n');chmodSync(tar,0o755);
const absent=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.notEqual(absent.status,0);assert.match(absent.stderr,/package.json.*missing/);assert.ok(!existsSync(called));unlinkSync(tar);
const fail=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.notEqual(fail.status,0);assert.match(fail.stderr,/npm ci failed/);assert.ok(existsSync(called));assert.deepEqual(execFileSync('ls',['-A',s.copies],{encoding:'utf8'}),'');
});
async function until(fn,ms=10000){const end=Date.now()+ms;while(Date.now()<end){if(fn())return;await new Promise(r=>setTimeout(r,25));}throw Error('fixture startup timed out');}
async function childResult(command,args,env,input){
const p=spawn(command,args,{env,stdio:['pipe','pipe','pipe']});let stdout='',stderr='';
p.stdout.on('data',c=>stdout+=c);p.stderr.on('data',c=>stderr+=c);p.stdin.end(input);
const timer=setTimeout(()=>p.kill('SIGKILL'),15000);
try{const [status]=await once(p,'close');return {status,stdout,stderr};}finally{clearTimeout(timer)}
}
test('real pinned install loads bus and Console; same-copy mosaic talk passes proof, other trees refuse', {timeout:180000},async t=>{
const s=setup(t,true);
const copied=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.equal(copied.status,0,copied.stderr);
const root=join(s.copies,s.id),url=f=>pathToFileURL(join(root,f)).href;
const {checkEnginePin}=await import(url('packages/conversation/src/pi-pin.mjs'));assert.equal(checkEnginePin(root).version,'0.85.1');
const f=fixture(join(s.root,'home'));notifyConfig(f.dataRoot,'acme',null);
// Only synthetic configuration, no inherited credentials/agent environment.
const env={...s.env,MOSAIC_CONFIG:f.config};
const host=spawn(join(root,'scripts/mosaic'),['bus','start','acme'],{env,stdio:['ignore','pipe','pipe']});let hostOut='',hostErr='';
host.stdout.on('data',c=>hostOut+=c);host.stderr.on('data',c=>hostErr+=c);
try{await until(()=>hostOut.includes('bus host up')||host.exitCode!==null);assert.match(hostOut,/bus host up/,hostErr);}
finally{if(host.exitCode===null&&host.signalCode===null){const done=once(host,'close');host.kill('SIGTERM');await done;}}
const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr='';
web.stdout.on('data',c=>webOut+=c);web.stderr.on('data',c=>webErr+=c);
try{await until(()=>/Mosaic Console: http:/.test(webOut)||web.exitCode!==null);const port=webOut.match(/127\.0\.0\.1:(\d+)/)?.[1];assert.ok(port,webErr);assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status,200);}
finally{if(web.exitCode===null&&web.signalCode===null){const done=once(web,'close');web.kill();await done;}}
// The production runtime derives cliPath. Only agent ancestry is mocked;
// real pid/start/argv/nonce and the runtime's entry-path binding remain intact.
const proofScript=join(s.root,'proof-runtime.mjs');
writeFileSync(proofScript,`
import {mock} from 'node:test';
const humanURL=${JSON.stringify(url('packages/bus/src/human.mjs'))};
const real=await import(humanURL);
mock.module(humanURL,{namedExports:{verifyHuman:(proof,options)=>real.verifyHuman(proof,{...options,readProcess:pid=>({...real.readProcess(pid),ppid:1})})}});
const {startBroker}=await import(${JSON.stringify(url('packages/bus/src/runtime.mjs'))});
const runtime=await startBroker({dataRoot:${JSON.stringify(f.dataRoot)},businesses:${JSON.stringify(BUSINESSES)}});
const broker=runtime.broker;
const pm=broker.bindLaunch({business:'demo',role:'pm',run:'pm-fixture',harness:'pi'});broker.request(pm,{verb:'role.claim'});
const request=broker.request.bind(broker);broker.request=(cap,r)=>{const result=request(cap,r);if(r.verb==='message.send'&&r.args.to==='pm')request(pm,{verb:'message.send',args:{to:'human',body:'same-copy reply',in_reply_to:result.id}});return result;};
process.on('SIGTERM',async()=>{await runtime.close();process.exit(0)});
console.log('proof runtime ready');
`);
const proof=spawn(process.execPath,['--experimental-test-module-mocks',proofScript],{env,stdio:['ignore','pipe','pipe']});let proofOut='',proofErr='';
proof.stdout.on('data',c=>proofOut+=c);proof.stderr.on('data',c=>proofErr+=c);
const socket=join(f.dataRoot,'bus','broker.sock');
try{
await until(()=>proofOut.includes('proof runtime ready')||proof.exitCode!==null);assert.match(proofOut,/proof runtime ready/,proofErr);
const result=await childResult(join(root,'scripts/mosaic'),['talk','pm','hello','--business','demo','--wait','1'],env);
assert.equal(result.status,0,result.stderr);assert.match(result.stdout,/sent .* to pm/);assert.match(result.stdout,/same-copy reply/);
for(const other of [ROOT,join(s.root,'other-copy')]){
if(other!==ROOT)cpSync(join(root,'packages'),join(other,'packages'),{recursive:true});
const result=await childResult(process.execPath,[join(other,'packages/bus/src/human-cli.mjs'),socket],env,JSON.stringify({business:'demo',verb:'inbox'})+'\n');
assert.notEqual(result.status,0);assert.equal(result.stderr.trim(),'human-required');
}
}finally{if(proof.exitCode===null&&proof.signalCode===null){const done=once(proof,'close');proof.kill('SIGTERM');await done;}}
});
test('bus status reads effective copy/pm/drop-ins without writes; check refuses an overridden ExecStart',t=>{
const s=setup(t);assert.equal(s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]).status,0);
const copy=join(s.copies,s.id),drop=join(s.root,'pm.conf');
const rows=`LoadState=loaded\nWorkingDirectory=${copy}\nExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start demo --pm ; }\nDropInPaths=${drop}\n`;
s.fake(`case "$*" in *show*) cat <<'ROWS'\n${rows}ROWS\n;; *is-active*) echo inactive;; *is-enabled*) echo disabled;; *)exit 99;;esac\n`);
const status=s.run('bus-service.sh',['status','demo']);assert.equal(status.status,0,status.stderr);assert.match(status.stdout,new RegExp(`copy: ${s.id}`));assert.match(status.stdout,/pm: on/);assert.ok(status.stdout.includes(drop));
});
test('drop-ins in either override field and malformed metadata refuse; uninstall also checks --no-reload',t=>{
const s=setup(t),units=join(s.root,'units'),dropdir=join(s.search,'[email protected]');mkdirSync(dropdir);const drop=join(dropdir,'override.conf');
for(const field of ['ExecStart','WorkingDirectory']){
writeFileSync(drop,`[Service]\n${field}=override\n`);
s.fake(`case "$*" in *list-units*) echo '[email protected] loaded inactive dead fixture';; *[email protected]*) echo 'DropInPaths=${drop}';; *)echo DropInPaths=;;esac\n`);
const r=s.run('bus-service.sh',['install','--dir',units,'--no-reload']);assert.notEqual(r.status,0);assert.ok(r.stderr.includes(drop));assert.ok(!existsSync(units));
}
s.fake('exit 0\n');s.analyze('exit 0\n');const unknown=s.run('bus-service.sh',['install','--dir',units,'--no-reload']);assert.notEqual(unknown.status,0);assert.match(unknown.stderr,/unknown/);
mkdirSync(units);const file=join(units,'[email protected]');writeFileSync(file,'unchanged');
s.fake("echo '[email protected] loaded active running fixture'\n");assert.notEqual(s.run('bus-service.sh',['uninstall','--dir',units,'--no-reload']).status,0);assert.equal(readFileSync(file,'utf8'),'unchanged');
});
test('install never shows a template name on systemd 261; template drop-ins still refuse',t=>{
const s=setup(t),units=join(s.root,'units'),calls=join(s.root,'systemctl-calls');
s.fake(`echo "$*" >> '${calls}'\ncase "$*" in *show*[email protected]*) echo 'neither a valid invocation ID nor unit name' >&2; exit 1;; *list-units*) echo '[email protected] loaded inactive dead fixture';; *show*)echo DropInPaths=;; esac\n`);
const result=s.run('bus-service.sh',['install','--dir',units,'--no-reload']);assert.equal(result.status,0,result.stderr);
assert.doesNotMatch(readFileSync(calls,'utf8'),/show mosaic-bus@\.service/);
const dropdir=join(s.search,'[email protected]');mkdirSync(dropdir);const file=join(dropdir,'pm.conf');writeFileSync(file,'[Service]\nExecStart=override\n');
const before=readFileSync(join(units,'[email protected]'),'utf8');
const denied=s.run('bus-service.sh',['install','--dir',units,'--no-reload']);assert.notEqual(denied.status,0);assert.ok(denied.stderr.includes(file));assert.equal(readFileSync(join(units,'[email protected]'),'utf8'),before);
});
test('unlisted instance drop-in is found in another unit lookup directory; path discovery errors refuse',t=>{
const s=setup(t),units=join(s.root,'units'),second=join(s.root,'second-search');mkdirSync(second);
const dropdir=join(second,'[email protected]');mkdirSync(dropdir);const file=join(dropdir,'cwd.conf');writeFileSync(file,'[Service]\nWorkingDirectory=/other\n');
s.analyze(`printf '%s\\n' '${s.search}' '${second}'\n`);
s.fake("case \"$*\" in *list-units*) echo '[email protected] loaded inactive dead fixture';; *show*)echo DropInPaths=;; esac\n");
const denied=s.run('bus-service.sh',['install','--dir',units,'--no-reload']);assert.notEqual(denied.status,0);assert.ok(denied.stderr.includes(file));assert.ok(!existsSync(units));
s.analyze('exit 1\n');const failed=s.run('bus-service.sh',['install','--dir',units,'--no-reload']);assert.notEqual(failed.status,0);assert.match(failed.stderr,/unit-paths failed/);assert.ok(!existsSync(units));
});
+18 -18
View File
@@ -110,24 +110,24 @@ system config at start: without it serve refuses with `--send needs the bus
configured` and exits 2, which the unit doesn't restart. `status` says
whether the installed unit sends.
The unit runs a read-only pinned copy of the Console's paths, not the
working checkout (row 77). `scripts/webui-service.sh copy COMMIT` makes
one from a commit on `refactor`, and `install --copy ID` points the unit
at it, with serve's `--checkout` naming the checkout, where the queue read
still runs, so the queue view isn't pinned and shows the queue as it is
now. The update step is `copy`, stop, `install --copy ID` with the same
flags, start; rollback is the same with the earlier id (one at or after
row 77's landing, since an older `cli.mjs` refuses `--checkout`), and
copies are kept (they are read-only, files and directories; remove one
with `chmod -R u+w DIR && rm -rf DIR`). `install --copy ID` refuses unless
the bus unit runs from the same copy, because the bus accepts a human
proof only from its own `human-cli.mjs`. The bus still runs from the
checkout, so the update step can't be used until a later row puts the bus
on the copy. The stop and start are Jason's or Mos's: `install` and
`uninstall` refuse while the unit runs. With `--auth-header
X-authentik-uid` the allowed user must be the 64-character Authentik uid
(lead decision 101). `status` names the copy and whether `refactor` has
moved past it.
The unit can run a read-only shared stack copy (rows 77 and 85), with
serve's --checkout naming the canonical checkout so the queue stays current.
webui-service.sh copy calls stack-copy.sh copy: the default directory is
mosaic-stack/copies/<full id>, and commits must be on both local and pushed
refactor. The copy includes bus/CLI/Console code, adapters and pinned npm
dependencies. install --copy ID refuses unless the bus's effective
ExecStart and WorkingDirectory name that same copy, including drop-ins.
It also refuses while the Console runs. With --auth-header X-authentik-uid,
allow-user must be the 64-character Authentik uid (decision 101). Status
names the copy and refactor drift. Files and directories are read-only;
copies are retained. Existing Console-only copies are not migrated or pruned.
The single update and rollback sequence is
[TOOLS.md, Pinned stack update](../../docs/TOOLS.md#pinned-stack-update).
A bus restart ends managed sessions, so no PM turn may be in flight.
Switching the Console, bus or wrapper, changing any unit, and starting or
stopping either service requires Mos's go for that live step. Agents build
and test fixtures; they stop before the live sequence.
## Message a role (row 76, #1570)
+15 -11
View File
@@ -33,15 +33,18 @@ function tmp(t, prefix) {
function fixtureRepo(t) {
const dir = tmp(t, 'webui-copy-');
const repo = join(dir, 'repo');
const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean);
const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'adapters', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean);
for (const f of files) if (existsSync(join(REPO, f))) cpSync(join(REPO, f), join(repo, f));
writeFileSync(join(repo, 'RELEASE'), '0.0.0-fixture\n');
writeFileSync(join(repo,'package.json'),JSON.stringify({name:'copy-fixture',version:'1.0.0'}));
writeFileSync(join(repo,'package-lock.json'),JSON.stringify({name:'copy-fixture',version:'1.0.0',lockfileVersion:3,packages:{'':{name:'copy-fixture',version:'1.0.0'}}}));
git(dir, 'init', '-q', '-b', 'refactor', repo);
// The commit leaves thousands of loose objects; a detached gc --auto
// would still be deleting them when the cleanup's chmod walks .git.
git(repo, 'config', 'gc.auto', '0');
git(repo, 'add', '-A');
git(repo, 'commit', '-q', '-m', 'fixture');
git(repo,'update-ref','refs/remotes/origin/refactor',git(repo,'rev-parse','HEAD'));
return { dir, repo, script: join(repo, 'scripts', 'webui-service.sh'), head: git(repo, 'rev-parse', 'HEAD') };
}
@@ -69,12 +72,12 @@ test('copy writes the Console paths at the full id, read-only, and leaves an exi
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`^copied: ${target}$`, 'm'));
assert.deepEqual(readdirSync(copies), [head], 'no temp directory left behind');
assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'packages', 'roles', 'scripts']);
assert.deepEqual(readdirSync(join(target, 'scripts')), ['mosaic-config.mjs']);
assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts']);
assert.deepEqual(readdirSync(join(target, 'scripts')), ['mosaic', 'mosaic-config.mjs']);
assert.ok(existsSync(join(target, 'packages', 'webui', 'README.md')), "the unit's Documentation=");
assert.equal(statSync(join(target, 'packages', 'webui', 'README.md')).mode & 0o222, 0, 'README.md is read-only');
assert.ok(existsSync(join(target, 'packages', 'tasks', 'fields.json')), 'the task field table');
assert.ok(!existsSync(join(target, 'packages', 'queue')), 'the queue read runs from the checkout');
assert.ok(existsSync(join(target, 'packages', 'queue', 'src')), 'wrapper queue branch imports queue code; mutations still refuse off checkout');
assert.equal(readFileSync(join(target, 'RELEASE'), 'utf8'), '0.0.0-fixture\n');
const files = filesUnder(target);
assert.ok(files.length > 50, String(files.length));
@@ -99,7 +102,7 @@ test('copy writes the Console paths at the full id, read-only, and leaves an exi
// The default directory is under XDG_DATA_HOME, else ~/.local/share.
const byDefault = await run(dir, script, ['copy', 'refactor']);
assert.equal(byDefault.status, 0, byDefault.stderr);
assert.ok(existsSync(join(dir, '.local', 'share', 'mosaic-console', head, 'packages', 'webui', 'src', 'cli.mjs')));
assert.ok(existsSync(join(dir, '.local', 'share', 'mosaic-stack', 'copies', head, 'packages', 'webui', 'src', 'cli.mjs')));
assert.equal(git(repo, 'status', '--porcelain'), '', 'the checkout is untouched');
});
@@ -112,7 +115,7 @@ test('copy refuses a commit off refactor, an unknown one, a worktree and a bad c
git(repo, 'commit', '-q', '-m', 'side');
const side = git(repo, 'rev-parse', 'HEAD');
git(repo, 'checkout', '-q', 'refactor');
for (const [commit, why] of [[side, new RegExp(`^webui-service: refusing: ${side} is not reachable from refs/heads/refactor$`, 'm')], ['side', /is not reachable from refs\/heads\/refactor/], ['nope', /^webui-service: not a commit: nope$/m], ['a b', /commit must be a commit id or branch name/], ['HEAD~1', /commit must be a commit id or branch name/], ['.refactor', /^webui-service: commit must be a commit id or branch name: \.refactor$/m]]) {
for (const [commit, why] of [[side, new RegExp(`^stack-copy: refusing: ${side} is not reachable from refs/heads/refactor$`, 'm')], ['side', /is not reachable from refs\/heads\/refactor/], ['nope', /^stack-copy: not a commit: nope$/m], ['a b', /commit must be a commit id or branch name/], ['HEAD~1', /commit must be a commit id or branch name/], ['.refactor', /^stack-copy: commit must be a commit id or branch name: \.refactor$/m]]) {
const r = await run(dir, script, ['copy', commit, '--copies', copies]);
assert.equal(r.status, 1, commit);
assert.match(r.stderr, why, commit);
@@ -132,7 +135,7 @@ test('copy refuses a commit off refactor, an unknown one, a worktree and a bad c
git(repo, 'worktree', 'add', '-q', '--detach', tree, 'refactor');
const fromTree = await run(dir, join(tree, 'scripts', 'webui-service.sh'), ['copy', 'refactor', '--copies', copies]);
assert.equal(fromTree.status, 1);
assert.match(fromTree.stderr, /^webui-service: refusing: \S+ is not the canonical checkout \(its \.git is not a directory\)/m);
assert.match(fromTree.stderr, /^stack-copy: refusing: \S+ is not the canonical checkout \(its \.git is not a directory\)/m);
assert.ok(!existsSync(copies));
const allowed = await run(dir, join(tree, 'scripts', 'webui-service.sh'), ['copy', 'refactor', '--copies', copies, '--allow-worktree']);
assert.equal(allowed.status, 0, allowed.stderr);
@@ -145,9 +148,10 @@ test('copy writes nothing when an archived path is missing at the commit', async
const copies = join(dir, 'copies');
git(repo, 'rm', '-q', 'scripts/mosaic-config.mjs');
git(repo, 'commit', '-q', '-m', 'drop the config check');
git(repo,'update-ref','refs/remotes/origin/refactor',git(repo,'rev-parse','HEAD'));
const r = await run(dir, script, ['copy', 'refactor', '--copies', copies]);
assert.equal(r.status, 1);
assert.match(r.stderr, /^webui-service: git archive of [0-9a-f]{40} failed; no copy written$/m);
assert.match(r.stderr, /^stack-copy: git archive failed; no copy written$/m);
assert.deepEqual(readdirSync(copies), [], 'the temp directory is removed');
});
@@ -194,7 +198,7 @@ async function copyWithMv(t, mvBody) {
test('a copy whose rename fails with no target refuses, writes no copy and leaves no temp directory', async (t) => {
const r = await copyWithMv(t, 'exit 1');
assert.equal(r.status, 1);
assert.equal(r.stderr, `webui-service: could not rename the copy into ${r.target}\n`);
assert.ok(r.stderr.includes(`stack-copy: could not rename copy into ${r.target}\n`));
assert.equal(r.stdout, '');
assert.deepEqual(readdirSync(r.copies), [], 'no target and no temp directory');
});
@@ -211,7 +215,7 @@ test('a copy that meets a target holding files says exists: and moves nothing in
test('a copy that meets an empty target directory replaces it by rename, not by moving into it', async (t) => {
const r = await copyWithMv(t, 'for a; do t="$a"; done\nmkdir "$t"\nexec /usr/bin/mv "$@"');
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout, `copied: ${r.target}\ninstall it with: scripts/webui-service.sh install --copy ${r.head}, with the unit's flags\n`);
assert.equal(r.stdout, `copied: ${r.target}\n`);
assert.deepEqual(readdirSync(r.copies), [r.head], 'the temp directory is gone');
assert.ok(existsSync(join(r.target, 'packages', 'webui', 'README.md')), 'the copy is the target itself');
});
@@ -266,7 +270,7 @@ test('serve runs from a copy: /healthz 200, the config check, roles and RELEASE
assert.equal((await plain.get('/healthz')).status, 200);
const none = await plain.get('/api/queue');
assert.equal(none.status, 503);
assert.equal(none.body.error, 'read-failed');
assert.equal(none.body.error, 'queue-refused');
});
test('serve refuses a --checkout that is not an absolute directory path', async (t) => {
+12 -10
View File
@@ -24,7 +24,7 @@ const PREVIEW_FLAGS = `--public-host console.example.com --auth-header X-authent
// A copy as `copy` leaves it, reduced to the file install looks for.
const COPY_ID = '0123456789abcdef0123456789abcdef01234567';
const INSTALL = ['--copy', COPY_ID, '--allow-worktree'];
const copyRoot = (dir) => join(dir, '.local', 'share', 'mosaic-console', COPY_ID);
const copyRoot = (dir) => join(dir, '.local', 'share', 'mosaic-stack', 'copies', COPY_ID);
function fakeCopy(dir) {
mkdirSync(join(copyRoot(dir), 'packages', 'webui', 'src'), { recursive: true });
writeFileSync(join(copyRoot(dir), 'packages', 'webui', 'src', 'cli.mjs'), '');
@@ -61,9 +61,9 @@ function tmp(t) {
return dir;
}
// The bus unit as `systemctl show -p LoadState -p ExecStart` prints it,
// The bus unit as `systemctl show -p LoadState -p WorkingDirectory -p ExecStart` prints it,
// started from `root`.
const busShow = (root) => `LoadState=loaded\nExecStart={ path=${root}/scripts/mosaic ; argv[]=${root}/scripts/mosaic bus start acme ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }\n`;
const busShow = (root) => `LoadState=loaded\nWorkingDirectory=${root}\nExecStart={ path=${root}/scripts/mosaic ; argv[]=${root}/scripts/mosaic bus start acme ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }\n`;
const BUS_UNITS = '[email protected] loaded active running Mosaic bus host (acme)\n';
// A stand-in systemctl that prints `lines` for the Console's list-units and
@@ -81,7 +81,7 @@ function standIn(dir, lines = '', { listFails = false, bus = null, busUnits = BU
if (bus !== null) writeFileSync(join(dir, 'bus-show.txt'), bus);
if (busOther !== null) writeFileSync(join(dir, 'bus-show-other.txt'), busOther);
const fail = 'echo "Failed to connect to bus" >&2; exit 1';
const show = bus !== null ? `cat "${dir}/bus-show.txt"` : `r="$HOME/.local/share/mosaic-console/${COPY_ID}"; printf '%s' "${busShow('$r')}"`;
const show = bus !== null ? `cat "${dir}/bus-show.txt"` : `r="$HOME/.local/share/mosaic-stack/copies/${COPY_ID}"; printf '%s' "${busShow('$r')}"`;
writeFileSync(join(bin, 'systemctl'), `#!/bin/sh
echo "$*" >> "${dir}/calls.txt"
case "$*" in
@@ -106,7 +106,7 @@ function quiet(dir) {
return join(home, 'bin');
}
// Every call but the reads: the Console's state and install's bus check.
const READS = /^--user (list-units --no-legend --plain mosaic-webui\.service|list-units --all --no-legend --plain mosaic-bus@\*\.service|show mosaic-bus@\S+ -p LoadState -p ExecStart)$/;
const READS = /^--user (list-units --no-legend --plain mosaic-webui\.service|list-units --all --no-legend --plain mosaic-bus@\*\.service|show mosaic-bus@\S+ -p LoadState -p WorkingDirectory -p ExecStart)$/;
const otherCalls = (file) => readFileSync(file, 'utf8').split('\n').filter(Boolean).filter((c) => !READS.test(c));
// Async, so an in-process server can answer the script's /healthz request.
@@ -227,7 +227,7 @@ test('install without --no-reload runs daemon-reload and nothing that starts the
const r = await run(dir, ['install', ...INSTALL, '--dir', join(dir, 'units')], bin);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /^daemon-reload done$/m);
assert.equal(readFileSync(join(dir, 'calls.txt'), 'utf8'), '--user list-units --all --no-legend --plain mosaic-bus@*.service\n--user show [email protected] -p LoadState -p ExecStart\n--user list-units --no-legend --plain mosaic-webui.service\n--user daemon-reload\n');
assert.equal(readFileSync(join(dir, 'calls.txt'), 'utf8'), '--user list-units --all --no-legend --plain mosaic-bus@*.service\n--user show [email protected] -p LoadState -p WorkingDirectory -p ExecStart\n--user list-units --no-legend --plain mosaic-webui.service\n--user daemon-reload\n');
});
test('uninstall refuses while the unit is running, with or without --no-reload, and removes it once it is not', async (t) => {
@@ -478,7 +478,7 @@ test('install needs --copy naming a copy that exists, and refuses outside the ca
[['--copy', COPY_ID.slice(0, 12)], /^webui-service: --copy must be a full 40-character commit id: 0123456789ab$/m],
[['--copy', COPY_ID.toUpperCase()], /^webui-service: --copy must be a full 40-character commit id: /m],
[['--copy', `${COPY_ID}0`], /^webui-service: --copy must be a full 40-character commit id: /m],
[['--copy', 'f'.repeat(40)], new RegExp(`^webui-service: no copy ${'f'.repeat(40)} in ${esc(join(dir, '.local', 'share', 'mosaic-console'))}; make it with: scripts/webui-service\\.sh copy ${'f'.repeat(40)}$`, 'm')],
[['--copy', 'f'.repeat(40)], new RegExp(`^webui-service: no copy ${'f'.repeat(40)} in ${esc(join(dir, '.local', 'share', 'mosaic-stack', 'copies'))}; make it with: scripts/webui-service\\.sh copy ${'f'.repeat(40)}$`, 'm')],
[['--copy', COPY_ID, '--copies', 'relative'], /^webui-service: copies directory must be an absolute path: relative$/m],
[['--copy', COPY_ID, '--copies', join(dir, 'no copies')], /^webui-service: copies directory contains a character the unit cannot carry: /m],
];
@@ -532,20 +532,22 @@ test('install refuses unless the bus unit runs from the same copy, naming both p
// The same with --business, which names the one unit to read.
s = sys({ bus: busShow(REPO) });
refused(await install(s.bin, ['--business', 'acme']), `[email protected] runs from ${REPO}/scripts/mosaic, not the copy ${copy}; the bus accepts only its own human-cli.mjs, so every Console bus call would fail human-required until the bus runs from the same copy`);
assert.equal(readFileSync(join(s.d, 'calls.txt'), 'utf8'), '--user show [email protected] -p LoadState -p ExecStart\n');
assert.equal(readFileSync(join(s.d, 'calls.txt'), 'utf8'), '--user show [email protected] -p LoadState -p WorkingDirectory -p ExecStart\n');
// A path that only starts with the copy's name is not inside it.
s = sys({ bus: busShow(`${copy}-old`) });
refused(await install(s.bin), `[email protected] runs from ${copy}-old/scripts/mosaic, not the copy ${copy}; the bus accepts only its own human-cli.mjs, so every Console bus call would fail human-required until the bus runs from the same copy`);
s = sys({bus:busShow(copy).replace(`WorkingDirectory=${copy}`, 'WorkingDirectory=/different')});
refused(await install(s.bin), `[email protected] WorkingDirectory is /different, not the copy ${copy}; nothing was changed`);
// Two loaded bus units: each must run from the copy.
s = sys({ busUnits: `${BUS_UNITS}[email protected] loaded active running Mosaic bus host (other)\n` });
assert.equal((await install(s.bin)).status, 0);
assert.match(readFileSync(join(s.d, 'calls.txt'), 'utf8'), /show mosaic-bus@other\.service -p LoadState -p ExecStart/);
assert.match(readFileSync(join(s.d, 'calls.txt'), 'utf8'), /show mosaic-bus@other\.service -p LoadState -p WorkingDirectory -p ExecStart/);
rmSync(unitDir, { recursive: true });
// The second loaded unit is off the copy: refused, naming the second unit and its path.
const two = `${BUS_UNITS}[email protected] loaded active running Mosaic bus host (other)\n`;
s = sys({ busUnits: two, bus: busShow(copy), busOther: busShow(REPO) });
refused(await install(s.bin), `[email protected] runs from ${REPO}/scripts/mosaic, not the copy ${copy}; the bus accepts only its own human-cli.mjs, so every Console bus call would fail human-required until the bus runs from the same copy`);
assert.match(readFileSync(join(s.d, 'calls.txt'), 'utf8'), /show mosaic-bus@acme\.service -p LoadState -p ExecStart\n.*show mosaic-bus@other\.service -p LoadState -p ExecStart/s);
assert.match(readFileSync(join(s.d, 'calls.txt'), 'utf8'), /show mosaic-bus@acme\.service -p LoadState -p WorkingDirectory -p ExecStart\n.*show mosaic-bus@other\.service -p LoadState -p WorkingDirectory -p ExecStart/s);
// No bus unit installed for the business: refused.
s = sys({ bus: 'LoadState=not-found\nExecStart=\n' });