From 56aeefb370cd7952d27ac5e84cc7222c1ff4d5ce Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 12 Nov 2026 17:49:10 -0600 Subject: [PATCH] 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. --- packages/bus/src/broker.mjs | 10 ++++- packages/bus/src/copy-launch.mjs | 9 +++-- packages/bus/tests/copy-bindings.test.mjs | 37 +++++++++++++++++++ packages/cli/src/instances.mjs | 5 ++- packages/cli/tests/agent-copies.test.mjs | 20 ++++++++++ .../organization/src/copy-binding-status.mjs | 2 + packages/webui/src/decision-http.mjs | 3 +- packages/webui/src/public/bus.js | 2 +- packages/webui/src/serve.mjs | 8 +++- packages/webui/tests/browser.mjs | 5 ++- .../webui/tests/hire-console-browser.test.mjs | 11 +++--- .../tests/unified-workspace-browser.test.mjs | 14 ++++--- 12 files changed, 106 insertions(+), 20 deletions(-) diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 12d152e68..83c8a5691 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -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); } }; diff --git a/packages/bus/src/copy-launch.mjs b/packages/bus/src/copy-launch.mjs index 71746b951..3370b9f54 100644 --- a/packages/bus/src/copy-launch.mjs +++ b/packages/bus/src/copy-launch.mjs @@ -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); diff --git a/packages/bus/tests/copy-bindings.test.mjs b/packages/bus/tests/copy-bindings.test.mjs index 2a44096ac..04fd2a6d3 100644 --- a/packages/bus/tests/copy-bindings.test.mjs +++ b/packages/bus/tests/copy-bindings.test.mjs @@ -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); +}); diff --git a/packages/cli/src/instances.mjs b/packages/cli/src/instances.mjs index 0f36694a9..572fd854c 100644 --- a/packages/cli/src/instances.mjs +++ b/packages/cli/src/instances.mjs @@ -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); diff --git a/packages/cli/tests/agent-copies.test.mjs b/packages/cli/tests/agent-copies.test.mjs index 1677b53b0..d6ae04507 100644 --- a/packages/cli/tests/agent-copies.test.mjs +++ b/packages/cli/tests/agent-copies.test.mjs @@ -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); +}); diff --git a/packages/organization/src/copy-binding-status.mjs b/packages/organization/src/copy-binding-status.mjs index 034d02fe2..d75f40118 100644 --- a/packages/organization/src/copy-binding-status.mjs +++ b/packages/organization/src/copy-binding-status.mjs @@ -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.", diff --git a/packages/webui/src/decision-http.mjs b/packages/webui/src/decision-http.mjs index 61e13cc48..8806a53fc 100644 --- a/packages/webui/src/decision-http.mjs +++ b/packages/webui/src/decision-http.mjs @@ -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); diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 9b9acaed2..804c956e0 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -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' ? `

Credential binding

${bindingChange(c.binding) === 'unbind' ? 'Unbind and revoke the credentials of' : 'Bind credentials for'} copy ${txt(c.binding.instance)}.

` : ''; const hireAdjustment = c => { if(c?.kind!=='hire' || !c.role)return ''; diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 0b9301569..41822cc51 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -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 }); diff --git a/packages/webui/tests/browser.mjs b/packages/webui/tests/browser.mjs index 54aa75118..76bd99ae5 100644 --- a/packages/webui/tests/browser.mjs +++ b/packages/webui/tests/browser.mjs @@ -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); diff --git a/packages/webui/tests/hire-console-browser.test.mjs b/packages/webui/tests/hire-console-browser.test.mjs index 9c45d6817..fabf16b99 100644 --- a/packages/webui/tests/hire-console-browser.test.mjs +++ b/packages/webui/tests/hire-console-browser.test.mjs @@ -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:`(()=>{ diff --git a/packages/webui/tests/unified-workspace-browser.test.mjs b/packages/webui/tests/unified-workspace-browser.test.mjs index 8643cdffe..24ad97050 100644 --- a/packages/webui/tests/unified-workspace-browser.test.mjs +++ b/packages/webui/tests/unified-workspace-browser.test.mjs @@ -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']}`);