honker: row 194 b4 (sage-2164, sage-2166, sage-2217): a hire and the role's hireable list refuse a creative definition; a library mapping change isn't reloaded (T14); the listing's endProtected hides a Delete the server refuses; DEFERRED creative delete; C1 testerNoSignIn closed; row 213's writer-restart pin follows D3 and T16 uses row 196's tester socket

This commit is contained in:
jason.woltje committed 2026-10-21 18:25:52 -05:00
1 parent 9ff946579a
commit dfabbbe8a6
15 files changed
+130 -27

No files matched your search

+36
View File
@@ -755,3 +755,39 @@ Follow-up (option 2, not in b3): run `beforeWrite` inside `writer.write`, under
`organization.mjs:188` check. Until then a window remains: a write landing between the early read and the `organization.mjs:188` check. Until then a window remains: a write landing between the early read and the
writer's lock still lets a fire or delete revoke and then refuse `revision-conflict`, and an `outcome-unknown` writer's lock still lets a fire or delete revoke and then refuse `revision-conflict`, and an `outcome-unknown`
after a revoke still doesn't say the tokens are gone. after a revoke still doesn't say the tokens are gone.
### b4: rebase onto c3d88840c (sage-2157, sage-2164, 2026-10-21T21:20Z)
`honker/row194-b4` is b3's five commits replayed onto c3d88840c (rows 196, 210, 213, 234, 235, C2-03a/04
landed in between). b3 is unchanged. The first commit conflicted in 18 hunks across 11 files and the second
in 4; every one kept both sides (list in honker-0067). The `webui/src/bus.mjs` channel-proof pin was
recomputed on the merged file.
What the rebase exposed, fixed on b4:
- Creative hire (row 213, M10). `closed()` lets a creative definition through `definitionFor` for the
human's add and change, and hire used `definitionFor`. Hire now refuses `definition-not-hireable` for any
CREATIVE_DEFINITIONS entry, and `checkHireable` refuses a creative name in a role's hireable list at load.
- Reload and library projects (C2-04). A library entry has no `root` and its mirror is ensured at start, but
`fixedAtStart().roots` took every project's root, so a changed mapping reloaded live. `roots` now holds the
non-library roots, the Credentials store's set, and `libraries` fixes each library entry whole (reload T14).
- Creative delete (sage-2164). `deleteRefusal` refuses END_PROTECTED, creative definitions included, while
the listing's `protected` is false for a creative instance, so the Agents page offered a Delete the server
refuses. The listing adds `endProtected`, the same check `deleteRefusal` runs, and the page hides Delete on
it. The refusal stays; the human delete of a creative instance is a DEFERRED entry. A human can't fire one
either (fire refuses END_PROTECTED for every actor); `instances.set` with `launch: false` delists it.
- Row 213's companion listing test (`instance-settings.test.mjs:274`) didn't have 194's `kept`; it now
pins `kept` and `endProtected`.
- DEFERRED C1 (Duckworth N2 on row 196): `testerNoSignIn` bound the next row touching `serve.mjs`. 194
touches it, so the dead parameter is removed from `serve.mjs`, `webui/src/cli.mjs` and `sso.test.mjs`.
- Manifest notes for Webby (sage-2166): C1 is closed by 194 (the three `testerNoSignIn` lines), and
`docs/plans/DEFERRED.md` is its own manifest path (the creative delete entry; C1 marked at landing).
- Slot run 1 (sage-2217, 2026-10-21T22:36Z, 25 whole files on 646cd493a plus the tree): 312 pass, 2 fail.
Both came from combining rows on the c3d88840c base, so committed 646cd493a fails both too:
- `instance-settings.test.mjs:283` (row 213) expected the writer's `restart` true on a creative definition
change. 194's D3 makes the writer always answer false, and the bus sets restart from its reload, so
bean isn't running and a reload is the designed answer (reload T14 covers the running case). Repinned to false.
- `agents.test.mjs` T16 started the tester listener on a TCP port. Row 196 (7247d80c7) serves it only on
the private socket (`tester listener requires private socket`), so T16 now uses `startTesterSocket`
under a temp data root and `testerRequest`, as `tester-socket.test.mjs` and `sso.test.mjs` do.
- Drift: c3d88840c..eb017d77f overlaps the manifest only on `docs/plans/DEFERRED.md` (merge-tree clean);
the requirements tests changed their own counts, `host-run.test.mjs` took the #453 T27 fix.
+12
View File
@@ -372,6 +372,18 @@ at every gate. Started 2026-09-12 during the control board MVP.
credential and a real `release.sh activate`, and records both results on credential and a real `release.sh activate`, and records both results on
the re-pin row. Morgana's wording (row 244 BUILD.md), Webby N2 on row 244 the re-pin row. Morgana's wording (row 244 BUILD.md), Webby N2 on row 244
r1 (#1757). (2026-10-21) r1 (#1757). (2026-10-21)
- **Creative instances: the human can't delete one.** (Sage's ruling
sage-2164 on the row 194 b4 rebase, #1692.) Row 213 put the creative
definitions in END_PROTECTED, and row 194's `deleteRefusal`
(`instance-settings.mjs`) refuses END_PROTECTED for every actor, so
the human can't delete a companion instance and fire refuses one too.
The Agents page hides Delete on the listing's `endProtected`. The fix
threads the actor through `deleteRefusal` and the launcher's delete
pass, so the human may delete one and the tester may not
(`creativeHumanOnly`). Until then, the human can delist
(instances.set launch: false) but neither fire nor delete removes a
creative entry (sage-2166). The row covers fire and delete together;
Sage files it. (2026-10-21)
## Queue ## Queue
+4 -1
View File
@@ -207,9 +207,12 @@ export async function startBroker({
const fixedAtStart = b => ({ const fixedAtStart = b => ({
roles: Object.fromEntries(Object.entries(b.roles).map(([role, r]) => [role, fixedRole(r)])), roles: Object.fromEntries(Object.entries(b.roles).map(([role, r]) => [role, fixedRole(r)])),
sync: canonicalJson(b.tracker?.sync ?? null), sync: canonicalJson(b.tracker?.sync ?? null),
roots: canonicalJson(Object.values(b.projects ?? {}).map(p => p.root).sort()), roots: canonicalJson(Object.values(b.projects ?? {}).filter(p => p.kind !== 'library').map(p => p.root).sort()),
// A library entry has no root, and its mirror is ensured at start (C2-04), so each one is fixed whole.
libraries: canonicalJson(Object.fromEntries(Object.entries(b.projects ?? {}).filter(([, p]) => p.kind === 'library'))),
}); });
const sameFixed = (boot, b) => fixedAtStart(b).sync === boot.sync && fixedAtStart(b).roots === boot.roots const sameFixed = (boot, b) => fixedAtStart(b).sync === boot.sync && fixedAtStart(b).roots === boot.roots
&& fixedAtStart(b).libraries === boot.libraries
&& Object.entries(fixedAtStart(b).roles).every(([role, f]) => f === (boot.roles[role] ?? fixedRole(null))); && Object.entries(fixedAtStart(b).roles).every(([role, f]) => f === (boot.roles[role] ?? fixedRole(null)));
const booted = new Map(Object.entries(businesses).map(([id, b]) => [id, fixedAtStart(b)])); const booted = new Map(Object.entries(businesses).map(([id, b]) => [id, fixedAtStart(b)]));
const reloadLog = entry => process.stderr.write(`organization: ${JSON.stringify(entry)}\n`); const reloadLog = entry => process.stderr.write(`organization: ${JSON.stringify(entry)}\n`);
+17 -1
View File
@@ -13,6 +13,7 @@ import { Store } from '../src/store.mjs';
import { loadBusiness } from '../../business/src/business.mjs'; import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs'; import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs'; import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { mappingEntry } from '../../library/tests/helpers.mjs';
// The bus form of the business file, built as cli bootConfig builds it. // The bus form of the business file, built as cli bootConfig builds it.
function busForm(root, rolesDir, id = 'demo') { function busForm(root, rolesDir, id = 'demo') {
@@ -22,11 +23,12 @@ function busForm(root, rolesDir, id = 'demo') {
return busBusiness(b, resolved); return busBusiness(b, resolved);
} }
async function setup(t) { async function setup(t, mutate) {
const root = mkdtempSync(join(tmpdir(), 'reload-')); const root = mkdtempSync(join(tmpdir(), 'reload-'));
const rolesDir = rolesCopy(root); const rolesDir = rolesCopy(root);
const doc = businessDoc(root, 'demo'); const doc = businessDoc(root, 'demo');
doc.plugins = { harness: ['pi', 'claude-code'] }; doc.plugins = { harness: ['pi', 'claude-code'] };
mutate?.(doc);
const file = writeJson(join(root, 'businesses/demo.json'), doc); const file = writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 }); mkdirSync(join(root, 'data'), { mode: 0o700 });
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busForm(root, rolesDir) }, organization: { dir: root, rolesDir, system: systemFor(root) } }); const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busForm(root, rolesDir) }, organization: { dir: root, rolesDir, system: systemFor(root) } });
@@ -152,6 +154,20 @@ test('T14 a file whose project root differs from the boot copy is not reloaded,
assert.equal(f.reloads.length, 0); assert.equal(f.reloads.length, 0);
}); });
test('T14 a file whose library mapping differs from the boot copy is not reloaded, because the mirror is ensured at start', async (t) => {
const f = await setup(t, doc => { doc.projects.books = mappingEntry(); });
const doc = JSON.parse(readFileSync(f.file, 'utf8'));
doc.projects.books.branch = 'other';
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
const added = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer' });
assert.equal(added.restart, true);
assert.equal(f.reloads.length, 0);
// The same library entry reloads.
const g = await setup(t, doc => { doc.projects.books = mappingEntry(); });
assert.equal(g.ask('instances.set', { revision: g.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer' }).restart, false);
assert.equal(g.reloads.length, 1);
});
test('T14 reloadBusiness refuses an unknown business, a changed human and a document the boot check refuses', (t) => { test('T14 reloadBusiness refuses an unknown business, a changed human and a document the boot check refuses', (t) => {
const root = mkdtempSync(join(tmpdir(), 'reload-unit-')); const root = mkdtempSync(join(tmpdir(), 'reload-unit-'));
t.after(() => rmSync(root, { recursive: true, force: true })); t.after(() => rmSync(root, { recursive: true, force: true }));
+11 -2
View File
@@ -26,11 +26,14 @@ const revisionOk = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(v
const HIRE_VAR = key => key === "model" || key.startsWith("limits."); const HIRE_VAR = key => key === "model" || key.startsWith("limits.");
// Row 194 (#1692, V5): why an instance can't be deleted, checked in the design's order, or null. // Row 194 (#1692, V5): why an instance can't be deleted, checked in the design's order, or null.
// The last-listed check runs when the proposed file is validated (deletedDocument). // The last-listed check runs when the proposed file is validated (deletedDocument). A creative
// definition is END_PROTECTED too (row 213), so no one deletes one yet (sage-2164, DEFERRED). The
// listing's endProtected is this same check, so the Agents page offers no Delete it would refuse.
const endProtected = definition => END_PROTECTED.includes(definition);
export function deleteRefusal(document, rolesDir, instance) { export function deleteRefusal(document, rolesDir, instance) {
if (!Object.hasOwn(document.roles, instance)) return { code: "organization-unknown-instance" }; if (!Object.hasOwn(document.roles, instance)) return { code: "organization-unknown-instance" };
const entry = document.roles[instance]; const entry = document.roles[instance];
if (END_PROTECTED.includes(entry.definition)) return { code: "organization-definition-refused" }; if (endProtected(entry.definition)) return { code: "organization-definition-refused" };
const references = []; const references = [];
if (document.launch?.by === instance) references.push("launch.by"); if (document.launch?.by === instance) references.push("launch.by");
for (const kind of ["delivery", "technical"]) if (document.arbiters?.[kind] === instance) references.push(`arbiters.${kind}`); for (const kind of ["delivery", "technical"]) if (document.arbiters?.[kind] === instance) references.push(`arbiters.${kind}`);
@@ -164,6 +167,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
launcher: launch ? launch.by === name : false, launcher: launch ? launch.by === name : false,
protected: closed(entry.definition), protected: closed(entry.definition),
kept: (launch?.keep ?? []).includes(name), kept: (launch?.keep ?? []).includes(name),
endProtected: endProtected(entry.definition),
}])); }]));
// Row 194 (D1): the human's forbid lists and the retired names. The bus adds running runs. // Row 194 (D1): the human's forbid lists and the retired names. The bus adds running runs.
return { return {
@@ -297,6 +301,11 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
throw new OrganizationError("invalid-request", "hire vars may set only model and limits keys"); throw new OrganizationError("invalid-request", "hire vars may set only model and limits keys");
} }
if ((old.launch.noHire ?? []).includes(definition)) throw new OrganizationError("definition-not-hireable"); if ((old.launch.noHire ?? []).includes(definition)) throw new OrganizationError("definition-not-hireable");
// Row 213 (M10): definitionFor lets a creative definition through for the human's add and
// change. A hire is the chief's, so it refuses one here.
if (CREATIVE_DEFINITIONS.includes(definition)) {
throw new OrganizationError("definition-not-hireable", `${definition} is a creative definition, which only the human adds`);
}
definitionFor(rolesDir, definition); definitionFor(rolesDir, definition);
if ((old.retired ?? []).includes(name)) throw new OrganizationError("organization-instance-retired"); if ((old.retired ?? []).includes(name)) throw new OrganizationError("organization-instance-retired");
if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept"); if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept");
+3 -1
View File
@@ -81,11 +81,13 @@ export { unhireableAction };
// Row 194 (#1692, D2.3): optional. The role definitions this role may hire, by name. Each one must // Row 194 (#1692, D2.3): optional. The role definitions this role may hire, by name. Each one must
// be a version 2 file in the same directory whose every action is on HIRE_ALLOWED. The named file // be a version 2 file in the same directory whose every action is on HIRE_ALLOWED. The named file
// is validated without its own "hireable" check, so a list can't recurse. // is validated without its own "hireable" check, so a list can't recurse. A creative definition
// (row 213, M10) is never hireable: only the human adds one.
function checkHireable(hireable, file) { function checkHireable(hireable, file) {
const dir = dirname(file); const dir = dirname(file);
return requireDistinctList(hireable, "role hireable", (name) => { return requireDistinctList(hireable, "role hireable", (name) => {
requireId(name, "role hireable entry"); requireId(name, "role hireable entry");
if (CREATIVE_DEFINITIONS.includes(name)) refuse(`hireable names ${name}, a creative definition only the human adds`);
const path = join(dir, `${name}.json`); const path = join(dir, `${name}.json`);
if (!existsSync(path)) refuse(`hireable names ${name}, which has no role file`); if (!existsSync(path)) refuse(`hireable names ${name}, which has no role file`);
const named = validateRoleDocument(readJsonFile(path, "role file"), path, { hireable: false }); const named = validateRoleDocument(readJsonFile(path, "role file"), path, { hireable: false });
@@ -61,7 +61,7 @@ test("row 165 read lists each instance and the definitions add accepts", () => {
const view = f.settings.read("acme"); const view = f.settings.read("acme");
assert.match(view.revision, /^[a-f0-9]{64}$/); assert.match(view.revision, /^[a-f0-9]{64}$/);
assert.equal(view.launch, true); assert.equal(view.launch, true);
assert.deepEqual(view.instances.coder, { definition: "coder", vars: { harness: "pi", "limits.network": "none" }, launched: true, launcher: false, protected: false, kept: false }); assert.deepEqual(view.instances.coder, { definition: "coder", vars: { harness: "pi", "limits.network": "none" }, launched: true, launcher: false, protected: false, kept: false, endProtected: false });
assert.equal(view.instances.chief.launcher, true); assert.equal(view.instances.chief.launcher, true);
assert.equal(view.instances.assistant.protected, true); assert.equal(view.instances.assistant.protected, true);
assert.deepEqual(view.definitions["security-reviewer"], { services: [], accepted: true }); assert.deepEqual(view.definitions["security-reviewer"], { services: [], accepted: true });
@@ -75,12 +75,12 @@ test("row 165 add: two instances join, validate exits 0, the log names the human
const f = fixture(); const f = fixture();
const view = f.settings.read("acme"); const view = f.settings.read("acme");
const added = f.settings.set("acme", { revision: view.revision, instance: "sec-1", op: "add", definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launch: true }, "jason"); const added = f.settings.set("acme", { revision: view.revision, instance: "sec-1", op: "add", definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launch: true }, "jason");
assert.deepEqual(added.instances["sec-1"], { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false, kept: false }); assert.deepEqual(added.instances["sec-1"], { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false, kept: false, endProtected: false });
assert.notEqual(added.revision, view.revision); assert.notEqual(added.revision, view.revision);
assert.match(added.transaction, /\S/); assert.match(added.transaction, /\S/);
assert.equal(added.restart, false); assert.equal(added.restart, false);
const gate = f.settings.set("acme", { revision: added.revision, instance: "gate-1", op: "add", definition: "merge-gate" }, "jason"); const gate = f.settings.set("acme", { revision: added.revision, instance: "gate-1", op: "add", definition: "merge-gate" }, "jason");
assert.deepEqual(gate.instances["gate-1"], { definition: "merge-gate", vars: {}, launched: false, launcher: false, protected: false, kept: false }); assert.deepEqual(gate.instances["gate-1"], { definition: "merge-gate", vars: {}, launched: false, launcher: false, protected: false, kept: false, endProtected: false });
const file = JSON.parse(readFileSync(f.file, "utf8")); const file = JSON.parse(readFileSync(f.file, "utf8"));
assert.deepEqual(file.roles["gate-1"], { definition: "merge-gate" }); assert.deepEqual(file.roles["gate-1"], { definition: "merge-gate" });
assert.deepEqual(file.launch.instances, ["coder", "reviewer", "sec-1"]); assert.deepEqual(file.launch.instances, ["coder", "reviewer", "sec-1"]);
@@ -271,7 +271,7 @@ test("row 213 a creative instance is the human's: add, vars and definition chang
e => e.code === "organization-creative-human-only", `${actor} redefine`); e => e.code === "organization-creative-human-only", `${actor} redefine`);
} }
let done = f.settings.set("acme", { revision: view.revision, ...add }, "jason"); let done = f.settings.set("acme", { revision: view.revision, ...add }, "jason");
assert.deepEqual(done.instances.bean, { definition: "companion", vars: persona, launched: false, launcher: false, protected: false }); assert.deepEqual(done.instances.bean, { definition: "companion", vars: persona, launched: false, launcher: false, protected: false, kept: false, endProtected: true });
for (const actor of ["chief-of-staff.chief", "tester"]) { for (const actor of ["chief-of-staff.chief", "tester"]) {
for (const change of [{ vars: {} }, { launch: true }, { definition: "security-reviewer" }]) { for (const change of [{ vars: {} }, { launch: true }, { definition: "security-reviewer" }]) {
assert.throws(() => f.settings.set("acme", { revision: done.revision, instance: "bean", op: "change", ...change }, actor), assert.throws(() => f.settings.set("acme", { revision: done.revision, instance: "bean", op: "change", ...change }, actor),
@@ -280,7 +280,8 @@ test("row 213 a creative instance is the human's: add, vars and definition chang
} }
done = f.settings.set("acme", { revision: done.revision, instance: "bean", op: "change", definition: "ideas", vars: { ...persona, "persona.name": "Bean Two" } }, "jason"); done = f.settings.set("acme", { revision: done.revision, instance: "bean", op: "change", definition: "ideas", vars: { ...persona, "persona.name": "Bean Two" } }, "jason");
assert.equal(done.instances.bean.definition, "ideas"); assert.equal(done.instances.bean.definition, "ideas");
assert.equal(done.restart, true); // Row 194 (D3): the writer's restart is false; the bus sets it from its reload, and bean isn't running.
assert.equal(done.restart, false);
// A persona var on a definition that isn't creative is refused by the validator (D1). // A persona var on a definition that isn't creative is refused by the validator (D1).
refuses(f, { instance: "bean", op: "change", definition: "security-reviewer" }, "BusinessError"); refuses(f, { instance: "bean", op: "change", definition: "security-reviewer" }, "BusinessError");
assert.equal(f.validate().status, 0); assert.equal(f.validate().status, 0);
@@ -7,6 +7,7 @@ import { spawnSync } from "node:child_process";
import { readFileSync, rmSync } from "node:fs"; import { readFileSync, rmSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { instanceSettings } from "../src/instance-settings.mjs"; import { instanceSettings } from "../src/instance-settings.mjs";
import { CREATIVE_DEFINITIONS } from "../src/vocabulary.mjs";
import { businessDoc, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs"; import { businessDoc, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs";
const CLI = join(REPO, "packages", "business", "src", "cli.mjs"); const CLI = join(REPO, "packages", "business", "src", "cli.mjs");
@@ -152,7 +153,7 @@ test("D2.2 hire adds and lists a new instance, logs the chief's context, and set
const f = fixture(); const f = fixture();
const done = hire(f, { name: "sec-2", definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" } }); const done = hire(f, { name: "sec-2", definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" } });
assert.deepEqual(done.instances["sec-2"], { assert.deepEqual(done.instances["sec-2"], {
definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false, kept: false, definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false, kept: false, endProtected: false,
}); });
assert.equal(done.restart, false); assert.equal(done.restart, false);
assert.deepEqual(f.onDisk().launch.instances, ["coder", "reviewer", "sec-1", "sec-2"]); assert.deepEqual(f.onDisk().launch.instances, ["coder", "reviewer", "sec-1", "sec-2"]);
@@ -182,6 +183,8 @@ test("D2.2 hire refuses noHire, a kept name and a listed name, and re-lists a fi
doc.launch.keep = ["sec-3"]; doc.launch.keep = ["sec-3"];
}); });
refuses(f, () => hire(f, { name: "gate-1", definition: "merge-gate" }), "definition-not-hireable"); refuses(f, () => hire(f, { name: "gate-1", definition: "merge-gate" }), "definition-not-hireable");
// Row 213 (M10): a creative definition is the human's to add, never the chief's to hire.
for (const definition of CREATIVE_DEFINITIONS) refuses(f, () => hire(f, { name: "muse-1", definition }), "definition-not-hireable");
refuses(f, () => hire(f, { name: "sec-3", definition: "security-reviewer" }), "instance-kept"); refuses(f, () => hire(f, { name: "sec-3", definition: "security-reviewer" }), "instance-kept");
refuses(f, () => hire(f, { name: "sec-1", definition: "security-reviewer" }), "organization-instance-exists"); refuses(f, () => hire(f, { name: "sec-1", definition: "security-reviewer" }), "organization-instance-exists");
refuses(f, () => hire(f, { name: "chief", definition: "chief-of-staff" }), "organization-instance-exists"); refuses(f, () => hire(f, { name: "chief", definition: "chief-of-staff" }), "organization-instance-exists");
+5
View File
@@ -4,6 +4,7 @@ import { existsSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { loadRole, loadRoleFile, validateRoleDocument, ACTIONS, GATED_ONLY, HIRE_ALLOWED, BusinessError } from "../src/index.mjs"; import { loadRole, loadRoleFile, validateRoleDocument, ACTIONS, GATED_ONLY, HIRE_ALLOWED, BusinessError } from "../src/index.mjs";
import { REPO_ROLES, tmp, writeJson } from "./helpers.mjs"; import { REPO_ROLES, tmp, writeJson } from "./helpers.mjs";
import { CREATIVE_DEFINITIONS } from "../src/vocabulary.mjs";
function refuses(fn, pattern, exitCode = 2) { function refuses(fn, pattern, exitCode = 2) {
assert.throws(fn, (error) => { assert.throws(fn, (error) => {
@@ -314,6 +315,10 @@ test("hireable: a mixed definition refuses, a clean list loads, and the named fi
test("hireable: a missing, version 1, duplicate or badly named entry refuses, and the key is optional", () => { test("hireable: a missing, version 1, duplicate or badly named entry refuses, and the key is optional", () => {
refuses(() => loadRole(hireDir({ chief: { hireable: ["ghost"] } }), "chief"), /hireable names ghost, which has no role file/); refuses(() => loadRole(hireDir({ chief: { hireable: ["ghost"] } }), "chief"), /hireable names ghost, which has no role file/);
refuses(() => loadRole(hireDir({ chief: { hireable: ["old"] }, old: 1 }), "chief"), /hireable names old, which is role version 1/); refuses(() => loadRole(hireDir({ chief: { hireable: ["old"] }, old: 1 }), "chief"), /hireable names old, which is role version 1/);
// Row 213 (M10): a creative definition refuses by name, before its file is read.
for (const name of CREATIVE_DEFINITIONS) {
refuses(() => loadRole(hireDir({ chief: { hireable: [name] } }), "chief"), new RegExp(`hireable names ${name}, a creative definition only the human adds$`));
}
refuses(() => loadRole(hireDir({ chief: { hireable: ["w", "w"] }, w: {} }), "chief"), /duplicate entry in role hireable/); refuses(() => loadRole(hireDir({ chief: { hireable: ["w", "w"] }, w: {} }), "chief"), /duplicate entry in role hireable/);
refuses(() => loadRole(hireDir({ chief: { hireable: ["../w"] } }), "chief"), /role hireable entry/); refuses(() => loadRole(hireDir({ chief: { hireable: ["../w"] } }), "chief"), /role hireable entry/);
refuses(() => loadRole(hireDir({ chief: { hireable: "w" } }), "chief"), /role hireable must be an array/); refuses(() => loadRole(hireDir({ chief: { hireable: "w" } }), "chief"), /role hireable must be an array/);
+1 -1
View File
@@ -114,7 +114,7 @@ try {
const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') }; const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
try { try {
testerServer = await startTesterSocket({ dataRoot: system.dataRoot, board: options.board, testerServer = await startTesterSocket({ dataRoot: system.dataRoot, board: options.board,
tester: true, testerNoSignIn: ssoOn, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads, tester: true, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args), projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args), libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args),
instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args), instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args),
+2 -1
View File
@@ -40,7 +40,8 @@
if (!c.isTester) { if (!c.isTester) {
acts.push(confirmPair('regenerate', name, 'Regenerate', `Regenerate ${name}? Its workspace is renamed under .retired and it starts fresh.`)); acts.push(confirmPair('regenerate', name, 'Regenerate', `Regenerate ${name}? Its workspace is renamed under .retired and it starts fresh.`));
acts.push(e.kept ? button('unkeep', name, 'Unkeep') : button('keep', name, 'Keep')); acts.push(e.kept ? button('unkeep', name, 'Unkeep') : button('keep', name, 'Keep'));
if (!e.protected) acts.push(confirmPair('delete', name, 'Delete', `Delete ${name}? Its evidence and its workspace under .retired stay.`)); // endProtected is the server's delete check, creative definitions included (sage-2164).
if (!e.endProtected) acts.push(confirmPair('delete', name, 'Delete', `Delete ${name}? Its evidence and its workspace under .retired stay.`));
} }
return `<tr><th scope="row">${txt(name)}</th><td>${txt(e.definition)}</td><td>${e.launched ? 'yes' : 'no'}</td><td>${e.kept ? 'yes' : 'no'}</td><td>${running.length ? running.map(r => `<code>${txt(r)}</code>`).join(' ') : '<span class="muted">none</span>'}</td><td class="agent-actions">${acts.join(' ')}</td></tr>`; return `<tr><th scope="row">${txt(name)}</th><td>${txt(e.definition)}</td><td>${e.launched ? 'yes' : 'no'}</td><td>${e.kept ? 'yes' : 'no'}</td><td>${running.length ? running.map(r => `<code>${txt(r)}</code>`).join(' ') : '<span class="muted">none</span>'}</td><td class="agent-actions">${acts.join(' ')}</td></tr>`;
}).join(''); }).join('');
+2 -3
View File
@@ -574,11 +574,10 @@ async function projectFileRead(res, read, path, search) {
} }
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode or `sso`. // `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode or `sso`.
// `sso` is createSso() from sso.mjs (Q175 phase 1): the human listener signs people in itself. // `sso` is createSso() from sso.mjs (Q175 phase 1): the human listener signs people in itself.
// `testerNoSignIn` makes the tester listener refuse anything that looks like a sign-in (--sso). `log` // `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json. // tests; the field table is otherwise the committed packages/tasks/fields.json.
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request. // `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerNoSignIn = false, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) { export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host'); if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1'; if (host === 'localhost') host = '127.0.0.1';
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode'); if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
+6 -2
View File
@@ -11,7 +11,7 @@ import {close} from './fixture.mjs';
const hostile='<img src=x onerror="window.injected=true">'; const hostile='<img src=x onerror="window.injected=true">';
const REV='a'.repeat(64), ID='0b5e8f1c-3a2d-4c6e-9f10-1234567890ab'; const REV='a'.repeat(64), ID='0b5e8f1c-3a2d-4c6e-9f10-1234567890ab';
const view={business:'acme',revision:REV,launch:true,noHire:['reviewer'],retired:['old-coder'], const view={business:'acme',revision:REV,launch:true,noHire:['reviewer'],retired:['old-coder'],
instances:{coder:{definition:'coder',vars:{},launched:true,launcher:false,protected:false,kept:false,running:['run-1']},[hostile]:{definition:'coder',vars:{},launched:false,launcher:false,protected:false,kept:true,running:[]},assistant:{definition:'assistant',vars:{},launched:false,launcher:false,protected:true,kept:false,running:[]}}, instances:{coder:{definition:'coder',vars:{},launched:true,launcher:false,protected:false,kept:false,running:['run-1']},[hostile]:{definition:'coder',vars:{},launched:false,launcher:false,protected:false,kept:true,running:[]},assistant:{definition:'assistant',vars:{},launched:false,launcher:false,protected:true,kept:false,endProtected:true,running:[]},bean:{definition:'companion',vars:{},launched:false,launcher:false,protected:false,kept:false,endProtected:true,running:[]}},
definitions:{assistant:{services:[],accepted:false},coder:{services:[],accepted:true},reviewer:{services:[],accepted:true}}}; definitions:{assistant:{services:[],accepted:false},coder:{services:[],accepted:true},reviewer:{services:[],accepted:true}}};
test('row 194 T16 Settings Agents: unavailable read-only, launch follows its request, confirms, reason and refusal',{timeout:90000},async()=>{ test('row 194 T16 Settings Agents: unavailable read-only, launch follows its request, confirms, reason and refusal',{timeout:90000},async()=>{
@@ -29,7 +29,7 @@ test('row 194 T16 Settings Agents: unavailable read-only, launch follows its req
await b.navigate(`http://127.0.0.1:${port}/#/settings`); await b.navigate(`http://127.0.0.1:${port}/#/settings`);
await wait('!!document.querySelector("#agents-table")'); await wait('!!document.querySelector("#agents-table")');
assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#agents-table tbody tr')].map(r=>[...r.querySelectorAll('th,td')].slice(0,5).map(c=>c.textContent))"),[ assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#agents-table tbody tr')].map(r=>[...r.querySelectorAll('th,td')].slice(0,5).map(c=>c.textContent))"),[
['coder','coder','yes','no','run-1'],[hostile,'coder','no','yes','none'],['assistant','assistant','no','no','none'], ['coder','coder','yes','no','run-1'],[hostile,'coder','no','yes','none'],['assistant','assistant','no','no','none'],['bean','companion','no','no','none'],
]); ]);
assert.equal(await b.evaluate('window.injected===true'),false); assert.equal(await b.evaluate('window.injected===true'),false);
assert.equal(await b.evaluate('document.querySelector("#agents-unavailable").textContent'),'Agent actions are unavailable here. Use mosaic business instance from the instance CLI.'); assert.equal(await b.evaluate('document.querySelector("#agents-unavailable").textContent'),'Agent actions are unavailable here. Use mosaic business instance from the instance CLI.');
@@ -37,6 +37,10 @@ test('row 194 T16 Settings Agents: unavailable read-only, launch follows its req
assert.equal(await b.evaluate('document.querySelectorAll("#agents-section [data-agent-action]").length>0'),true); assert.equal(await b.evaluate('document.querySelectorAll("#agents-section [data-agent-action]").length>0'),true);
// The protected assistant has no Delete; the kept instance offers Unkeep; reviewer is no-hire. // The protected assistant has no Delete; the kept instance offers Unkeep; reviewer is no-hire.
assert.equal(await b.evaluate('!!document.querySelector(\'[data-agent-action="delete"][data-agent-name="assistant"]\')'),false); assert.equal(await b.evaluate('!!document.querySelector(\'[data-agent-action="delete"][data-agent-name="assistant"]\')'),false);
// sage-2164: a creative instance isn't protected (the human changes it), yet the server refuses its
// delete, so endProtected hides Delete for it too.
assert.equal(await b.evaluate('!!document.querySelector(\'[data-agent-action="delete"][data-agent-name="bean"]\')'),false);
assert.equal(await b.evaluate('!!document.querySelector(\'[data-agent-action="launch"][data-agent-name="bean"]\')'),true);
assert.equal(await b.evaluate(`document.querySelector('[data-agent-action="unkeep"]').dataset.agentName`),hostile); assert.equal(await b.evaluate(`document.querySelector('[data-agent-action="unkeep"]').dataset.agentName`),hostile);
assert.equal(await b.evaluate(`document.querySelector('[data-agent-action="hire"]').dataset.agentName`),'reviewer'); assert.equal(await b.evaluate(`document.querySelector('[data-agent-action="hire"]').dataset.agentName`),'reviewer');
assert.match(await b.evaluate('document.querySelector("#agents-section").textContent'),/Retired names, never reused: old-coder\./); assert.match(await b.evaluate('document.querySelector("#agents-section").textContent'),/Retired names, never reused: old-coder\./);
+20 -8
View File
@@ -6,7 +6,12 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { request } from 'node:http'; import { request } from 'node:http';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer } from '../src/serve.mjs'; import { startServer } from '../src/serve.mjs';
import { startTesterSocket } from '../src/tester-socket.mjs';
import { testerRequest } from '../../cli/src/tester-http.mjs';
import { BusReadError } from '../src/bus.mjs'; import { BusReadError } from '../src/bus.mjs';
const preview = { host: 'console.example', header: 'x-user', user: 'jason' }; const preview = { host: 'console.example', header: 'x-user', user: 'jason' };
@@ -24,16 +29,22 @@ const ANSWERS = {
async function setup(t, { on = true, agent = async (verb, args) => ANSWERS[verb](args), instances = async args => ({ request: ID, args }), tester = false } = {}) { async function setup(t, { on = true, agent = async (verb, args) => ANSWERS[verb](args), instances = async args => ({ request: ID, args }), tester = false } = {}) {
const calls = [], lines = []; const calls = [], lines = [];
const server = await startServer({ const options = {
port: 0, log: line => lines.push(line), log: line => lines.push(line),
...(tester ? { tester: true } : { preview, send: on ? async () => ({}) : null }),
agentWrite: agent ? async (verb, args) => { calls.push([verb, args]); return agent(verb, args); } : null, agentWrite: agent ? async (verb, args) => { calls.push([verb, args]); return agent(verb, args); } : null,
instancesWrite: instances ? async args => { calls.push(['instances.set', args]); return instances(args); } : null, instancesWrite: instances ? async args => { calls.push(['instances.set', args]); return instances(args); } : null,
}); };
// Row 196 (#1694): the tester listener serves only on its private socket under the data root.
if (tester) {
const dataRoot = mkdtempSync(join(tmpdir(), 'g194-tester-'));
t.after(() => rmSync(dataRoot, { recursive: true, force: true }));
const server = await startTesterSocket({ dataRoot, ...options });
t.after(() => new Promise(r => server.close(r)));
return { calls, lines, dataRoot };
}
const server = await startServer({ port: 0, preview, send: on ? async () => ({}) : null, ...options });
t.after(() => new Promise(r => server.close(r))); t.after(() => new Promise(r => server.close(r)));
const port = server.address().port; return { calls, lines, port: server.address().port, headers };
const hs = tester ? { host: `127.0.0.1:${port}`, origin: `http://127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } : headers;
return { calls, lines, port, headers: hs };
} }
const send = (s, path, data, hs = s.headers, method = 'POST') => new Promise((resolve, reject) => { const send = (s, path, data, hs = s.headers, method = 'POST') => new Promise((resolve, reject) => {
const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => { const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => {
@@ -184,9 +195,10 @@ test('row 194 T16: delete joins add and change on /api/instances with only revis
test('row 194 T16 D4.1: the tester listener launches, terminates and reads requests; regenerate, veto and delete are refused with no call', async t => { test('row 194 T16 D4.1: the tester listener launches, terminates and reads requests; regenerate, veto and delete are refused with no call', async t => {
const s = await setup(t, { tester: true }); const s = await setup(t, { tester: true });
const send = (s, path, body) => testerRequest({ dataRoot: s.dataRoot, path, method: 'POST', body });
assert.equal((await send(s, '/api/agent/launch', { instance: 'coder' })).status, 200); assert.equal((await send(s, '/api/agent/launch', { instance: 'coder' })).status, 200);
assert.equal((await send(s, '/api/agent/terminate', { instance: 'coder' })).status, 200); assert.equal((await send(s, '/api/agent/terminate', { instance: 'coder' })).status, 200);
assert.equal((await get(s, `/api/agent/request?id=${ID}`)).status, 200); assert.equal((await testerRequest({ dataRoot: s.dataRoot, path: `/api/agent/request?id=${ID}` })).status, 200);
for (const [path, body] of [['/api/agent/regenerate', { instance: 'coder' }], ['/api/veto', { revision: REV, op: 'keep', name: 'coder' }], for (const [path, body] of [['/api/agent/regenerate', { instance: 'coder' }], ['/api/veto', { revision: REV, op: 'keep', name: 'coder' }],
['/api/instances', { revision: REV, instance: 'coder', op: 'delete' }]]) { ['/api/instances', { revision: REV, instance: 'coder', op: 'delete' }]]) {
const r = await send(s, path, body); const r = await send(s, path, body);
+1 -1
View File
@@ -389,7 +389,7 @@ test('12: under --sso the tester listener refuses each kind of sign-in, has no /
const env = await setup(t); const env = await setup(t);
const dataRoot = mkdtempSync(join(tmpdir(),'g196-sso-')); const dataRoot = mkdtempSync(join(tmpdir(),'g196-sso-'));
t.after(()=>rmSync(dataRoot,{recursive:true,force:true})); t.after(()=>rmSync(dataRoot,{recursive:true,force:true}));
const tester = await startTesterSocket({ dataRoot, board: 'http://127.0.0.1:9', bus: env.bus, testerNoSignIn: true, log: () => {} }); const tester = await startTesterSocket({ dataRoot, board: 'http://127.0.0.1:9', bus: env.bus, log: () => {} });
t.after(() => close(tester)); t.after(() => close(tester));
const callTester = options => testerRequest({dataRoot,...options}); const callTester = options => testerRequest({dataRoot,...options});
const { session, g, begun } = await signIn(env); const { session, g, begun } = await signIn(env);