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:
1 parent
dbb69c8a41
commit
c5bf71ab08
5 files changed
+127
-2
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(", ")})`);
|
||||
|
||||
@@ -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] });
|
||||
}
|
||||
Reference in new issue
Block a user