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:
1 parent
3046fd60a3
commit
661890e909
5 files changed
+859
-5
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = "OddName\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);
|
||||
});
|
||||
Executable
+228
@@ -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
@@ -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)"
|
||||
|
||||
Reference in new issue
Block a user