gosalyn: row 230 r2, a case for each precheck refusal webby-0046 B1 names, the no-claude-code case without /usr/bin/claude, a re-install over running units says they stay on the old copy and must restart together (N1, sage-1589), README rewrap, row 195 carry items
This commit is contained in:
1 parent
f2a2197906
commit
96768766c0
4 files changed
+150
-18
No files matched your search
@@ -43,7 +43,7 @@ Sage accepted all six for the build (sage-1570). Webby rules on each.
|
||||
6. With `--auth-header X-Authentik-Uid`, the allowed user must be 64 lower-case hex characters (lead
|
||||
decision 101). The test covers both the refusal and the pass.
|
||||
|
||||
## Tests (packages/cli/tests/case2-unit.test.mjs, 28 cases)
|
||||
## Tests (packages/cli/tests/case2-unit.test.mjs, 30 cases after round 2)
|
||||
|
||||
The tests use a scratch unit directory and a scratch home, plus fake systemctl, getent, sudo, id,
|
||||
node, bwrap and claude. They never touch /etc/systemd, ~/.mosaic-dev, ~/.config/mosaic-dev or a
|
||||
@@ -65,6 +65,40 @@ live unit. Every precheck case asserts its output doesn't hold the fixture key.
|
||||
|
||||
## Verification
|
||||
|
||||
Singles, run before the gate: case2-unit.test.mjs passes 28/28 with no skips, and host.test.mjs (it
|
||||
Round 1 singles, run before the gate: case2-unit.test.mjs passes 28/28 with no skips, and host.test.mjs (it
|
||||
reads the cli README) passes 42/42. No test pins a hash of the README. No other test reads the two
|
||||
scripts.
|
||||
|
||||
## Round 2 (webby-0046, sage-1589, sage-1590, sage-1591)
|
||||
|
||||
Base: origin/refactor (rebased from 00d4cc1f6; the 15 commits between touch none of the five paths
|
||||
or the two unit templates). Branch gosalyn/c2-13a-r2.
|
||||
|
||||
B1. The precheck now has a case for each refusal Webby named: the user missing from passwd (:77),
|
||||
a business file that is unparsable, missing or fails the schema (:137), a run root that is a symlink
|
||||
to a 0700 directory (:151), a Console ExecStart with no --port (:179), an unreadable port floor
|
||||
(:180), a claude reply without the marker (:223) and a claude reply that isn't JSON (:224). The fake
|
||||
claude gained `nomarker` and `text` kinds. The no-claude-code case and the missing-claude case share
|
||||
a PATH without `/usr/bin/claude` (`pathWithoutClaude`), so the no-claude-code case now shows a host
|
||||
with no claude passes.
|
||||
|
||||
N1 (ruled in for this round). Before writing, `case2-unit.sh` asks `systemctl is-active` for both
|
||||
units. For each unit that was running, it prints that the unit is still on its old copy, never
|
||||
"started", and then that the bus and Console must restart together to reach the new copy. A unit
|
||||
that wasn't running gets "started ... on copy". A fresh install prints the round 1 line. The script
|
||||
still restarts nothing. The new test covers both units running and only the Console running.
|
||||
|
||||
N4. The README line at :713 is rewrapped.
|
||||
|
||||
Mutants, run in Sage's slot on a scratch worktree, one branch each: :77, :137, :151, :179, :180,
|
||||
:223, :224, a precheck that refuses with no claude on PATH (for the no-claude-code case), and an
|
||||
install that prints "started" for a running unit (sage-1591).
|
||||
|
||||
## Carry items for row 195
|
||||
|
||||
- N1's restart. Row 195 restarts the bus and Console together after a re-install over running
|
||||
units, as the install line now asks.
|
||||
- N2. A system drop-in check like `bus-service.sh:122-145`, so a drop-in under
|
||||
`[email protected]/` or `mosaic-webui.service.d/` can't change what the checked unit runs.
|
||||
- N3. The precheck finds claude on the shell PATH. The bus unit's `PATH=` is what a session uses
|
||||
when `MOSAIC_CLAUDE_BIN` is unset, so the check should search that PATH.
|
||||
@@ -710,8 +710,9 @@ The session credential comes from
|
||||
`EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env`, the
|
||||
one place the path is set. The file holds one line, for example
|
||||
`CLAUDE_CODE_OAUTH_TOKEN=<token>`, mode 0600. The operator writes it;
|
||||
nothing in the repository creates it, only the Case 2 precheck reads it, and it prints only variable names, never values. No session sees the
|
||||
file itself, only the variable. The leading `-` lets the unit start
|
||||
nothing in the repository creates it, and only the Case 2 precheck reads
|
||||
it, printing variable names, never values. No session sees the file
|
||||
itself, only the variable. The leading `-` lets the unit start
|
||||
without it, so Pi sessions still launch and a Claude Code launch refuses
|
||||
`no-session-credential`. To change the credential, edit or swap the file
|
||||
(or this line and this paragraph), then restart the unit; a running host
|
||||
|
||||
@@ -68,7 +68,9 @@ function hub(t, { uid = UID } = {}) {
|
||||
group) [ "$2" = "${GID}" ] && echo "mosaic:x:${GID}:" ;;
|
||||
*) exit 2 ;;
|
||||
esac`);
|
||||
// is-active answers yes for each unit named in CASE2_TEST_ACTIVE.
|
||||
script(join(bin, "systemctl"), `echo "$*" >>'${log}'
|
||||
if [ "$1" = is-active ]; then case " \${CASE2_TEST_ACTIVE:-} " in *" $3 "*) exit 0 ;; *) exit 3 ;; esac; fi
|
||||
[ "$*" != "\${CASE2_TEST_FAIL:-}" ]`);
|
||||
return { root, home, secrets, proof, units, bin, log };
|
||||
}
|
||||
@@ -132,7 +134,10 @@ test("a clean library render writes both units 0644 with the fixed lines, and en
|
||||
assert.deepEqual(lines(bus, "CapabilityBoundingSet"), []);
|
||||
assert.deepEqual(lines(webui, "AmbientCapabilities"), ["CAP_NET_BIND_SERVICE"]);
|
||||
assert.deepEqual(lines(webui, "CapabilityBoundingSet"), ["CAP_NET_BIND_SERVICE"]);
|
||||
assert.match(r.out, /enabled and started mosaic-bus@acme\.service, then mosaic-webui\.service\n$/);
|
||||
assert.deepEqual(read(h.log).trim().split("\n"), [
|
||||
"is-active --quiet [email protected]",
|
||||
"is-active --quiet mosaic-webui.service",
|
||||
"daemon-reload",
|
||||
"enable [email protected] mosaic-webui.service",
|
||||
"start [email protected]",
|
||||
@@ -152,7 +157,7 @@ test("a failed bus start refuses with the Console not started", (t) => {
|
||||
const h = hub(t);
|
||||
const r = unit(h, { env: { CASE2_TEST_FAIL: "start [email protected]" } });
|
||||
refuses(r, /start mosaic-bus@acme\.service failed; the Console is not started/);
|
||||
assert.deepEqual(read(h.log).trim().split("\n"), ["daemon-reload", "enable [email protected] mosaic-webui.service", "start [email protected]"]);
|
||||
assert.deepEqual(read(h.log).trim().split("\n").slice(2), ["daemon-reload", "enable [email protected] mosaic-webui.service", "start [email protected]"]);
|
||||
});
|
||||
|
||||
test("the script never links a unit file", () => {
|
||||
@@ -177,6 +182,26 @@ test("factory renders --pm once and one MOSAIC_CLAUDE_BIN line, and without them
|
||||
assert.doesNotMatch(text, /MOSAIC_CLAUDE_BIN|@PM_FLAGS@|@CLAUDE_ENV@/);
|
||||
});
|
||||
|
||||
test("a re-install over running units says they are still on the old copy and must restart together, never that it started them", (t) => {
|
||||
const h = hub(t);
|
||||
assert.equal(unit(h).code, 0);
|
||||
const both = unit(h, { copy: COPY_B, env: { CASE2_TEST_ACTIVE: "[email protected] mosaic-webui.service" } });
|
||||
assert.equal(both.code, 0, both.err);
|
||||
assert.doesNotMatch(both.out, /started/);
|
||||
assert.match(both.out, new RegExp(`mosaic-bus@acme\\.service was already running, so it is still on copy ${COPY_A}, not copy ${COPY_B}`));
|
||||
assert.match(both.out, new RegExp(`mosaic-webui\\.service was already running, so it is still on copy ${COPY_A}, not copy ${COPY_B}`));
|
||||
assert.match(both.out, new RegExp(`restart mosaic-bus@acme\\.service and mosaic-webui\\.service together to put both on copy ${COPY_B}`));
|
||||
assert.deepEqual(read(h.log).trim().split("\n").slice(-2), ["start [email protected]", "start mosaic-webui.service"]);
|
||||
|
||||
// Only the Console ran (the bus had stopped): the bus starts on the new copy, the Console stays.
|
||||
const one = unit(h, { copy: COPY_A, env: { CASE2_TEST_ACTIVE: "mosaic-webui.service" } });
|
||||
assert.equal(one.code, 0, one.err);
|
||||
assert.match(one.out, new RegExp(`started mosaic-bus@acme\\.service on copy ${COPY_A}`));
|
||||
assert.match(one.out, new RegExp(`mosaic-webui\\.service was already running, so it is still on copy ${COPY_B}, not copy ${COPY_A}`));
|
||||
assert.doesNotMatch(one.out, /started mosaic-webui/);
|
||||
assert.match(one.out, /restart mosaic-bus@acme\.service and mosaic-webui\.service together/);
|
||||
});
|
||||
|
||||
test("a re-install to another copy re-renders both units, and an edited installed unit refuses", (t) => {
|
||||
const h = hub(t);
|
||||
assert.equal(unit(h).code, 0);
|
||||
@@ -413,16 +438,27 @@ function host(t, { roles = {}, vars = {}, keyFile = `ZAI_API_KEY=${FIXTURE_KEY}\
|
||||
return { root, home, configDir, config, secrets, units, sys, bin, dataRoot: join(home, ".mosaic-dev") };
|
||||
}
|
||||
|
||||
// A fake claude: replace answers with the marker and a small prompt; append answers with a large one.
|
||||
// A fake claude: replace answers with the marker and a small prompt; append answers with a large one;
|
||||
// nomarker answers with a small prompt and no marker; text answers with plain text, not JSON.
|
||||
function fakeClaude(dir, kind) {
|
||||
return script(join(dir, "claude"), `[ "$1" = --version ] && { echo "9.9.9 (Claude Code, ${kind} fake)"; exit 0; }
|
||||
file=''
|
||||
while [ $# -gt 0 ]; do [ "$1" = --system-prompt-file ] && file="$2"; shift; done
|
||||
marker="$(awk '{ print $NF }' "$file")"
|
||||
tokens=${kind === "replace" ? 40 : 9000}
|
||||
${kind === "nomarker" ? "marker=something-else" : ""}
|
||||
${kind === "text" ? 'echo "$marker"; exit 0' : ""}
|
||||
tokens=${kind === "append" ? 9000 : 40}
|
||||
printf '{"result":"%s","usage":{"input_tokens":%s,"cache_read_input_tokens":0}}\\n' "$marker" "$tokens"`);
|
||||
}
|
||||
|
||||
// This host may have a claude in /usr/bin, so this PATH links every /usr/bin entry but that one.
|
||||
function pathWithoutClaude(x) {
|
||||
const usr = join(x.root, "usr-bin");
|
||||
mkdirSync(usr);
|
||||
for (const name of readdirSync("/usr/bin")) if (name !== "claude") symlinkSync(join("/usr/bin", name), join(usr, name));
|
||||
return `${x.bin}:${usr}`;
|
||||
}
|
||||
|
||||
function precheck(x, { profile = "library", env = {}, path } = {}) {
|
||||
const r = spawnSync(PRECHECK, ["--business", "acme", "--profile", profile], {
|
||||
encoding: "utf8",
|
||||
@@ -482,6 +518,36 @@ test("the precheck refuses each failed host check", (t) => {
|
||||
blocked(precheck(bad), /REFUSED config:/);
|
||||
});
|
||||
|
||||
test("the precheck refuses a user missing from passwd, a business file that does not load, and a symlinked run root", (t) => {
|
||||
const nouser = host(t);
|
||||
script(join(nouser.bin, "getent"), "exit 2");
|
||||
blocked(precheck(nouser), /REFUSED user: no .* in the passwd database/);
|
||||
|
||||
const unparsed = host(t);
|
||||
writeFileSync(join(unparsed.configDir, "businesses", "acme.json"), "{ not json");
|
||||
blocked(precheck(unparsed), /REFUSED business file: cannot read .*acme\.json/);
|
||||
const gone = host(t);
|
||||
rmSync(join(gone.configDir, "businesses", "acme.json"));
|
||||
blocked(precheck(gone), /REFUSED business file: cannot read .*acme\.json: ENOENT/);
|
||||
const invalid = host(t);
|
||||
const file = join(invalid.configDir, "businesses", "acme.json");
|
||||
const doc = JSON.parse(read(file));
|
||||
doc.notAField = true;
|
||||
writeJson(file, doc);
|
||||
const r = precheck(invalid);
|
||||
blocked(r, /REFUSED business file: /);
|
||||
assert.doesNotMatch(r.out, /REFUSED business file: cannot read/);
|
||||
|
||||
// stat follows a link, so a link to a 0700 directory would read as 0700.
|
||||
const link = host(t);
|
||||
const target = join(link.root, "elsewhere");
|
||||
mkdirSync(target, { mode: 0o700 });
|
||||
chmodSync(target, 0o700);
|
||||
mkdirSync(join(link.dataRoot, "launches"), { recursive: true });
|
||||
symlinkSync(target, join(link.dataRoot, "launches", "acme"));
|
||||
blocked(precheck(link), /REFUSED run root .* is a symlink/);
|
||||
});
|
||||
|
||||
test("a bwrap that fails refuses and prints the sysctl and where bwrap is", (t) => {
|
||||
const x = host(t);
|
||||
blocked(precheck(x, { env: { CASE2_TEST_BWRAP: "1" } }), new RegExp(`REFUSED bwrap .* failed; bwrap: ${join(x.bin, "bwrap")}; kernel\\.apparmor_restrict_unprivileged_userns: 1`));
|
||||
@@ -496,6 +562,12 @@ test("an unprivileged port floor at or below the Console's port refuses, and so
|
||||
const x = host(t);
|
||||
rmSync(join(x.units, "mosaic-webui.service"));
|
||||
blocked(precheck(x), /REFUSED no installed Console unit/);
|
||||
const noPort = host(t);
|
||||
writeFileSync(join(noPort.units, "mosaic-webui.service"), "[Service]\nExecStart=/usr/bin/node /x/cli.mjs serve --business acme\n");
|
||||
blocked(precheck(noPort), /REFUSED .*mosaic-webui\.service has no --port in its ExecStart/);
|
||||
const noFloor = host(t);
|
||||
rmSync(join(noFloor.sys, "net", "ipv4", "ip_unprivileged_port_start"));
|
||||
blocked(precheck(noFloor), /REFUSED cannot read net\.ipv4\.ip_unprivileged_port_start/);
|
||||
});
|
||||
|
||||
test("bus.tester: library refuses it on, factory needs it on, and factory refuses a library business", (t) => {
|
||||
@@ -511,11 +583,12 @@ test("bus.tester: library refuses it on, factory needs it on, and factory refuse
|
||||
blocked(precheck(lib, { profile: "factory" }), /REFUSED profile-library-business: .* projects stack are library projects/);
|
||||
});
|
||||
|
||||
test("the claude check runs only for a claude-code instance, and refuses a claude that appends", (t) => {
|
||||
test("the claude check runs only for a claude-code instance, and refuses a claude that appends or answers wrong", (t) => {
|
||||
// No claude-code instance and no claude anywhere on PATH: the precheck passes.
|
||||
const none = host(t);
|
||||
const r = precheck(none);
|
||||
const r = precheck(none, { path: pathWithoutClaude(none) });
|
||||
assert.equal(r.code, 0, r.out);
|
||||
assert.doesNotMatch(r.out, /--system-prompt-file/);
|
||||
assert.doesNotMatch(r.out, /--system-prompt-file|claude is installed/);
|
||||
|
||||
const good = host(t, { roles: { cto: { harness: "claude-code" } } });
|
||||
fakeClaude(good.bin, "replace");
|
||||
@@ -528,12 +601,16 @@ test("the claude check runs only for a claude-code instance, and refuses a claud
|
||||
fakeClaude(bad.bin, "append");
|
||||
blocked(precheck(bad), /REFUSED claude adds to its own prompt with --system-prompt-file: 9000 input tokens/);
|
||||
|
||||
// This host may have a claude in /usr/bin, so the PATH here links every /usr/bin entry but that one.
|
||||
const nomarker = host(t, { roles: { cto: { harness: "claude-code" } } });
|
||||
fakeClaude(nomarker.bin, "nomarker");
|
||||
blocked(precheck(nomarker), /REFUSED claude's reply to the marker prompt lacks the marker \(40 input tokens\)/);
|
||||
|
||||
const text = host(t, { roles: { cto: { harness: "claude-code" } } });
|
||||
fakeClaude(text.bin, "text");
|
||||
blocked(precheck(text), /REFUSED claude gave no JSON reply to the marker prompt/);
|
||||
|
||||
const missing = host(t, { roles: { cto: { harness: "claude-code" } } });
|
||||
const usr = join(missing.root, "usr-bin");
|
||||
mkdirSync(usr);
|
||||
for (const name of readdirSync("/usr/bin")) if (name !== "claude") symlinkSync(join("/usr/bin", name), join(usr, name));
|
||||
blocked(precheck(missing, { path: `${missing.bin}:${usr}` }), /REFUSED an instance runs claude-code and no claude is installed/);
|
||||
blocked(precheck(missing, { path: pathWithoutClaude(missing) }), /REFUSED an instance runs claude-code and no claude is installed/);
|
||||
});
|
||||
|
||||
test("the claude check runs the bus unit's MOSAIC_CLAUDE_BIN over claude on PATH", (t) => {
|
||||
|
||||
+23
-3
@@ -27,7 +27,9 @@
|
||||
#
|
||||
# On a re-install the installed units must equal the render in everything
|
||||
# but the copy id, and both must name one copy, so a re-pin or rollback
|
||||
# re-renders the pair together.
|
||||
# re-renders the pair together. A unit that was already running stays on its
|
||||
# old copy, since `systemctl start` leaves it alone; the script says so and
|
||||
# that the bus and Console must restart together, and restarts nothing.
|
||||
#
|
||||
# Test seams, for packages/cli/tests/case2-unit.test.mjs only:
|
||||
# CASE2_UNIT_DIR the unit directory, default /etc/systemd/system;
|
||||
@@ -384,6 +386,14 @@ done
|
||||
read -r -a ids <<<"$installed_ids"
|
||||
[ "${#ids[@]}" -lt 2 ] || [ "${ids[0]}" = "${ids[1]}" ] || die "the installed units name different copies, ${ids[0]} and ${ids[1]}"
|
||||
|
||||
# `systemctl start` leaves a running unit on the copy it started from, so
|
||||
# note which units run before the write (row 195 owns the restart).
|
||||
BUS_NAME="mosaic-bus@$BUSINESS.service"
|
||||
running=''
|
||||
for name in "$BUS_NAME" "$WEBUI_UNIT"; do
|
||||
if "$SYSTEMCTL" is-active --quiet "$name"; then running="$running $name"; fi
|
||||
done
|
||||
|
||||
# Write each unit to a temp file in the unit directory, then rename.
|
||||
write_unit() {
|
||||
local name="$1" text="$2" tmp
|
||||
@@ -397,9 +407,19 @@ write_unit "$BUS_UNIT" "$BUS_TEXT"
|
||||
write_unit "$WEBUI_UNIT" "$WEBUI_TEXT"
|
||||
# Enable both, then start the bus, then the Console (design 10 step 2c).
|
||||
# serve calls the bus per request, so the units carry no After= or Wants=.
|
||||
BUS_NAME="mosaic-bus@$BUSINESS.service"
|
||||
"$SYSTEMCTL" daemon-reload || die "systemctl daemon-reload failed; the units are written, nothing is enabled or started"
|
||||
"$SYSTEMCTL" enable "$BUS_NAME" "$WEBUI_UNIT" || die "systemctl enable $BUS_NAME $WEBUI_UNIT failed; nothing is started"
|
||||
"$SYSTEMCTL" start "$BUS_NAME" || die "systemctl start $BUS_NAME failed; the Console is not started"
|
||||
"$SYSTEMCTL" start "$WEBUI_UNIT" || die "systemctl start $WEBUI_UNIT failed; the bus is running"
|
||||
echo "case2-unit: wrote $UNIT_DIR/$BUS_UNIT and $UNIT_DIR/$WEBUI_UNIT from copy $COPY_ID; enabled and started $BUS_NAME, then $WEBUI_UNIT"
|
||||
if [ -z "$running" ]; then
|
||||
echo "case2-unit: wrote $UNIT_DIR/$BUS_UNIT and $UNIT_DIR/$WEBUI_UNIT from copy $COPY_ID; enabled and started $BUS_NAME, then $WEBUI_UNIT"
|
||||
exit 0
|
||||
fi
|
||||
echo "case2-unit: wrote $UNIT_DIR/$BUS_UNIT and $UNIT_DIR/$WEBUI_UNIT from copy $COPY_ID and enabled both"
|
||||
for name in "$BUS_NAME" "$WEBUI_UNIT"; do
|
||||
case " $running " in
|
||||
*" $name "*) echo "case2-unit: $name was already running, so it is still on ${ids[0]:+copy }${ids[0]:-the copy it started from}, not copy $COPY_ID" ;;
|
||||
*) echo "case2-unit: started $name on copy $COPY_ID" ;;
|
||||
esac
|
||||
done
|
||||
echo "case2-unit: restart $BUS_NAME and $WEBUI_UNIT together to put both on copy $COPY_ID; until then they may run different copies"
|
||||
Reference in new issue
Block a user