fix(bus,webui,cli): CB5 review fixes, binding summary on the inbox, raise and trail reads, copy-service-unbindable status, Console allowlist (#1873)

- broker: the hire.raise answer, the inbox console preview and the trail read carry a
  binding as {instance, change} only; the raise event keeps the full proposal the resolve reads.
- copy-launch status(): a copy whose role needs a service a copy cannot bind reads
  copy-service-unbindable (new fixed status text) instead of a gitea binding step.
- cli instances: an unbound copy's line names its missing services.
- webui serve: the instances verb rebuilds each copy binding from the fixed status table.
- bus.js reads the binding change field only.
- browser tests: system Chromium starts with --disable-extensions; the A3 booted-bus test passes it.
This commit is contained in:
jason.woltje committed 2026-11-12 19:19:22 -06:00
1 parent fdbc34413b
commit 56aeefb370
12 files changed
+106 -20

No files matched your search

+8 -2
View File
@@ -280,6 +280,11 @@ const fail = (code) => {
throw new BusError(code);
};
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
// #1873 CB5: a copy binding proposal leaves the bus only as its copy and its change. The copy id,
// incarnation, definition pin, services, grant ids and slots stay in the raise event, which the
// resolve reads; the inbox preview and the trail read carry this summary (design lines 162-163).
const bindingSummary = (v) => (object(v?.binding)
? { ...v, binding: { instance: v.binding.instance, change: v.binding.after === null ? 'unbind' : 'bind' } } : v);
// Lifecycle Task 5a: keep these pure helpers here because broker.mjs is also loaded by the
// sandbox client. A new host-only import would widen its mounted source closure.
const createJson = value => {
@@ -2539,7 +2544,7 @@ export class Broker {
options.some(o => !object(o) || !id(o.key) || !text(o.text, 1024)) ||
new Set(options.map(o => o.key)).size !== options.length) return null;
const digest = sha256(JSON.stringify([{ ...d, options }, raise]));
if (['role.hire','role.fire','role.change'].includes(d.action)) return raise.hire ? {kind:'hire',options,...raise.hire} : null;
if (['role.hire','role.fire','role.change'].includes(d.action)) return raise.hire ? {kind:'hire',options,...bindingSummary(raise.hire)} : null;
if (d.action !== 'credential.mint') return 'grant' in raise ? null : { kind: 'decision', options, digest };
if (!object(raise.grant) || typeof raise.grant.method !== 'string' || options.length !== 2) return null;
const approve = options.find((o) => o.key === raise.approvalChoice);
@@ -6784,7 +6789,7 @@ export class Broker {
this.#event(s,'action.allowed',{operation:'decision.raise',decision:did,action,approvalChoice:'yes',target:p.name,hire:p,autonomyLevel:level},did);
});
if(immediate) return this.#hireConsole(s,'hire.resolve.console',{id:did,choice:'yes',digest:p.digest});
return {...this.#decisionView(this.#decision(s,did)),hire:p};
return {...this.#decisionView(this.#decision(s,did)),hire:bindingSummary(p)};
}
// The harness fact comes from the host's loaded modules; no host, no account coverage.
#budgetCovered(b) { return authorityBudgetCovered(b,{countsAccount:this.#autonomyHost?.countsAccount}); }
@@ -9483,6 +9488,7 @@ export class Broker {
try {
r[k] = JSON.parse(r[k]);
} catch {}
if (table === 'events' && object(r.body?.hire)) r.body = { ...r.body, hire: bindingSummary(r.body.hire) };
rows.set(table + ':' + row.seq, r);
}
};
+6 -3
View File
@@ -381,10 +381,13 @@ export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snaps
// { status, next }. The same checks a launch runs, without the loss report (the sweep makes it)
// and without returning anything the check read. An approved binding not yet issued, or one a
// refused issue left pending, needs the owner's issue before anything else. `entry` is the copy's
// boot entry: only one bound at boot, or unbound for want of a bindable service, has a status.
// boot entry: only one bound at boot, or unbound for want of a service, has a status. One that
// lacks a service a copy cannot bind is refused at boot whatever it binds (above), so it reads
// copy-service-unbindable and never asks for a gitea binding.
status(business, instance, entry) {
const needs = Array.isArray(entry?.bound) || (entry?.state === 'unbound' && Array.isArray(entry.missing) && entry.missing.some((s) => COPY_BINDABLE.includes(s)));
if (!needs) return null;
const unbound = entry?.state === 'unbound' && Array.isArray(entry.missing) && entry.missing.length > 0;
if (!Array.isArray(entry?.bound) && !unbound) return null;
if (unbound && entry.missing.some((s) => !COPY_BINDABLE.includes(s))) return copyBindingStatus('copy-service-unbindable');
let code = 'ready';
try {
check(business, instance);
+37
View File
@@ -133,3 +133,40 @@ test('CB1 bus: every new refusal has a table entry with a next step', () => {
assert.ok(!/(^|[\s("'`])(\/|~\/)/.test(REFUSALS[code].nextStep), code);
}
});
// #1873 CB5 (security review P2-1, P3-1): the binding record the raise holds (copy id, incarnation,
// definition pin, services, grant ids) leaves the bus only through the owner's preview verb. The
// raise answer, the inbox preview and the trail give every other session the copy and the change.
test('CB5 bus: the raise answer, the inbox preview and the trail carry a binding as its copy and change only', async t => {
const f = await fixture(t);
const d = await f.raise();
const raised = f.store.all('SELECT body FROM events').map((e) => JSON.parse(e.body)).find((b) => b.operation === 'decision.raise' && b.decision === d.id);
const held = raised.hire.binding;
assert.equal(typeof held.copyId, 'string');
const secrets = [held.copyId, held.orgIncarnation, held.definition?.sha256].filter((v) => typeof v === 'string' && v.length >= 8);
assert.equal(secrets.length, 3);
const summary = { instance: 'rev', change: 'bind' };
assert.deepEqual(d.hire.binding, summary);
const sessions = { owner: f.b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }), tester: f.b.bindTester({ business: 'demo' }), reader: f.b.bindReader({ business: 'demo' }), chief: f.caps.chief, coder: f.caps.coder };
const seen = [JSON.stringify(d)];
for (const name of ['owner', 'tester', 'reader']) {
const rows = await f.call(sessions[name], 'inbox');
const row = rows.find((r) => r.id === d.id);
assert.deepEqual(row?.console?.binding, summary, `${name} inbox`);
seen.push(JSON.stringify(rows));
}
for (const name of Object.keys(sessions)) {
const trail = await f.call(sessions[name], 'trail', { subject: d.id });
const ev = trail.find((r) => r.table === 'events' && r.body?.operation === 'decision.raise');
assert.deepEqual(ev?.body?.hire?.binding, summary, `${name} trail`);
seen.push(JSON.stringify(trail));
}
for (const text of seen) for (const v of secrets) assert.equal(text.includes(v), false);
// The owner's preview still has the record, and the resolve (which reads the raise) still writes it.
const preview = await f.call(f.owner, 'hire.preview.console', { id: d.id });
assert.equal(preview.binding.copyId, held.copyId);
await f.call(f.owner, 'hire.resolve.console', { id: d.id, choice: 'yes', digest: d.hire.digest });
assert.equal(f.bindings().rev.state, 'pending');
const after = JSON.stringify(await f.call(sessions.tester, 'trail', { subject: d.id }));
for (const v of secrets) assert.equal(after.includes(v), false);
});
+4 -1
View File
@@ -12,6 +12,7 @@
import { readFileSync } from "node:fs";
import { CliError } from "./errors.mjs";
import { copyBindingStatus } from "../../organization/src/copy-binding-status.mjs";
import { SERVICES } from "../../organization/src/vocabulary.mjs";
export const INSTANCES_USAGE = "mosaic business instances BUSINESS [--json] [--tester] | " +
"business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] [--bot BOT --bot-id N] --revision REV [--tester] | " +
@@ -226,7 +227,9 @@ export function formatInstances(view) {
for (const [name, copy] of Object.entries(view.copies ?? {})) {
if (!copy?.binding) continue;
const { status, next } = copyBindingStatus(copy.binding.status);
lines.push(` ${name} (copy): ${status}. ${next}`);
// The services the copy lacks, as registered service labels only; nothing else the bus sent.
const missing = copy.state === "unbound" && Array.isArray(copy.missing) ? SERVICES.filter((s) => copy.missing.includes(s)) : [];
lines.push(` ${name} (copy): ${status}${missing.length ? ` (missing ${missing.join(", ")})` : ""}. ${next}`);
}
const definitions = Object.entries(view.definitions);
const accepted = definitions.filter(([, d]) => d.accepted).map(([name]) => name);
+20
View File
@@ -1089,3 +1089,23 @@ test("CB5 operator: the owner's instances read and mosaic org instances show eac
assert.deepEqual(leaks(said.join("")), []);
assert.deepEqual(leaks([...first.logs, ...s.logs].map((l) => (typeof l === "string" ? l : JSON.stringify(l))).join("\n")), []);
});
// #1873 CB5 review: a shipped role that needs gitea and vikunja. Boot denies such a copy whatever it
// binds, so its status must not send the owner to approve a gitea binding.
test("CB5 operator: a copy whose role needs a service a copy cannot bind reads copy-service-unbindable and names its missing services", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const file = join(p.root, "config", "agents.json");
const doc = JSON.parse(readFileSync(file, "utf8"));
doc.agents.rev = { definition: "reviewer", vars: VARS };
writeJson(file, doc, 0o600);
const s = await start(t, p);
const v = s.call("instances");
assert.equal(v.copies.rev.state, "unbound");
assert.deepEqual(v.copies.rev.binding, { status: "copy-service-unbindable", next: COPY_BINDING_STATUS["copy-service-unbindable"] });
// The gitea-only copy beside it still reads credential-unbound.
assert.deepEqual(v.copies.researcher.binding, { status: "credential-unbound", next: COPY_BINDING_STATUS["credential-unbound"] });
const text = formatInstances(v);
assert.ok(text.includes(` rev (copy): copy-service-unbindable (missing gitea, vikunja). ${COPY_BINDING_STATUS["copy-service-unbindable"]}\n`), text);
assert.ok(text.includes(` researcher (copy): credential-unbound (missing gitea). ${COPY_BINDING_STATUS["credential-unbound"]}\n`), text);
assert.equal(COPY_BINDING_STATUS["copy-service-unbindable"].includes("credentials.gitea"), false);
});
@@ -9,6 +9,8 @@ export const COPY_BINDING_STATUS = Object.freeze({
"issue-required": "The binding is approved; the owner must issue the copy's token with mosaic credentials issue, then restart the organization bus.",
"restart-required": "Restart the organization bus so it verifies the binding before the copy launches.",
"credential-unbound": "credentials.gitea needs an owner-approved binding.",
// Boot denies a copy whose role needs a service a copy cannot bind yet, whatever it binds.
"copy-service-unbindable": "this copy's role needs a credential service a copy cannot bind yet, so it cannot launch in this organization; binding gitea alone does not change that.",
"credential-revoking": "credentials.gitea revocation is in progress; read the copy's status again when it finishes.",
"credential-revoke-failed": "credentials.gitea revocation is incomplete; the owner must retry cleanup.",
"credential-revoked": "credentials.gitea was revoked; the owner must approve a new binding.",
+2 -1
View File
@@ -64,7 +64,8 @@ export const BINDING_NEXT = Object.freeze({
unbind: "Unbind approved. The bus stops the copy's runs and revokes its credential. The copy's status on the Agents grid shows when the revoke finishes.",
});
// A binding decision's preview names its copy and whether it binds or unbinds. Only those two leave
// this server: the copy id, incarnation, definition pin and services stay with the bus.
// this server: the copy id, incarnation, definition pin and services stay with the bus. The owner's
// preview verb is the one bus answer that carries them; the inbox and the trail send the same two.
const bindingOf = (v) => (v?.binding && typeof v.binding === 'object' && subjectOk(v.binding.instance) &&
(v.binding.after === null || (typeof v.binding.after === 'object' && !Array.isArray(v.binding.after)))
? { instance: v.binding.instance, change: v.binding.after === null ? 'unbind' : 'bind' } : null);
+1 -1
View File
@@ -400,7 +400,7 @@
// #1873 CB5: a copy credential binding decision names its copy and whether it binds or unbinds;
// the scope lines above are the bus's computed ones. The confirmation says `change`; the inbox
// row carries the bus's preview, where an unbind is `after: null`.
const bindingChange = b => b.change === 'unbind' || (b.change === undefined && b.after === null) ? 'unbind' : 'bind';
const bindingChange = b => b.change === 'unbind' ? 'unbind' : 'bind';
const bindingLine = c => c?.binding && typeof c.binding === 'object' ? `<h3>Credential binding</h3><p data-binding-change="${bindingChange(c.binding)}">${bindingChange(c.binding) === 'unbind' ? 'Unbind and revoke the credentials of' : 'Bind credentials for'} copy <code>${txt(c.binding.instance)}</code>.</p>` : '';
const hireAdjustment = c => {
if(c?.kind!=='hire' || !c.role)return '';
+7 -1
View File
@@ -12,6 +12,7 @@ import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from
const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403 : coreBusStatus(code);
import { ReadError, readStatus, fieldTable } from './reads.mjs';
import { THREAD_DEFINITION, THREAD_DEFINITIONS } from '../../organization/src/vocabulary.mjs';
import { copyBindingStatus } from '../../organization/src/copy-binding-status.mjs';
import { OnboardingError, approveNorthStarDraft, interviewMessage, loadInterviewTemplate, northStarTemplateText } from '../../project/src/onboarding.mjs';
import { chatHTTP } from './chat-http.mjs';
import { projectArchiveHTTP } from './project-archive-http.mjs';
@@ -179,7 +180,12 @@ async function busRead(res, bus, verb, search) {
try {
const value = await bus.instances();
if (!/^[a-f0-9]{64}$/.test(value?.revision ?? '') || !value.instances || typeof value.instances !== 'object' || !value.definitions || typeof value.definitions !== 'object') throw new BusReadError('invalid-response');
return json(res, 200, { rows: [value], at: new Date().toISOString() });
// #1873 CB5: a copy's binding status reaches the page as the fixed table's { status, next }
// for its code, never as text the bus sent; an unknown code reads credential-unavailable.
const copies = value.copies && typeof value.copies === 'object' && !Array.isArray(value.copies)
? Object.fromEntries(Object.entries(value.copies).map(([n, c]) => [n, c && typeof c === 'object' && 'binding' in c ? { ...c, binding: copyBindingStatus(c.binding?.status) } : c]))
: value.copies;
return json(res, 200, { rows: [copies === undefined ? value : { ...value, copies }], at: new Date().toISOString() });
} catch (err) {
const code = err instanceof BusReadError ? err.code : 'invalid-response';
return json(res, busStatus(code), { error: code, message: code });
+4 -1
View File
@@ -19,10 +19,13 @@ process.once('exit', () => { for (const child of live) killGroup(child); });
// so by default every page this browser opens starts with Show machine sessions on, as if the
// person had turned it on before. Pass { machine: false } to see the page as a first visit does.
// `args` adds Chromium flags, for example --host-resolver-rules for a test host name.
// #1873 CB5: the system Chromium starts with --disable-extensions, so no host extension (and no
// native host it would start) runs in a test. A CHROMIUM launcher is the zoom tests' wrapper, which
// loads their one extension with --disable-extensions-except, so it gets no blanket flag.
export async function browser({ machine = true, args = [] } = {}) {
const profile = await mkdtemp(join(tmpdir(), 'dewey-brand-browser-'));
const child = spawn(process.env.CHROMIUM || '/usr/bin/chromium', [
'--headless', '--no-first-run', '--no-default-browser-check',
'--headless', '--no-first-run', '--no-default-browser-check', ...(process.env.CHROMIUM ? [] : ['--disable-extensions']),
'--disable-dev-shm-usage', '--remote-debugging-pipe', `--user-data-dir=${profile}`, ...args,
], { detached: true, stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
live.add(child);
@@ -50,14 +50,15 @@ for(const width of [1440,390])test(`A4 Console hire start and narrowed confirmat
posts=await b.evaluate('window.__posts');assert.deepEqual(Object.keys(posts.find(x=>x.p.endsWith('/resolve')).body).sort(),['choice','confirmation']);
});
// #1873 CB5: a copy credential binding decision on its decision page. The inbox row carries the bus's
// preview (an unbind is `after: null`); the page names the copy and the change and lists the computed
// scope lines, the confirmation says the change, and the receipt adds decision-http's fixed next step.
// No value besides the copy's instance reaches the page: a sentinel in every other binding field never shows.
// summary, the copy and its change; the page names both and lists the computed scope lines, the
// confirmation says the change, and the receipt adds decision-http's fixed next step. Nothing besides
// the copy's instance reaches the page: a sentinel in any other field never shows, and an `after`
// that contradicts `change` (a bus older than the summary) never decides the line.
for(const change of ['bind','unbind'])test(`CB5 Console binding decision: ${change} confirmation and receipt`,{timeout:120000},async t=>{
const SENTINEL='cb5-sentinel-7f3a9c41d2e8b6a0';
const f=await fixture(),b=await browser({machine:false,args:['--disable-extensions']});t.after(async()=>{await b.close();await f.close();});
const after=change==='unbind'?null:{gitea:{scopes:['read:repository'],projects:['web'],slot:SENTINEL}};
const binding={instance:'acme/researcher',source:'root',copyId:SENTINEL,fresh:false,orgIncarnation:SENTINEL,definition:SENTINEL,before:{gitea:{grantId:SENTINEL}},after,generation:2};
const after=change==='unbind'?{gitea:{scopes:['read:repository'],projects:['web'],slot:SENTINEL}}:null;
const binding={instance:'acme/researcher',change,copyId:SENTINEL,orgIncarnation:SENTINEL,definition:SENTINEL,before:{gitea:{grantId:SENTINEL}},after};
const hire={kind:'hire',flagged:[],credentialLines:[{service:'gitea',scope:'read:repository'}],expiresAt:'2099-01-01T00:00:00.000Z',binding,options:[{key:'yes',text:'Approve'},{key:'no',text:'Deny'}]};
const d={id:'d2',at:'2026-11-12T00:00:00.000Z',raised_by_role:'jason',raised_by_run:null,class:'gated',action:'role.hire',route_to:'human',question:`${change} credentials for copy acme/researcher?`,options:hire.options,blocking:false,console:hire};
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`(()=>{
@@ -546,7 +546,7 @@ test('agents grid takes a running copy, an unbound copy and a refused project fr
const root = { state: 'ok', ...checkRootAgents(rootDoc, { rolesDir }) };
const agentLevels = { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } };
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, agentLevels, log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const b = await browser({ machine: false, args: ['--disable-extensions'] }); t.after(() => b.close());
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/agents`);
await waitFor(b)('document.querySelectorAll(".ws-levels [data-org=ddigital] [data-copy-state]").length === 4');
@@ -630,7 +630,9 @@ test('agents grid shows a running copy, an unbound copy and a refused project wi
// #1873 CB5: a copy that needs a credential binding carries `binding` from its bus: the status code and
// the owner's next step, copy-binding-status.mjs's fixed text. A booted copy whose binding is revoked
// can't launch and says the next step; an unbound copy missing gitea adds the step after the services;
// a ready binding launches as before with no reason line.
// a copy whose role also needs a service a copy cannot bind says so, without asking for a gitea
// binding; a ready binding launches as before with no reason line. The Console rebuilds each binding
// from its status code, so text the bus put in `next` (a sentinel here) never reaches the page.
test('agents grid shows a copy\'s binding status and next step, and a ready binding launches', async t => {
const { checkRootAgents } = await import('../../organization/src/index.mjs');
const { COPY_BINDING_STATUS } = await import('../../organization/src/copy-binding-status.mjs');
@@ -641,12 +643,13 @@ test('agents grid shows a copy\'s binding status and next step, and a ready bind
} }, { rolesDir }) };
const DOCS = { ddigital: { roles: { chief: { definition: 'chief-of-staff' } }, projects: {} }, forge: { roles: { chief: { definition: 'chief-of-staff' } }, projects: {} } };
const copy = (name, state, extra = {}) => ({ name, level: 'root', project: null, definition: name === 'reviewer' ? 'reviewer' : 'research', state, running: [], ...extra });
const binding = (status) => ({ status, next: COPY_BINDING_STATUS[status] });
const SENTINEL = 'cb5-bus-next-sentinel-4c19e2';
const binding = (status) => ({ status, next: `${SENTINEL} ${status}`, extra: SENTINEL });
const copies = {
ddigital: { researcher: copy('researcher', 'instance', { bound: ['gitea'], binding: binding('credential-revoked') }),
reviewer: copy('reviewer', 'unbound', { missing: ['gitea'], binding: binding('issue-required') }) },
forge: { researcher: copy('researcher', 'instance', { bound: ['gitea'], running: ['r4'], binding: binding('ready') }),
reviewer: copy('reviewer', 'unbound', { missing: ['vikunja'] }) },
reviewer: copy('reviewer', 'unbound', { missing: ['gitea', 'vikunja'], binding: binding('copy-service-unbindable') }) },
};
const orgs = workspaceOrgs({ copies });
const organizations = { ...orgs, members: async () => Object.entries(DOCS).map(([id, doc]) => ({ id, doc })) };
@@ -665,8 +668,9 @@ test('agents grid shows a copy\'s binding status and next step, and a ready bind
assert.deepEqual(why, {
'ddigital:researcher': `researcher: ${COPY_BINDING_STATUS['credential-revoked']}`,
'ddigital:reviewer': `reviewer: no binding in this organization for gitea. ${COPY_BINDING_STATUS['issue-required']}`,
'forge:reviewer': 'reviewer: no binding in this organization for vikunja',
'forge:reviewer': `reviewer: no binding in this organization for gitea, vikunja. ${COPY_BINDING_STATUS['copy-service-unbindable']}`,
});
assert.equal((await b.evaluate('document.body.innerText')).includes(SENTINEL), false);
const rows = await b.evaluate('Object.fromEntries([...document.querySelectorAll("[data-agents-available] tbody tr")].map(tr => [tr.cells[0].innerText.trim().split(/\\s/)[0], tr.cells[4].innerText.replace(/\\s+/g, " ").trim()]))');
assert.equal(rows.researcher, `▲ can't launch ${COPY_BINDING_STATUS['credential-revoked']}`);
assert.equal(rows.reviewer, `▲ can't launch no binding in this organization for gitea. ${COPY_BINDING_STATUS['issue-required']}`);