From cdac7ba92b1b6138b44343eeb272eb945217982b Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 20 Oct 2026 15:48:52 -0500 Subject: [PATCH] stack: row 190 round 2 (#1688): T56 after row 189's T55, N1 copy and usage, N3 Console comment, N5 and N6 Filbert's round 1 (comment 29634) and Sage's N3 ruling (sage-1474). Row 189 also named a test T55, so row 190's is T56. webui-service.sh copy refuses --instance like stack-copy.sh copy; TOOLS.md shows --instance on the bus and discord usage. A named Console unit's comment names MOSAIC_CONFIG, and the default keeps the template's line. check --instance needs the wrapper's MOSAIC_CONFIG line. TOOLS.md says an existing directory keeps its mode. --- docs/TOOLS.md | 33 +++++++++++---------- packages/cli/tests/stack-copy.test.mjs | 17 +++++++++++ packages/cli/tests/stack-instances.test.mjs | 21 ++++++++++++- packages/cli/tests/stack-release.test.mjs | 14 ++++++++- scripts/instance.sh | 6 ++-- scripts/stack-copy.sh | 2 ++ scripts/webui-service.sh | 10 +++++-- 7 files changed, 82 insertions(+), 21 deletions(-) diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 01ebe6403..c5d83e272 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -270,9 +270,9 @@ scripts/mosaic follow --thread [--tester] scripts/mosaic stop scripts/mosaic launches off | on [--business ] scripts/mosaic launches list [--json] -scripts/bus-service.sh render [--copy ID [--copies DIR]] [--pm] [--claude PATH] -scripts/bus-service.sh install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH] -scripts/bus-service.sh uninstall | status +scripts/bus-service.sh [--instance NAME] render [--copy ID [--copies DIR]] [--pm] [--claude PATH] +scripts/bus-service.sh [--instance NAME] install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH] +scripts/bus-service.sh [--instance NAME] uninstall | status ``` The human commands read and resolve through the broker's human transport @@ -333,16 +333,17 @@ scripts/bootstrap.sh --instance NAME NAME matches `^[a-z][a-z0-9]{1,15}$`; anything else exits 4 (`instance-name-invalid`), and `--instance dev` is the default. Bootstrap -writes `~/.config/mosaic-NAME/config.json` (directory 0700, file 0644) and -claims `~/.mosaic-NAME` (0700) with the marker `instance.json`, -`{"instanceVersion":1,"name":"NAME"}`. It refuses with exit 3 and writes +writes `~/.config/mosaic-NAME/config.json` (0644) and claims `~/.mosaic-NAME` +with the marker `instance.json`, `{"instanceVersion":1,"name":"NAME"}`. It +creates either directory 0700; one that already exists keeps its mode. It refuses with exit 3 and writes nothing when the root exists, is not empty and holds no marker naming NAME. A second run changes nothing. `~` is the home of the OS user that runs the instance. `--instance NAME` is taken, once and in any position, by `bootstrap.sh`, -`bus-service.sh`, `webui-service.sh`, `discord-service.sh`, `stack-copy.sh` -(`wrapper` and `check`) and `stack-release.sh`. Inside a process the config +`bus-service.sh`, `webui-service.sh` (all but `copy`), `discord-service.sh`, +`stack-copy.sh` (`wrapper` and `check`) and `stack-release.sh`. Copies serve +every instance, so both `copy` verbs refuse it with exit 4. Inside a process the config travels as `MOSAIC_CONFIG`. Selection, checked before any read or write: | Given | Config | Names | @@ -368,11 +369,13 @@ missing config exits 3 with the bootstrap command that creates it. | Console port | `--port`, 7332 when absent | `--port` required; 7330, 7332 and 7333 exit 4 `port-reserved` | A named instance's units add `Environment=MOSAIC_CONFIG=` and -` [NAME]` after the Description. The default's renders are byte-identical -to the templates' renders before row 190. `stack-copy.sh wrapper --instance -NAME` creates `$BIN/mosaic-NAME` or replaces one that carries NAME's -marker, and refuses any other file. `check --instance NAME` needs -`--business ID`. +` [NAME]` after the Description, and the Console unit's comment on the +variables it sets names `MOSAIC_CONFIG`. The default's renders are +byte-identical to the templates' renders before row 190. `stack-copy.sh +wrapper --instance NAME` creates `$BIN/mosaic-NAME` or replaces one that +carries NAME's marker, and refuses any other file. `check --instance NAME` +needs `--business ID`, and fails with `CLI: (MOSAIC_CONFIG mismatch)` +when the wrapper does not export NAME's config. For every instance, a Console `--port` or `--tester-port` that another installed Console unit in the unit directory already takes refuses @@ -676,7 +679,7 @@ its runs alone. No unit passes it, and `--pm` with it is a usage error. ```bash scripts/webui-service.sh copy COMMIT [--copies DIR] # make a pinned copy -# render, install, uninstall and status also take --instance NAME (row 190) +# render, install, uninstall and status also take --instance NAME (row 190); copy refuses it (exit 4) scripts/webui-service.sh render [--copy ID [--copies DIR]] [FLAGS] # print the unit scripts/webui-service.sh install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [FLAGS] scripts/webui-service.sh uninstall [--dir DIR] [--no-reload] @@ -781,7 +784,7 @@ scripts/discord.sh stop scripts/discord.sh unlock scripts/discord.sh recover scripts/discord.sh reload -scripts/discord-service.sh render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status +scripts/discord-service.sh [--instance NAME] render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status ``` One seat's conversation reachable from listed channels of one Discord diff --git a/packages/cli/tests/stack-copy.test.mjs b/packages/cli/tests/stack-copy.test.mjs index 95bb4fc6c..4127a5ead 100644 --- a/packages/cli/tests/stack-copy.test.mjs +++ b/packages/cli/tests/stack-copy.test.mjs @@ -66,6 +66,23 @@ test('wrapper preserves Q81 lease and marker, rejects foreign/symlink; check use 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('row 190 N6: check --instance passes only when the wrapper exports the instance\'s MOSAIC_CONFIG',t=>{ + const s=setup(t);assert.equal(s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]).status,0); + const boot=s.run('bootstrap.sh',['--instance','alpha']);assert.equal(boot.status,0,boot.stderr); + const copy=join(s.copies,s.id),wrapper=join(s.bin,'mosaic-alpha'),cfg=join(s.root,'.config','mosaic-alpha','config.json'); + const w=s.run('stack-copy.sh',['wrapper',s.id,'--instance','alpha','--copies',s.copies,'--bin',s.bin]);assert.equal(w.status,0,w.stderr); + s.fake(`echo 'LoadState=loaded';echo 'WorkingDirectory=${copy}';case "$*" in *mosaic-bus-alpha@demo*) echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start demo ; }';; *mosaic-webui-alpha*) echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';; *) exit 1;; esac\n`); + const check=()=>s.run('stack-copy.sh',['check','--instance','alpha','--copies',s.copies,'--bin',s.bin,'--business','demo']); + const good=readFileSync(wrapper,'utf8');assert.equal(good.split('\n')[3],`export MOSAIC_CONFIG='${cfg}'`); + let r=check();assert.equal(r.status,0,r.stdout+r.stderr);assert.equal(r.stdout,`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy}\n`); + for(const [label,text] of [ + ['another config',good.replace(`export MOSAIC_CONFIG='${cfg}'`,`export MOSAIC_CONFIG='${join(s.root,'.config','mosaic-beta','config.json')}'`)], + ['no config line',good.replace(`export MOSAIC_CONFIG='${cfg}'\n`,'')], + ]){ + writeFileSync(wrapper,text);r=check(); + assert.equal(r.status,1,label);assert.equal(r.stdout,`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy} (MOSAIC_CONFIG mismatch)\n`,label); + } +}); 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']; diff --git a/packages/cli/tests/stack-instances.test.mjs b/packages/cli/tests/stack-instances.test.mjs index f9a1fc3ab..3b71f41e3 100644 --- a/packages/cli/tests/stack-instances.test.mjs +++ b/packages/cli/tests/stack-instances.test.mjs @@ -1,6 +1,6 @@ // Row 190 (#1688): several stack instances on one host. The config and bootstrap cases (T-I1, T-I2, // the config part of T-I7), two real bus hosts side by side (T-I5) and the guard (T-I10). Design and -// T table: agents/darkwing/work/queue-190/DESIGN.md D7. The unit, port and release cases are T55 in +// T table: agents/darkwing/work/queue-190/DESIGN.md D7. The unit, port and release cases are T56 in // stack-release.test.mjs. HOME is a scratch directory in every test, so ~/.config/mosaic-* and // ~/.mosaic-* resolve inside it; nothing here reads or writes ~/.mosaic-dev or ~/.config/mosaic-dev. import { test } from "node:test"; @@ -20,6 +20,8 @@ const SCRIPTS = fileURLToPath(new URL("../../../scripts/", import.meta.url)); const CONFIG = join(SCRIPTS, "mosaic-config.mjs"); const BOOTSTRAP = join(SCRIPTS, "bootstrap.sh"); const BUS_SERVICE = join(SCRIPTS, "bus-service.sh"); +const WEBUI_SERVICE = join(SCRIPTS, "webui-service.sh"); +const STACK_COPY = join(SCRIPTS, "stack-copy.sh"); const MOSAIC = join(SCRIPTS, "mosaic"); // The environment for a scratch home: no MOSAIC_CONFIG unless a test sets one. @@ -276,6 +278,23 @@ test("T-I2 selection: the four rows of D1 through the instance op and through an assert.equal(bad.code, 4); assert.match(bad.err, /instance-name-invalid/); }); + + await t.test("copy takes no --instance in either script: every instance shares the copies", () => { + const copies = join(home, "copies-probe"); + const before = snapshot(home); + for (const [cmd, args, err] of [ + [STACK_COPY, ["--instance", "alpha", "copy", "HEAD", "--copies", copies], /^usage: stack-copy\.sh copy COMMIT/], + [STACK_COPY, ["copy", "HEAD", "--copies", copies, "--instance", "alpha"], /^usage: stack-copy\.sh copy COMMIT/], + [WEBUI_SERVICE, ["--instance", "alpha", "copy", "HEAD", "--copies", copies], /^webui-service: copy takes no --instance; every instance shares the copies\n$/], + [WEBUI_SERVICE, ["copy", "HEAD", "--copies", copies, "--instance", "alpha"], /^webui-service: copy takes no --instance; every instance shares the copies\n$/], + ]) { + const r = run(cmd, args, homeEnv(home)); + assert.equal(r.code, 4, `${args.join(" ")}: ${r.err}`); + assert.equal(r.out, ""); + assert.match(r.err, err, args.join(" ")); + } + assert.deepEqual(snapshot(home), before); + }); }); test("T-I7 claims: the instance op refuses a root another instance claims, and a named root with no marker", async (t) => { diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs index d2c059929..a5d718f33 100644 --- a/packages/cli/tests/stack-release.test.mjs +++ b/packages/cli/tests/stack-release.test.mjs @@ -2236,6 +2236,11 @@ test('R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops t // test above is D1's "no flag, no marker" case and passes unchanged. const shOut=(env,name,...a)=>spawnSync('bash',[join(repo,'scripts',name),...a],{env,encoding:'utf8'}); +// The Console template's comment on the variables its unit sets, as the default and a named instance render it. +const CONSOLE_VARS={ + default:'# and 94). The unit sets no MOSAIC_*, CLAUDE*, CODEX* or PI_* variable.', + named:'# and 94). The unit sets MOSAIC_CONFIG and no other MOSAIC_*, CLAUDE*, CODEX* or PI_* variable.', +}; const SHARED_DROPINS=['mosaic-bus-.service.d','mosaic-webui-.service.d','mosaic-discord-.service.d']; // Every path under each of ps with its mode, size, mtime and, for a file, its sha256. function stamp(...ps){ @@ -2255,7 +2260,7 @@ async function watchDir(e,d){ (e.watches??=[]).push(async()=>{ch.stdin.end();if(ch.exitCode===null)await once(ch,'close');return [d,Number(out.split('\n')[1])]}); } -test('T55 row 190: named instances side by side on one unit directory, $BIN and set of stand-ins',async(t)=>{ +test('T56 row 190: named instances side by side on one unit directory, $BIN and set of stand-ins',async(t)=>{ await t.test('T-I3 each instance renders its own config, session env and Description, and nothing of another',async(t)=>{ const e=await mk(t),a=await named(e,'alpha'),b=await named(e,'beta'); for(const [x,y] of [[a,b],[b,a]]){ @@ -2271,8 +2276,15 @@ test('T55 row 190: named instances side by side on one unit directory, $BIN and for(const not of [y.name,'mosaic-dev'])assert.ok(!r.stdout.includes(not),`${x.name} ${k} holds ${not}`); } assert.ok(renders.bus.stdout.includes(`\nEnvironmentFile=-${dirname(x.cfg)}/secrets/claude-session.env\n`),x.name); + assert.ok(renders.console.stdout.includes(`\n${CONSOLE_VARS.named}\n`),x.name); + assert.ok(!renders.console.stdout.includes('sets no MOSAIC_'),x.name); assert.equal(renders.bus.stdout.match(/^Environment(File)?=.*$/gm).filter((l)=>l.includes('MOSAIC_CONFIG')||l.startsWith('EnvironmentFile')).length,2,x.name); } + // Sage's N3 ruling: the default Console keeps the template's comment byte for byte. + const dflt=shOut(e.env,'webui-service.sh','render','--copy',B,'--copies',COPIES,'--business','acme'); + assert.equal(dflt.status,0,dflt.stderr); + assert.ok(dflt.stdout.includes(`\n${CONSOLE_VARS.default}\n`),dflt.stdout); + assert.ok(!dflt.stdout.includes('MOSAIC_CONFIG'),dflt.stdout); await seed(a);await seed(b); for(const x of [a,b]){ const r=shOut(x.env,'discord-service.sh','install','--instance',x.name,'--dir',e.units,'--no-reload');assert.equal(r.status,0,r.stderr); diff --git a/scripts/instance.sh b/scripts/instance.sh index e37436842..0b2dffb43 100644 --- a/scripts/instance.sh +++ b/scripts/instance.sh @@ -35,8 +35,9 @@ # in a unit template: the default deletes the # @CONFIG_ENV@ line, so its render is byte-identical to # the template before row 190; NAME gets a comment and -# Environment=MOSAIC_CONFIG=, and " [NAME]" after -# the Description +# Environment=MOSAIC_CONFIG=, " [NAME]" after +# the Description, and the Console comment's "sets no +# MOSAIC_*" says it sets MOSAIC_CONFIG (Sage, N3) # # Also set: MOSAIC_INSTANCE (dev or NAME), MOSAIC_INSTANCE_CONFIG, # MOSAIC_INSTANCE_CONFIG_DIR, MOSAIC_INSTANCE_DATA_ROOT (empty when no flag @@ -105,6 +106,7 @@ instance_select() { -e "s|^@CONFIG_ENV@\$|# Stack instance $MOSAIC_INSTANCE (row 190): the system config its processes read.\\nEnvironment=MOSAIC_CONFIG=$MOSAIC_INSTANCE_CONFIG|" -e "s|@SESSION_ENV@|$SESSION_ENV|g" -e "s|^\\(Description=.*\\)\$|\\1 [$MOSAIC_INSTANCE]|" + -e 's|The unit sets no MOSAIC_\*, CLAUDE\*, CODEX\* or PI_\* variable\.$|The unit sets MOSAIC_CONFIG and no other MOSAIC_*, CLAUDE*, CODEX* or PI_* variable.|' ) } diff --git a/scripts/stack-copy.sh b/scripts/stack-copy.sh index 9c8c975c7..0db1a1318 100755 --- a/scripts/stack-copy.sh +++ b/scripts/stack-copy.sh @@ -117,6 +117,8 @@ check() { line="$(tail -n 1 "$wfile")" path="${line#exec \"}";path="${path%\" \"\$@\"}" if [ "$line" = "exec \"$path\" \"\$@\"" ] && [[ "$path" == */scripts/mosaic ]];then cli="${path%/scripts/mosaic}";fi + # A named wrapper must also carry NAME's config, as wrapper_instance writes it. + if [ -n "$MOSAIC_UNIT_SUFFIX" ] && [ "$cli" != absent ] && [ "$(sed -n '4p' "$wfile")" != "export MOSAIC_CONFIG='$MOSAIC_INSTANCE_CONFIG'" ];then cli="$cli (MOSAIC_CONFIG mismatch)";fi fi printf 'bus: %s\nConsole: %s\nCLI: %s\n' "$bus" "$console" "$cli" [[ "$bus" == "$COPIES/"* ]] && [[ "${bus#"$COPIES/"}" =~ ^[0-9a-f]{40}$ ]] && [ -f "$bus/scripts/mosaic" ] && [ "$bus" = "$console" ] && [ "$bus" = "$cli" ] diff --git a/scripts/webui-service.sh b/scripts/webui-service.sh index 36fc87d2d..5f983d04f 100755 --- a/scripts/webui-service.sh +++ b/scripts/webui-service.sh @@ -71,7 +71,9 @@ # and render and install need --port, which may not be 7330, 7332 or 7333 # (exit 4, port-reserved). For every instance, install refuses a --port or # --tester-port that another installed Console unit in the unit directory -# already takes (port-claimed). See scripts/instance.sh. +# already takes (port-claimed). copy refuses --instance (exit 4), as +# stack-copy.sh copy does: every instance shares the copies. See +# scripts/instance.sh. # # Exit codes: 0 ok, 1 operation failed, 3 the data root is claimed by another # instance or not claimed (scripts/instance.sh), 4 usage. @@ -82,10 +84,14 @@ TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in" . "$REPO/scripts/instance.sh" instance_args "$@" set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"} +if [ "${1:-}" = copy ] && [ -n "$INSTANCE_FLAG" ]; then + echo "webui-service: copy takes no --instance; every instance shares the copies" >&2 + exit 4 +fi instance_select UNIT_NAME="$CONSOLE_UNIT" BUS_UNIT_PREFIX="${BUS_TEMPLATE%.service}" -USAGE="usage: scripts/webui-service.sh [--instance NAME] copy COMMIT [--copies DIR] [--allow-worktree] | render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR] +USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | [--instance NAME] render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR] FLAGS: [--port N] [--business ID] [--board URL] [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH] --tester-port: 1024..65535, different from --port and never 7332; loopback tester listener. --allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout."