feat(bus,cli): CB5 operator status, each copy's binding status and next step in the owner's instances read and mosaic org instances (#1873)

The launch gate's status() runs the same checks a launch runs, without the loss report, and
answers a fixed { status, next } from organization copy-binding-status.mjs (design section 6
texts plus issue-required and ready). A tester's read never carries it.
This commit is contained in:
jason.woltje committed 2026-11-12 19:19:22 -06:00
1 parent dbb69c8a41
commit c5bf71ab08
5 files changed
+127 -2

No files matched your search

+5
View File
@@ -6450,6 +6450,11 @@ 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 one) 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 && (c.state === 'unbound' || Array.isArray(c.bound))
? { binding: this.#copyLaunch.status(s.business, name) } : {}),
}]));
return result;
}
+21
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,26 @@ 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.
status(business, instance) {
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
+9
View File
@@ -11,6 +11,7 @@
// 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";
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 +220,14 @@ 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);
lines.push(` ${name} (copy): ${status}. ${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(", ")})`);
+62 -2
View File
@@ -30,6 +30,8 @@ import { organizationWriter } from "../../organization/src/organization.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;
@@ -607,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);
@@ -1029,3 +1031,61 @@ test("CB5 two organizations: each binds and runs its own research copy in a thre
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")), []);
});
@@ -0,0 +1,30 @@
// #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.",
"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] });
}