diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 8fecefb40..20be532e9 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -291,7 +291,8 @@ Failure publishes nothing and removes the temporary tree. `wrapper` is a live switching step, reserved for Jason or Mos. It accepts only an existing copy and a regular non-symlink mosaic wrapper with the Q81 marker on line 2 and its exact lease line to mosaic-old. It preserves -both lines and atomically replaces the wrapper's final exec with the +both lines, adds comments naming the copy root and Q81's mosaic-old lease +probe, and atomically replaces the wrapper's final exec with the copy's scripts/mosaic. --bin is a fixture override; the default is ~/.npm-global/bin. Neither the Q81 worktree nor mosaic-old is removed. `check` changes nothing: it reads effective systemd WorkingDirectory and @@ -305,7 +306,8 @@ Bus render/install accept --copy ID, --copies DIR, --pm and the unchanged --pm adds the PM startup; a refusal exits 3, and RestartPreventExitStatus prevents a retry. Install refuses any busy bus instance, even with --no-reload; a failed state/drop-in read also refuses. Any template or -instance drop-in assigning ExecStart or WorkingDirectory refuses, +instance drop-in, or inherited mosaic-.service.d/ or service.d/ drop-in, +assigning ExecStart or WorkingDirectory refuses, naming the file. Status reads effective roots, --pm and DropInPaths, reports checkout or copy identity and refactor drift, and writes nothing. diff --git a/packages/cli/README.md b/packages/cli/README.md index 4c05b4213..fb4532c84 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -470,11 +470,25 @@ mosaic-stack/copies/. 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. +state reads and drop-ins overriding ExecStart or WorkingDirectory, including +inherited mosaic-.service.d/ and service.d/ in every unit lookup directory. 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 wrapper keeps the Q81 marker on line 2 and the final copy exec on the +last line. Comments name the copy root and explain that the Q81 lease probe +still answers at mosaic-old. + +Copy npm ci may warn that @google/genai's preinstall, esbuild's postinstall +and protobufjs's postinstall are blocked. This is expected for the current +copy paths: Mosaic launches Pi's published bundled CLI, whose runtime does +not load those installed package entrypoints. @google/genai's preinstall +only echoes; esbuild's script installs/checks its build binary; protobufjs's +script only warns about dependency version schemes. The copy runs the +already published bundle and does not build it. No allowScripts change is +needed for these paths; adding a runtime loader requires a fresh audit. + 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 diff --git a/packages/cli/tests/host.test.mjs b/packages/cli/tests/host.test.mjs index 6a1170357..63dab06c3 100644 --- a/packages/cli/tests/host.test.mjs +++ b/packages/cli/tests/host.test.mjs @@ -828,7 +828,7 @@ test("bus-service.sh renders the unit and installs it into a given directory", ( // A user unit cannot order on a system target (Darkwing F3). assert.doesNotMatch(render.stdout, /network-online/); 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,"systemctl"),"#!/bin/sh\ncase \"$*\" in *list-units*) exit 0;; *)exit 99;; esac\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 }); @@ -852,7 +852,7 @@ 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","systemctl"),'#!/bin/sh\ncase "$*" in *list-units*) exit 0;; *)exit 99;; esac\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")); diff --git a/packages/cli/tests/stack-copy.test.mjs b/packages/cli/tests/stack-copy.test.mjs index 8460b9198..d98e771ac 100644 --- a/packages/cli/tests/stack-copy.test.mjs +++ b/packages/cli/tests/stack-copy.test.mjs @@ -46,6 +46,11 @@ test('wrapper preserves Q81 lease and marker, rejects foreign/symlink; check use 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); + const lines=readFileSync(wrapper,'utf8').trimEnd().split('\n'); + assert.equal(lines[1],marker); + assert.equal(lines[2],`# CLI runs from pinned copy: ${join(s.copies,s.id)}`); + assert.equal(lines[3],'# The Q81 lease probe still answers at mosaic-old.'); + assert.equal(lines.at(-1),`exec "${join(s.copies,s.id)}/scripts/mosaic" "$@"`); 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'); @@ -54,7 +59,11 @@ test('wrapper preserves Q81 lease and marker, rejects foreign/symlink; check use 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 foreign=`#!/usr/bin/env bash\n# foreign wrapper\n${lease}\nexec \"${copy}/scripts/mosaic\" \"$@\"\n`;writeFileSync(wrapper,foreign); + // Restore matching effective roots: only the foreign marker refuses check. + s.fake(`echo 'LoadState=loaded';echo 'WorkingDirectory=${copy}';case "$*" in *mosaic-bus*) echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/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); + 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=>{ @@ -65,7 +74,7 @@ test('bus unit copy/pm pins, install refuses all busy states and failed reads, d for(const state of ['active','activating','deactivating','reloading']){s.fake(`echo 'mosaic-bus@demo.service 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,'mosaic-bus@.service.d');mkdirSync(dropdir);const drop=join(dropdir,'pm.conf');writeFileSync(drop,'[Service]\nExecStart=elsewhere\n'); - s.fake(`case "$*" in *list-units*) echo 'mosaic-bus@demo.service loaded inactive dead fixture';; *show*) echo 'LoadState=loaded';echo 'DropInPaths=${drop}';; esac\n`); + s.fake(`case "$*" in *list-units*) echo 'mosaic-bus@demo.service loaded inactive dead fixture';; *)exit 99;; 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); }); @@ -151,7 +160,7 @@ test('drop-ins in either override field and malformed metadata refuse; uninstall const s=setup(t),units=join(s.root,'units'),dropdir=join(s.search,'mosaic-bus@demo.service.d');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 'mosaic-bus@demo.service loaded inactive dead fixture';; *mosaic-bus@demo.service*) echo 'DropInPaths=${drop}';; *)echo DropInPaths=;;esac\n`); + s.fake(`case "$*" in *list-units*) echo 'mosaic-bus@demo.service loaded inactive dead fixture';; *)exit 99;;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/); @@ -162,9 +171,11 @@ test('drop-ins in either override field and malformed metadata refuse; uninstall 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*mosaic-bus@.service*) echo 'neither a valid invocation ID nor unit name' >&2; exit 1;; *list-units*) echo 'mosaic-bus@demo.service loaded inactive dead fixture';; *show*)echo DropInPaths=;; esac\n`); + s.fake(`echo "$*" >> '${calls}'\ncase "$*" in *show*mosaic-bus@.service*) echo 'neither a valid invocation ID nor unit name' >&2; exit 1;; *list-units*) echo 'mosaic-bus@demo.service loaded inactive dead fixture';; *)exit 99;; 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/); + // This rejection arm is deliberately exercised; it is not a dead show reply. + assert.equal(spawnSync(join(s.bin,'systemctl'),['--user','show','mosaic-bus@.service'],{env:s.env}).status,1); const dropdir=join(s.search,'mosaic-bus@.service.d');mkdirSync(dropdir);const file=join(dropdir,'pm.conf');writeFileSync(file,'[Service]\nExecStart=override\n'); const before=readFileSync(join(units,'mosaic-bus@.service'),'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,'mosaic-bus@.service'),'utf8'),before); @@ -173,7 +184,29 @@ test('unlisted instance drop-in is found in another unit lookup directory; path const s=setup(t),units=join(s.root,'units'),second=join(s.root,'second-search');mkdirSync(second); const dropdir=join(second,'mosaic-bus@unlisted.service.d');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 'mosaic-bus@demo.service loaded inactive dead fixture';; *show*)echo DropInPaths=;; esac\n"); + s.fake("case \"$*\" in *list-units*) echo 'mosaic-bus@demo.service loaded inactive dead fixture';; *)exit 99;; 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)); }); + +// Row 108: inherited drop-ins apply even without a loaded bus instance. +for(const name of ['mosaic-.service.d','service.d'])test(`inherited ${name} override refuses without changing the unit`,t=>{ + const s=setup(t),units=join(s.root,'units');mkdirSync(units); + const target=join(units,'mosaic-bus@.service');writeFileSync(target,'previous unit bytes'); + s.fake('exit 0\n'); + const dropdir=join(s.search,name);mkdirSync(dropdir);const file=join(dropdir,'override.conf'); + for(const field of ['WorkingDirectory','ExecStart']){ + writeFileSync(file,`[Service]\n${field}=/other\n`); + const denied=s.run('bus-service.sh',['install','--dir',units,'--no-reload']); + assert.equal(denied.status,1,denied.stderr);assert.ok(denied.stderr.includes(file));assert.equal(readFileSync(target,'utf8'),'previous unit bytes'); + } + writeFileSync(file,'[Service]\nEnvironment=SAFE=1\n'); + const allowed=s.run('bus-service.sh',['install','--dir',units,'--no-reload']);assert.equal(allowed.status,0,allowed.stderr); +}); +test('inherited drop-in unreadable directory or file refuses before writing',t=>{ + const s=setup(t),units=join(s.root,'units');mkdirSync(units);const target=join(units,'mosaic-bus@.service');writeFileSync(target,'previous unit bytes');s.fake('exit 0\n'); + const dropdir=join(s.search,'service.d');symlinkSync(join(s.root,'absent-dir'),dropdir); + const dir=s.run('bus-service.sh',['install','--dir',units,'--no-reload']);assert.equal(dir.status,1);assert.ok(dir.stderr.includes(dropdir));assert.equal(readFileSync(target,'utf8'),'previous unit bytes'); + unlinkSync(dropdir);mkdirSync(dropdir);const file=join(dropdir,'broken.conf');symlinkSync(join(s.root,'absent-file'),file); + const broken=s.run('bus-service.sh',['install','--dir',units,'--no-reload']);assert.equal(broken.status,1);assert.ok(broken.stderr.includes(file));assert.equal(readFileSync(target,'utf8'),'previous unit bytes'); +}); diff --git a/scripts/bus-service.sh b/scripts/bus-service.sh index 297c633b2..f71250204 100755 --- a/scripts/bus-service.sh +++ b/scripts/bus-service.sh @@ -7,8 +7,8 @@ # business (mosaic-bus@). Nothing here reads a capability, the # binding or the token. See packages/cli/README.md, "Bus host". # -# render [--claude PATH] print the rendered unit on stdout -# install [--dir DIR] [--no-reload] [--claude PATH] +# render [--copy ID [--copies DIR]] [--pm] [--claude PATH] print the rendered unit on stdout +# install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH] # write the unit (write to a temp file, then # rename) and run `systemctl --user daemon-reload` # uninstall [--dir DIR] [--no-reload] @@ -115,7 +115,7 @@ install_guard() { [ -e "$directory" ] || [ -L "$directory" ] || continue [ -d "$directory" ] && [ -r "$directory" ] && [ -x "$directory" ] || die "refusing: cannot inspect unit path $directory; nothing was changed" # * includes the empty instance, i.e. mosaic-bus@.service.d. - for dropdir in "$directory"/mosaic-bus@*.service.d; do + for dropdir in "$directory"/mosaic-bus@*.service.d "$directory"/mosaic-.service.d "$directory"/service.d; do [ -e "$dropdir" ] || [ -L "$dropdir" ] || continue [ -d "$dropdir" ] && [ -r "$dropdir" ] && [ -x "$dropdir" ] || die "refusing: cannot inspect drop-ins $dropdir; nothing was changed" for file in "$dropdir"/*.conf; do diff --git a/scripts/stack-copy.sh b/scripts/stack-copy.sh index a3b6f9143..f384a983d 100755 --- a/scripts/stack-copy.sh +++ b/scripts/stack-copy.sh @@ -66,7 +66,7 @@ wrapper() { [ "$(grep -Fxc "$lease" "$file" || true)" = 1 ] || die 'refusing: Q81 lease line missing or ambiguous' tmp="$(mktemp "$BIN/.mosaic.XXXXXX")" trap 'rm -f "$tmp"' EXIT - printf '#!/usr/bin/env bash\n%s\n%s\nexec "%s/scripts/mosaic" "$@"\n' "$MARKER" "$lease" "$root" > "$tmp" + printf '#!/usr/bin/env bash\n%s\n# CLI runs from pinned copy: %s\n# The Q81 lease probe still answers at mosaic-old.\n%s\nexec "%s/scripts/mosaic" "$@"\n' "$MARKER" "$root" "$lease" "$root" > "$tmp" chmod 0755 "$tmp";mv -f "$tmp" "$file";trap - EXIT echo "wrapper: $root" }