feat(bus,webui,cli): copy binding status and next steps, two-org acceptance (CB5, #1873) #1958
No files matched your search
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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(", ")})`);
|
||||
|
||||
@@ -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] });
|
||||
}
|
||||
@@ -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' });
|
||||
|
||||
@@ -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}`); }
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']}`);
|
||||
});
|
||||
Reference in new issue
Block a user