gosalyn: row 226 C2-13b build, case2-install.sh writes the business file and personas through a plan step before any write, case2-precheck.sh adds the D6a mode, persona and Discord checks and --library-status with lastError first and the wider escape, case2-install.test.mjs, run sheet lines; candidate before the gate (webby-0087 N2 N3, sage-2195, sage-2205, #1727)

This commit is contained in:
jason.woltje committed 2026-10-23 13:50:45 -05:00
1 parent 893b4fd250
commit f3fabf51bd
5 files changed
+859 -5

No files matched your search

+98
View File
@@ -231,3 +231,101 @@ names are hers, not text.
- N5: `problem` prefix and a `problems` count.
- N6: the run sheet lines are :240 and :275.
- New D8, the design 10 text change, and its path in D10.
## Build
Webby approved d2 in webby-0087 (REVIEW-design-d2.md, webby/reviews 2f488b4ea). Nothing above this
section changed after approval. This section records the build.
### D8 lands on case2-design
Sage ruled in sage-2195, after Webby's N1, that D8 goes on gosalyn/case2-design, because
agents/gosalyn/work/case2/DESIGN.md isn't on refactor. The change is commit 624212842 there, pushed:
step 2b drops `release.sh package` and `activate`, new step 4a runs `--library-status`, and the C2-13b
row text says the precheck reads the bus's recorded sync state. DESIGN.md is not in this row's manifest.
The brief's lines docs/plans/2026-10-20_case2-rows.md:1093-1096 still name release package, activate and
a library.sync fetch. Sage owns the brief and will amend those lines to match D8 in a doc-only commit
after the re-pin window (sage-2205). They are not in this row's manifest.
### Webby's d2 notes
- N2: the `problem` line keeps the path. After `JSON.stringify` the precheck also escapes DEL, the C1
controls U+0080-009F, U+200E and U+200F, U+2028 and U+2029, and the bidi controls U+202A-202E and
U+2066-2069 as `\uXXXX`. Webby named C1 and bidi. I added the rest because `JSON.stringify` leaves
them raw too, and each one can change how a terminal shows the line. The test path holds U+202E and
U+0085, and the test checks that neither prints raw.
- N3: `lastError` is checked before `lastFetch`. A first sync with no token records
`library-credential-missing` and leaves `lastFetch` null, so it refuses `library-last-error`, not
`library-never-synced`. The fresh-mirror test has `lastError` null and refuses `library-never-synced`.
### Choices made in the build
- The config dir. The persona import refuses a config dir with group or other bits, and bootstrap makes
one with the default mode. So the install makes the config dir at 0700 when it's absent, before
bootstrap, and refuses `config-dir-mode` when it exists with a group or other bit. It checks
`secrets/` and `secrets/<id>/` the same way under library (`secrets-mode`, `slot-mode`).
- `--home` must equal `$HOME` (`home-mismatch`). Every path comes from `$HOME` and `MOSAIC_CONFIG`, as
bootstrap's does, so a different `--home` would only name a second home the script doesn't use.
- A plan step runs before anything is written. It reads the input and the staging manifest, computes
the persona vars from the manifest, and compares the result with what's on disk. It refuses every
input rule, `business-file-differs` and `persona-differs` there, so a refused run writes nothing.
After the import the script checks that the import's two `instance` lines carry the manifest's
digests.
- A re-run with both persona files present and matching skips the import, which would refuse to
overwrite them. One matching file and one missing refuses `persona-differs`.
- A diverged library prints one line under `--library-status`, naming the tip `library.sync --accept`
would take. It doesn't refuse. The design doesn't list it as a refusal, and the bus still serves the
old tip.
- The precheck's library checks print `ok` lines for each check they pass, as the rest of the script
does. A business file that's missing prints no extra line, since the existing business-file check
already refuses it.
- The business id must match the persona import's rule, `^[a-z0-9][a-z0-9-]{0,63}$`. That's narrower
than the precheck's, which also takes `.` and `_`, because the persona dir uses the id.
### Outside readers
`git grep` over packages/*/tests/*, packages/*/src/* and scripts/test-*.sh finds case2-precheck.sh read
by case2-unit.test.mjs, which runs it, and case1-accept.test.mjs, which names a copy's path but runs a
fake. No test reads host-run-sheet.md. No test pins a hash of either script. case2-unit.test.mjs's
precheck host has a 0600 business file and no persona vars, so the new D6a checks pass there.
### Mutants
24, in /mnt/storage/scratch/gosalyn-r226/mutate.py. Each replaces one exact string once. Line numbers
are the candidate's. run.sh stops with BASE RED before any mutant unless the base run is rc=0 and
fail 0.
| Mutant | Line | Check removed |
|---|---|---|
| I1 | install :73 | run-as-root |
| I2 | install :74 | home-mismatch |
| I3 | install :81 | config-dir-mode |
| I4 | install :101 | business-id-mismatch |
| I5 | install :107 | profile-library-business |
| I6 | install :108 | bus-tester-off |
| I7 | install :111 | bus-tester-on |
| I8 | install :115 | persona-var-input |
| I9 | install :158 | business-file-differs |
| I10 | install :152 | persona-differs |
| I11 | install :218 | N4, removes the file even when this run didn't write it |
| I12 | install :218 | validate's removal of the file this run wrote |
| P1 | precheck :229 | business-file-mode |
| P2 | precheck :235 | history-mode for changes.jsonl |
| P3 | precheck :246 | persona-mode for a persona file |
| P4 | precheck :251 | persona-mismatch |
| P5 | precheck :241 | persona-mode for a persona dir |
| P6 | precheck :258 | discord-binding |
| P7 | precheck :104 | library-mirror-missing |
| P8 | precheck :107 | library-last-error |
| P9 | precheck :107 | N3, the order swapped |
| P10 | precheck :108 | library-never-synced |
| P11 | precheck :92 | N2, the escape after stringify |
| P12 | precheck :65 | --library-status refuses --profile |
`history-mode` for the history dir itself has no mutant and no test. The brief names only the
`changes.jsonl` case.
### Tests
Not run yet. They wait for a slot.
+8 -2
View File
@@ -234,10 +234,16 @@ When a run doesn't exit 0, send Mos the exit and the last lines of `/var/log/mos
Precheck lines: none. Root's checkout at `<sha>` and the stack's user's clone: precheck line missing.
After install, `stack-copy.sh check --units external` reads the copy the units run.
The stack's user's `scripts/case2-install.sh` (C2-13b, design 10 step 2b) makes the first copy with
`stack-copy.sh copy <sha>`. The copy runs `npm ci` from `package-lock.json`, which installs the pinned
harness, so the host needs to reach the npm registry during the install. The install runs no
`release.sh package` or `activate`.
## 7. The args file
`/etc/mosaic-stack/dev.args` holds the `case2-unit.sh` arguments, one per line, with the serve flags after a
line holding `--`. I2's install writes it; ops-01 doesn't write it by hand. It must be root's, with no group
line holding `--`. Root writes it at the unit render, from the values the go names. The stack's user's
install never writes it: that user can't write `/etc`, and root reads nothing that user wrote. It must be root's, with no group
or other write (`chmod 0644` or tighter). Before `--` it takes only `--user`, `--home`, `--business`,
`--copy` (exactly once), `--node`, `--profile`, `--claude` and `--pm`. `stack-upgrade.sh` changes only the
`--copy` line, and only after confirm passes.
@@ -272,7 +278,7 @@ file with `stat -c '%U %a %n' PATH`, never `cat`. `<business>` is the host's bus
|---|---|---|---|---|---|
| `/srv/mosaic/.config/mosaic-dev/secrets/` | `claude-session.env`, one line `ZAI_API_KEY=...` | 0600 | mosaic | library | `<path> sets ZAI_API_KEY` |
| `/srv/mosaic/.config/mosaic-dev/secrets/` | the Console's proof file, the path the args file's `--proof-file` names | 0600 or 0400 | mosaic | library | none. `case2-unit.sh` refuses at render. Precheck line missing. |
| `/srv/mosaic/.config/mosaic-dev/secrets/<business>/` | `library-gitea` | 0600 | mosaic | library | C2-13b's `library.sync` (not landed) |
| `/srv/mosaic/.config/mosaic-dev/secrets/<business>/` | `library-gitea` | 0600 | mosaic | library | `case2-precheck.sh --business <business> --library-status`, after the bus is up: `library-last-error library-credential-missing` until it's placed, then `library <project> last fetched ...` |
| `/srv/mosaic/.config/mosaic-dev/secrets/agents/<business>/<agent>/` | one file per service | 0600, dirs 0700 | mosaic | both | none. Filled only by `mosaic credentials issue`, or Approve after #436. Never by hand. |
The installer key, `<configDir>/secrets/hosts/<business>/<hostId>/installer`, is not a slot anyone places.
+401
View File
@@ -0,0 +1,401 @@
// Case 2 row C2-13b (#1727): scripts/case2-install.sh installs a hub as <user>, and
// scripts/case2-precheck.sh's library checks and --library-status mode. Brief:
// docs/plans/2026-10-20_case2-rows.md "C2-13b". Every case runs in a scratch home with its own
// MOSAIC_CONFIG, a stub stack-copy and fake getent, sudo, id, node, bwrap and systemctl. The library
// sync runs in-process against a local bare repo, as the bus would. Nothing here reads or writes
// ~/.mosaic-dev, ~/.config/mosaic-dev, /etc or a network host, and no fixture value is a credential.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { businessDoc, writeJson } from "../../business/tests/helpers.mjs";
import { BASE, MARKER, git, mappingEntry, repo } from "../../library/tests/helpers.mjs";
import { ensureMirror, mirrorPath, sync } from "../../library/src/index.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { SAFE } from "../../project/src/git.mjs";
import { tmp } from "./helpers.mjs";
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
const INSTALL = join(REPO, "scripts", "case2-install.sh");
const PRECHECK = join(REPO, "scripts", "case2-precheck.sh");
const COMMIT = "c".repeat(40);
const UID = process.getuid();
const GID = process.getgid();
// Made-up values; no output may hold them.
const FIXTURE_KEY = "fixture-zai-key-8d41a7";
const PERSONA_TEXT = { bean: "Bean is warm and brief. PERSONA-TEXT-bean-61f0\n", chet: "Chet is dry and exact. PERSONA-TEXT-chet-2b9e\n" };
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
const mode = (p) => statSync(p).mode & 0o777;
function script(file, text) {
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, `#!/usr/bin/env bash\n${text}\n`);
chmodSync(file, 0o755);
return file;
}
// A scratch <user>: a home, an input business file, a persona staging dir and fakes.
function world(t, { profile = "library" } = {}) {
const root = tmp(t, "case2-install-");
const home = join(root, "home");
const configDir = join(home, ".config", "mosaic-dev");
// claude-session.env is in place before the install, as 2d would leave it on a re-run.
const secrets = join(configDir, "secrets");
mkdirSync(secrets, { recursive: true, mode: 0o700 });
chmodSync(configDir, 0o700);
writeFileSync(join(secrets, "claude-session.env"), `ZAI_API_KEY=${FIXTURE_KEY}\n`, { mode: 0o600 });
const doc = businessDoc(root);
for (const r of Object.values(doc.roles)) r.vars = { ...r.vars, harness: "pi" };
if (profile === "library") {
doc.projects.books = { ...mappingEntry(), user: "writer" };
doc.roles.bean = { definition: "companion", vars: { harness: "pi" } };
doc.roles.chet = { definition: "companion", vars: { harness: "pi" } };
} else doc.vars["bus.tester"] = true;
const input = writeJson(join(root, "input", "acme.json"), doc);
const staging = join(root, "staging");
mkdirSync(staging, { mode: 0o700 });
const personas = [];
for (const [instance, name] of [["bean", "Bean"], ["chet", "Chet"]]) {
writeFileSync(join(staging, `${instance}.md`), PERSONA_TEXT[instance], { mode: 0o600 });
personas.push({ instance, name, bytes: Buffer.byteLength(PERSONA_TEXT[instance]), sha256: sha256(PERSONA_TEXT[instance]) });
}
writeFileSync(join(staging, "manifest.json"), `${JSON.stringify({ version: 1, personas }, null, 2)}\n`, { mode: 0o600 });
const bin = join(root, "bin");
const log = join(root, "calls.log");
script(join(bin, "stack-copy"), `echo "stack-copy $*" >>'${log}'
[ "$1" = copy ] && [ "$3" = --copies ] || exit 4
mkdir -p "$4/$2/scripts" && : >"$4/$2/scripts/mosaic" && echo "copied: $4/$2"`);
script(join(bin, "systemctl"), `echo "systemctl $*" >>'${log}'`);
const me = spawnSync("id", ["-un"], { encoding: "utf8" }).stdout.trim();
script(join(bin, "getent"), `[ "$1" = passwd ] && [ "$2" = '${me}' ] && echo '${me}:x:${UID}:${GID}::${home}:/bin/bash'`);
script(join(bin, "sudo"), "exit 1");
script(join(bin, "id"), `if [ "$1" = -G ]; then echo '${GID}'; elif [ "$1" = -u ] && [ -n "\${CASE2_TEST_UID:-}" ]; then echo "$CASE2_TEST_UID"; else exec /usr/bin/id "$@"; fi`);
script(join(bin, "node"), `if [ "$1" = --version ]; then echo v24.3.0; exit 0; fi\nexec '${process.execPath}' "$@"`);
script(join(bin, "bwrap"), "exit 0");
const units = join(root, "units");
mkdirSync(units);
writeFileSync(join(units, "mosaic-webui.service"), "[Service]\nExecStart=/usr/bin/node /x/cli.mjs serve --port 443 --business acme\n");
writeFileSync(join(units, "[email protected]"), "[Service]\nEnvironment=PATH=/usr/bin\n");
const sys = join(root, "sys");
mkdirSync(join(sys, "net", "ipv4"), { recursive: true });
mkdirSync(join(sys, "kernel"), { recursive: true });
writeFileSync(join(sys, "net", "ipv4", "ip_unprivileged_port_start"), "1024\n");
writeFileSync(join(sys, "kernel", "apparmor_restrict_unprivileged_userns"), "1\n");
return {
root, home, configDir, secrets, input, staging, bin, log, units, sys, doc,
config: join(configDir, "config.json"),
business: join(configDir, "businesses", "acme.json"),
personaDir: join(configDir, "personas", "acme"),
slot: join(secrets, "acme"),
dataRoot: join(home, ".mosaic-dev"),
};
}
function env(x, extra = {}) {
return { PATH: `${x.bin}:/usr/bin:/bin`, HOME: x.home, MOSAIC_CONFIG: x.config, CASE2_STACK_COPY: join(x.bin, "stack-copy"), ...extra };
}
function clean(out) {
assert.doesNotMatch(out, new RegExp(FIXTURE_KEY), "the output holds the fixture key");
assert.doesNotMatch(out, /PERSONA-TEXT/, "the output holds persona text");
assert.doesNotMatch(out, new RegExp(MARKER), "the output holds her text");
}
function install(x, { profile = "library", args = null, extra = {} } = {}) {
const argv = args ?? ["--business", "acme", "--business-file", x.input, "--commit", COMMIT, "--profile", profile,
...(profile === "library" ? ["--personas", x.staging] : [])];
const r = spawnSync(INSTALL, argv, { encoding: "utf8", timeout: 120000, env: env(x, extra) });
const out = r.stdout + r.stderr;
clean(out);
return { code: r.status, out };
}
function precheck(x, { args = ["--business", "acme", "--profile", "library"] } = {}) {
const r = spawnSync(PRECHECK, args, {
encoding: "utf8",
timeout: 60000,
env: env(x, { CASE2_UNIT_DIR: x.units, CASE2_PROC_SYS: x.sys, CASE2_GETENT: join(x.bin, "getent") }),
});
const out = r.stdout + r.stderr;
clean(out);
return { code: r.status, out };
}
const status = (x) => precheck(x, { args: ["--business", "acme", "--library-status"] });
function refused(r, pattern) {
assert.equal(r.code, 1, r.out);
assert.match(r.out, pattern);
}
const calls = (x) => (existsSync(x.log) ? readFileSync(x.log, "utf8") : "");
// ---- case2-install.sh ----
test("the library install writes the business file, personas and slot dir, makes the copy and starts nothing", (t) => {
const x = world(t);
const r = install(x);
assert.equal(r.code, 0, r.out);
assert.equal(mode(x.business), 0o600);
const doc = JSON.parse(readFileSync(x.business, "utf8"));
for (const [instance, name] of [["bean", "Bean"], ["chet", "Chet"]]) {
assert.equal(doc.roles[instance].vars["persona.name"], name);
assert.equal(doc.roles[instance].vars["persona.sha256"], sha256(PERSONA_TEXT[instance]));
assert.equal(doc.roles[instance].vars.harness, "pi");
const file = join(x.personaDir, `${instance}.md`);
assert.equal(readFileSync(file, "utf8"), PERSONA_TEXT[instance]);
assert.equal(mode(file), 0o600);
}
assert.equal(mode(join(x.configDir, "personas")), 0o700);
assert.equal(mode(x.personaDir), 0o700);
assert.equal(mode(x.slot), 0o700);
assert.deepEqual(readdirSync(x.slot), []);
assert.equal(calls(x), `stack-copy copy ${COMMIT} --copies ${join(x.home, ".local", "share", "mosaic-stack", "copies")}\n`);
assert.doesNotMatch(r.out, /release/);
assert.match(r.out, /business acme validates/);
assert.match(r.out, /--library-status/);
assert.ok(existsSync(x.config));
});
test("a library re-run passes and leaves every file as it was", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
const before = readFileSync(x.business);
const r = install(x);
assert.equal(r.code, 0, r.out);
assert.match(r.out, /already matches/);
assert.match(r.out, /personas already installed/);
assert.deepEqual(readFileSync(x.business), before);
});
test("a re-run refuses business-file-differs and leaves the file unchanged", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
const doc = JSON.parse(readFileSync(x.business, "utf8"));
doc.vars["tracker.pollSeconds"] = 120;
writeJson(x.business, doc);
const before = readFileSync(x.business);
refused(install(x), /business-file-differs/);
assert.deepEqual(readFileSync(x.business), before);
});
test("a re-run refuses persona-differs when an installed persona isn't the staged one, and writes nothing", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
rmSync(x.business);
writeFileSync(join(x.personaDir, "bean.md"), "Someone else. PERSONA-TEXT-other\n");
refused(install(x), /persona-differs/);
assert.equal(existsSync(x.business), false);
});
test("the factory install writes no library mapping, persona or slot dir", (t) => {
const x = world(t, { profile: "factory" });
const r = install(x, { profile: "factory" });
assert.equal(r.code, 0, r.out);
const doc = JSON.parse(readFileSync(x.business, "utf8"));
assert.equal(Object.values(doc.projects).some((p) => p.kind === "library"), false);
assert.equal(existsSync(join(x.configDir, "personas")), false);
assert.equal(existsSync(x.slot), false);
assert.doesNotMatch(r.out, /--library-status/);
});
test("the factory install refuses a library project and bus.tester off, before anything is written", (t) => {
const x = world(t, { profile: "factory" });
const doc = JSON.parse(readFileSync(x.input, "utf8"));
doc.projects.books = { ...mappingEntry(), user: "writer" };
writeJson(x.input, doc);
refused(install(x, { profile: "factory" }), /profile-library-business/);
delete doc.projects.books;
doc.vars["bus.tester"] = false;
writeJson(x.input, doc);
refused(install(x, { profile: "factory" }), /bus-tester-off/);
assert.equal(calls(x), "");
assert.equal(existsSync(x.business), false);
});
test("the library install refuses bus.tester on and a persona var in the input", (t) => {
const x = world(t);
const doc = JSON.parse(readFileSync(x.input, "utf8"));
doc.vars["bus.tester"] = true;
writeJson(x.input, doc);
refused(install(x), /bus-tester-on/);
doc.vars["bus.tester"] = false;
doc.roles.bean.vars["persona.name"] = "Bean";
writeJson(x.input, doc);
refused(install(x), /persona-var-input/);
assert.equal(calls(x), "");
});
test("the install refuses root, a business id mismatch, another --home and a config dir with group bits", (t) => {
const x = world(t);
refused(install(x, { extra: { CASE2_TEST_UID: "0" } }), /run-as-root/);
refused(install(x, { args: ["--business", "other", "--business-file", x.input, "--commit", COMMIT, "--personas", x.staging] }), /business-id-mismatch/);
refused(install(x, { args: ["--business", "acme", "--business-file", x.input, "--commit", COMMIT, "--personas", x.staging, "--home", x.root] }), /home-mismatch/);
chmodSync(x.configDir, 0o750);
refused(install(x), /config-dir-mode/);
assert.equal(calls(x), "");
assert.equal(existsSync(x.business), false);
});
test("the library install needs --personas", (t) => {
const x = world(t);
const r = install(x, { args: ["--business", "acme", "--business-file", x.input, "--commit", COMMIT] });
assert.equal(r.code, 4, r.out);
assert.match(r.out, /needs --personas/);
});
test("a validate refusal removes the business file this run wrote", (t) => {
const x = world(t);
unlinkSync(x.doc.roles.pm.credentials.gitea.file);
const r = install(x);
refused(r, /validate: .*the business file this run wrote is removed/);
assert.equal(existsSync(x.business), false);
});
test("N4: a validate refusal on a re-run leaves the matching business file in place, byte for byte", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
const before = readFileSync(x.business);
unlinkSync(x.doc.roles.pm.credentials.gitea.file);
const r = install(x);
refused(r, /validate: /);
assert.doesNotMatch(r.out, /is removed/);
assert.deepEqual(readFileSync(x.business), before);
});
// ---- case2-precheck.sh, the library profile (D6a) ----
test("a clean library host passes the precheck with no mirror yet", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
const r = precheck(x);
assert.equal(r.code, 0, r.out);
assert.match(r.out, /ok {6}business file .* with no group or other bit/);
assert.match(r.out, /ok {6}no history for acme yet/);
assert.match(r.out, new RegExp(`ok {6}persona bean sha256 ${sha256(PERSONA_TEXT.bean)}`));
assert.match(r.out, new RegExp(`ok {6}persona chet sha256 ${sha256(PERSONA_TEXT.chet)}`));
assert.match(r.out, /ok {6}no Discord binding/);
assert.equal(existsSync(mirrorPath({ business: { id: "acme", projects: { books: x.doc.projects.books } }, project: "books", dataRoot: x.dataRoot })), false);
});
test("the library precheck refuses a persona file at 0644", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
chmodSync(join(x.personaDir, "bean.md"), 0o644);
refused(precheck(x), /REFUSED persona-mode: .*bean\.md must be .* 0600 file \(mode 644\)/);
});
test("the library precheck refuses a persona whose digest isn't persona.sha256", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
writeFileSync(join(x.personaDir, "chet.md"), "Not Chet. PERSONA-TEXT-other\n");
refused(precheck(x), /REFUSED persona-mismatch: persona chet has sha256 [0-9a-f]{64}, and persona.sha256 is /);
});
test("the library precheck refuses a persona dir with group bits", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
chmodSync(x.personaDir, 0o750);
refused(precheck(x), /REFUSED persona-mode: .*personas\/acme must be .* 0700 directory/);
});
test("the library precheck refuses a business file at 0644", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
chmodSync(x.business, 0o644);
refused(precheck(x), /REFUSED business-file-mode: .*acme\.json .*\(mode 644\)/);
});
test("the library precheck refuses a changes.jsonl at 0644", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
const history = join(x.configDir, "businesses", ".history", "acme");
mkdirSync(history, { recursive: true, mode: 0o700 });
writeFileSync(join(history, "changes.jsonl"), "", { mode: 0o644 });
chmodSync(join(history, "changes.jsonl"), 0o644);
refused(precheck(x), /REFUSED history-mode: .*changes\.jsonl must be .* 0600 file \(mode 644\)/);
chmodSync(join(history, "changes.jsonl"), 0o600);
assert.equal(precheck(x).code, 0);
});
test("the library precheck refuses a Discord binding", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
mkdirSync(join(x.dataRoot, "discord"), { recursive: true });
writeFileSync(join(x.dataRoot, "discord", "her.json"), "{}\n", { mode: 0o600 });
refused(precheck(x), /REFUSED discord-binding: .*her\.json exists/);
});
// ---- case2-precheck.sh --library-status (D6b) ----
// The context the bus builds for books (runtime.mjs:161-164), and a sync the way the bus's timer
// runs it, against a local bare clone through the command parameter.
function library(x) {
const context = { business: { id: "acme", projects: { books: x.doc.projects.books } }, project: "books", dataRoot: x.dataRoot };
mkdirSync(x.dataRoot, { recursive: true, mode: 0o700 });
chmodSync(x.dataRoot, 0o700);
const run = (bare, slot) => sync(context, {
slot,
command: (kind, url, { mirror, branch }) => ({
argv: [...SAFE, "-c", "protocol.file.allow=always", "-c", "core.createObject=rename", "-C", mirror, "fetch", "--no-tags",
"--no-write-fetch-head", "--end-of-options", bare, `+refs/heads/${branch}:refs/remotes/library/${branch}`],
env: { ...gitEnv(), LC_ALL: "C", GIT_CEILING_DIRECTORIES: dirname(mirror) },
}),
});
return { context, run };
}
test("--library-status refuses library-mirror-missing before the bus has made the mirror", (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
refused(status(x), /REFUSED library-mirror-missing: library books has no mirror at .*the bus makes it when it starts/);
});
test("--library-status refuses library-never-synced on a fresh mirror", async (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
await ensureMirror(library(x).context);
const r = status(x);
refused(r, /REFUSED library-never-synced: library books has no fetch yet/);
assert.doesNotMatch(r.out, /library-last-error/);
});
test("--library-status names a recorded lastError before the null lastFetch it leaves (N3)", async (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
const lib = library(x);
await lib.run("/nonexistent", join(x.slot, "library-gitea"));
const r = status(x);
refused(r, /REFUSED library-last-error: library books: library-credential-missing at \d{4}-\d\d-\d\dT/);
assert.doesNotMatch(r.out, /library-never-synced/);
});
test("--library-status prints each R-problem's path as an escaped JSON string, then the count, and passes", async (t) => {
const x = world(t);
assert.equal(install(x).code, 0);
const odd = "Odd‮Name\u0085.md";
const f = repo(t, { ...BASE, [odd]: `${MARKER}\n` });
const bare = join(f.root, "remote.git");
git(f.root, "clone", "-q", "--bare", "--no-local", f.repo, bare);
writeFileSync(join(x.slot, "library-gitea"), "placeholder-not-a-token\n", { mode: 0o600 });
const { state } = await library(x).run(bare, join(x.slot, "library-gitea"));
assert.equal(state.lastError, null);
const r = status(x);
assert.equal(r.code, 0, r.out);
assert.match(r.out, new RegExp(`ok {6}library books last fetched \\S+, serving ${f.commit}`));
assert.match(r.out, /^problem {3}R2 "Inbox Note\.md"$/m);
assert.match(r.out, /^problem {3}R\d+ "Odd\\u202eName\\u0085\.md"$/m);
assert.doesNotMatch(r.out, /[\u0085‮]/);
const lines = r.out.split("\n").filter((l) => l.startsWith("problem "));
assert.equal(lines.length, state.problems.length);
assert.match(r.out, new RegExp(`^problems ${state.problems.length}$`, "m"));
assert.match(r.out, /case2-precheck: library status passed/);
});
test("--library-status takes no --profile", (t) => {
const x = world(t);
const r = precheck(x, { args: ["--business", "acme", "--library-status", "--profile", "library"] });
assert.equal(r.code, 4, r.out);
});
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env bash
# Case 2 row C2-13b (design 10 step 2b): a hub's install, run as <user>
# after root's 2a and before root's 2c renders the units.
#
# case2-install.sh --business ID --business-file FILE --commit SHA
# [--profile factory|library] [--personas DIR] [--home DIR]
#
# Steps, in order: refuse root; scripts/bootstrap.sh; stack-copy.sh copy
# SHA, whose npm ci installs the pinned harness; for library, the persona
# import's install from the staging DIR; the business file at 0600; for
# library, the empty 0700 slot dir <configDir>/secrets/<id>/; `mosaic
# business validate`; then the next steps. It runs no release package or
# activate, starts no unit, writes no unit file and nothing under /etc, and
# never writes the library token, which is Jason's step 4.
#
# FILE is the business document written for the host from the go. The
# script adds only roles.bean.vars and roles.chet.vars persona.name and
# persona.sha256, from the staging manifest, and checks the input against
# the precheck's profile rules first: library needs a library project,
# bus.tester off and bean and chet as companion roles with no persona var;
# factory needs no library project and bus.tester on.
#
# Every refusal it can see before writing comes first, so a refused run
# writes nothing. A re-run passes when the business file and personas on
# disk match what it would write, and refuses business-file-differs or
# persona-differs when they don't, touching neither. If validate refuses,
# the business file goes only when this run created it.
#
# --home must be $HOME. The config dir is dirname of $MOSAIC_CONFIG, or
# <home>/.config/mosaic-dev; the install makes it 0700 when absent and
# refuses config-dir-mode when it has a group or other bit.
#
# Test seam, for packages/cli/tests/case2-install.test.mjs only:
# CASE2_STACK_COPY the copy script, default scripts/stack-copy.sh
#
# Exit 0 ok, 1 refused, 4 usage.
set -uo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
STACK_COPY="${CASE2_STACK_COPY:-$REPO/scripts/stack-copy.sh}"
usage() { echo 'usage: case2-install.sh --business ID --business-file FILE --commit SHA [--profile factory|library] [--personas DIR] [--home DIR]' >&2; exit 4; }
PROFILE=library BUSINESS='' BFILE='' COMMIT='' PERSONAS='' HOME_ARG="${HOME:-}"
while [ $# -gt 0 ]; do
case "$1" in
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; shift 2 ;;
--business-file) [ $# -ge 2 ] || usage; BFILE="$2"; shift 2 ;;
--commit) [ $# -ge 2 ] || usage; COMMIT="$2"; shift 2 ;;
--profile) [ $# -ge 2 ] || usage; PROFILE="$2"; shift 2 ;;
--personas) [ $# -ge 2 ] || usage; PERSONAS="$2"; shift 2 ;;
--home) [ $# -ge 2 ] || usage; HOME_ARG="$2"; shift 2 ;;
*) usage ;;
esac
done
[ -n "$BUSINESS" ] && [ -n "$BFILE" ] && [ -n "$COMMIT" ] || usage
case "$PROFILE" in factory|library) ;; *) echo "case2-install: --profile must be factory or library: $PROFILE" >&2; exit 4 ;; esac
[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]{0,63}$ ]] || { echo "case2-install: --business must be a business id: $BUSINESS" >&2; exit 4; }
[[ "$COMMIT" =~ ^[0-9a-f]{40}$ ]] || { echo "case2-install: --commit must be a full 40-character commit id: $COMMIT" >&2; exit 4; }
if [ "$PROFILE" = library ] && [ -z "$PERSONAS" ]; then echo 'case2-install: the library profile needs --personas DIR' >&2; exit 4; fi
if [ "$PROFILE" = factory ] && [ -n "$PERSONAS" ]; then echo 'case2-install: the factory profile takes no --personas' >&2; exit 4; fi
say() { echo "case2-install: $1"; }
refuse() { echo "case2-install: refused: $1" >&2; exit 1; }
mode_of() { stat -c '%a' -- "$1"; }
# An existing directory, not a link, owned by this uid, with no group or other bit.
private_dir() { [ ! -L "$1" ] && [ -d "$1" ] && [ "$(stat -c '%u' -- "$1")" = "$(id -u)" ] && [ $((8#$(mode_of "$1") & 8#077)) -eq 0 ]; }
# An absent path, or a private directory; anything else refuses CODE.
absent_or_private() {
if [ ! -e "$1" ] && [ ! -L "$1" ]; then return 0; fi
private_dir "$1" || refuse "$2: $1 must be $(id -un)'s directory with no group or other bit"
}
# 1. Never as root: root owns nothing under <home> here.
[ "$(id -u)" != 0 ] || refuse 'run-as-root: run the install as <user>, not root'
[ -n "${HOME:-}" ] && [ "$HOME_ARG" = "$HOME" ] || refuse "home-mismatch: --home $HOME_ARG is not \$HOME ${HOME:-unset}"
CONFIG="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}"
case "$CONFIG" in /*) ;; *) refuse "config-path: MOSAIC_CONFIG must be an absolute path: $CONFIG" ;; esac
CONFIG_DIR="$(dirname -- "$CONFIG")"
SECRETS="$CONFIG_DIR/secrets"
SLOT="$SECRETS/$BUSINESS"
COPIES="$HOME/.local/share/mosaic-stack/copies"
absent_or_private "$CONFIG_DIR" config-dir-mode
if [ "$PROFILE" = library ]; then
absent_or_private "$SECRETS" secrets-mode
absent_or_private "$SLOT" slot-mode
fi
# The plan and the write share one helper. plan checks the input, the
# staging manifest and what is on disk, and prints one decision per line;
# write makes the business file with O_EXCL. Neither prints persona text.
HELPER='
import { closeSync, constants, fchmodSync, lstatSync, openSync, readFileSync, writeSync } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
const [verb, id, input, profile, staging, configDir] = process.argv.slice(1);
const say = (...f) => console.log(f.join("\t"));
const stop = (code, message) => { say("REFUSE", `${code}: ${message}`); process.exit(0); };
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
let doc;
try { doc = JSON.parse(readFileSync(input, "utf8")); } catch (e) { stop("business-file-input", `cannot read ${input} as JSON (${e.code ?? "parse"})`); }
if (doc === null || typeof doc !== "object" || Array.isArray(doc)) stop("business-file-input", `${input} is not a JSON object`);
if (doc.id !== id) stop("business-id-mismatch", `${input} has id ${JSON.stringify(doc.id)}, not ${id}`);
const projects = doc.projects && typeof doc.projects === "object" ? doc.projects : {};
const library = Object.keys(projects).filter((k) => projects[k]?.kind === "library");
const tester = doc.vars?.["bus.tester"] === true;
let personas = [];
if (profile === "factory") {
if (library.length) stop("profile-library-business", `the factory profile refuses library projects ${library.join(",")}`);
if (!tester) stop("bus-tester-off", "the factory profile needs vars bus.tester true");
} else {
if (!library.length) stop("no-library-project", `${input} has no library project`);
if (tester) stop("bus-tester-on", "the library profile needs bus.tester off");
for (const instance of ["bean", "chet"]) {
const role = doc.roles?.[instance];
if (role?.definition !== "companion") stop("companion-roles", `roles.${instance} must have definition companion`);
if (Object.keys(role.vars ?? {}).some((k) => k.startsWith("persona."))) stop("persona-var-input", `roles.${instance}.vars sets a persona var; the install adds them`);
}
let manifest;
try { manifest = JSON.parse(readFileSync(join(staging, "manifest.json"), "utf8")); } catch (e) { stop("persona-manifest", `cannot read ${join(staging, "manifest.json")} (${e.code ?? "parse"})`); }
const entries = manifest?.version === 1 && Array.isArray(manifest.personas) ? manifest.personas : [];
const want = (instance) => entries.filter((m) => m?.instance === instance);
for (const instance of ["bean", "chet"]) {
const m = want(instance);
if (entries.length !== 2 || m.length !== 1 || m[0].name !== instance[0].toUpperCase() + instance.slice(1) || !/^[0-9a-f]{64}$/.test(m[0].sha256 ?? ""))
stop("persona-manifest", "the staging manifest does not list Bean and Chet once each with a sha256");
personas.push(m[0]);
}
for (const m of personas) doc.roles[m.instance].vars = { ...(doc.roles[m.instance].vars ?? {}), "persona.name": m.name, "persona.sha256": m.sha256 };
}
const text = `${JSON.stringify(doc, null, 2)}\n`;
const file = join(configDir, "businesses", `${id}.json`);
if (verb === "write") {
let fd;
try { fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
catch (e) { stop("business-file-raced", `${file} could not be made new (${e.code})`); }
fchmodSync(fd, 0o600);
writeSync(fd, text);
closeSync(fd);
say("WROTE", file);
process.exit(0);
}
// plan: the personas on disk, then the business file on disk.
if (profile === "library") {
const dir = join(configDir, "personas", id);
const seen = personas.map((m) => {
const p = join(dir, `${m.instance}.md`);
const s = lstatSync(p, { throwIfNoEntry: false });
if (!s) return "absent";
return s.isFile() && sha256(readFileSync(p)) === m.sha256 ? "same" : "differs";
});
if (seen.every((s) => s === "absent")) say("PERSONAS", "install");
else if (seen.every((s) => s === "same")) say("PERSONAS", "present");
else stop("persona-differs", `the persona files under ${dir} are not the staged pair; nothing written`);
for (const m of personas) say("PERSONA", m.instance, m.name, m.sha256);
}
const s = lstatSync(file, { throwIfNoEntry: false });
if (!s) say("BUSINESS", "new", file);
else if (s.isFile() && !s.isSymbolicLink() && readFileSync(file, "utf8") === text) say("BUSINESS", "same", file);
else stop("business-file-differs", `${file} exists and differs from the one this install writes; nothing written`);
'
helper() { (cd "$REPO" && node --input-type=module -e "$HELPER" "$1" "$BUSINESS" "$BFILE" "$PROFILE" "$PERSONAS" "$CONFIG_DIR" 2>&1); }
field() { printf '%s\n' "$plan" | awk -F'\t' -v k="$1" '$1 == k { print $2 }'; }
plan="$(helper plan)" || refuse "plan: the check failed: $(printf '%s' "$plan" | head -n 1)"
r="$(printf '%s\n' "$plan" | awk -F'\t' '$1 == "REFUSE" { print $2; exit }')"
[ -z "$r" ] || refuse "$r"
BUSINESS_STATE="$(field BUSINESS)"
[ -n "$BUSINESS_STATE" ] || refuse "plan: no decision: $(printf '%s' "$plan" | head -n 1)"
PERSONA_STATE="$(field PERSONAS)"
# 2. bootstrap: idempotent, and it never rewrites an existing config.
if [ ! -e "$CONFIG_DIR" ]; then
mkdir -p -- "$(dirname -- "$CONFIG_DIR")" && mkdir -m 700 -- "$CONFIG_DIR" || refuse "config-dir: cannot make $CONFIG_DIR"
fi
out="$("$REPO/scripts/bootstrap.sh" 2>&1)" || refuse "bootstrap: $(printf '%s' "$out" | tail -n 1)"
say "config $CONFIG"
# 3. The stack copy row 195's units run from.
out="$("$STACK_COPY" copy "$COMMIT" --copies "$COPIES" 2>&1)" || refuse "stack-copy: $(printf '%s' "$out" | tail -n 1)"
[ -f "$COPIES/$COMMIT/scripts/mosaic" ] || refuse "stack-copy: no copy $COMMIT in $COPIES after the copy step"
say "copy $COPIES/$COMMIT"
# 4. library: the personas, from the staging dir.
if [ "$PROFILE" = library ]; then
if [ "$PERSONA_STATE" = install ]; then
out="$(node "$REPO/scripts/case2-persona-import.mjs" install --staging "$PERSONAS" --config-dir "$CONFIG_DIR" --business "$BUSINESS" 2>&1)" \
|| refuse "persona-import: $(printf '%s' "$out" | tail -n 1)"
while IFS=$'\t' read -r _ inst name sum; do
printf '%s\n' "$out" | grep -qxF "instance $inst: persona.name $name, persona.sha256 $sum" \
|| refuse "persona-import: the import did not report $inst with the manifest's sha256"
done < <(printf '%s\n' "$plan" | awk -F'\t' '$1 == "PERSONA"')
say "personas installed under $CONFIG_DIR/personas/$BUSINESS"
else
say "personas already installed under $CONFIG_DIR/personas/$BUSINESS"
fi
fi
# 5. The business file, at 0600 and new, unless an equal one is there.
CREATED=0
if [ "$BUSINESS_STATE" = new ]; then
mkdir -p -m 700 -- "$CONFIG_DIR/businesses" || refuse "business-file: cannot make $CONFIG_DIR/businesses"
w="$(helper write)" || refuse "business-file: $(printf '%s' "$w" | head -n 1)"
case "$w" in WROTE*) CREATED=1 ;; REFUSE*) refuse "${w#REFUSE }" ;; *) refuse "business-file: $(printf '%s' "$w" | head -n 1)" ;; esac
say "business file $CONFIG_DIR/businesses/$BUSINESS.json written"
else
say "business file $CONFIG_DIR/businesses/$BUSINESS.json already matches"
fi
# 6. library: the empty slot dir. The token is Jason's, design 10 step 4.
if [ "$PROFILE" = library ]; then
[ -e "$SECRETS" ] || mkdir -m 700 -- "$SECRETS" || refuse "slot: cannot make $SECRETS"
[ -e "$SLOT" ] || mkdir -m 700 -- "$SLOT" || refuse "slot: cannot make $SLOT"
private_dir "$SLOT" || refuse "slot-mode: $SLOT must be a 0700 directory"
say "slot dir $SLOT"
fi
# 7. validate, the business package's own schema.
if ! out="$("$REPO/scripts/mosaic" business validate "$BUSINESS" 2>&1)"; then
if [ "$CREATED" = 1 ]; then rm -f -- "$CONFIG_DIR/businesses/$BUSINESS.json"; fi
refuse "validate: $(printf '%s' "$out" | tail -n 1)$( [ "$CREATED" = 1 ] && echo '; the business file this run wrote is removed')"
fi
say "business $BUSINESS validates"
# 8. What comes next. Root's steps run from root's own checkout.
say "done. Next: root runs case2-unit.sh --copy $COMMIT --business $BUSINESS (design 10 step 2c), then 2d and 3."
if [ "$PROFILE" = library ]; then
say "Then Jason places the library token in $SLOT (step 4), and <user> runs case2-precheck.sh --business $BUSINESS --library-status (step 4a)."
fi
exit 0
+124 -3
View File
@@ -5,6 +5,7 @@
# any one failed.
#
# case2-precheck.sh --business ID [--profile factory|library]
# case2-precheck.sh --business ID --library-status
#
# Checks: the config and business file validate; `sudo -n true` fails and
# `id -G` prints one group; bus.tester is off for library and on for factory,
@@ -17,6 +18,22 @@
# instance's provider is zai. It prints free memory and checks that HOME is
# <home>. It never prints a value from claude-session.env.
#
# C2-13b adds to the library profile: the business file is <user>'s with no
# group or other bit; businesses/.history/<id>/ is 0700 and its changes.jsonl
# 0600, when present; the persona dirs are 0700 and each persona file is
# <user>'s at 0600 with the sha256 its persona.sha256 names; and no Discord
# binding exists under <dataRoot>/discord. These read no mirror and sync
# nothing, since the bus makes the mirror when it starts.
#
# --library-status (design 10 step 4a) runs instead, once the bus is up and
# the token is placed. It reads the bus's recorded sync state for each library
# project through libraryStatus(), the call the bus's library.status makes,
# and syncs nothing: sync() fetches outside the library lock, so a second
# process would race the bus's timer. It refuses a missing mirror, a recorded
# lastError and a null lastFetch, in that order, and prints each R-problem as a
# `problem` line with its path as a JSON string, then a count. It prints no
# text from her repo.
#
# Test seams, for packages/cli/tests/case2-unit.test.mjs only:
# CASE2_UNIT_DIR the installed units, default /etc/systemd/system
# CASE2_PROC_SYS default /proc/sys
@@ -34,22 +51,86 @@ WEBUI_UNIT="$UNIT_DIR/mosaic-webui.service"
# Claude Code's own prompt is many times this.
MARKER_TOKEN_CEILING=2000
usage() { echo 'usage: case2-precheck.sh --business ID [--profile factory|library]' >&2; exit 4; }
PROFILE=library BUSINESS=''
usage() { echo 'usage: case2-precheck.sh --business ID [--profile factory|library] | --business ID --library-status' >&2; exit 4; }
PROFILE=library PROFILE_SET=0 BUSINESS='' STATUS=0
while [ $# -gt 0 ]; do
case "$1" in
--profile) [ $# -ge 2 ] || usage; PROFILE="$2"; shift 2 ;;
--profile) [ $# -ge 2 ] || usage; PROFILE="$2"; PROFILE_SET=1; shift 2 ;;
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; shift 2 ;;
--library-status) STATUS=1; shift ;;
*) usage ;;
esac
done
[ -n "$BUSINESS" ] || usage
[ "$STATUS" = 0 ] || [ "$PROFILE_SET" = 0 ] || usage
case "$PROFILE" in factory|library) ;; *) echo "case2-precheck: --profile must be factory or library: $PROFILE" >&2; exit 4 ;; esac
[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]] || { echo "case2-precheck: --business must be a business id: $BUSINESS" >&2; exit 4; }
FAILED=0
ok() { echo "ok $1"; }
refuse() { echo "REFUSED $1"; FAILED=1; }
mode_of() { stat -c '%a' -- "$1"; }
# Regular, not a link, owned by this uid, with no group or other bit.
private_file() { [ ! -L "$1" ] && [ -f "$1" ] && [ "$(stat -c '%u' -- "$1")" = "$(id -u)" ] && [ $((8#$(mode_of "$1") & 8#077)) -eq 0 ]; }
# A directory, not a link, owned by this uid, at 0700.
private_dir() { [ ! -L "$1" ] && [ -d "$1" ] && [ "$(stat -c '%u' -- "$1")" = "$(id -u)" ] && [ "$(mode_of "$1")" = 700 ]; }
# --library-status: the bus's recorded sync state, and nothing else.
if [ "$STATUS" = 1 ]; then
status="$(cd "$REPO" && node --input-type=module -e '
import { lstatSync } from "node:fs";
const [repo, id] = process.argv.slice(1);
const { loadSystem, rolesDir } = await import(`${repo}/packages/cli/src/config.mjs`);
const { loadBusiness } = await import(`${repo}/packages/business/src/index.mjs`);
const { libraryStatus, mirrorPath } = await import(`${repo}/packages/library/src/index.mjs`);
const say = (...f) => console.log(f.join("\t"));
// A library error code is a fixed [a-z-] string; anything else prints as one name.
const code = (e) => typeof e?.code === "string" && /^[a-z-]{1,64}$/.test(e.code) ? e.code : "library-status-failed";
// JSON.stringify leaves DEL, C1 controls, the line and paragraph separators and the bidi controls
// raw. They print as \uXXXX too, so a path from her repo cannot move the cursor or reorder a line.
const RAW = /[\u007f-\u009f\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
const quote = (s) => JSON.stringify(s).replace(RAW, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
let system, b;
try { system = loadSystem(); } catch (e) { say("BAD", `config: ${e.message.split("\n")[0]}`); process.exit(0); }
try { b = loadBusiness(id, { rolesDir: rolesDir() }); } catch (e) { say("BAD", `business file: ${e.message.split("\n")[0]}`); process.exit(0); }
const projects = Object.keys(b.projects).filter((p) => b.projects[p].kind === "library");
if (!projects.length) say("BAD", `business ${id} has no library project`);
for (const project of projects) {
const context = { business: b, project, dataRoot: system.dataRoot };
let mirror, s;
try { mirror = mirrorPath(context); } catch (e) { say("ERROR", project, code(e)); continue; }
let there;
try { there = lstatSync(mirror, { throwIfNoEntry: false }); } catch (e) { say("ERROR", project, "library-git"); continue; }
if (!there) { say("MISSING", project, mirror); continue; }
try { s = await libraryStatus(context); } catch (e) { say("ERROR", project, code(e)); continue; }
// lastError first: a first sync with no token records an error and leaves lastFetch null.
if (s.lastError !== null) { say("LASTERROR", project, s.lastError.code, s.lastError.at); continue; }
if (s.lastFetch === null) { say("NEVER", project); continue; }
say("OK", project, s.lastFetch, s.served ?? "none");
if (s.diverged !== null) say("DIVERGED", project, s.diverged.commit);
for (const p of s.problems) say("PROBLEM", p.code, quote(p.path));
say("PROBLEMS", project, s.problems.length);
}
' "$REPO" "$BUSINESS" 2>&1)" || true
[ -n "$status" ] || refuse "library status: the check printed nothing"
while IFS=$'\t' read -r kind a b c; do
case "$kind" in
BAD) refuse "$a" ;;
ERROR) refuse "library-status: library $a: $b" ;;
MISSING) refuse "library-mirror-missing: library $a has no mirror at $b; the bus makes it when it starts" ;;
LASTERROR) refuse "library-last-error: library $a: $b at $c" ;;
NEVER) refuse "library-never-synced: library $a has no fetch yet; wait for the bus's next sync, 60 s, or run library.sync" ;;
OK) ok "library $a last fetched $b, serving $c" ;;
DIVERGED) echo " library $a: a diverged tip $b waits for library.sync --accept" ;;
PROBLEM) echo "problem $a $b" ;;
PROBLEMS) echo "problems $b" ;;
*) refuse "library status: $kind${a:+ $a}" ;;
esac
done <<<"$status"
if [ "$FAILED" = 0 ]; then echo "case2-precheck: library status passed"; exit 0; fi
echo "case2-precheck: refused"
exit 1
fi
# One setting's value from an installed unit: the last KEY= line in the file.
unit_value() { [ -f "$1" ] && sed -n "s/^[[:space:]]*$2[[:space:]]*=[[:space:]]*//p" -- "$1" | tail -n 1; }
@@ -100,6 +181,8 @@ let b;
try { b = loadBusiness(id, { rolesDir: rolesDir() }); } catch (e) { say("BUSINESS", "bad", e.message.split("\n")[0]); process.exit(0); }
say("BUSINESS", "ok", file);
say("TESTER", b.vars["bus.tester"] === true ? "on" : "off");
for (const [instance, role] of Object.entries(raw.roles ?? {}))
if (typeof role?.vars?.["persona.sha256"] === "string") say("PERSONA", instance, role.vars["persona.sha256"]);
const vars = systemVars(system);
const models = b.launch?.models ?? null;
for (const instance of Object.keys(b.roles)) {
@@ -138,6 +221,44 @@ elif [ -n "$cfg" ] && [ "${cfg#CONFIG ok}" != "$cfg" ]; then
esac
fi
# C2-13b: the library files' modes, the persona digests and no Discord
# binding (design 4). Names and digests print, never persona text.
if [ "$PROFILE" = library ] && [ -n "$CONFIG_DIR" ]; then
bfile="$CONFIG_DIR/businesses/$BUSINESS.json"
if [ ! -e "$bfile" ] && [ ! -L "$bfile" ]; then :
elif private_file "$bfile"; then ok "business file $bfile is $ME's with no group or other bit"
else refuse "business-file-mode: $bfile must be $ME's regular file with no group or other bit (mode $(mode_of "$bfile"))"; fi
hdir="$CONFIG_DIR/businesses/.history/$BUSINESS"
if [ ! -e "$hdir" ] && [ ! -L "$hdir" ]; then ok "no history for $BUSINESS yet"
elif ! private_dir "$hdir"; then refuse "history-mode: $hdir must be $ME's 0700 directory (mode $(mode_of "$hdir"))"
elif [ ! -e "$hdir/changes.jsonl" ] && [ ! -L "$hdir/changes.jsonl" ]; then ok "history $hdir is 0700"
elif private_file "$hdir/changes.jsonl" && [ "$(mode_of "$hdir/changes.jsonl")" = 600 ]; then ok "history $hdir is 0700 and changes.jsonl 0600"
else refuse "history-mode: $hdir/changes.jsonl must be $ME's 0600 file (mode $(mode_of "$hdir/changes.jsonl"))"; fi
personas="$(line PERSONA)"
if [ -z "$personas" ]; then ok "no instance has persona vars"
else
for d in "$CONFIG_DIR/personas" "$CONFIG_DIR/personas/$BUSINESS"; do
if private_dir "$d"; then ok "persona dir $d is 0700"
else refuse "persona-mode: $d must be $ME's 0700 directory"; fi
done
while IFS=$'\t' read -r _ inst want; do
pfile="$CONFIG_DIR/personas/$BUSINESS/$inst.md"
if ! private_file "$pfile" || [ "$(mode_of "$pfile")" != 600 ]; then
refuse "persona-mode: $pfile must be $ME's 0600 file$( [ -e "$pfile" ] && echo " (mode $(mode_of "$pfile"))")"
continue
fi
have="$(sha256sum -- "$pfile" | cut -d' ' -f1)"
if [ "$have" = "$want" ]; then ok "persona $inst sha256 $have"
else refuse "persona-mismatch: persona $inst has sha256 $have, and persona.sha256 is $want"; fi
done <<<"$personas"
fi
fi
if [ "$PROFILE" = library ] && [ -n "$DATA_ROOT" ]; then
binding="$(find "$DATA_ROOT/discord" -maxdepth 1 -name '*.json' -print -quit 2>/dev/null)"
if [ -n "$binding" ]; then refuse "discord-binding: $binding exists; a library hub has no Discord binding"
else ok "no Discord binding under $DATA_ROOT/discord"; fi
fi
# No sudo, and one group.
if sudo -n true >/dev/null 2>&1; then refuse "sudo -n true succeeds as $ME"; else ok "sudo -n true fails as $ME"; fi
groups="$(id -G)"