From dbb69c8a41eb2b426c63c1f2d0110bbdb88b3544 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 12 Nov 2026 17:15:00 -0600 Subject: [PATCH 1/6] test(bus,cli): CB5 two-organization acceptance and the CB4 deferred P3 tests (#1873) The two-org test boots real buses for two organizations with a shipped Gitea-requiring thread role, binds each copy through its own owner decision and verified issue, and checks identity separation, record isolation, token isolation, B through A's revoke, archive and delete, and refusal of a replayed raw A binding or A token in B. P3-1 shadowed cleanup, P3-2 runtime revoke wiring, P3-3 ordinary-run stop. Each passes on BASE; guard-removal mutants recorded. --- packages/bus/tests/copy-revoke.test.mjs | 79 ++++++- packages/cli/tests/agent-copies.test.mjs | 285 ++++++++++++++++++++++- 2 files changed, 357 insertions(+), 7 deletions(-) diff --git a/packages/bus/tests/copy-revoke.test.mjs b/packages/bus/tests/copy-revoke.test.mjs index d7b5865b4..d0863dd59 100644 --- a/packages/bus/tests/copy-revoke.test.mjs +++ b/packages/bus/tests/copy-revoke.test.mjs @@ -62,7 +62,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 +72,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 +747,79 @@ 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' }); +}); diff --git a/packages/cli/tests/agent-copies.test.mjs b/packages/cli/tests/agent-copies.test.mjs index d9c407b99..b374b5549 100644 --- a/packages/cli/tests/agent-copies.test.mjs +++ b/packages/cli/tests/agent-copies.test.mjs @@ -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,9 +25,10 @@ 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"; const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url)); @@ -60,8 +62,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 +88,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 +145,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), @@ -756,3 +758,274 @@ 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 /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); +}); -- 2.54.0 From c5bf71ab087333ffd053af2bebcf731c30c75c8f Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 12 Nov 2026 17:22:28 -0600 Subject: [PATCH 2/6] 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] }); +} -- 2.54.0 From fdbc34413b793f1623f30670508c890f5b7d5ec7 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 12 Nov 2026 17:30:07 -0600 Subject: [PATCH 3/6] feat(webui,bus): CB5 Console operator flow, binding confirmation and next steps, copy binding status on the Agents grid (#1873) The bus instances read adds a copy's binding status only when the copy was bound at boot or is unbound for want of a bindable service, never to a tester. decision-http returns an allowlisted binding (instance and change) on a binding confirmation and fixed next-step text on an approved resolve and on a binding hire start. The decision page names the copy and the change; the receipt shows the next step; the Agents grid shows can't launch with the binding's next step. Every new string is fixed text. --- packages/bus/src/broker.mjs | 9 ++- packages/bus/src/copy-launch.mjs | 7 ++- packages/webui/src/decision-http.mjs | 24 ++++++-- packages/webui/src/public/bus.js | 11 +++- packages/webui/src/public/workspace.js | 14 +++-- packages/webui/tests/decision-http.test.mjs | 59 ++++++++++++++++++- .../webui/tests/hire-console-browser.test.mjs | 47 +++++++++++++++ .../tests/unified-workspace-browser.test.mjs | 48 ++++++++++++++- 8 files changed, 199 insertions(+), 20 deletions(-) diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 022f6547a..12d152e68 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -6450,11 +6450,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 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) } : {}), + // #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; } diff --git a/packages/bus/src/copy-launch.mjs b/packages/bus/src/copy-launch.mjs index 0604cff42..71746b951 100644 --- a/packages/bus/src/copy-launch.mjs +++ b/packages/bus/src/copy-launch.mjs @@ -380,8 +380,11 @@ export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snaps // 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) { + // 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 bindable service, has a status. + status(business, instance, entry) { + const needs = Array.isArray(entry?.bound) || (entry?.state === 'unbound' && Array.isArray(entry.missing) && entry.missing.some((s) => COPY_BINDABLE.includes(s))); + if (!needs) return null; let code = 'ready'; try { check(business, instance); diff --git a/packages/webui/src/decision-http.mjs b/packages/webui/src/decision-http.mjs index 0255689ca..61e13cc48 100644 --- a/packages/webui/src/decision-http.mjs +++ b/packages/webui/src/decision-http.mjs @@ -57,6 +57,17 @@ 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. +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 +161,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 +185,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 +204,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' }); diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index c5a961762..9b9acaed2 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -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' ? '' : `

Flagged grants — owner approval required

    ${c.flagged.map(l=>`
  • ${txt(l.field)}: ${txt(l.value)}
  • `).join('')||'
  • None
  • '}

Requested credential scopes — owner approval required

    ${c.credentialLines.map(l=>`
  • ${txt(l.service)}${l.scope?`: ${txt(l.scope)}`:''}
  • `).join('')||'
  • None
  • '}

Expires ${txt(c.expiresAt)}

`; + const hireLines = c => c?.kind !== 'hire' ? '' : `

Flagged grants — owner approval required

    ${c.flagged.map(l=>`
  • ${txt(l.field)}: ${txt(l.value)}
  • `).join('')||'
  • None
  • '}

Requested credential scopes — owner approval required

    ${c.credentialLines.map(l=>`
  • ${txt(l.service)}${l.scope?`: ${txt(l.scope)}`:''}
  • `).join('')||'
  • None
  • '}

Expires ${txt(c.expiresAt)}

${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' || (b.change === undefined && b.after === null) ? 'unbind' : 'bind'; + const bindingLine = c => c?.binding && typeof c.binding === 'object' ? `

Credential binding

${bindingChange(c.binding) === 'unbind' ? 'Unbind and revoke the credentials of' : 'Bind credentials for'} copy ${txt(c.binding.instance)}.

` : ''; const hireAdjustment = c => { if(c?.kind!=='hire' || !c.role)return ''; const list=(key,values)=>(values??[]).map((v,i)=>``).join(' '); @@ -478,7 +483,7 @@ let body; if (a.result) { const r = a.result; - body = `

${['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.

${window.MosaicInbox?.receipt?.(r, a.decision) ?? ''}`; + body = `

${['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.

${r.next ? `

${txt(r.next)}

` : ''}${window.MosaicInbox?.receipt?.(r, a.decision) ?? ''}`; } else if (a.confirm) { const c = a.confirm, label = `Confirm ${c.action}`; body = `

${label}

@@ -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}`); } } diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 56307107a..24063ec70 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -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 'status unavailable'; const { key, copy: c } = copyFor(mine?.copies, a.name, a.level === 'project' ? a.project : null); if (!c) return 'starts with the next bus start'; - return `${dot(...copyDot(key, c, mine.agents), true)}${c.state === 'instance' ? '' : `${esc(copyWhy(c))}`}`; + return `${dot(...copyDot(key, c, mine.agents), true)}${copyBlocked(c) ? `${esc(copyWhy(c))}` : ''}`; } if (inst === null) return 'status unavailable'; 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' ? `${esc(name)}: ${esc(copyWhy(c))}` : ''; + const why = c && copyBlocked(c) ? `${esc(name)}: ${esc(copyWhy(c))}` : ''; return `${dot(...mark)}${esc(name)} ${esc(small)}${why}`; }; // Root: one copy per organization, each running inside that organization; an opt-out is said. diff --git a/packages/webui/tests/decision-http.test.mjs b/packages/webui/tests/decision-http.test.mjs index 63cf5d086..7a2e49322 100644 --- a/packages/webui/tests/decision-http.test.mjs +++ b/packages/webui/tests/decision-http.test.mjs @@ -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); +}); diff --git a/packages/webui/tests/hire-console-browser.test.mjs b/packages/webui/tests/hire-console-browser.test.mjs index aa20ad82b..9c45d6817 100644 --- a/packages/webui/tests/hire-console-browser.test.mjs +++ b/packages/webui/tests/hire-console-browser.test.mjs @@ -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,49 @@ 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 +// preview (an unbind is `after: null`); the page names the copy and the change and lists the computed +// scope lines, the confirmation says the change, and the receipt adds decision-http's fixed next step. +// No value besides the copy's instance reaches the page: a sentinel in every other binding field never shows. +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'?null:{gitea:{scopes:['read:repository'],projects:['web'],slot:SENTINEL}}; + const binding={instance:'acme/researcher',source:'root',copyId:SENTINEL,fresh:false,orgIncarnation:SENTINEL,definition:SENTINEL,before:{gitea:{grantId:SENTINEL}},after,generation:2}; + 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); +}); diff --git a/packages/webui/tests/unified-workspace-browser.test.mjs b/packages/webui/tests/unified-workspace-browser.test.mjs index 3ad71c2ef..8643cdffe 100644 --- a/packages/webui/tests/unified-workspace-browser.test.mjs +++ b/packages/webui/tests/unified-workspace-browser.test.mjs @@ -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,48 @@ 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 ready binding launches as before with no reason line. +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 binding = (status) => ({ status, next: COPY_BINDING_STATUS[status] }); + 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: ['vikunja'] }) }, + }; + 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 vikunja', + }); + 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']}`); +}); -- 2.54.0 From 56aeefb370cd7952d27ac5e84cc7222c1ff4d5ce Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 12 Nov 2026 17:49:10 -0600 Subject: [PATCH 4/6] fix(bus,webui,cli): CB5 review fixes, binding summary on the inbox, raise and trail reads, copy-service-unbindable status, Console allowlist (#1873) - broker: the hire.raise answer, the inbox console preview and the trail read carry a binding as {instance, change} only; the raise event keeps the full proposal the resolve reads. - copy-launch status(): a copy whose role needs a service a copy cannot bind reads copy-service-unbindable (new fixed status text) instead of a gitea binding step. - cli instances: an unbound copy's line names its missing services. - webui serve: the instances verb rebuilds each copy binding from the fixed status table. - bus.js reads the binding change field only. - browser tests: system Chromium starts with --disable-extensions; the A3 booted-bus test passes it. --- packages/bus/src/broker.mjs | 10 ++++- packages/bus/src/copy-launch.mjs | 9 +++-- packages/bus/tests/copy-bindings.test.mjs | 37 +++++++++++++++++++ packages/cli/src/instances.mjs | 5 ++- packages/cli/tests/agent-copies.test.mjs | 20 ++++++++++ .../organization/src/copy-binding-status.mjs | 2 + packages/webui/src/decision-http.mjs | 3 +- packages/webui/src/public/bus.js | 2 +- packages/webui/src/serve.mjs | 8 +++- packages/webui/tests/browser.mjs | 5 ++- .../webui/tests/hire-console-browser.test.mjs | 11 +++--- .../tests/unified-workspace-browser.test.mjs | 14 ++++--- 12 files changed, 106 insertions(+), 20 deletions(-) diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 12d152e68..83c8a5691 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -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); @@ -6784,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}); } @@ -9483,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); } }; diff --git a/packages/bus/src/copy-launch.mjs b/packages/bus/src/copy-launch.mjs index 71746b951..3370b9f54 100644 --- a/packages/bus/src/copy-launch.mjs +++ b/packages/bus/src/copy-launch.mjs @@ -381,10 +381,13 @@ export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snaps // { 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 bindable service, has a status. + // 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. status(business, instance, entry) { - const needs = Array.isArray(entry?.bound) || (entry?.state === 'unbound' && Array.isArray(entry.missing) && entry.missing.some((s) => COPY_BINDABLE.includes(s))); - if (!needs) return null; + const unbound = entry?.state === 'unbound' && Array.isArray(entry.missing) && entry.missing.length > 0; + if (!Array.isArray(entry?.bound) && !unbound) return null; + if (unbound && entry.missing.some((s) => !COPY_BINDABLE.includes(s))) return copyBindingStatus('copy-service-unbindable'); let code = 'ready'; try { check(business, instance); diff --git a/packages/bus/tests/copy-bindings.test.mjs b/packages/bus/tests/copy-bindings.test.mjs index 2a44096ac..04fd2a6d3 100644 --- a/packages/bus/tests/copy-bindings.test.mjs +++ b/packages/bus/tests/copy-bindings.test.mjs @@ -133,3 +133,40 @@ 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); +}); diff --git a/packages/cli/src/instances.mjs b/packages/cli/src/instances.mjs index 0f36694a9..572fd854c 100644 --- a/packages/cli/src/instances.mjs +++ b/packages/cli/src/instances.mjs @@ -12,6 +12,7 @@ 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] | " + @@ -226,7 +227,9 @@ export function formatInstances(view) { 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}`); + // 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); diff --git a/packages/cli/tests/agent-copies.test.mjs b/packages/cli/tests/agent-copies.test.mjs index 1677b53b0..d6ae04507 100644 --- a/packages/cli/tests/agent-copies.test.mjs +++ b/packages/cli/tests/agent-copies.test.mjs @@ -1089,3 +1089,23 @@ test("CB5 operator: the owner's instances read and mosaic org instances show eac 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); +}); diff --git a/packages/organization/src/copy-binding-status.mjs b/packages/organization/src/copy-binding-status.mjs index 034d02fe2..d75f40118 100644 --- a/packages/organization/src/copy-binding-status.mjs +++ b/packages/organization/src/copy-binding-status.mjs @@ -9,6 +9,8 @@ export const COPY_BINDING_STATUS = Object.freeze({ "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.", diff --git a/packages/webui/src/decision-http.mjs b/packages/webui/src/decision-http.mjs index 61e13cc48..8806a53fc 100644 --- a/packages/webui/src/decision-http.mjs +++ b/packages/webui/src/decision-http.mjs @@ -64,7 +64,8 @@ export const BINDING_NEXT = Object.freeze({ 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. +// 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); diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 9b9acaed2..804c956e0 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -400,7 +400,7 @@ // #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' || (b.change === undefined && b.after === null) ? 'unbind' : 'bind'; + const bindingChange = b => b.change === 'unbind' ? 'unbind' : 'bind'; const bindingLine = c => c?.binding && typeof c.binding === 'object' ? `

Credential binding

${bindingChange(c.binding) === 'unbind' ? 'Unbind and revoke the credentials of' : 'Bind credentials for'} copy ${txt(c.binding.instance)}.

` : ''; const hireAdjustment = c => { if(c?.kind!=='hire' || !c.role)return ''; diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 0b9301569..41822cc51 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -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 }); diff --git a/packages/webui/tests/browser.mjs b/packages/webui/tests/browser.mjs index 54aa75118..76bd99ae5 100644 --- a/packages/webui/tests/browser.mjs +++ b/packages/webui/tests/browser.mjs @@ -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); diff --git a/packages/webui/tests/hire-console-browser.test.mjs b/packages/webui/tests/hire-console-browser.test.mjs index 9c45d6817..fabf16b99 100644 --- a/packages/webui/tests/hire-console-browser.test.mjs +++ b/packages/webui/tests/hire-console-browser.test.mjs @@ -50,14 +50,15 @@ for(const width of [1440,390])test(`A4 Console hire start and narrowed confirmat 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 -// preview (an unbind is `after: null`); the page names the copy and the change and lists the computed -// scope lines, the confirmation says the change, and the receipt adds decision-http's fixed next step. -// No value besides the copy's instance reaches the page: a sentinel in every other binding field never shows. +// 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'?null:{gitea:{scopes:['read:repository'],projects:['web'],slot:SENTINEL}}; - const binding={instance:'acme/researcher',source:'root',copyId:SENTINEL,fresh:false,orgIncarnation:SENTINEL,definition:SENTINEL,before:{gitea:{grantId:SENTINEL}},after,generation:2}; + 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:`(()=>{ diff --git a/packages/webui/tests/unified-workspace-browser.test.mjs b/packages/webui/tests/unified-workspace-browser.test.mjs index 8643cdffe..24ad97050 100644 --- a/packages/webui/tests/unified-workspace-browser.test.mjs +++ b/packages/webui/tests/unified-workspace-browser.test.mjs @@ -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'); @@ -630,7 +630,9 @@ 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 ready binding launches as before with no reason line. +// 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'); @@ -641,12 +643,13 @@ test('agents grid shows a copy\'s binding status and next step, and a ready bind } }, { 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 binding = (status) => ({ status, next: COPY_BINDING_STATUS[status] }); + 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: ['vikunja'] }) }, + 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 })) }; @@ -665,8 +668,9 @@ test('agents grid shows a copy\'s binding status and next step, and a ready bind 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 vikunja', + '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']}`); -- 2.54.0 From 4b35e3c1d2e3911a55b028ed92c1f2c8b087f6a8 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 12 Nov 2026 17:59:27 -0600 Subject: [PATCH 5/6] fix(bus): CB5 re-check, a running or failed revoke outranks copy-service-unbindable; unbind summary tested (#1873) - copy-launch status(): a fenced or closed copy falls through to the gate check, so the owner sees credential-revoking or credential-revoke-failed for a binding issued to a copy whose role also needs a service a copy cannot bind. - copy-bindings: an unbind raise reads change 'unbind' in the raise answer, every inbox and trail. --- packages/bus/src/copy-launch.mjs | 6 ++++-- packages/bus/tests/copy-bindings.test.mjs | 12 ++++++++++++ packages/bus/tests/copy-revoke.test.mjs | 17 +++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/bus/src/copy-launch.mjs b/packages/bus/src/copy-launch.mjs index 3370b9f54..6694779e3 100644 --- a/packages/bus/src/copy-launch.mjs +++ b/packages/bus/src/copy-launch.mjs @@ -383,11 +383,13 @@ export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snaps // 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. + // 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; - if (unbound && entry.missing.some((s) => !COPY_BINDABLE.includes(s))) return copyBindingStatus('copy-service-unbindable'); + 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); diff --git a/packages/bus/tests/copy-bindings.test.mjs b/packages/bus/tests/copy-bindings.test.mjs index 04fd2a6d3..505c4ba78 100644 --- a/packages/bus/tests/copy-bindings.test.mjs +++ b/packages/bus/tests/copy-bindings.test.mjs @@ -169,4 +169,16 @@ test('CB5 bus: the raise answer, the inbox preview and the trail carry a binding 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`); + } }); diff --git a/packages/bus/tests/copy-revoke.test.mjs b/packages/bus/tests/copy-revoke.test.mjs index d0863dd59..a0b3e244b 100644 --- a/packages/bus/tests/copy-revoke.test.mjs +++ b/packages/bus/tests/copy-revoke.test.mjs @@ -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' }; @@ -823,3 +824,19 @@ test('CB5 a revoke of a copy on the ordinary run path ends its role claim, kills 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'] }); +}); -- 2.54.0 From a9d72b3cd13a471177e2d29f688f861b2448fc1f Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 12 Nov 2026 18:39:40 -0600 Subject: [PATCH 6/6] fix(business): CB5 old-path re-export for copy-binding-status.mjs, the rename r2 B guard (#1873) Every packages/organization/src module has a one-line re-export at its old packages/business/src path; the CB5 status table had none, so rename-b-identity refused it. --- packages/business/src/copy-binding-status.mjs | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 packages/business/src/copy-binding-status.mjs diff --git a/packages/business/src/copy-binding-status.mjs b/packages/business/src/copy-binding-status.mjs new file mode 100644 index 000000000..387585b0b --- /dev/null +++ b/packages/business/src/copy-binding-status.mjs @@ -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"; -- 2.54.0