From c5bf71ab087333ffd053af2bebcf731c30c75c8f Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 12 Nov 2026 17:22:28 -0600 Subject: [PATCH] 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. --- packages/bus/src/broker.mjs | 5 ++ packages/bus/src/copy-launch.mjs | 21 ++++++ packages/cli/src/instances.mjs | 9 +++ packages/cli/tests/agent-copies.test.mjs | 64 ++++++++++++++++++- .../organization/src/copy-binding-status.mjs | 30 +++++++++ 5 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 packages/organization/src/copy-binding-status.mjs diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index dc586768c..022f6547a 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -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; } diff --git a/packages/bus/src/copy-launch.mjs b/packages/bus/src/copy-launch.mjs index 5d3ae4359..0604cff42 100644 --- a/packages/bus/src/copy-launch.mjs +++ b/packages/bus/src/copy-launch.mjs @@ -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 diff --git a/packages/cli/src/instances.mjs b/packages/cli/src/instances.mjs index 95c01ae4e..0f36694a9 100644 --- a/packages/cli/src/instances.mjs +++ b/packages/cli/src/instances.mjs @@ -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(", ")})`); diff --git a/packages/cli/tests/agent-copies.test.mjs b/packages/cli/tests/agent-copies.test.mjs index b374b5549..1677b53b0 100644 --- a/packages/cli/tests/agent-copies.test.mjs +++ b/packages/cli/tests/agent-copies.test.mjs @@ -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")), []); +}); diff --git a/packages/organization/src/copy-binding-status.mjs b/packages/organization/src/copy-binding-status.mjs new file mode 100644 index 000000000..034d02fe2 --- /dev/null +++ b/packages/organization/src/copy-binding-status.mjs @@ -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] }); +}