feat(bus,webui,cli): copy binding status and next steps, two-org acceptance (CB5, #1873) #1958

Merged
jason.woltje merged 6 commits from org-cb5 into refactor 2026-11-13 02:04:25 +00:00
16 changed files with 800 additions and 27 deletions

No files matched your search

+12 -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);
@@ -6450,6 +6455,10 @@ export class Broker {
if (request.verb === "instances" && result?.instances && typeof result.instances === "object" && this.#business(s.business).copies)
result.copies = Object.fromEntries(Object.entries(this.#business(s.business).copies).map(([name, c]) => [name, {
...structuredClone(c), running: c.state === 'instance' ? this.#running(s.business, name) : [],
// #1873 CB5: a copy that needs a binding (bound at boot, or missing a bindable service)
// carries its binding status and next step, fixed text from the launch gate's own checks.
// Never to a tester, and never a binding record.
...(!s.tester && this.#copyLaunch?.status ? ((binding) => (binding ? { binding } : {}))(this.#copyLaunch.status(s.business, name, c)) : {}),
}]));
return result;
}
@@ -6780,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}); }
@@ -9479,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);
}
};
+29
View File
@@ -37,6 +37,7 @@ import { boundRepositories, tokenLevels, verifyGiteaToken } from './copy-grant.m
import { readOrgEntrySync } from '../../organization/src/org-registry.mjs';
import { allGranted, CAUSE_CODES, copyBotLogin, definitionPin, resolveCopyBinding } from '../../organization/src/copy-bindings.mjs';
import { COPY_BINDABLE, materializeCopies } from '../../organization/src/agent-copies.mjs';
import { copyBindingStatus } from '../../organization/src/copy-binding-status.mjs';
import { checkDefinitions, readTemplate } from '../../organization/src/definitions.mjs';
import { loadRole } from '../../organization/src/role.mjs';
import { resolveAgentLevels } from '../../organization/src/agent-levels.mjs';
@@ -376,6 +377,34 @@ export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snaps
if (current(business, instance).authority !== digest) deny('copy-authority-changed');
sameRevision(business, revision);
},
// CB5: the copy's binding status for its owner's views, as copy-binding-status.mjs's fixed
// { 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 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, unless a revoke of a binding it
// was issued anyway is running or failed: that state is the owner's next step.
status(business, instance, entry) {
const unbound = entry?.state === 'unbound' && Array.isArray(entry.missing) && entry.missing.length > 0;
if (!Array.isArray(entry?.bound) && !unbound) return null;
const revoking = business === org && (Boolean(fences.get(instance)) || closed.has(instance));
if (unbound && !revoking && entry.missing.some((s) => !COPY_BINDABLE.includes(s))) return copyBindingStatus('copy-service-unbindable');
let code = 'ready';
try {
check(business, instance);
} catch (e) {
code = typeof e?.code === 'string' ? e.code : 'credential-unavailable';
}
if (['credential-unbound', 'credential-revoked', 'restart-required'].includes(code)) {
let record = null;
try {
record = read(business).copyBindings?.[instance];
} catch {}
if (object(record) && record.state === 'pending') code = 'issue-required';
}
return copyBindingStatus(code);
},
revoking: (business, instance) => business === org && (Boolean(fences.get(instance)) || closed.has(instance)),
// CB4: shut the copy before its revoke publishes anything. It admits nothing and every check
// answers `code` until open(); the boot's proof of it is forgotten, so nothing reopens it but a
+49
View File
@@ -133,3 +133,52 @@ 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);
// An unbind of that binding reads as an unbind in the raise answer, the inbox and the trail.
const u = await f.raise({ binding: { services: null } });
const unbind = { instance: 'rev', change: 'unbind' };
assert.deepEqual(u.hire.binding, unbind);
for (const name of ['owner', 'tester', 'reader']) {
const row = (await f.call(sessions[name], 'inbox')).find((r) => r.id === u.id);
assert.deepEqual(row?.console?.binding, unbind, `${name} inbox unbind`);
}
for (const name of Object.keys(sessions)) {
const ev = (await f.call(sessions[name], 'trail', { subject: u.id })).find((r) => r.table === 'events' && r.body?.operation === 'decision.raise');
assert.deepEqual(ev?.body?.hire?.binding, unbind, `${name} trail unbind`);
}
});
+95 -1
View File
@@ -26,6 +26,7 @@ import { loadWithCopies, materializeCopies, promoteCopies } from '../../organiza
import { activateOrg, listOrgs, readOrgEntrySync, reserveOrg } from '../../organization/src/org-registry.mjs';
import * as orgService from '../../organization/src/org-service.mjs';
import { copyBotLogin } from '../../organization/src/copy-bindings.mjs';
import { COPY_BINDING_STATUS } from '../../organization/src/copy-binding-status.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
@@ -62,7 +63,7 @@ async function world(t) {
// they stand, as a restart of the bus process would; boot() runs the gate's boot proof and promotes.
// stopRuns acknowledges each run the broker told the launcher to kill, as the launcher's exit does,
// unless `ack` is false. The revoke's remote step goes to the organization's admin slot.
async function org(t, w, { id = 'demo', name = 'rev', services = READ, perm = 'read', definition = 'research', admin = true, adminRotateBy = ahead(60), stopMs = 2000, project = null, gitOnly = false } = {}) {
async function org(t, w, { id = 'demo', name = 'rev', services = READ, perm = 'read', definition = 'research', admin = true, adminRotateBy = ahead(60), stopMs = 2000, project = null, gitOnly = false, definitions = null } = {}) {
const { root, rolesDir, api } = w;
const owner = id === 'demo' ? 'owner' : id;
const doc = { ...businessDoc(root, id), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
@@ -72,6 +73,7 @@ async function org(t, w, { id = 'demo', name = 'rev', services = READ, perm = 'r
// The project root the organization service derives (archive and delete check it).
doc.projects.stack.root = join(w.data, 'org-runtime', id, 'projects', 'stack');
if (project) doc.projects.stack.agents = project;
if (definitions) doc.definitions = definitions;
const file = writeJson(join(root, `businesses/${id}.json`), doc);
chmodSync(join(root, 'businesses'), 0o700);
const ctx = { configDir: root, dataRoot: w.data, instance: 'test', principal: LOCAL };
@@ -746,3 +748,95 @@ test('CB4 a second organization launches and calls throughout the first one\'s r
assert.ok(existsSync(acme.slot));
assert.deepEqual(demo.bot.tokens, []);
});
// #1873 CB5 (CB4 review P3-1): the gate finds a shadowing name only in the organization it serves,
// and the runtime gives a gate only to a boot that made a copy (cli config.mjs bootConfig passes no
// snapshot otherwise). A named instance the owner wrote over the only copy while the bus was down
// leaves the next start with no copy and no gate; only the revoker's own sweep then finds the former
// copy's record and ends its binding.
test('CB5 shadowing in an organization no gate serves: only the revoker\'s own sweep finds the former copy, and it revokes the binding', async (t) => {
const w = await world(t);
const f = await org(t, w);
await f.bind();
assert.equal(f.rec().state, 'bound');
assert.ok(existsSync(f.slot));
f.edit((d) => { d.roles[f.name] = { definition: 'research', credentials: { gitea: d.roles.coder.credentials.gitea } }; });
f.start();
// The start makes no copy of the name, so the runtime would build no gate for this organization.
assert.deepEqual(Object.keys(f.snapshot.copies ?? {}), []);
const revoker = copyRevoker({ writer: f.writer, gate: () => null, credentials: f.credentials, hooks: {}, stopRuns: (...a) => f.stopRuns(...a),
remote: giteaCopyRemote({ grant: f.grant, read: (x) => f.writer.read(x).document, incarnation: (x) => readOrgEntrySync(f.root, x)?.incarnation ?? null }),
definitions: () => ({}) });
assert.equal(revoker.pending(f.id, f.name), true);
revoker.sweep(f.id);
await until(() => tombstoned(f), 'shadowed tombstone');
assert.equal(f.rec().revoke.cause, 'shadowed');
assert.deepEqual(f.rec().revoke.remote, { gitea: 'revoked' });
assert.ok(!existsSync(f.slot));
assert.deepEqual(f.bot.tokens, []);
assert.equal(revoker.pending(f.id, f.name), false);
// A second pass finds a finished tombstone and starts nothing.
const deletes = w.api.calls().filter((c) => c.startsWith('DELETE')).length;
revoker.sweep(f.id);
await sleep(50);
assert.equal(revoker.has(f.id, f.name), false);
assert.equal(w.api.calls().filter((c) => c.startsWith('DELETE')).length, deletes);
});
// #1873 CB5 (CB4 review P3-3): an organization's own definition that needs gitea alone is not a
// thread definition, so its copy runs on the ordinary path: a role claim, no thread. A revoke ends
// that claim, kills the run, drains its call in flight and leaves its session dead.
const GITTER = { rev: 1, role: { tools: ['read'], network: 'api-only', authority: { withinRole: ['message.send'], crossRole: [] },
credentials: [{ service: 'gitea', scopes: ['read:repository', 'read:user'] }] }, instructions: 'Read the stack repository.' };
test('CB5 a revoke of a copy on the ordinary run path ends its role claim, kills the run, drains its call and kills its session', async (t) => {
const w = await world(t);
const f = await org(t, w, { name: 'rev.stack', project: { rev: { definition: 'org:gitter' } }, definitions: { gitter: GITTER } });
f.rootAgents = {};
f.start();
await f.bind({ level: 'project', project: 'stack', name: 'rev' });
assert.equal(f.rec().state, 'bound');
f.start();
await f.boot();
assert.deepEqual(Object.keys(f.gate.bound()), ['rev.stack']);
// An ordinary run: a thread is refused, none is required.
const revision = f.writer.read(f.id).revision;
const { authority } = await f.gate.prepare(f.id, f.name, revision);
assert.throws(() => f.b.bindLaunch({ business: f.id, role: f.name, run: 'run-t', harness: 'pi', thread: 'x', copyAuthority: authority, revision }), refuses('thread-not-allowed'));
const cap = f.b.bindLaunch({ business: f.id, role: f.name, run: 'run-1', harness: 'pi', copyAuthority: authority, revision });
await f.call(cap, 'role.claim');
const claim = () => f.store.get('SELECT op, holder_run, reason, by FROM role_claims WHERE business=? AND role=? ORDER BY seq DESC LIMIT 1', f.id, f.name);
assert.deepEqual({ ...claim() }, { op: 'claim', holder_run: 'run-1', reason: null, by: 'run-1' });
assert.equal((await f.use(cap)).status, 200);
const held = f.hold((e) => e.path === f.GET.path);
const inflight = f.use(cap).then((x) => ({ ok: x }), (e) => ({ err: e }));
await held.arrived;
assert.equal(f.gate.inflight(f.id, f.name), 1);
const out = await f.call(f.human, 'credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Owner revoke' });
assert.equal(out.state, 'unbound');
assert.equal(f.gate.inflight(f.id, f.name), 0, 'drained before the revoke answered');
held.release();
const settled = await inflight;
assert.ok(settled.err || settled.ok?.status === 0, 'the in-flight call was aborted');
// The claim ended for the revoke, by the bus, and the run got its kill end.
assert.deepEqual({ ...claim() }, { op: 'revoke', holder_run: 'run-1', reason: 'copy-revoked', by: 'bus' });
assert.deepEqual(f.killed(), ['run-1']);
assert.throws(() => f.b.copySession(cap), (e) => e instanceof BusError);
await assert.rejects(f.call(cap, 'role.claim'), (e) => e instanceof BusError);
await assertRevoked(f, null, { cause: 'revoke', code: 'credential-revoked' });
});
// #1873 CB5 re-check: a copy whose role needs a service a copy cannot bind reads
// copy-service-unbindable, but a revoke of a binding it was issued anyway outranks that.
test('CB5 status: copy-service-unbindable never hides a revoke that is running or failed', async (t) => {
const w = await world(t);
const f = await org(t, w);
const unbindable = { state: 'unbound', missing: ['gitea', 'vikunja'] };
assert.equal(f.gate.status('demo', f.name, unbindable).status, 'copy-service-unbindable');
let release;
const held = f.gate.fence('demo', f.name, () => new Promise((r) => { release = r; }));
assert.deepEqual(f.gate.status('demo', f.name, unbindable), { status: 'credential-revoking', next: COPY_BINDING_STATUS['credential-revoking'] });
release();
await held;
f.gate.close('demo', f.name, 'credential-revoke-failed');
assert.deepEqual(f.gate.status('demo', f.name, unbindable), { status: 'credential-revoke-failed', next: COPY_BINDING_STATUS['credential-revoke-failed'] });
});
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/copy-binding-status.mjs (rename design r2, stage B).
export * from "../../organization/src/copy-binding-status.mjs";
+12
View File
@@ -11,6 +11,8 @@
// instance.request until the request closes or --wait runs out (D1.1 item 5).
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] | " +
@@ -219,6 +221,16 @@ export function formatInstances(view) {
const vars = Object.keys(entry.vars).length ? ` vars ${JSON.stringify(entry.vars)}` : "";
lines.push(` ${name}: ${entry.definition}${marks.length ? ` (${marks.join(", ")})` : ""}${vars}`);
}
// #1873 CB5: each copy that needs a credential binding, with the bus's status code and its fixed
// next step (organization copy-binding-status.mjs). A code the table doesn't know prints its own
// fixed fallback; nothing else from the answer is printed for it.
for (const [name, copy] of Object.entries(view.copies ?? {})) {
if (!copy?.binding) continue;
const { status, next } = copyBindingStatus(copy.binding.status);
// 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);
const bound = definitions.filter(([, d]) => d.services.length).map(([name, d]) => `${name} (${d.services.join(", ")})`);
+361 -8
View File
@@ -8,6 +8,7 @@ import { requestHostOps } from './request-host-ops.mjs';
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
@@ -24,10 +25,13 @@ import { startTimeOf } from "../src/host.mjs";
import { createLauncher } from "../src/launcher.mjs";
import { fakeHarnesses, fixture, tmp, withoutHostCredentials } from "./helpers.mjs";
import { fakeGiteaApi } from "../../bus/tests/fake-gitea-api.mjs";
import { send as giteaSend } from "../../bus/src/gitea-grant.mjs";
import { giteaGrant, send as giteaSend } from "../../bus/src/gitea-grant.mjs";
import { organizationWriter } from "../../organization/src/organization.mjs";
import { activateOrg, reserveOrg } from "../../organization/src/org-registry.mjs";
import { activateOrg, listOrgs, reserveOrg } from "../../organization/src/org-registry.mjs";
import * as orgService from "../../organization/src/org-service.mjs";
import { copyBotLogin } from "../../organization/src/copy-bindings.mjs";
import { COPY_BINDING_STATUS } from "../../organization/src/copy-binding-status.mjs";
import { formatInstances } from "../src/instances.mjs";
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
const skip = sandboxProblem() ?? false;
@@ -60,8 +64,8 @@ function gitRepo(dir) {
// holds gate (merge-gate, no credential: bound), researcher (research, needs gitea: unbound in every
// organization) and sec (security-reviewer, excluded from acme). The coder's gitea token file is
// named for, and holds, the test secret, so it is bound elsewhere in the organization.
function prepare(t, edit = (doc) => doc) {
const root = tmp(t, "mosaic-copy-");
function prepare(t, edit = (doc) => doc, prefix = "mosaic-copy-") {
const root = tmp(t, prefix);
const f = fixture(root, "acme", (doc) => {
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
doc.roles.coder.vars.model = "claude-sonnet-5-5";
@@ -86,7 +90,7 @@ function prepare(t, edit = (doc) => doc) {
return { root, f, adapter };
}
async function start(t, { f, adapter }, { businessId = "acme", orgRoot, copyTransport = null, beforeBind = null } = {}) {
async function start(t, { f, adapter }, { businessId = "acme", orgRoot, copyTransport = null, beforeBind = null, readOnly = [FAKE] } = {}) {
const system = loadSystem({ env: f.env });
const { trackers, ...boot } = bootConfig({ system, businessId, env: f.env, ...(orgRoot ? { orgRoot } : {}) });
const runtime = await startBroker({ ...boot, ...(copyTransport ? { copyTransport } : {}) });
@@ -143,7 +147,7 @@ async function start(t, { f, adapter }, { businessId = "acme", orgRoot, copyTran
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
sessionDefaults: { pollInterval: 100 },
harnessVersion: "0.85.1",
readOnly: [FAKE],
readOnly,
threadTickMs: 3600000,
stopTimeoutMs: 1000,
log: (l) => logs.push(l),
@@ -605,11 +609,11 @@ test("A3: a second organization boots its own copies from the same root file and
// #1873 CB3: the researcher copy (research, gitea alone) bound and granted on a fake Gitea, through
// the real bus, its boot check and the launcher. `sent` is every request the bus sends to the service.
async function boundCopy(t) {
async function boundCopy(t, edit = (doc) => doc) {
const api = await fakeGiteaApi(t, tmp(t, "cb3-api-"));
const p = prepare(t, (doc) => {
doc.projects.stack.remote = `${api.url}/owner/stack`;
return doc;
return edit(doc);
});
const ctx = { configDir: join(p.root, "config"), dataRoot: p.f.dataRoot, instance: "test" };
chmodSync(ctx.configDir, 0o700);
@@ -756,3 +760,352 @@ test("CB3 race: an organization file change between the launcher's admission and
assert.ok(events.some((e) => e.event === "refused" && e.why === "launch bind refused: organization-changed"), JSON.stringify(events));
assert.ok(!events.some((e) => e.event === "launch"), JSON.stringify(events));
});
// #1873 CB5 (CB4 review P3-2): startBroker's own wiring for a former copy a named instance now
// shadows. At start the named instance's slot is held (never loaded as its credential), and the first
// pass begins the shadowed revoke without waiting for the minute's sweep; the revoke deletes the copy's
// token at the service through the organization's admin slot.
test("CB5 runtime: a restart over a named instance that shadows a bound copy holds the slot, and the start's own pass revokes the copy at the service", { skip, timeout: 120000 }, async (t) => {
const p = await boundCopy(t);
const first = await start(t, p, { copyTransport: p.copyTransport });
const login = await p.bind(first);
await first.close();
const day = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
giteaGrant({ configDir: join(p.root, "config") }).writeAdmin("acme", p.api.adminToken, { url: p.api.url, account: "mosaic-grant", rotateBy: day(60), setAt: new Date().toISOString() });
// The owner names an instance over the copy, through the writer; the binding record stays as it was.
const writer = organizationWriter({ dir: join(p.root, "config"), rolesDir: p.f.roles, dataRoot: p.f.dataRoot });
const { document, revision } = writer.read("acme");
const bindings = JSON.stringify(document.copyBindings);
const d = structuredClone(document);
d.roles.researcher = { definition: "research", vars: VARS, credentials: { gitea: { store: true, rotateBy: day(30) } } };
writer.write("acme", d, { revision, actor: "local-dev-owner" });
assert.equal(JSON.stringify(writer.read("acme").document.copyBindings), bindings);
// The token's delete waits until the test has looked at the held slot.
let go, arrived;
const deleting = new Promise((r) => { arrived = r; });
const release = new Promise((r) => { go = r; });
p.api.rule((e) => e.method === "DELETE" && e.path.includes("/tokens/"), async () => { arrived(); await release; return { pass: true }; });
const s = await start(t, p, { copyTransport: p.copyTransport });
// The start's own pass, not the minute's sweep, begins it.
await Promise.race([deleting, new Promise((_, no) => setTimeout(() => no(new Error("no revoke began at start")), 20000).unref())]);
assert.equal(p.rec().state, "revoking");
assert.equal(p.rec().revoke.cause, "shadowed");
// The slot went before the remote step. Had start loaded it as the named instance's credential
// (no hold), the discard would have refused and the record would never finish.
const slot = join(p.root, "config", "secrets", "agents", "acme", "researcher", "gitea");
assert.equal(existsSync(slot), false);
assert.deepEqual(s.runtime.credentialStatus("acme", "researcher").map((c) => [c.service, c.state]), [["gitea", "not-issued"]]);
go();
await until(() => p.rec().state === "unbound" && p.rec().revoke?.done === true);
assert.deepEqual(p.rec().revoke.remote, { gitea: "revoked" });
assert.deepEqual(p.rec().revoke.failed ?? null, null);
assert.deepEqual(p.api.tokens(login), []);
assert.ok(p.api.requests.some((e) => e.method === "DELETE" && e.as === "mosaic-grant"));
});
// #1873 CB5 (CB4 review P3-2): the gate's loss at boot reaches the revoker through startBroker's
// onLoss. A bound copy whose slot is gone at the next start is lost `credential-unavailable`, and its
// binding is revoked at the service with cause `unavailable`, without waiting for a sweep.
test("CB5 runtime: a bound copy whose slot is gone at restart is lost at boot, and onLoss revokes it at the service", { skip, timeout: 120000 }, async (t) => {
const p = await boundCopy(t);
const first = await start(t, p, { copyTransport: p.copyTransport });
const login = await p.bind(first);
await first.close();
const day = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
giteaGrant({ configDir: join(p.root, "config") }).writeAdmin("acme", p.api.adminToken, { url: p.api.url, account: "mosaic-grant", rotateBy: day(60), setAt: new Date().toISOString() });
const slot = join(p.root, "config", "secrets", "agents", "acme", "researcher", "gitea");
assert.ok(existsSync(slot));
renameSync(slot, slot + ".gone");
const s = await start(t, p, { copyTransport: p.copyTransport });
assert.deepEqual(s.runtime.copyBound, {});
await until(() => p.rec().state === "unbound" && p.rec().revoke?.done === true, 20000);
assert.equal(p.rec().revoke.cause, "unavailable");
assert.deepEqual(p.rec().revoke.remote, { gitea: "revoked" });
assert.deepEqual(p.api.tokens(login), []);
assert.equal(refusedAt(s, "researcher").code, "credential-unavailable");
});
// #1873 CB5 two-organization acceptance (A3's deferred test). acme and beta each bind the shipped
// research copy (researcher, gitea alone) on one fake Gitea, each run by its own bus with its own
// transport. beta is registered as the organization service makes one (registry entry, owner proof,
// the derived project root), so archive and delete run on it. A transport keeps the sha256 of every
// credential its bus sends, never the value.
const LOCAL = { provider: "local-dev", iss: "local-dev", sub: "local-dev-owner" };
const THREAD_ADAPTER = fileURLToPath(new URL("./fixtures/thread-adapter.mjs", import.meta.url));
const sha = (x) => createHash("sha256").update(String(x)).digest("hex");
function hashingTransport(api) {
const auth = [];
return { auth, ca: api.ca, send: (req) => {
auth.push({ op: `${req.method} ${req.path.split("?")[0]}`, hash: sha(req.auth?.basic ? req.auth.basic.slice(1).join(":") : req.auth?.token) });
return giteaSend(req);
} };
}
async function twoOrgs(t) {
const api = await fakeGiteaApi(t, tmp(t, "cb5-api-"));
const p = prepare(t, (doc) => {
doc.projects.stack.remote = `${api.url}/owner/stack`;
return doc;
// A short root: beta's data root is the registry's <dataRoot>/org-runtime/beta, and a run's socket
// path under it must fit 107 bytes.
}, "c5-");
// The thread adapter keeps a history in the thread's store and answers with what it found there.
writeFileSync(p.adapter, `exec '${process.execPath}' '${THREAD_ADAPTER}'\n`);
const configDir = join(p.root, "config");
const dataRoot = p.f.dataRoot;
const ctx = { configDir, dataRoot, instance: "test", principal: LOCAL };
chmodSync(configDir, 0o700);
chmodSync(join(configDir, "businesses"), 0o700);
await reserveOrg(ctx, "acme");
await activateOrg(ctx, "acme");
const beta = businessDoc(p.root, "beta");
for (const r of Object.values(beta.roles)) r.vars = { ...VARS, ...r.vars, model: r.vars?.model ?? "claude-sonnet-5-5" };
beta.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
beta.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
beta.plugins = { ...beta.plugins, harness: ["pi", "claude-code"] };
beta.projects.stack.remote = `${api.url}/beta/stack`;
beta.projects.stack.root = join(dataRoot, "org-runtime", "beta", "projects", "stack");
writeJson(join(configDir, "businesses", "beta.json"), beta);
const entry = await reserveOrg(ctx, "beta");
writeFileSync(join(entry.root, "owner.json"), JSON.stringify(LOCAL) + "\n", { mode: 0o600, flag: "wx" });
gitRepo(beta.projects.stack.root);
await activateOrg(ctx, "beta");
const writer = organizationWriter({ dir: configDir, rolesDir: p.f.roles, dataRoot });
const day = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
const orgs = {
acme: { id: "acme", repo: api.addRepo("owner", "stack"), transport: hashingTransport(api), orgRoot: undefined },
beta: { id: "beta", repo: api.addRepo("beta", "stack"), transport: hashingTransport(api), orgRoot: entry.root },
};
for (const o of Object.values(orgs)) {
o.rec = () => writer.read(o.id).document.copyBindings?.researcher;
o.slot = join(configDir, "secrets", "agents", o.id, "researcher", "gitea");
o.start = () => start(t, p, { businessId: o.id, orgRoot: o.orgRoot, copyTransport: o.transport, readOnly: [THREAD_ADAPTER] });
// Bind (the owner's console) and issue (the human), on a bus that is already up.
o.bind = async (s) => {
const owner = s.runtime.broker.bindConsole({ business: o.id, human: writer.read(o.id).document.human, subject: { provider: "test", iss: "https://issuer.test", sub: "owner" } });
await s.runtime.broker.request(owner, { verb: "hire.start.console", args: { op: "change", level: "root", name: "researcher", reason: "Bind the copy",
binding: { services: { gitea: { scopes: ["read:repository", "read:user"], projects: ["stack"] } } } } });
const r = o.rec();
o.login = copyBotLogin(r.orgIncarnation, r.copyId, "gitea");
api.addUser(o.login);
o.repo.collaborators.push(o.login); o.repo.permissions[o.login] = "read";
o.token = api.addToken(o.login, "t1", ["read:repository", "read:user"]).sha1;
await s.call("credentials.issue", { agent: "researcher", service: "gitea", date: day(30), token: o.token });
giteaGrant({ configDir }).writeAdmin(o.id, api.adminToken, { url: api.url, account: "mosaic-grant", rotateBy: day(60), setAt: new Date().toISOString() });
};
}
return { p, api, ctx, writer, orgs, day };
}
const orgLaunches = (s) => existsSync(launchLogFile(s.dataRoot, s.host.business))
? readFileSync(launchLogFile(s.dataRoot, s.host.business), "utf8").trim().split("\n").map((l) => JSON.parse(l)) : [];
// Send to a thread and wait for the copy's RESULT reply; the reply says what the thread's store held.
async function askThread(s, thread, body) {
const sent = s.call("thread.send", { thread, body });
await s.launcher.tick();
const reply = await until(() => s.call("thread.messages", { thread }).find((m) => m.in_reply_to === sent.id && m.class === "RESULT"), 30000)
.catch((e) => { throw new Error(`no reply: ${JSON.stringify(orgLaunches(s).slice(-3))}`, { cause: e }); });
return { sent, reply, saw: JSON.parse(reply.body.match(/THREAD (\{.*\})/)[1]) };
}
test("CB5 two organizations: each binds and runs its own research copy in a thread; identities, records and tokens stay apart; B works through A's revoke, archive and delete; A's raw binding or grant replayed in B is refused", { skip, timeout: 240000 }, async (t) => {
const { api, ctx, writer, orgs, day } = await twoOrgs(t);
const A = orgs.beta, B = orgs.acme;
// Bind each, then restart each so its boot proves the slot live.
for (const o of [A, B]) {
const first = await o.start();
await o.bind(first);
await first.close();
o.s = await o.start();
assert.deepEqual(o.s.runtime.copyBound, { researcher: ["gitea"] }, o.id);
}
assert.notEqual(A.login, B.login);
assert.notEqual(A.rec().copyId, B.rec().copyId);
assert.notEqual(A.rec().orgIncarnation, B.rec().orgIncarnation);
assert.notEqual(sha(readFileSync(A.slot, "utf8")), sha(readFileSync(B.slot, "utf8")));
// One thread per organization, launched, written and read.
for (const o of [A, B]) {
const project = o.s.call("project.create", { name: "stack work", repo: "stack" }).id;
o.thread = o.s.call("thread.create", { project, name: `work-${o.id}`, instance: "researcher" }).id;
const first = await askThread(o.s, o.thread, `hello ${o.id}`);
assert.deepEqual(first.saw.before, [], o.id);
const launch = orgLaunches(o.s).find((e) => e.event === "launch" && e.thread === o.thread);
assert.ok(launch, o.id);
o.run = launch.run;
[o.entry] = o.s.running("researcher");
assert.equal(o.entry.run, o.run, o.id);
o.session = o.s.runtime.threadsState(o.id).find((x) => x.thread === o.thread).session;
o.store = join(o.s.dataRoot, "threads", o.id, o.thread, "sessions", o.session);
assert.equal(readFileSync(join(o.store, "session", "history"), "utf8"), `hello ${o.id}\n`, o.id);
}
// Distinct session, run, trail, workspace and slot identities.
assert.notEqual(A.s.dataRoot, B.s.dataRoot);
for (const k of ["thread", "session", "run"]) assert.notEqual(A[k], B[k], k);
assert.notEqual(A.entry.workspace, B.entry.workspace);
assert.notEqual(A.slot, B.slot);
for (const [mine, other] of [[A, B], [B, A]]) {
assert.ok(mine.entry.workspace.startsWith(mine.s.dataRoot + "/"), mine.id);
assert.ok(mine.entry.runDir.startsWith(join(mine.s.dataRoot, "launches", mine.id) + "/"), mine.id);
// No record of the other's is visible here: not its thread, its session, its run or its request.
const seen = trail(mine.s.dataRoot);
for (const k of ["thread", "session", "run"]) assert.equal(seen.includes(other[k]), false, `${mine.id} trail holds ${other.id}'s ${k}`);
// The default organization's data root is the instance's, and every named organization's root
// sits under its org-runtime: that subtree is the other bus's, not this one's.
const own = readdirSync(mine.s.dataRoot, { recursive: true }).filter((x) => mine.orgRoot || !x.startsWith("org-runtime/"));
assert.equal(own.join("\n").includes(other.run), false, mine.id);
assert.ok(!mine.s.call("thread.list").some((x) => x.id === other.thread), mine.id);
assert.throws(() => mine.s.call("thread.messages", { thread: other.thread }), BusError, mine.id);
assert.ok(!mine.s.running("researcher").some((x) => x.run === other.run), mine.id);
}
// Reopen each: a restart of its bus, and the same thread finds its own history and session.
for (const o of [A, B]) {
await o.s.close();
o.s = await o.start();
const again = await askThread(o.s, o.thread, `again ${o.id}`);
assert.deepEqual(again.saw.before, [`hello ${o.id}`], o.id);
assert.equal(o.s.runtime.threadsState(o.id).find((x) => x.thread === o.thread).session, o.session, o.id);
}
// So far no request of B's bus carried A's token, and none of A's carried B's.
// The issue's identity check (copy-grant verifyGiteaToken): the server version, then the token's owner.
const CHECK = ["GET /api/v1/version", "GET /api/v1/token"];
const carried = (o, token) => o.transport.auth.filter((x) => x.hash === sha(token)).map((x) => x.op);
assert.deepEqual(carried(B, A.token), []);
assert.deepEqual(carried(A, B.token), []);
assert.ok(carried(B, B.token).length > 0);
// A's raw binding record written into B's file is refused, and B's file is as it was.
const { document, revision } = writer.read(B.id);
const replay = structuredClone(document);
replay.copyBindings = { ...replay.copyBindings, researcher: A.rec() };
assert.throws(() => writer.write(B.id, replay, { revision, actor: "local-dev-owner" }), (e) => e.code === "organization-bindings-protected");
assert.equal(writer.read(B.id).revision, revision);
// A's grant (its live token) issued to B's copy is refused at the service's identity check. The
// issue deactivates first (design section 5): the record is left pending with no grant and the
// staged file goes, so A's token is never B's credential. B's copy keeps its own identity.
const bound = B.rec();
await assert.rejects(Promise.resolve().then(() => B.s.call("credentials.issue", { agent: "researcher", service: "gitea", date: day(30), token: A.token })),
(e) => e instanceof BusError && e.code === "credential-identity-refused");
assert.equal(B.rec().state, "pending");
assert.equal(B.rec().services.gitea.grantId, undefined);
for (const k of ["copyId", "orgIncarnation", "decision"]) assert.equal(B.rec()[k], bound[k], k);
assert.equal(existsSync(B.slot), false);
// The only requests that carried A's token from B's bus are the identity check that refused it.
assert.deepEqual(carried(B, A.token), CHECK);
// The owner's recovery is a new verified issue of B's own token, then a restart.
B.token = api.addToken(B.login, "t2", ["read:repository", "read:user"]).sha1;
await B.s.call("credentials.issue", { agent: "researcher", service: "gitea", date: day(30), token: B.token });
assert.equal(B.rec().state, "bound");
await B.s.close();
B.s = await B.start();
assert.deepEqual(B.s.runtime.copyBound, { researcher: ["gitea"] });
const bTokens = api.tokens(B.login).length;
// B keeps working through each of A's lifecycle steps.
const bWorks = async (label) => {
const r = await askThread(B.s, B.thread, `after ${label}`);
assert.equal(r.saw.body, `after ${label}`, label);
assert.equal(B.rec().state, "bound", label);
assert.ok(existsSync(B.slot), label);
assert.equal(api.tokens(B.login).length, bTokens, label);
assert.deepEqual(B.s.runtime.copyBound, { researcher: ["gitea"] }, label);
};
const out = await A.s.call("credentials.revoke", { agent: "researcher", service: "gitea", reason: "Owner revoke" });
assert.equal(out.state, "unbound");
assert.deepEqual(out.remote, { gitea: "revoked" });
assert.deepEqual(api.tokens(A.login), []);
await until(() => A.s.running("researcher").length === 0);
await bWorks("revoke");
const rev = async () => (await listOrgs(ctx)).find((o) => o.id === A.id).revision;
const busStop = async () => { await A.s.close(); return { stopped: true, reason: "stopped" }; };
const archived = await orgService.archiveOrg(ctx, A.id, { ifRevision: await rev(), busStop });
assert.equal(archived.outcome, "done");
await bWorks("archive");
const deleted = await orgService.deleteOrg(ctx, A.id, { confirm: A.id, ifRevision: await rev(), busStop });
assert.equal(deleted.outcome ?? "done", "done");
assert.equal(existsSync(A.slot), false);
await bWorks("delete");
// Through all of it, B's bus sent A's token only in the refused issue's check, and A's never sent B's.
assert.deepEqual(carried(B, A.token), CHECK);
assert.deepEqual(carried(A, B.token), []);
assert.ok(carried(B, B.token).length > 0);
});
// #1873 CB5 operator flow: the copy's binding status in the owner's instances read and in
// `mosaic org instances`, at each step a binding takes, as fixed text. The copy's token is the
// sentinel: it, its hash and its leading fragment never appear in a read, the CLI text, the trail,
// the launcher's log or the bus's own stderr.
test("CB5 operator: the owner's instances read and mosaic org instances show each copy's binding status and next step in fixed text; the token never appears", { skip, timeout: 120000 }, async (t) => {
// bus.tester on, so the tester's read below is answered and its exclusion is the broker's.
const p = await boundCopy(t, (doc) => ({ ...doc, vars: { ...(doc.vars ?? {}), "bus.tester": true } }));
const said = [];
const write = process.stderr.write;
process.stderr.write = function (chunk, ...rest) { said.push(String(chunk)); return write.call(this, chunk, ...rest); };
t.after(() => { process.stderr.write = write; });
const first = await start(t, p, { copyTransport: p.copyTransport });
const seen = [];
const status = (s) => { const v = s.call("instances"); seen.push(JSON.stringify(v), formatInstances(v)); return v.copies.researcher.binding; };
const fixed = (code) => ({ status: code, next: COPY_BINDING_STATUS[code] });
// Nothing approved yet.
assert.deepEqual(status(first), fixed("credential-unbound"));
// Approved: the owner's next step is the issue.
const owner = first.runtime.broker.bindConsole({ business: "acme", human: p.f.doc.human, subject: { provider: "test", iss: "https://issuer.test", sub: "owner" } });
await first.runtime.broker.request(owner, { verb: "hire.start.console", args: { op: "change", level: "root", name: "researcher", reason: "Bind the copy",
binding: { services: { gitea: { scopes: ["read:repository", "read:user"], projects: ["stack"] } } } } });
assert.equal(p.rec().state, "pending");
assert.deepEqual(status(first), fixed("issue-required"));
// Issued: a restart verifies it.
const r = p.rec();
const login = copyBotLogin(r.orgIncarnation, r.copyId, "gitea");
p.api.addUser(login);
const repo = p.api.repos.find((x) => x.owner === "owner" && x.name === "stack");
repo.collaborators.push(login); repo.permissions[login] = "read";
const token = p.api.addToken(login, "t1", ["read:repository", "read:user"]).sha1;
await first.call("credentials.issue", { agent: "researcher", service: "gitea", date: new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10), token });
assert.equal(p.rec().state, "bound");
assert.deepEqual(status(first), fixed("restart-required"));
await first.close();
// Verified at the next start.
const s = await start(t, p, { copyTransport: p.copyTransport });
assert.deepEqual(s.runtime.copyBound, { researcher: ["gitea"] });
assert.deepEqual(status(s), fixed("ready"));
// A tester's read lists the copy and never its binding status (design section 4).
const tester = s.runtime.broker.bindTester({ business: "acme" });
const asTester = await s.runtime.broker.request(tester, { verb: "instances" });
assert.ok(asTester.copies?.researcher, "the tester sees the copy");
assert.equal(Object.hasOwn(asTester.copies.researcher, "binding"), false);
seen.push(JSON.stringify(asTester));
// The owner's revoke.
await s.call("credentials.revoke", { agent: "researcher", service: "gitea", reason: "Owner revoke" });
assert.deepEqual(status(s), fixed("credential-revoked"));
// The CLI prints the status code and its next step for the copy.
const text = formatInstances(s.call("instances"));
assert.ok(text.includes(` researcher (copy): credential-revoked. ${COPY_BINDING_STATUS["credential-revoked"]}\n`), text);
// The sentinel never appears.
const leaks = (x) => [token, sha(token), token.slice(0, 8)].filter((v) => x.includes(v));
assert.deepEqual(leaks(seen.join("\n")), []);
assert.deepEqual(leaks(trail(p.f.dataRoot)), []);
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);
});
@@ -0,0 +1,32 @@
// #1873 CB5: an agent copy's credential binding status in its owner's views (the bus instances read,
// the Console agents table and `mosaic org instances`), with the one next step each status names.
// The texts are design docs/plans/2026-11-11_copy-credential-binding-design.md section 6's, and the
// three steps a binding takes before it is usable (approved, issued, verified at a bus start). Every
// string is fixed: none interpolates a name, a path, a URL, a service answer or anything from a token.
// A code this table does not know is credential-unavailable, never echoed.
export const COPY_BINDING_STATUS = Object.freeze({
ready: "credentials.gitea is bound and was verified at the last bus start.",
"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.",
"credential-unavailable": "credentials.gitea is unavailable; the owner must verify the slot.",
"credential-expired": "credentials.gitea has expired; the owner must approve a replacement.",
"credential-role-changed": "credentials.gitea no longer matches the approved definition; the owner must review the binding.",
"credential-scope-exceeded": "credentials.gitea.scopes exceeds the approved role.",
"credential-scope-unverified": "credentials.gitea permissions could not be verified.",
"copy-unavailable": "copy is no longer active in this organization.",
"copy-refused": "copy identity is ambiguous; the owner must resolve the definition collision.",
"org-archived": "organization is archived.",
"org-unavailable": "organization is not active.",
"organization-unreadable": "the organization file could not be read; the owner must check it before the copy launches.",
});
export function copyBindingStatus(code) {
const status = typeof code === "string" && Object.hasOwn(COPY_BINDING_STATUS, code) ? code : "credential-unavailable";
return Object.freeze({ status, next: COPY_BINDING_STATUS[status] });
}
+21 -4
View File
@@ -57,6 +57,18 @@ const HIRE_START_REFUSALS = {
'definition-shared': [409, 'Another agent uses the same role definition, so this change cannot replace it. Nothing was applied.'],
'no-chief-of-staff': [409, 'This organization has no chief of staff, so a hire, fire or change cannot be checked against it. Nothing was applied.'],
};
// #1873 CB5: the owner's next step once a copy credential binding decision is approved, fixed text
// (design section 6 and its three steps). Nothing in it comes from the decision, the bus or a token.
export const BINDING_NEXT = Object.freeze({
bind: "Binding approved. Issue the copy's token with mosaic credentials issue, then restart the organization bus. The copy's status on the Agents grid names each next step.",
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. 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);
const BODY_MAX = 4096;
const TOKEN = /^[a-f0-9]{64}$/;
const DIGEST = /^[a-f0-9]{64}$/;
@@ -150,7 +162,10 @@ export async function decisionHTTP(req, res, { path, decide, session, live, conf
if(step==='start' && (typeof result?.id!=='string'||result.op!=='resolved'||result.choice!=='yes'))return refuse('outcome-unknown');
if(step==='templates' && !Array.isArray(result?.templates))return refuse('confirmation-unavailable');
line(step==='start'?'hire=recorded':'templates=read');
return json(res,200,step==='start'?{id:result.id,op:result.op,choice:result.choice}:result);
// CB5: an approved binding start says what the owner does next, fixed text by its shape.
const change=step==='start' && value.hire?.binding && typeof value.hire.binding==='object' && 'services' in value.hire.binding
? (value.hire.binding.services===null?'unbind':'bind') : null;
return json(res,200,step==='start'?{id:result.id,op:result.op,choice:result.choice,...(change?{next:BINDING_NEXT[change]}:{})}:result);
} catch(err) {
if(step!=='start' || !(err instanceof BusReadError) || !Object.hasOwn(HIRE_START_REFUSALS,err.code))
return refuse(busCode(err,step==='start'?'outcome-unknown':'confirmation-unavailable'));
@@ -171,11 +186,12 @@ export async function decisionHTTP(req, res, { path, decide, session, live, conf
const action = ordinary ? 'answer' : value.choice === v.approve.key ? 'approve' : 'deny';
if (live() !== session) return refuse('sign-in-required');
if(value.adjust!==undefined && v.kind!=='hire')return refuse('invalid-request');
const confirmation = confirmations.issue({ session, business: v.business, decision: id, choice: value.choice, digest: v.digest, ...(v.kind==='hire'?{kind:'hire',...(value.adjust===undefined?{}:{adjust:structuredClone(value.adjust)})}:{}) });
const binding = v.kind === 'hire' ? bindingOf(v) : null;
const confirmation = confirmations.issue({ session, business: v.business, decision: id, choice: value.choice, digest: v.digest, ...(v.kind==='hire'?{kind:'hire',...(value.adjust===undefined?{}:{adjust:structuredClone(value.adjust)}),...(binding?{change:binding.change}:{})}:{}) });
if (!confirmation) return refuse('confirmation-unavailable');
line(`action=${action}`);
return json(res, 200, { id, question: typeof v.question === 'string' ? v.question : '', action, choice: value.choice, text: chosen.text,
...(v.kind==='hire'?{kind:'hire',flagged:v.flagged,credentialLines:v.credentialLines,expiresAt:v.expiresAt}:ordinary ? { kind: 'decision' } : { grant: v.grant }), confirmation });
...(v.kind==='hire'?{kind:'hire',flagged:v.flagged,credentialLines:v.credentialLines,expiresAt:v.expiresAt,...(binding?{binding}:{})}:ordinary ? { kind: 'decision' } : { grant: v.grant }), confirmation });
}
const keys = Object.keys(value);
if (keys.length !== 2 || !keys.includes('choice') || !keys.includes('confirmation') || typeof value.choice !== 'string') return refuse('invalid-request');
@@ -189,7 +205,8 @@ export async function decisionHTTP(req, res, { path, decide, session, live, conf
} catch (err) { return refuse(busCode(err, 'outcome-unknown')); }
if (r?.id !== id || r.op !== 'resolved' || r.choice !== held.choice) return refuse('outcome-unknown');
line(`choice=${JSON.stringify(held.choice.slice(0, 64))}${r.grant ? ` grant=${r.grant}` : ''}`);
return json(res, 200, { id, op: 'resolved', choice: r.choice, ...(r.grant === 'started' ? { grant: 'started' } : {}) });
return json(res, 200, { id, op: 'resolved', choice: r.choice, ...(r.grant === 'started' ? { grant: 'started' } : {}),
...(held.change && r.choice === 'yes' ? { next: BINDING_NEXT[held.change] } : {}) });
}
function json(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
+8 -3
View File
@@ -396,7 +396,12 @@
let canDecide = false, ssoOn = false, answer = null;
document.addEventListener('mosaic-signed-out', () => { answer = null; });
const ACTIONS = { approve: 'Approve', deny: 'Deny' };
const hireLines = c => c?.kind !== 'hire' ? '' : `<h3>Flagged grants — owner approval required</h3><ul>${c.flagged.map(l=>`<li>${txt(l.field)}: ${txt(l.value)}</li>`).join('')||'<li>None</li>'}</ul><h3>Requested credential scopes — owner approval required</h3><ul>${c.credentialLines.map(l=>`<li>${txt(l.service)}${l.scope?`: ${txt(l.scope)}`:''}</li>`).join('')||'<li>None</li>'}</ul><p>Expires ${txt(c.expiresAt)}</p>`;
const hireLines = c => c?.kind !== 'hire' ? '' : `<h3>Flagged grants — owner approval required</h3><ul>${c.flagged.map(l=>`<li>${txt(l.field)}: ${txt(l.value)}</li>`).join('')||'<li>None</li>'}</ul><h3>Requested credential scopes — owner approval required</h3><ul>${c.credentialLines.map(l=>`<li>${txt(l.service)}${l.scope?`: ${txt(l.scope)}`:''}</li>`).join('')||'<li>None</li>'}</ul><p>Expires ${txt(c.expiresAt)}</p>${bindingLine(c)}`;
// #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' ? '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 '';
const list=(key,values)=>(values??[]).map((v,i)=>`<label><input type="checkbox" data-hire-list="${esc(key)}" data-index="${i}" checked> ${txt(v)}</label>`).join(' ');
@@ -478,7 +483,7 @@
let body;
if (a.result) {
const r = a.result;
body = `<p class="receipt" id="decide-status" tabindex="-1">${['decision','hire'].includes(r.kind) ? 'Recorded' : r.action === 'approve' ? 'Approved' : 'Denied'}: you chose “${txt(r.choice)}”, ${txt(r.text)}.${['decision','hire'].includes(r.kind) ? '' : ` ${r.grant === 'started' ? 'The grant executor started.' : r.action === 'approve' ? 'The bus started no grant for it.' : 'Nothing starts for a denial.'}`} History below shows it once the bus is read again.</p>${window.MosaicInbox?.receipt?.(r, a.decision) ?? ''}`;
body = `<p class="receipt" id="decide-status" tabindex="-1">${['decision','hire'].includes(r.kind) ? 'Recorded' : r.action === 'approve' ? 'Approved' : 'Denied'}: you chose “${txt(r.choice)}”, ${txt(r.text)}.${['decision','hire'].includes(r.kind) ? '' : ` ${r.grant === 'started' ? 'The grant executor started.' : r.action === 'approve' ? 'The bus started no grant for it.' : 'Nothing starts for a denial.'}`} History below shows it once the bus is read again.</p>${r.next ? `<p class="receipt" data-binding-next>${txt(r.next)}</p>` : ''}${window.MosaicInbox?.receipt?.(r, a.decision) ?? ''}`;
} else if (a.confirm) {
const c = a.confirm, label = `Confirm ${c.action}`;
body = `<div class="s1-confirm" role="group" aria-labelledby="b-confirm"><h3 id="b-confirm" class="small">${label}</h3>
@@ -548,7 +553,7 @@
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); else render(false); return; }
if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering. The sign-in proxy redirected the answer, so it never reached the Console.' }; paintDecide('#decide-status'); signOut(); return; }
if (res?.ok && json?.id === a.id && json.op === 'resolved' && json.choice === c.choice) {
a.result = { kind: c.kind, action: c.action, choice: c.choice, text: c.text, grant: json.grant ?? null, grantDetails: c.grant ?? null };
a.result = { kind: c.kind, action: c.action, choice: c.choice, text: c.text, grant: json.grant ?? null, grantDetails: c.grant ?? null, next: typeof json.next === 'string' ? json.next : null };
paintDecide('#decide-status'); announce(['decision','hire'].includes(c.kind) ? 'Recorded' : c.action === 'approve' ? 'Approved' : 'Denied'); render(false);
} else { a.error = decideRefusal(res, json, 'outcome-unknown'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); }
}
+10 -4
View File
@@ -283,8 +283,14 @@
const NOT_RUNNING = ['s-idle', '–', 'not running'];
// A copy's status: running or not as the bus reports its runs, or why it can't launch.
const CANT = ['s-error', '▲', "can't launch"];
const copyWhy = (c) => (c.state === 'unbound' ? `no binding in this organization for ${(c.missing || []).join(', ')}` : c.reason || 'refused');
const copyDot = (name, c, agents) => (c.state === 'instance' ? activity(name, c, agents) : CANT);
// #1873 CB5: the bus adds `binding` to a copy that needs a credential binding: its status code and
// the owner's next step, fixed text from the bus. A booted copy whose binding is not ready can't
// launch, and says why.
const bindingNext = (c) => (typeof c?.binding?.next === 'string' && c.binding.status !== 'ready' ? c.binding.next : '');
const copyBlocked = (c) => c.state !== 'instance' || Boolean(bindingNext(c));
const copyWhy = (c) => (c.state === 'unbound' ? [`no binding in this organization for ${(c.missing || []).join(', ')}`, bindingNext(c)].filter(Boolean).join('. ')
: c.state === 'instance' ? bindingNext(c) : c.reason || 'refused');
const copyDot = (name, c, agents) => (copyBlocked(c) ? CANT : activity(name, c, agents));
// The bus names each copy's instance. Find an agent's copy by its name and project, never by
// rebuilding the instance name: a collision refuses one entry that lists every claimant.
const copyFor = (copies, name, project = null) => {
@@ -329,7 +335,7 @@
if (inst === null) return '<span class="muted">status unavailable</span>';
const { key, copy: c } = copyFor(mine?.copies, a.name, a.level === 'project' ? a.project : null);
if (!c) return '<span class="muted">starts with the next bus start</span>';
return `${dot(...copyDot(key, c, mine.agents), true)}${c.state === 'instance' ? '' : `<span class="t2">${esc(copyWhy(c))}</span>`}`;
return `${dot(...copyDot(key, c, mine.agents), true)}${copyBlocked(c) ? `<span class="t2">${esc(copyWhy(c))}</span>` : ''}`;
}
if (inst === null) return '<span class="muted">status unavailable</span>';
return dot(...(inst[a.name] ? activity(a.name, inst[a.name], mine.agents) : NOT_RUNNING), true);
@@ -378,7 +384,7 @@
const copyPill = (copies, agents, name, project, small) => {
const { key, copy: c } = copyFor(copies, name, project);
const mark = copies === null ? ['s-unknown', '?', 'status unavailable'] : !c ? ['s-unknown', '?', 'starts with the next bus start'] : copyDot(key, c, agents);
const why = c && c.state !== 'instance' ? `<span class="note ws-off" data-copy-why="${esc(key)}">${esc(name)}: ${esc(copyWhy(c))}</span>` : '';
const why = c && copyBlocked(c) ? `<span class="note ws-off" data-copy-why="${esc(key)}">${esc(name)}: ${esc(copyWhy(c))}</span>` : '';
return `<span class="ws-agent" data-level-agent="${esc(name)}" data-copy-state="${esc(c?.state || 'unknown')}">${dot(...mark)}${esc(name)} <small>${esc(small)}</small></span>${why}`;
};
// Root: one copy per organization, each running inside that organization; an opt-out is said.
+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);
+58 -1
View File
@@ -4,7 +4,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { BusReadError } from '../src/bus.mjs';
import { DECISION_REFUSALS, createConfirmations, decisionHTTP } from '../src/decision-http.mjs';
import { BINDING_NEXT, DECISION_REFUSALS, createConfirmations, decisionHTTP } from '../src/decision-http.mjs';
const ORIGIN = 'https://console.test';
const DIGEST = 'a'.repeat(64);
@@ -353,3 +353,60 @@ test('D4 templates, confirmation and resolve keep their open mapping for a defin
const ok=await s.confirm('yes');assert.equal(ok.status,200);
refused(await s.resolve({choice:'yes',confirmation:ok.json().confirmation}),'outcome-unknown');
});
// #1873 CB5: a copy credential binding decision on the Console. The confirmation carries the bus's
// computed scope lines, the copy's name and whether it binds or unbinds, and nothing else of the
// binding; the approved answer carries the owner's fixed next step. The sentinel stands for a token
// the bus should never send: placed in every binding field the bus could add, it never reaches a
// response or a log line.
const SENTINEL='sentinel-0123456789abcdef0123456789abcdef01234567';
const bindingPreview=(after)=>({id:'d1',business:'demo',kind:'hire',question:'bind credentials for copy researcher?',digest:DIGEST,
options:[{key:'yes',text:'Approve'},{key:'no',text:'Deny'}],flagged:[],credentialLines:[{service:'gitea',scope:'read:repository'}],expiresAt:'2099-01-01T00:00:00.000Z',
binding:{instance:'researcher',source:{level:'root',name:SENTINEL,project:null},copyId:SENTINEL,fresh:true,orgIncarnation:SENTINEL,definition:{ref:SENTINEL,sha256:SENTINEL},
before:{gitea:{slot:SENTINEL,grantId:SENTINEL,scopes:['read:repository'],resources:{projects:['stack']}}},after,generation:0}});
const leaks=(s,...texts)=>texts.filter(t=>t.includes(SENTINEL)||t.includes(SENTINEL.slice(0,16))).length;
test('CB5 binding confirmation names the copy and the change with the computed scope lines, and the approval names the next step; nothing else of the binding leaves',async()=>{
const after={gitea:{slot:'self',scopes:['read:repository'],resources:{projects:['stack']},token:SENTINEL}};
const s=setup({answer:()=>bindingPreview(after),resolve:a=>({id:a.id,op:'resolved',choice:a.choice,transaction:SENTINEL,binding:{token:SENTINEL}})});
const c=await s.confirm('yes');assert.equal(c.status,200,c.text);
const body=c.json();
assert.deepEqual({...body,confirmation:'x'},{id:'d1',question:'bind credentials for copy researcher?',action:'answer',choice:'yes',text:'Approve',kind:'hire',
flagged:[],credentialLines:[{service:'gitea',scope:'read:repository'}],expiresAt:'2099-01-01T00:00:00.000Z',binding:{instance:'researcher',change:'bind'},confirmation:'x'});
const r=await s.resolve({choice:'yes',confirmation:body.confirmation});assert.equal(r.status,200,r.text);
assert.deepEqual(r.json(),{id:'d1',op:'resolved',choice:'yes',next:BINDING_NEXT.bind});
assert.equal(s.calls[1].verb,'hire.resolve.console');
assert.equal(leaks(s,c.text,r.text,...s.lines),0);
assert.match(s.lines[0],/ step=confirmation action=answer$/);assert.match(s.lines[1],/ step=resolve choice="yes"$/);
});
test('CB5 an unbind says the bus revokes; a denial carries no next step; a malformed binding is not echoed',async()=>{
const u=setup({answer:()=>bindingPreview(null),resolve:a=>({id:a.id,op:'resolved',choice:a.choice})});
const uc=await u.confirm('yes');assert.deepEqual(uc.json().binding,{instance:'researcher',change:'unbind'});
const ur=await u.resolve({choice:'yes',confirmation:uc.json().confirmation});
assert.deepEqual(ur.json(),{id:'d1',op:'resolved',choice:'yes',next:BINDING_NEXT.unbind});
const d=setup({answer:()=>bindingPreview(null),resolve:a=>({id:a.id,op:'resolved',choice:a.choice})});
const dc=await d.confirm('no');
assert.deepEqual((await d.resolve({choice:'no',confirmation:dc.json().confirmation})).json(),{id:'d1',op:'resolved',choice:'no'});
// An instance name the subject check refuses, or an after that is neither null nor an object, sends
// no binding field (the copy name is the only binding value the page shows).
for(const bad of [{instance:'../'+SENTINEL,after:null},{instance:'researcher',after:[SENTINEL]},{instance:'researcher',after:SENTINEL}]){
const b=setup({answer:()=>({...bindingPreview(null),binding:{...bindingPreview(null).binding,...bad}}),resolve:a=>({id:a.id,op:'resolved',choice:a.choice})});
const bc=await b.confirm('yes');assert.equal(bc.status,200,bc.text);
assert.equal(Object.hasOwn(bc.json(),'binding'),false,bc.text);
const br=await b.resolve({choice:'yes',confirmation:bc.json().confirmation});
assert.equal(Object.hasOwn(br.json(),'next'),false,br.text);
assert.equal(leaks(b,bc.text,br.text,...b.lines),0);
}
assert.equal(leaks(u,uc.text,ur.text,...u.lines,...d.lines),0);
});
test('CB5 a binding recorded and approved through the Console start names the next step by its shape, and only a binding start does',async()=>{
const s=setup({resolve:()=>({id:'b1',op:'resolved',choice:'yes',binding:{token:SENTINEL}})});
const bind={op:'change',level:'root',name:'researcher',reason:'token '+SENTINEL,binding:{services:{gitea:{scopes:['read:repository'],projects:['stack']}}}};
const r=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:bind}}));
assert.equal(r.status,200,r.text);assert.deepEqual(r.json(),{id:'b1',op:'resolved',choice:'yes',next:BINDING_NEXT.bind});
const u=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:{...bind,binding:{services:null}}}}));
assert.deepEqual(u.json(),{id:'b1',op:'resolved',choice:'yes',next:BINDING_NEXT.unbind});
const h=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:START_HIRE}}));
assert.deepEqual(h.json(),{id:'b1',op:'resolved',choice:'yes'});
assert.equal(leaks(s,r.text,u.text,h.text,...s.lines),0);
for(const t of Object.values(BINDING_NEXT))assert.equal(leaks(s,t),0);
});
@@ -4,6 +4,7 @@ import {fixture} from './fixture.mjs';
import {mkdir} from 'node:fs/promises';
import {join} from 'node:path';
import {browser} from './browser.mjs';
import {BINDING_NEXT} from '../src/decision-http.mjs';
for(const width of [1440,390])test(`A4 Console hire start and narrowed confirmation at ${width}px`,{timeout:120000},async t=>{
const f=await fixture(),b=await browser({machine:false});t.after(async()=>{await b.close();await f.close();});
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`(()=>{
@@ -48,3 +49,50 @@ for(const width of [1440,390])test(`A4 Console hire start and narrowed confirmat
await b.evaluate('document.querySelector("[data-decide=confirm]").click()');await wait('document.querySelector("#decide-status")?.textContent.startsWith("Recorded")');
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
// 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'?{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:`(()=>{
const real=fetch;window.__posts=[];const d=${JSON.stringify(d)};
const json=v=>new Response(JSON.stringify(v),{headers:{'content-type':'application/json'}});
window.fetch=async(url,init={})=>{
const p=new URL(url,location.href).pathname;
if(p==='/api/config')return json({sso:true,send:true,decide:true});
if(p==='/api/business')return json({business:{id:'acme',projects:[]}});
if(p==='/api/bus/inbox')return json({rows:[d]});
if(p==='/api/bus/trail')return json({rows:[{...d,table:'decisions'}]});
if(p==='/api/bus/agents'||p==='/api/bus/messages'||p==='/api/chat/projects'||p==='/api/chat/threads')return json({rows:[]});
if(p.startsWith('/api/decisions/')){
const body=JSON.parse(init.body);window.__posts.push({p,body});
if(p.endsWith('/confirmation'))return json({id:'d2',kind:'hire',action:'answer',question:d.question,choice:body.choice,text:'Approve',flagged:[],credentialLines:d.console.credentialLines,expiresAt:d.console.expiresAt,binding:{instance:'acme/researcher',change:${JSON.stringify(change)}},confirmation:'c'.repeat(64)});
return json({id:'d2',op:'resolved',choice:body.choice,next:${JSON.stringify(BINDING_NEXT[change])}});
}
if(p==='/api/processing'||p==='/api/bus-status')return json({error:'unavailable'});
return real(url,init);
};
})()`});
const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expr})return;await new Promise(r=>setTimeout(r,50));}throw Error('Timed out: '+${JSON.stringify(expr)})})()`);
const text=()=>b.evaluate('document.body.innerText');
const line=change==='unbind'?'Unbind and revoke the credentials of copy acme/researcher.':'Bind credentials for copy acme/researcher.';
await b.viewport(1440,900);await b.navigate(f.base+'/#/inbox/d2');await wait('document.querySelector("#decide-section [data-binding-change]")');
assert.equal(await b.evaluate('document.querySelector("#decide-section [data-binding-change]").dataset.bindingChange'),change);
assert.equal(await b.evaluate('document.querySelector("#decide-section [data-binding-change]").textContent'),line);
assert.match(await b.evaluate('document.querySelector("#decide-section").innerText'),/gitea: read:repository/);
assert.equal(await b.evaluate('!!document.querySelector("#hire-adjust")'),false);
await b.evaluate('document.querySelector(\'[data-decide="option-0"]\').click()');await wait('document.querySelector("[data-decide=confirm]")');
assert.equal(await b.evaluate('document.querySelector("#decide-section [data-binding-change]").textContent'),line);
await b.evaluate('document.querySelector("[data-decide=confirm]").click()');await wait('document.querySelector("#decide-status")?.textContent.startsWith("Recorded")');
assert.equal(await b.evaluate('document.querySelector("[data-binding-next]")?.textContent'),BINDING_NEXT[change]);
const posts=await b.evaluate('window.__posts');assert.deepEqual(Object.keys(posts.find(x=>x.p.endsWith('/resolve')).body).sort(),['choice','confirmation']);
assert.equal(JSON.stringify(posts).includes(SENTINEL),false);
const page=await text();assert.equal(page.includes(SENTINEL),false);assert.equal(page.includes(SENTINEL.slice(0,16)),false);
});
@@ -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');
@@ -554,7 +554,8 @@ test('agents grid takes a running copy, an unbound copy and a refused project fr
assert.deepEqual(pills, [['gate', 'instance', 'running'], ['researcher', 'unbound', "can't launch"], ['checker', 'instance', 'not running'], ['lint', 'refused', "can't launch"]]);
const why = await b.evaluate('Object.fromEntries([...document.querySelectorAll(".ws-levels [data-org=ddigital] [data-copy-why]")].map(n => [n.dataset.copyWhy, n.innerText.trim()]))');
assert.deepEqual(Object.keys(why), ['researcher', 'lint.web']);
assert.equal(why.researcher, 'researcher: no binding in this organization for gitea');
// CB5: the bus adds the binding's next step for a copy missing gitea, a bindable service.
assert.equal(why.researcher, 'researcher: no binding in this organization for gitea. credentials.gitea needs an owner-approved binding.');
assert.match(why['lint.web'], /^lint: project web refuses its agents: .*ghost/);
const text = await b.evaluate('document.body.innerText');
for (const value of [SECRET, dir]) assert.equal(text.includes(value), false, value);
@@ -625,3 +626,52 @@ 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 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');
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
const root = { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: {
researcher: { definition: 'research' },
reviewer: { definition: 'reviewer' },
} }, { 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 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: ['gitea', 'vikunja'], binding: binding('copy-service-unbindable') }) },
};
const orgs = workspaceOrgs({ copies });
const organizations = { ...orgs, members: async () => Object.entries(DOCS).map(([id, doc]) => ({ id, doc })) };
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, 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], .ws-levels [data-org=forge] [data-copy-state]").length === 4');
const pills = await b.evaluate('[...document.querySelectorAll(".ws-levels [data-org=ddigital], .ws-levels [data-org=forge]")].flatMap(c => [...c.querySelectorAll("[data-copy-state]")].map(p => [c.dataset.org, p.dataset.levelAgent, p.dataset.copyState, p.querySelector(".ws-dot").title]))');
assert.deepEqual(pills, [
['ddigital', 'researcher', 'instance', "can't launch"], ['ddigital', 'reviewer', 'unbound', "can't launch"],
['forge', 'researcher', 'instance', 'running'], ['forge', 'reviewer', 'unbound', "can't launch"],
]);
const why = await b.evaluate('Object.fromEntries([...document.querySelectorAll(".ws-levels [data-org=ddigital], .ws-levels [data-org=forge]")].flatMap(c => [...c.querySelectorAll("[data-copy-why]")].map(n => [`${c.dataset.org}:${n.dataset.copyWhy}`, n.innerText.trim()])))');
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 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']}`);
});