From ec82624742603e77ce6b27bca359706f6f6a9e83 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 22 Oct 2026 03:57:05 -0500 Subject: [PATCH] honker: row 256 (#1761) b1: the human deletes a creative instance and any other actor is refused organization-creative-human-only; protectedRefusal is the delete check and the listing's per-reader endProtected; actor threaded through planDelete/checkDelete/instances.check; fire unchanged; DEFERRED entry closed for delete; tests T1-T8, unrun --- agents/honker/work/queue-256/BUILD.md | 67 +++++++++++++++++++ docs/plans/DEFERRED.md | 6 ++ packages/bus/src/runtime.mjs | 5 +- packages/bus/tests/creative.test.mjs | 29 +++++++- packages/business/src/instance-settings.mjs | 44 +++++++----- .../business/tests/instance-settings.test.mjs | 65 +++++++++++++++++- .../tests/lifecycle-settings.test.mjs | 15 +++++ packages/webui/src/public/agents.js | 3 +- packages/webui/tests/agents-browser.test.mjs | 30 ++++++++- 9 files changed, 241 insertions(+), 23 deletions(-) create mode 100644 agents/honker/work/queue-256/BUILD.md diff --git a/agents/honker/work/queue-256/BUILD.md b/agents/honker/work/queue-256/BUILD.md new file mode 100644 index 000000000..0527ad7cf --- /dev/null +++ b/agents/honker/work/queue-256/BUILD.md @@ -0,0 +1,67 @@ +# Row 256 (#1761) build notes (append only) + +## 2026-10-22 b1 (Honker) + +Design d2 8c50481d2 was accepted by Webby (webby-0123). The code is on honker/row256 from be2b008cc. + +### Correction to the design: the non-creative END_PROTECTED definition is `assistant` + +- DESIGN.md (d1 and d2) says "the chief of staff" for the END_PROTECTED definition that isn't creative, + following the brief's wording. In the code that definition is `assistant`: + - `THREAD_DEFINITION = "assistant"` (vocabulary.mjs:203). + - `END_PROTECTED = THREAD_DEFINITIONS = [assistant, ...CREATIVE_DEFINITIONS]`. +- The `chief-of-staff` definition (the `chief` instance) isn't END_PROTECTED. Its delete refuses + `organization-instance-referenced`, because it is launch.by, and that is unchanged. +- So T3 and M2 use an `assistant` instance. The code is the same either way: protectedRefusal refuses + every END_PROTECTED definition that isn't creative. +- DESIGN.md isn't edited after approval; this note is the record. + +### Persona file on delete (brief item 4) + +- `/personas//.md` is left in place. Nothing removes it, so there is no + new open and no O_NOFOLLOW test. +- Nothing reads it once the entry is gone. bundle.mjs reads it only for a launched creative instance, + and the name is retired, so `add` refuses it (instance-settings.mjs, organization-instance-retired) + and no later instance picks up the old persona. +- The human removes the file by hand if they want it gone. + +### What changed + +- packages/business/src/instance-settings.mjs: + - New `protectedRefusal(document, instance, actor)`. + - `deleteRefusal(..., actor)`, `planDelete(..., actor)` and `checkDelete(..., actor)` take the + actor, and set op delete passes its actor. + - `read(business, actor)`. Its `endProtected` is `protectedRefusal(...) !== null`. + - commit's and fire's returned listings read with the writer's actor. + - The comment at :28-31 is rewritten. +- packages/bus/src/runtime.mjs: `instances` and `instances.check` pass the actor. There is no broker + change. +- packages/webui/src/public/agents.js: comment only. +- docs/plans/DEFERRED.md: a closing line on the creative-delete entry. +- Tests: + - business instance-settings.test: T1-T3 and T5 in one test, T4 in its own test, and the row 213 + test's `endProtected` for the human's add is now `false`. + - business lifecycle-settings.test: checkDelete with the actor; fire unchanged. + - bus creative.test: T6 on the real runtime. + - webui agents-browser.test: T7/T8. The comment on the existing assertion is reworded. + +### Mutants for the gate (exact-once replacements, runb8 shape) + +- M1 (instance-settings.mjs): ` if (actor === document.human) return null;` becomes ` return null;`. + The kill is instance-settings.test "row 256 T1-T3". +- M2 (instance-settings.mjs): move the human check ahead of the creative one. Replace + ` if (!CREATIVE_DEFINITIONS.includes(definition)) return { code: "organization-definition-refused" };\n if (actor === document.human) return null;` + with + ` if (actor === document.human) return null;\n if (!CREATIVE_DEFINITIONS.includes(definition)) return { code: "organization-definition-refused" };`. + The kill is "row 256 T1-T3" (T3). +- M3 (instance-settings.mjs): ` endProtected: protectedRefusal(document, name, actor) !== null,` becomes + ` endProtected: END_PROTECTED.includes(entry.definition),`. The kills are "row 256 T1-T3" (T5) and + bus creative.test T6. +- M4a (runtime.mjs): `instances.checkDelete(id,args.instance,actor)` becomes + `instances.checkDelete(id,args.instance)`. The kill is creative.test "row 256 T6". +- M4b (instance-settings.mjs): `planDelete(old, instance, actor), { revision` becomes + `planDelete(old, instance), { revision`. The kills are "row 256 T1-T3" (T1) and creative.test T6. + +### Tests not run + +None of these have run. They wait for Sage's CLEAR, after the rebase onto 236. diff --git a/docs/plans/DEFERRED.md b/docs/plans/DEFERRED.md index 86666f504..370ea294f 100644 --- a/docs/plans/DEFERRED.md +++ b/docs/plans/DEFERRED.md @@ -396,6 +396,12 @@ at every gate. Started 2026-09-12 during the control board MVP. (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) + Closed for delete by row 256 (#1761, REQ-COS-3): `deleteRefusal` takes + the actor, so the human deletes a creative instance and any other actor + is refused `organization-creative-human-only`, and the listing's + endProtected is the same check for its reader. Fire still refuses every + END_PROTECTED instance, by the row 256 brief's ruling, not by a code + change. (2026-10-22) - **A fire stuck between step A and step B has no human escape.** (Webby N4 on row 194 b5, webby-0094; Sage's ruling sage-2241, #1692.) The broker's chief fire records `fire.stopped` at step A, and `#firing` diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index b58d4dfd8..865bbf463 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -276,11 +276,12 @@ export async function startBroker({ organization:settings?(id,verb,args,actor,context)=>{ if(verb==='limits')return withTokens(id,settings.read(id)); if(verb==='plugins')return plugins.read(id); - if(verb==='instances')return instances.read(id); + // Row 256 (#1761): the actor is the reader, so endProtected is the delete check for that reader. + if(verb==='instances')return instances.read(id,actor); // Row 65 (mos-0840): the Console's routing table. if(verb==='routes')return settings.routes(id); // Row 194 (#1692, V5): a delete's checks at the verb, with no write and no reload. - if(verb==='instances.check')return instances.checkDelete(id,args.instance); + if(verb==='instances.check')return instances.checkDelete(id,args.instance,actor); // Row 194 b6 (webby-0094 B1): a fire's checks against the file, before step A. if(verb==='instance.fireCheck')return instances.checkFire(id,args.name); let result; diff --git a/packages/bus/tests/creative.test.mjs b/packages/bus/tests/creative.test.mjs index f75c67bee..6cfbf1cd6 100644 --- a/packages/bus/tests/creative.test.mjs +++ b/packages/bus/tests/creative.test.mjs @@ -123,7 +123,7 @@ async function runtime(t) { 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 }; + return { bus, 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) => { @@ -151,3 +151,30 @@ test('M10 through the broker: the chief and the tester are refused on bean, the 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']); }); + +// Row 256 (#1761, REQ-COS-3, T6): the human deletes bean through the verb and the launcher's pass, on +// the real writer, so the actor reaches the delete check at both. The chief is refused human-required +// and the tester tester-refused at the verb, so neither reaches the writer. Each reader's instances +// view carries its own endProtected. +test('row 256 T6 the human deletes a creative instance through the broker; the chief and the tester are refused at the verb', async (t) => { + const { bus, human, tester, chief, call, log, bytes } = await runtime(t); + assert.equal(call(human, 'instances').instances.bean.endProtected, false); + assert.equal(call(tester, 'instances').instances.bean.endProtected, true); + const { revision } = call(human, 'instances'); + const before = bytes(); + assert.throws(() => call(chief, 'instances.set', { revision, instance: 'bean', op: 'delete' }), { code: 'human-required' }); + assert.throws(() => call(tester, 'instances.set', { revision, instance: 'bean', op: 'delete' }), { code: 'tester-refused' }); + assert.equal(bytes(), before); + // The verb's check runs instances.check with the human as actor, and records the request. + const { request } = call(human, 'instances.set', { revision, instance: 'bean', op: 'delete' }); + assert.equal(bytes(), before); + assert.equal(bus.lifecycleTake({ business: 'demo', request }).go, true); + // The launcher's pass commits the delete with the request's by, the human. + const { transaction } = bus.lifecycleDelete({ business: 'demo', request }); + const file = JSON.parse(bytes()); + assert.equal(Object.hasOwn(file.roles, 'bean'), false); + assert.deepEqual(file.retired, ['bean']); + const committed = log().filter((e) => e.state === 'committed'); + assert.deepEqual(committed.map((e) => e.actor), ['jason']); + assert.equal(committed[0].transaction, transaction); +}); diff --git a/packages/business/src/instance-settings.mjs b/packages/business/src/instance-settings.mjs index 14356282c..2ec4d7c62 100644 --- a/packages/business/src/instance-settings.mjs +++ b/packages/business/src/instance-settings.mjs @@ -25,15 +25,25 @@ const revisionOk = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(v // Every limits.* key merges by intersection, so it can only narrow. 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). 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) { +// Row 256 (#1761, REQ-COS-3): the END_PROTECTED part of a delete, for one actor. A creative +// instance is the human's to delete, and any other actor is refused, as row 213 refuses it a +// change. The chief of staff is refused for every actor, the human included. The listing's +// endProtected is this same check for its reader, so the Agents page offers no Delete it would refuse. +function protectedRefusal(document, instance, actor) { + const { definition } = document.roles[instance]; + if (!END_PROTECTED.includes(definition)) return null; + if (!CREATIVE_DEFINITIONS.includes(definition)) return { code: "organization-definition-refused" }; + if (actor === document.human) return null; + return { code: "organization-creative-human-only", message: `${instance} is a ${definition} instance, which only the human deletes` }; +} + +// Row 194 (#1692, V5): why an instance can't be deleted by actor, checked in the design's order, or +// null. The last-listed check runs when the proposed file is validated (deletedDocument). +export function deleteRefusal(document, rolesDir, instance, actor) { if (!Object.hasOwn(document.roles, instance)) return { code: "organization-unknown-instance" }; const entry = document.roles[instance]; - if (endProtected(entry.definition)) return { code: "organization-definition-refused" }; + const refused = protectedRefusal(document, instance, actor); + if (refused) return 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}`); @@ -157,7 +167,9 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r return out; } - function read(business) { + // Row 256: actor is the reader, so endProtected is the delete check for that reader. A read with + // no actor (the bus's reloads) answers as for any non-human. + function read(business, actor) { const { document, revision } = writer.read(business); validateBusinessDocument(document, businessFilePath(business, dir), { rolesDir }); const launch = document.launch ?? null; @@ -168,7 +180,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), + endProtected: protectedRefusal(document, name, actor) !== null, }])); // Row 194 (D1): the human's forbid lists and the retired names. The bus adds running runs. return { @@ -204,7 +216,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r if (Object.keys(args).some(key => !["revision", "instance", "op"].includes(key))) throw new OrganizationError("invalid-request"); const services = Object.keys(old.roles[instance]?.credentials ?? {}); const beforeWrite = revoke ? () => revoke(business, instance, "delete", services) : null; - const result = commit(business, old, planDelete(old, instance), { revision: args.revision, actor, beforeWrite }); + const result = commit(business, old, planDelete(old, instance, actor), { revision: args.revision, actor, beforeWrite }); // sage-1610 (b): the bot user and any token it holds live in Vikunja, which this write // can't reach (row 199 A5). const bot = old.roles[instance]?.tracker?.bot; @@ -255,15 +267,15 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r // Row 194 (V5): the delete's checks against the file as it stands, and its document. The broker // runs this at the verb with no write, and the launcher's pass runs set op delete after the take. - function planDelete(document, instance) { - const refusal = deleteRefusal(document, rolesDir, instance); + function planDelete(document, instance, actor) { + const refusal = deleteRefusal(document, rolesDir, instance, actor); if (refusal) throw new OrganizationError(refusal.code, refusal.message); return deletedDocument(document, instance); } - function checkDelete(business, instance) { + function checkDelete(business, instance, actor) { const { document, revision } = writer.read(business); - const next = planDelete(document, instance); + const next = planDelete(document, instance, actor); validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir }); return { revision }; } @@ -335,7 +347,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r // its tokens revoked, with no write. A revoke is safe to repeat. if (!old.launch.instances.includes(name)) { const revoked = revoke ? revoke(business, name, "fire", services) : null; - return { ...read(business), transaction: null, delisted: false, ...(revoked ? { credentials: revoked } : {}) }; + return { ...read(business, actor), transaction: null, delisted: false, ...(revoked ? { credentials: revoked } : {}) }; } const next = structuredClone(old); next.launch.instances = old.launch.instances.filter(entry => entry !== name); @@ -402,7 +414,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r if (writer.read(business).revision !== revision) throw new OrganizationError("revision-conflict", "organization revision changed; read again"); if (beforeWrite) beforeWrite(); const receipt = writer.write(business, next, { revision, actor, context }); - return { ...read(business), transaction: receipt.transaction, restart: false }; + return { ...read(business, actor), transaction: receipt.transaction, restart: false }; } return { writer, read, set, checkDelete, checkFire, veto, hire, fire }; diff --git a/packages/business/tests/instance-settings.test.mjs b/packages/business/tests/instance-settings.test.mjs index ad6a93d6b..4693903fc 100644 --- a/packages/business/tests/instance-settings.test.mjs +++ b/packages/business/tests/instance-settings.test.mjs @@ -285,7 +285,8 @@ 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, kept: false, endProtected: true }); + // Row 256: the listing a write returns is read for the writer, and the human may delete bean. + assert.deepEqual(done.instances.bean, { definition: "companion", vars: persona, launched: false, launcher: false, protected: false, kept: false, endProtected: false }); 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), @@ -315,3 +316,65 @@ test("row 213 a creative instance is the human's: add, vars and definition chang assert.equal(st.mode & 0o777, st.isDirectory() ? 0o700 : 0o600, name); } }); + +// Row 256 (#1761, REQ-COS-3): the human deletes a creative instance and any other actor is refused +// with the creative code. The assistant, the END_PROTECTED definition that isn't creative, refuses +// every actor, the human included. The listing's endProtected is that same check for its reader. +test("row 256 T1-T3, T5 the human deletes a creative instance, no other actor does, and the assistant stays", () => { + const f = fixture(doc => { + doc.roles.bean = { definition: "companion" }; + doc.roles.muse = { definition: "ideas" }; + doc.roles.assistant = { definition: "assistant", holder: "mos" }; + }); + const del = (instance, actor) => () => f.settings.set("acme", { revision: f.settings.read("acme").revision, instance, op: "delete" }, actor); + const refused = (act, code, label) => { + const bytes = readFileSync(f.file, "utf8"); + assert.throws(act, e => e.code === code, label); + assert.equal(readFileSync(f.file, "utf8"), bytes, label); + }; + // T5: the reader's view. A read with no actor answers as for any non-human. + for (const [actor, bean] of [["jason", false], ["tester", true], ["chief-of-staff.chief", true], [undefined, true]]) { + const view = f.settings.read("acme", actor); + assert.equal(view.instances.bean.endProtected, bean, `bean ${actor}`); + assert.equal(view.instances.assistant.endProtected, true, `assistant ${actor}`); + assert.equal(view.instances.coder.endProtected, false, `coder ${actor}`); + } + assert.equal(f.settings.read("acme").instances.bean.endProtected, true); + // T2: every actor but the human gets the creative code, and the message says why. + for (const actor of ["chief-of-staff.chief", "tester", "mos", undefined]) { + refused(del("bean", actor), "organization-creative-human-only", String(actor)); + assert.throws(() => f.settings.checkDelete("acme", "bean", actor), e => e.code === "organization-creative-human-only", `check ${actor}`); + } + assert.throws(del("bean", "tester"), /bean is a companion instance, which only the human deletes/); + // T3: the assistant refuses every actor, the human included. + for (const actor of ["jason", "chief-of-staff.chief", "tester"]) { + refused(del("assistant", actor), "organization-definition-refused", `assistant ${actor}`); + } + // T1: the human's delete. + assert.deepEqual(f.settings.checkDelete("acme", "bean", "jason"), { revision: f.settings.read("acme").revision }); + const done = del("bean", "jason")(); + assert.equal(Object.hasOwn(done.instances, "bean"), false); + assert.deepEqual(done.retired, ["bean"]); + assert.equal(done.instances.muse.endProtected, false); + const file = JSON.parse(readFileSync(f.file, "utf8")); + assert.equal(Object.hasOwn(file.roles, "bean"), false); + assert.deepEqual(file.retired, ["bean"]); + assert.equal(f.log().at(-1).actor, "jason"); + const run = f.validate(); + assert.equal(run.status, 0, run.stderr); +}); + +// Row 256 (R1, webby-0122): a creative delete by the human goes on to the later checks. A creative +// instance can't be kept, launch.by or an arbiter, so the check it reaches is the last-listed one. +test("row 256 T4 the human's delete of the only listed instance, a creative one, is the last-listed refusal", () => { + const f = fixture(doc => { + doc.roles.bean = { definition: "companion" }; + doc.launch.instances = ["bean"]; + }); + const bytes = readFileSync(f.file, "utf8"); + const revision = f.settings.read("acme").revision; + assert.throws(() => f.settings.set("acme", { revision, instance: "bean", op: "delete" }, "jason"), e => e.name === "BusinessError"); + assert.throws(() => f.settings.checkDelete("acme", "bean", "jason"), e => e.name === "BusinessError"); + assert.throws(() => f.settings.set("acme", { revision, instance: "bean", op: "delete" }, "tester"), e => e.code === "organization-creative-human-only"); + assert.equal(readFileSync(f.file, "utf8"), bytes); +}); diff --git a/packages/business/tests/lifecycle-settings.test.mjs b/packages/business/tests/lifecycle-settings.test.mjs index 5c0d0015d..76f84bb0c 100644 --- a/packages/business/tests/lifecycle-settings.test.mjs +++ b/packages/business/tests/lifecycle-settings.test.mjs @@ -229,3 +229,18 @@ test("D2.2 fire delists and keeps the entry, a repeat is done with no write, and const one = fixture(doc => { doc.launch.instances = ["sec-1"]; }); refuses(one, () => fire(one, "sec-1"), "BusinessError"); }); + +// Row 256 (#1761): checkDelete takes the actor, and fire is unchanged: a creative instance refuses +// every actor's fire, the human's included. +test("row 256 checkDelete passes the human's creative delete and refuses another actor's; fire still refuses", () => { + const f = fixture(doc => { + doc.roles.bean = { definition: "companion" }; + doc.launch.instances.push("bean"); + }); + const bytes = readFileSync(f.file, "utf8"); + assert.deepEqual(f.settings.checkDelete("acme", "bean", "jason"), { revision: f.revision() }); + for (const actor of ["chief", "tester", undefined]) refuses(f, () => f.settings.checkDelete("acme", "bean", actor), "organization-creative-human-only"); + for (const actor of ["chief", "jason"]) refuses(f, () => f.settings.fire("acme", { name: "bean" }, { actor, context: CONTEXT }), "organization-definition-refused"); + assert.throws(() => f.settings.checkFire("acme", "bean"), e => e.code === "organization-definition-refused"); + assert.equal(readFileSync(f.file, "utf8"), bytes); +}); diff --git a/packages/webui/src/public/agents.js b/packages/webui/src/public/agents.js index 8e0790356..aadb11f0a 100644 --- a/packages/webui/src/public/agents.js +++ b/packages/webui/src/public/agents.js @@ -42,7 +42,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')); - // endProtected is the server's delete check, creative definitions included (sage-2164). + // endProtected is the server's delete check for this reader (row 256): false on the human's + // page for a creative instance, true for the chief of staff and on the tester's. 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'}${alive.map(r => ` still running after fire: ${txt(r)}`).join('')}${acts.join(' ')}`; diff --git a/packages/webui/tests/agents-browser.test.mjs b/packages/webui/tests/agents-browser.test.mjs index fd6dff7b9..f85722ca9 100644 --- a/packages/webui/tests/agents-browser.test.mjs +++ b/packages/webui/tests/agents-browser.test.mjs @@ -40,8 +40,8 @@ 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. + // A creative instance isn't protected (the human changes it). This listing marks bean endProtected, + // as a non-human reader's does, so the page hides its Delete; row 256's test below has the human's. 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); @@ -123,3 +123,29 @@ test('row 194 T16 Settings Agents: unavailable read-only, launch follows its req assert.equal(await b.evaluate('posts.length'),sent+1); }finally{await b.close();await close(web);} }); + +// Row 256 (#1761, T7, T8): the human's listing carries endProtected false for a creative instance +// (the server's delete check for that reader), so the human's page offers Delete for it. The tester's +// page offers no Delete even on that same listing; the tester's own listing marks bean endProtected +// as well (bus creative.test T6). +test('row 256 T7 T8 the human page offers Delete on a creative instance, the tester page never does',{timeout:90000},async()=>{ + const human={...view,instances:{...view.instances,bean:{...view.instances.bean,endProtected:false}}}; + const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>human}; + const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser(); + try{ + await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const q=new URL(location.href).searchParams;const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify(q.has('tester')?{tester:true}:{send:true}));return original(url,options);};`}); + const port=web.address().port; + const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<400;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`); + const del=name=>`document.querySelector('[data-agent-action="delete"][data-agent-name="${name}"]')`; + // T7: the human's page. + await b.navigate(`http://127.0.0.1:${port}/?human=1#/settings`); + await wait(`document.querySelector('[data-agent-action="launch"][data-agent-name="bean"]')?.disabled===false`); + assert.equal(await b.evaluate(`${del('bean')}?.disabled`),false); + assert.equal(await b.evaluate(`!!${del('assistant')}`),false); + assert.equal(await b.evaluate(`!!${del('coder')}`),true); + // T8: the tester's page, on the same listing. + await b.navigate(`http://127.0.0.1:${port}/?tester=1#/settings`); + await wait(`!!document.querySelector('#tester-banner')&&!!document.querySelector('[data-agent-action="launch"][data-agent-name="bean"]')`); + assert.equal(await b.evaluate(`document.querySelectorAll('[data-agent-action="delete"]').length`),0); + }finally{await b.close();await close(web);} +});