morgana: row 213 round 2 (webby-0040): M10 cases through the broker (B1), changes.jsonl mode (B2), adapter-test file as a deviation (N1), O_NOFOLLOW scope comment (N2), persona.name refuses U+2028/U+2029 (N3); mutants K2, K3, V1

This commit is contained in:
jason.woltje committed 2026-10-20 16:50:41 -05:00
1 parent 032be8c8bf
commit aee32d1851
8 files changed
+125 -13

No files matched your search

+40 -4
View File
@@ -67,6 +67,12 @@ code. My morgana-0021 message called it an existing code, which was wrong.
Test: `launcher.test.mjs` "row 213 a creative instance's persona is read from <configDir>/personas: off
its digest the launch fails with the mismatch as its reason". Mutant: D2.
- **Adapter tests' file** (webby-0040 N1). Brief line 68 puts the adapter tests in `claude-session.test.mjs`
and `pi-session.test.mjs`. They are in `harness/tests/creative.test.mjs` instead, next to the other row 213
harness tests, so the row's adapter cases sit in one file and the two session test files stay unchanged.
Same cases: argv holds the absolute prompt path and no marker, and pi refuses a missing, unreadable or
directory prompt.
The checkRoles change sits in its own block and the launcher change is one line, so the rebase over rows
194 and 199 (both touch `business.mjs`, and 199 touches `launcher.mjs`) stays small.
@@ -94,7 +100,7 @@ fact-checker,continuity}.{json,md}`.
Tests changed: `business/tests/{role,business,launch-settings,instance-settings,assistant}.test.mjs`,
`harness/tests/skills.test.mjs`, `bus/tests/project-runtime.test.mjs`, `cli/tests/launcher.test.mjs`.
New: `harness/tests/creative.test.mjs` (7), `bus/tests/creative.test.mjs` (2).
New: `harness/tests/creative.test.mjs` (7), `bus/tests/creative.test.mjs` (3).
I grepped `packages/*/tests` and `scripts/` for each changed source path next to a 64-hex string. The hits
are `thread-migration.test.mjs` (a DDL digest, not a pin on `broker.mjs`; 3/3 pass) and discord's own
@@ -105,7 +111,9 @@ are `thread-migration.test.mjs` (a DDL digest, not a pin on `broker.mjs`; 3/3 pa
Each file alone from its package with `node --test --test-reporter=tap <file>`, 2026-10-20, all fail 0:
- Touched: business role 17, business 17, launch-settings 16, instance-settings 12, assistant 11; harness
creative 7, skills 10; bus creative 2, project-runtime 3; cli launcher 30.
creative 7, skills 10, bundle 22; bus creative 3, project-runtime 3; cli launcher 30. Round 2 reran
bus creative, instance-settings, business, launch-settings, role, harness creative and bundle, bus
tester-writes 8 and chief-of-staff 18, and the vars readers business vars 7, discord context 7 and git 12.
- Other files that read a changed path: business hard-waits, record-grants, token-limit-settings,
workflow-roles, chief-of-staff, project-repo, plugin-settings; harness bundle 22, tools 22,
claude-session 8, pi-session 8, raise-domain, spec-tools, claude-gate, mcp-server, gate, runner 34,
@@ -121,8 +129,8 @@ Drift: `git diff --stat 088087ac9 305857c8f` over the 35 manifest paths is empty
Row 192's async `scripts/mosaic-config.mjs` and `webui-service.sh` are outside the manifest. The broker's
new import comes from `vocabulary.mjs`, which it already imported, so `RUNNER_FILES` doesn't change.
Mutants: `mutants.py` in this directory, 28 mutants over 10 files. It backs up each file to scratch first,
aborts on a bad backup or any anchor count other than one (all 28 checked as unique without editing), runs
Mutants: `mutants.py` in this directory, 31 mutants over 11 files. It backs up each file to scratch first,
aborts on a bad backup or any anchor count other than one (all 31 checked as unique without editing), runs
the named test files per mutant, restores each file after its mutant and again in `finally`. Log:
`mutants.log`, written on the run. The brief's `models` mutant is L1.
@@ -140,6 +148,8 @@ the named test files per mutant, restores each file after its mutant and again i
| B1-B8 mode bits, digest, headings, symlink, char cap, block, no dir, builtin web | bundle.mjs | harness creative |
| T1 raise_decision offered | tools.mjs | harness creative |
| K1 broker raise check off | broker.mjs | bus creative |
| K2/K3 chief or tester session reaches the writer as the human (round 2, B1) | broker.mjs | bus creative |
| V1 persona.name line separators accepted (round 2, N3) | vars.mjs | business |
| A1 pi without -f; A2/A3 relative path kept | adapters | harness creative, skills |
## Residuals
@@ -148,6 +158,11 @@ the named test files per mutant, restores each file after its mutant and again i
`creativeHumanOnly` compares the actor with `document.human`.
- TOCTOU between pi's `[ -f ] && [ -r ]` and pi's own read of the bundle's prompt file.
- The other-uid owner check in `readPersona` can't be tested without root.
- O_NOFOLLOW covers only the last path component, so a symlinked `personas/` or `personas/<business>/` is
followed (webby-0040 N2). The digest still refuses any bytes but the pinned ones. A comment in
`bundle.mjs` says so.
- The claude adapter checks the prompt file with `[ -r ]` only, and pi checks `-f` and `-r`. The brief asks
for pi's check (webby-0040 N4).
- **Launch-failed mapping.** A BundleError thrown from `buildBundle` inside `start()` becomes the Refusal
`launch-failed`, with the code's message in the log's `why`. That holds for the persona codes and for
webPlan's `web-source-not-allowed`. By then the run dir `launches/<business>/<run>/` exists with only
@@ -160,3 +175,24 @@ the named test files per mutant, restores each file after its mutant and again i
exists. Sage agreed it as a follow-up (sage-1501) and files it after this row lands.
- The webui `bus.mjs` code-to-HTTP map lacks the new codes.
- The cli `instances.mjs` message wording for the creative refusals.
## Round 2 (webby-0040, round 1 CHANGES on bb5f4600)
- **B1**: `bus/tests/creative.test.mjs` adds "M10 through the broker". It runs on a real organization file
and writer behind `startBroker`, with a claimed chief session, a bound tester session and the human.
- The chief's `organization.set` of bean's `launch.models` key is refused with
`organization-creative-human-only`. Its `instances` key is refused with `organization-creative-instance`.
- The tester's `organization.set` and `limits.set` are refused `tester-refused` (N5: the broker refuses
first). Its `instances.set` (change bean, add an ideas instance) and `routes.set` for bean reach the
writer as `tester` and are refused with `organization-creative-human-only`.
- The file bytes are unchanged and no committed log entry exists after all of these.
- The human's `limits.set` of the same change lands, and the one committed entry names `jason`.
- Mutants K2 and K3 change the broker's actor naming for a role or tester session.
- **B2**: `instance-settings.test.mjs` asserts the mode of `writer.logFile("acme")` (0600) and that it sits
in the history dir. It lives at `.history/acme/changes.jsonl`, so the round 1 loop over that dir already
covered it, but no line named it.
- **N1**: listed under Deviations. **N2**: comment and residual. **N3**: `persona.name` now refuses U+2028
and U+2029, with two values added to the business test and mutant V1. **N4**: noted under residuals.
- Drift: `git diff --stat 088087ac9 f41729178` over the 35 manifest paths is empty. Round 2 manifest
digest b5ff0ac0e754cb1e5891417f4ab1da9597f5cf49fb2fe2d5f0fbf066f821c43a.
@@ -5,19 +5,19 @@
a93b4efe213750b35730d6391594f3e26ffe667040a5d7baf5262b83341164b1 packages/business/src/instance-settings.mjs
3e957579bcfdf5f88e60f7235b7ead4fb27cb455e34b7588f4e22b8ab5542bec packages/business/src/launch-settings.mjs
9d3698ac66f3ed44644c5dbab01e33c4812f107d948d70970a64e746365e7dbe packages/business/src/role.mjs
da6d3b36a460b941c53fa2f313c83e227fe871b9f3792f10efc66f6a69913eeb packages/business/src/vars.mjs
f6b15ee8dad2d985c0ae66b1f106ca46cb56ec86ac8cd5dec8a05c887596a421 packages/business/src/vars.mjs
33bcac072757355c89411c0f8380130b0ffb49563a451be9dc41d34f4d977541 packages/business/src/vocabulary.mjs
b67917c5c7bb270b82f1cbd97071a195b79a57c9a343acbc5feae2a877fb9e8f packages/business/tests/assistant.test.mjs
fc908e27ada5fdc5b94192ccd4a16cc1569e5f6d76bcf7b5490b08bd9d5d520d packages/business/tests/business.test.mjs
11fcefebebc72400f36af08f367ecc4e78d67f697ea6021d0b2bb8d8df8e4eae packages/business/tests/instance-settings.test.mjs
b4606af3288624475e23ca506f7dea084a1e606efce4b1556d132b03230c2be1 packages/business/tests/business.test.mjs
2b80070db1e1d72b82a32cec75711c59a9383188b5a659854fd0ba8ae2dc0f5b packages/business/tests/instance-settings.test.mjs
647b4835836566070c7b859b2bbcaf3fd748030fe88c8f20e990f5038ec65e88 packages/business/tests/launch-settings.test.mjs
0c9f082d8c05582382dc646c74aa60a46b89804717f828db9fadde51f2e258a8 packages/business/tests/role.test.mjs
9169b8832ae1a0a0862198a930d421ce151a610e1a2e6a79dcd25df70cefd386 packages/bus/src/broker.mjs
4dd41b32d6120dc8e9bcc052a34b918de68af3e2dbbc3f16d9428bac4eecc99f packages/bus/tests/creative.test.mjs
21946cdecab263ae65791977eb4599594340c8e049b3c216e3181610638f0952 packages/bus/tests/creative.test.mjs
b8b1e4229472ee1986a3072d9745e711ee49a5fd15a7445ddcd294998b5f677a packages/bus/tests/project-runtime.test.mjs
b530ae2aba9a1d47e62d4271558bd69f8f6905dca42141a87fb18fd70f5ddc0f packages/cli/src/launcher.mjs
7933f256e5f097999ff36cc7e48b0c417385c6c3727919f38437bd501aecc438 packages/cli/tests/launcher.test.mjs
b422646e8f864e4de77bb8e3a11e3a83918b9dc2b0ef1c7a3c95dcca8e1b6943 packages/harness/src/bundle.mjs
2c1aad8d8be1a03be12eca93b4da36c656236ad38d09bfdc314e42a68240afc3 packages/harness/src/bundle.mjs
22f9e3c1dce25e5eb6713306ccdf22dc77a4ccf0c99e9186f2e16602f58aeb49 packages/harness/src/tools.mjs
305be35111c432a663d5671a4dac9bfe5bbb1a9b34ace77087e12c6f687602a1 packages/harness/tests/creative.test.mjs
3d68294493e41db3b647cd71cd94436c28fc531c64a8b1589be7644c3bf59f94 packages/harness/tests/skills.test.mjs
+4
View File
@@ -54,6 +54,10 @@ MUTANTS = [
# M6.
("T1 raise_decision offered to creative roles", TOOLS, ' if (!CREATIVE_DEFINITIONS.includes(resolved.definition)) add("raise_decision"', ' add("raise_decision"', T_HC),
("K1 broker creative raise check off", BROKER, " if (request.verb === 'decision.raise' && !s.human && !s.tester &&\n CREATIVE_DEFINITIONS", " if (false &&\n CREATIVE_DEFINITIONS", T_BUS),
# B1 (webby-0040): the broker names the writer's actor from the session.
("K2 chief session reaches the writer as the human", BROKER, 'actor = this.#business(s.business).roles[s.role].definition + "." + s.role;', 'actor = "jason";', T_BUS),
("K3 tester session reaches the writer as the human", BROKER, 'let actor = s.tester ? "tester" : s.human;', 'let actor = s.tester ? "jason" : s.human;', T_BUS),
("V1 persona.name line separators accepted (N3)", "packages/business/src/vars.mjs", "\\u007f\\u2028\\u2029]", "\\u007f]", T_BIZ),
# Prompt by file (adapters).
("A1 pi prompt check without -f", PI, '[ -f "$PROMPT_FILE" ] && [ -r "$PROMPT_FILE" ] ||', '[ -r "$PROMPT_FILE" ] ||', T_ADAPT),
("A2 pi relative prompt path kept", PI, ABS, " *) ;;", T_ADAPT),
+68 -1
View File
@@ -1,11 +1,13 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
// Row 213 (C2-08, #1714, Case 2 design 5.1): a creative instance talks with the user only, and it has
// no decision channel (M6). The messaging lists are read from the shipped role files.
@@ -84,3 +86,68 @@ test('a creative instance sends only to the user and hears only from the user',
assert.equal(count("SELECT count(*) n FROM messages WHERE from_role IN ('bean','quill')"), 2);
assert.equal(count("SELECT count(*) n FROM messages WHERE to_role IN ('bean','quill')"), 2);
});
// M10 through the broker (webby-0040 B1): the broker names the actor from the session, so these cases
// pin that a chief session reaches the writer as chief-of-staff.chief and a tester session as tester.
// A real organization file and writer, as tester-writes.test.mjs.
async function runtime(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-creative-m10-'));
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, 'demo');
doc.plugins = { ...doc.plugins, harness: ['pi'] };
doc.roles.bean = { definition: 'companion' };
for (const r of Object.values(doc.roles)) r.vars = { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high', ...r.vars };
// Only bean names the sonnet key, so the chief may still change every other key.
for (const [name, r] of Object.entries(doc.roles)) if (name !== 'bean') r.vars.model = 'claude-opus-5-5';
const file = join(root, 'businesses/demo.json');
writeJson(file, doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
const bus = await startBroker({
dataRoot: join(root, 'data'),
businesses: {
demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
plugins: { harness: ['pi'] },
roles: { chief: { definition: 'chief-of-staff', authority: shipped('chief-of-staff').authority }, bean: { definition: 'companion', ...within } },
},
},
organization: { dir: root, rolesDir, system: systemFor(root) },
});
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
const b = bus.broker;
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const tester = b.bindTester({ business: 'demo' });
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
b.request(chief, { verb: 'role.claim' });
const call = (cap, verb, args = {}) => b.request(cap, { verb, args });
const history = join(root, 'businesses/.history/demo/changes.jsonl');
const log = () => (existsSync(history) ? readFileSync(history, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse) : []);
const bytes = () => readFileSync(file, 'utf8');
return { human, tester, chief, call, log, bytes };
}
test('M10 through the broker: the chief and the tester are refused on bean, the human\'s limits.set of its models key works', async (t) => {
const { human, tester, chief, call, log, bytes } = await runtime(t);
const { revision, models } = call(human, 'limits');
const changed = { ...models, 'claude-sonnet-5-5': { ...models['claude-sonnet-5-5'], model: 'claude-sonnet-5-5-next' } };
const before = bytes();
// The chief of staff's organization.set reaches the writer as chief-of-staff.chief and is refused there.
assert.throws(() => call(chief, 'organization.set', { revision, models: changed }), { code: 'organization-creative-human-only' });
assert.throws(() => call(chief, 'organization.set', { revision, instances: { bean: 'claude-opus-5-5' } }), { code: 'organization-creative-instance' });
// The tester never reaches the writer through these two verbs (webby-0040 N5).
assert.throws(() => call(tester, 'organization.set', { revision, models: changed }), { code: 'tester-refused' });
assert.throws(() => call(tester, 'limits.set', { revision, models: changed }), { code: 'tester-refused' });
// Its instances.set and routes.set do reach the writer, as tester, and are refused there.
assert.throws(() => call(tester, 'instances.set', { revision, instance: 'bean', op: 'change', vars: { thinking: 'low' } }),
{ code: 'organization-creative-human-only' });
assert.throws(() => call(tester, 'instances.set', { revision, instance: 'muse', op: 'add', definition: 'ideas' }),
{ code: 'organization-creative-human-only' });
assert.throws(() => call(tester, 'routes.set', { revision, routes: { bean: { harness: 'pi', model: 'claude-opus-5-5', thinking: 'high', fallback: null } } }),
{ code: 'organization-creative-human-only' });
assert.equal(bytes(), before);
assert.deepEqual(log().filter((e) => e.state === 'committed'), []);
// The human's limits.set of the same change lands, logged with the human as actor.
call(human, 'limits.set', { revision, models: changed });
assert.equal(JSON.parse(bytes()).launch.models['claude-sonnet-5-5'].model, 'claude-sonnet-5-5-next');
assert.deepEqual(log().filter((e) => e.state === 'committed').map((e) => e.actor), ['jason']);
});
+1 -1
View File
@@ -150,7 +150,7 @@ export const REGISTRY = Object.freeze({
// business file; the bundle reads the file at launch and refuses it off this digest.
"persona.name": def(["agent"], (v, key) => {
requireString(v, `variable ${key}`, { max: 40 });
if (/[\u0000-\u001f\u007f]/.test(v)) refuse(`variable ${key} must be one line with no control characters`);
if (/[\u0000-\u001f\u007f\u2028\u2029]/.test(v)) refuse(`variable ${key} must be one line with no control characters`);
return v;
}),
"persona.sha256": def(["agent"], (v, key) => {
+1 -1
View File
@@ -251,7 +251,7 @@ test("persona vars: agent layer, creative definition only, both or neither", ()
refuses(() => check((d) => { d.roles.bean = { definition: "research", vars: { [key]: persona[key] } }; }),
/roles\.bean\.vars: persona\.name and persona\.sha256 are set together or not at all/);
}
for (const [key, value] of [["persona.name", "x".repeat(41)], ["persona.name", "Be\nan"], ["persona.name", "Be\u0007an"], ["persona.name", 7],
for (const [key, value] of [["persona.name", "x".repeat(41)], ["persona.name", "Be\nan"], ["persona.name", "Be\u0007an"], ["persona.name", "Be\u2028an"], ["persona.name", "Be\u2029an"], ["persona.name", 7],
["persona.sha256", "A".repeat(64)], ["persona.sha256", sha.slice(1)], ["persona.sha256", `${sha}0`]]) {
refuses(() => check((d) => { d.roles.bean = { definition: "companion", vars: { ...persona, [key]: value } }; }), new RegExp(`variable ${key.replace(".", "\\.")}`), 2);
}
@@ -254,7 +254,10 @@ test("row 213 a creative instance is the human's: add, vars and definition chang
}
// The writer's modes hold for the new file and its history.
assert.equal(statSync(f.file).mode & 0o777, 0o600);
// changes.jsonl sits in the history dir; the loop below covers it too, but the brief names it.
assert.equal(statSync(f.settings.writer.logFile("acme")).mode & 0o777, 0o600);
const history = join(f.root, "config", "businesses", ".history", "acme");
assert.ok(readdirSync(history).includes("changes.jsonl"));
assert.equal(statSync(history).mode & 0o777, 0o700);
for (const name of readdirSync(history)) {
const st = statSync(join(history, name));
+3 -1
View File
@@ -183,7 +183,9 @@ function skillSection(skills) {
// persona.sha256 var pins it. Read with O_NOFOLLOW; the file must be a regular file of the bus's
// uid with no group or other bit, at most PERSONA_CHARS characters, on its digest, and unable to
// close its fence or open a section. A file with no var is off its (absent) digest too, so a
// persona never reaches a session its business file doesn't name one for.
// persona never reaches a session its business file doesn't name one for. O_NOFOLLOW covers the
// last path component only, so a symlinked personas/ or personas/<business>/ is followed; the
// digest still refuses any bytes but the pinned ones.
export const PERSONA_CHARS = 8000;
const PERSONA_END = "</persona-file>";
function readPersona(resolved, personaDir) {