From dfabbbe8a6e3f791cd6f7ae6c90cbb4fb42f5342 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 21 Oct 2026 18:06:13 -0500 Subject: [PATCH] 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 --- agents/honker/work/queue-194/BUILD.md | 36 +++++++++++++++++++ docs/plans/DEFERRED.md | 12 +++++++ packages/bus/src/runtime.mjs | 5 ++- packages/bus/tests/reload.test.mjs | 18 +++++++++- packages/business/src/instance-settings.mjs | 13 +++++-- packages/business/src/role.mjs | 4 ++- .../business/tests/instance-settings.test.mjs | 11 +++--- .../tests/lifecycle-settings.test.mjs | 5 ++- packages/business/tests/role.test.mjs | 5 +++ packages/webui/src/cli.mjs | 2 +- packages/webui/src/public/agents.js | 3 +- packages/webui/src/serve.mjs | 5 ++- packages/webui/tests/agents-browser.test.mjs | 8 +++-- packages/webui/tests/agents.test.mjs | 28 ++++++++++----- packages/webui/tests/sso.test.mjs | 2 +- 15 files changed, 130 insertions(+), 27 deletions(-) diff --git a/agents/honker/work/queue-194/BUILD.md b/agents/honker/work/queue-194/BUILD.md index 6e3568701..d14c8a702 100644 --- a/agents/honker/work/queue-194/BUILD.md +++ b/agents/honker/work/queue-194/BUILD.md @@ -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 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. + +### 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. diff --git a/docs/plans/DEFERRED.md b/docs/plans/DEFERRED.md index 1bde9a15d..41752848f 100644 --- a/docs/plans/DEFERRED.md +++ b/docs/plans/DEFERRED.md @@ -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 the re-pin row. Morgana's wording (row 244 BUILD.md), Webby N2 on row 244 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 diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 54bd3f928..1f3c55ddd 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -207,9 +207,12 @@ export async function startBroker({ const fixedAtStart = b => ({ roles: Object.fromEntries(Object.entries(b.roles).map(([role, r]) => [role, fixedRole(r)])), 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 + && fixedAtStart(b).libraries === boot.libraries && 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 reloadLog = entry => process.stderr.write(`organization: ${JSON.stringify(entry)}\n`); diff --git a/packages/bus/tests/reload.test.mjs b/packages/bus/tests/reload.test.mjs index 8fd964095..e7f04196a 100644 --- a/packages/bus/tests/reload.test.mjs +++ b/packages/bus/tests/reload.test.mjs @@ -13,6 +13,7 @@ import { Store } from '../src/store.mjs'; import { loadBusiness } from '../../business/src/business.mjs'; import { resolveInstance } from '../../business/src/resolve.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. function busForm(root, rolesDir, id = 'demo') { @@ -22,11 +23,12 @@ function busForm(root, rolesDir, id = 'demo') { return busBusiness(b, resolved); } -async function setup(t) { +async function setup(t, mutate) { const root = mkdtempSync(join(tmpdir(), 'reload-')); const rolesDir = rolesCopy(root); const doc = businessDoc(root, 'demo'); doc.plugins = { harness: ['pi', 'claude-code'] }; + mutate?.(doc); const file = writeJson(join(root, 'businesses/demo.json'), doc); 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) } }); @@ -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); }); +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) => { const root = mkdtempSync(join(tmpdir(), 'reload-unit-')); t.after(() => rmSync(root, { recursive: true, force: true })); diff --git a/packages/business/src/instance-settings.mjs b/packages/business/src/instance-settings.mjs index 73364355f..49a0dabb6 100644 --- a/packages/business/src/instance-settings.mjs +++ b/packages/business/src/instance-settings.mjs @@ -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."); // 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) { if (!Object.hasOwn(document.roles, instance)) return { code: "organization-unknown-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 = []; if (document.launch?.by === instance) references.push("launch.by"); 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, protected: closed(entry.definition), 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. 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"); } 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); if ((old.retired ?? []).includes(name)) throw new OrganizationError("organization-instance-retired"); if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept"); diff --git a/packages/business/src/role.mjs b/packages/business/src/role.mjs index 67691c88f..e0868ad62 100644 --- a/packages/business/src/role.mjs +++ b/packages/business/src/role.mjs @@ -81,11 +81,13 @@ export { unhireableAction }; // 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 -// 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) { const dir = dirname(file); return requireDistinctList(hireable, "role hireable", (name) => { 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`); if (!existsSync(path)) refuse(`hireable names ${name}, which has no role file`); const named = validateRoleDocument(readJsonFile(path, "role file"), path, { hireable: false }); diff --git a/packages/business/tests/instance-settings.test.mjs b/packages/business/tests/instance-settings.test.mjs index cc0271b5c..27f9a5ebb 100644 --- a/packages/business/tests/instance-settings.test.mjs +++ b/packages/business/tests/instance-settings.test.mjs @@ -61,7 +61,7 @@ test("row 165 read lists each instance and the definitions add accepts", () => { const view = f.settings.read("acme"); assert.match(view.revision, /^[a-f0-9]{64}$/); 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.assistant.protected, 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 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"); - 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.match(added.transaction, /\S/); assert.equal(added.restart, false); 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")); assert.deepEqual(file.roles["gate-1"], { definition: "merge-gate" }); 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`); } 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 change of [{ vars: {} }, { launch: true }, { definition: "security-reviewer" }]) { 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"); 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). refuses(f, { instance: "bean", op: "change", definition: "security-reviewer" }, "BusinessError"); assert.equal(f.validate().status, 0); diff --git a/packages/business/tests/lifecycle-settings.test.mjs b/packages/business/tests/lifecycle-settings.test.mjs index 176afe6e5..5c0d0015d 100644 --- a/packages/business/tests/lifecycle-settings.test.mjs +++ b/packages/business/tests/lifecycle-settings.test.mjs @@ -7,6 +7,7 @@ import { spawnSync } from "node:child_process"; import { readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; 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"; 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 done = hire(f, { name: "sec-2", definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" } }); 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.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"]; }); 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-1", definition: "security-reviewer" }), "organization-instance-exists"); refuses(f, () => hire(f, { name: "chief", definition: "chief-of-staff" }), "organization-instance-exists"); diff --git a/packages/business/tests/role.test.mjs b/packages/business/tests/role.test.mjs index 7ee63c3d9..d5d5eb5bd 100644 --- a/packages/business/tests/role.test.mjs +++ b/packages/business/tests/role.test.mjs @@ -4,6 +4,7 @@ import { existsSync, readdirSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { loadRole, loadRoleFile, validateRoleDocument, ACTIONS, GATED_ONLY, HIRE_ALLOWED, BusinessError } from "../src/index.mjs"; import { REPO_ROLES, tmp, writeJson } from "./helpers.mjs"; +import { CREATIVE_DEFINITIONS } from "../src/vocabulary.mjs"; function refuses(fn, pattern, exitCode = 2) { 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", () => { 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/); + // 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"] } }), "chief"), /role hireable entry/); refuses(() => loadRole(hireDir({ chief: { hireable: "w" } }), "chief"), /role hireable must be an array/); diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index 3dfb76b57..1599d4776 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -114,7 +114,7 @@ try { const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') }; try { 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), 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), diff --git a/packages/webui/src/public/agents.js b/packages/webui/src/public/agents.js index 8f3f794a0..756666aaa 100644 --- a/packages/webui/src/public/agents.js +++ b/packages/webui/src/public/agents.js @@ -40,7 +40,8 @@ if (!c.isTester) { 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')); - 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 `${txt(name)}${txt(e.definition)}${e.launched ? 'yes' : 'no'}${e.kept ? 'yes' : 'no'}${running.length ? running.map(r => `${txt(r)}`).join(' ') : 'none'}${acts.join(' ')}`; }).join(''); diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 9b4013e31..8a491ae7e 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -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`. // `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` -// takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for +// `log` 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. // `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 (host === 'localhost') host = '127.0.0.1'; if (tester && preview !== null) throw new Error('tester listener cannot use preview mode'); diff --git a/packages/webui/tests/agents-browser.test.mjs b/packages/webui/tests/agents-browser.test.mjs index 9416ce57c..61aed40e2 100644 --- a/packages/webui/tests/agents-browser.test.mjs +++ b/packages/webui/tests/agents-browser.test.mjs @@ -11,7 +11,7 @@ import {close} from './fixture.mjs'; const hostile=''; const REV='a'.repeat(64), ID='0b5e8f1c-3a2d-4c6e-9f10-1234567890ab'; 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}}}; 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 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))"),[ - ['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('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); // 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); + // 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="hire"]').dataset.agentName`),'reviewer'); assert.match(await b.evaluate('document.querySelector("#agents-section").textContent'),/Retired names, never reused: old-coder\./); diff --git a/packages/webui/tests/agents.test.mjs b/packages/webui/tests/agents.test.mjs index c81d95fbd..ca7cd87bf 100644 --- a/packages/webui/tests/agents.test.mjs +++ b/packages/webui/tests/agents.test.mjs @@ -6,7 +6,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; 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 { startTesterSocket } from '../src/tester-socket.mjs'; +import { testerRequest } from '../../cli/src/tester-http.mjs'; import { BusReadError } from '../src/bus.mjs'; 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 } = {}) { const calls = [], lines = []; - const server = await startServer({ - port: 0, log: line => lines.push(line), - ...(tester ? { tester: true } : { preview, send: on ? async () => ({}) : null }), + const options = { + log: line => lines.push(line), 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, - }); + }; + // 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))); - const port = server.address().port; - 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 }; + return { calls, lines, port: server.address().port, headers }; } 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 => { @@ -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 => { 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/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' }], ['/api/instances', { revision: REV, instance: 'coder', op: 'delete' }]]) { const r = await send(s, path, body); diff --git a/packages/webui/tests/sso.test.mjs b/packages/webui/tests/sso.test.mjs index d5074c117..0a7d86cc1 100644 --- a/packages/webui/tests/sso.test.mjs +++ b/packages/webui/tests/sso.test.mjs @@ -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 dataRoot = mkdtempSync(join(tmpdir(),'g196-sso-')); 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)); const callTester = options => testerRequest({dataRoot,...options}); const { session, g, begun } = await signIn(env);