fix(bus): CB4 review round, fire revokes after the write, archive and delete revoke at Gitea (#1873)

Build review (changes 8) and security review (changes 4):
- P1-1: an owner-approved fire no longer writes the revoke before the
  apply (decision-stale). The fire stops the copy's runs, the fire is
  written, then the retire revoke starts for the fired instance.
- P2 (both): archive and delete from the CLI and the Console pass the
  organization's managed Gitea remote step (copyRemoteFor). A remote
  step that was not attempted or failed keeps the record revoking with
  failed remote, so the bus finishes it after restore; no done tombstone
  over a live token.
- Security P2-1: a bound server that cannot be read is a failed remote
  step, not not-managed. updateOrg refuses project-repo-bound when a
  project a copy binding grants loses or changes its remote or git.
- P3-1 (security): a delete whose organization file cannot be read says
  copiesUnreadable, never an empty copy list.
- P3-2 (security): the remote step requires the record's incarnation to
  equal the registry's live one.
- P3-5 (build): the archive's slot discard checks each parent directory
  (no symlink, own uid, private mode).
- P3-6 (build): resume keeps credential-revoke-failed as the gate code
  while a failed op runs again.
- P3-4 (build): a test pins the refused-begin reopen guard.
This commit is contained in:
jason.woltje committed 2026-11-12 16:08:08 -06:00
1 parent 90245765f2
commit ef1d3a4583
11 files changed
+250 -54

No files matched your search

+9 -5
View File
@@ -6782,21 +6782,25 @@ export class Broker {
for(const d of this.#store.all(`SELECT d.* FROM decisions d WHERE business=? AND action IN ('role.hire','role.fire','role.change') AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED})`,s.business))
this.#expireHire(s,d,this.#raised(s.business,d.id)?.hire,true);
}
// #1873 CB4: an unbind the decision left revoking runs its revoke now (the owner approved it);
// any other applied decision may have changed, retired or shadowed a copy, which the sweep finds.
// #1873 CB4: an unbind the decision left revoking runs its revoke now (the owner approved it); a
// fired copy's binding is revoked now that the fire is written (cause `retire`; the root agents
// file a root fire changes is not one the gate reads until the next start). Any other applied
// decision may have changed, retired or shadowed a copy, which the sweep finds.
#copyAfterHire(business,p) {
if(!this.#copyRevoker)return;
const instance=p.binding?.instance;
if(typeof instance==='string' && this.#copyRevoker.binding(business,instance)?.state==='revoking')
this.#copyRevoker.start(business,instance,{cause:'unbind',actor:'bus',owner:true});
const fired=p.op==='fire' && p.level!=='org' ? (p.level==='project'?`${p.name}.${p.project}`:p.name) : null;
if(fired && this.#copyRevoker.binding(business,fired))this.#copyRevoker.start(business,fired,{cause:'retire',actor:'bus'});
this.#copyRevoker.sweep(business);
}
async #stopCopyApproved(s,p) {
const instance=p.level==='project'?`${p.name}.${p.project}`:p.name;
const b=this.#business(s.business);
// #1873 CB4: a fired copy's binding is revoked, its runs stopped, before the fire is written.
if(this.#copyRevoker?.binding(s.business,instance) && (!Object.hasOwn(b.roles,instance) || b.roles[instance]?.copyBound))
this.#copyRevoker.start(s.business,instance,{cause:'retire',actor:'bus'});
// #1873 CB4: a fired copy's runs are stopped here, before the fire is written; #copyAfterHire
// revokes its binding after the write. A revoke begun here would write the organization file
// under the fire's own revision check and refuse the fire decision-stale.
if(!b.roles[instance])return;
if(this.#revoking.has(s.business+'/'+instance))fail('fire-in-progress');
let running=false;
+32 -4
View File
@@ -25,6 +25,11 @@ import { BusError } from './broker.mjs';
import { COPY_BINDABLE } from '../../organization/src/agent-copies.mjs';
import { copyBotLogin } from '../../organization/src/copy-bindings.mjs';
import { boundRepositories } from './copy-grant.mjs';
import { giteaGrant } from './gitea-grant.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { readOrgEntrySync } from '../../organization/src/org-registry.mjs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
export const REVOKE_POINTS = Object.freeze([
'beforePublication', 'afterPublication', 'beforeStop', 'afterStop', 'beforeSecret', 'afterSecret',
@@ -177,7 +182,8 @@ export function copyRevoker({ writer, gate = null, credentials, stopRuns, remote
const d = doc(business);
if (d === null) return [];
const pending = Object.entries(d.copyBindings ?? {}).filter(([, r]) => r?.state === 'revoking');
for (const [instance] of pending) gateOf(business)?.close(business, instance);
// A pass that already failed a step keeps its failure code while it runs again.
for (const [instance, r] of pending) gateOf(business)?.close(business, instance, r.revoke?.failed ? 'credential-revoke-failed' : undefined);
return Promise.allSettled(pending.map(([instance, r]) => revoker.run(business, instance, { cause: r.revoke.cause, actor: 'bus', owner: true })));
},
};
@@ -188,9 +194,11 @@ export function copyRevoker({ writer, gate = null, credentials, stopRuns, remote
// copy's token is deleted by id from its bot user. 204 is `revoked`, 404 `absent`. With no admin slot,
// or one on another server than the binding's projects, the organization does not manage the copy's
// Gitea and the step is `not-managed`: the record says so and never claims the token is gone. Any
// other answer, an admin slot that fails its checks, or a server that cannot be reached is `failed`,
// other answer, an admin slot that fails its checks, a server that cannot be reached, bound projects
// whose server can no longer be read from the organization file, or a record whose organization
// incarnation is not the live organization's (`incarnation`, business => string|null) is `failed`,
// and the copy stays fenced until a later pass revokes it.
export function giteaCopyRemote({ grant, read, today = () => new Date().toISOString().slice(0, 10) }) {
export function giteaCopyRemote({ grant, read, incarnation = null, today = () => new Date().toISOString().slice(0, 10) }) {
return async (business, instance, record, service) => {
if (service !== 'gitea' || !grant) return 'not-managed';
const m = /^gitea:(\d+):(\d+)$/.exec(record.services?.gitea?.grantId ?? '');
@@ -199,7 +207,14 @@ export function giteaCopyRemote({ grant, read, today = () => new Date().toISOStr
try {
origin = boundRepositories(read(business), record.services.gitea.resources?.projects).origin;
} catch {
return 'not-managed';
return 'failed';
}
if (typeof incarnation === 'function') {
let live = null;
try {
live = incarnation(business);
} catch {}
if (typeof live !== 'string' || live !== record.orgIncarnation) return 'failed';
}
let admin;
try {
@@ -226,3 +241,16 @@ export function giteaCopyRemote({ grant, read, today = () => new Date().toISOStr
}
};
}
// The managed remote step outside a running bus, for archive and delete from the CLI or the Console:
// the organization's Gitea admin slot, its file and its registry incarnation, each read when the
// step runs. `transport` and `ca` are the scratch tests' (giteaGrant's own options).
const ROLES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../../../roles');
export function managedCopyRemote({ configDir, dataRoot, transport = undefined, ca = undefined }) {
const writer = organizationWriter({ dir: configDir, rolesDir: ROLES_DIR, dataRoot });
return giteaCopyRemote({
grant: giteaGrant({ configDir, ...(transport ? { transport, ca } : {}) }),
read: (id) => writer.read(id).document,
incarnation: (id) => readOrgEntrySync(configDir, id)?.incarnation ?? null,
});
}
+2 -1
View File
@@ -49,6 +49,7 @@ import { COPY_BINDABLE, loadWithCopies, promoteCopies } from '../../organization
import { copyGrant, sweepCopySlots } from './copy-grant.mjs';
import { copyLaunchGate } from './copy-launch.mjs';
import { copyRevoker as makeCopyRevoker, giteaCopyRemote } from './copy-revoke.mjs';
import { readOrgEntrySync } from '../../organization/src/org-registry.mjs';
import { loadRole } from '../../organization/src/role.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
@@ -471,7 +472,7 @@ export async function startBroker({
const b = await brokerReady;
return b ? b.stopCopyRuns(id, instance, opts) : { confirmed: false };
},
remote: giteaCopyRemote({ grant, read: (id) => revokeWriter.read(id).document }),
remote: giteaCopyRemote({ grant, read: (id) => revokeWriter.read(id).document, incarnation: (id) => readOrgEntrySync(organization.dir, id)?.incarnation ?? null }),
definitions: (id) => (bootCopies?.org === id ? bootCopies.definitions ?? {} : {}),
});
}
+116 -17
View File
@@ -16,14 +16,14 @@ import { Credentials } from '../src/credentials.mjs';
import { REFUSALS } from '../src/refusals.mjs';
import { copyGrant } from '../src/copy-grant.mjs';
import { copyLaunchGate } from '../src/copy-launch.mjs';
import { copyRevoker, giteaCopyRemote, REVOKE_POINTS } from '../src/copy-revoke.mjs';
import { copyRevoker, giteaCopyRemote, managedCopyRemote, REVOKE_POINTS } from '../src/copy-revoke.mjs';
import { giteaGrant, send } from '../src/gitea-grant.mjs';
import { fakeGiteaApi } from './fake-gitea-api.mjs';
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { checkRootAgents } from '../../organization/src/agent-levels.mjs';
import { loadWithCopies, materializeCopies, promoteCopies } from '../../organization/src/agent-copies.mjs';
import { activateOrg, listOrgs, reserveOrg } from '../../organization/src/org-registry.mjs';
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 { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
@@ -121,7 +121,7 @@ async function org(t, w, { id = 'demo', name = 'rev', services = READ, perm = 'r
const gate = copyLaunchGate({ credentials, writer, configDir: root, rolesDir, snapshot, transport, ca: api.ca, timeoutMs: 3000, now: () => new Date(f.clock),
onLoss: (business, instance, cause) => revoker?.start(business, instance, { cause, actor: 'bus' }) });
revoker = copyRevoker({ writer, gate, credentials, hooks: f.hooks, stopRuns: (...a) => f.stopRuns(...a),
remote: giteaCopyRemote({ grant, read: (x) => writer.read(x).document }), definitions: () => snapshot.definitions ?? {} });
remote: giteaCopyRemote({ grant, read: (x) => writer.read(x).document, incarnation: (x) => readOrgEntrySync(root, x)?.incarnation ?? null }), definitions: () => snapshot.definitions ?? {} });
let promoted = snapshot;
const business = () => {
const d = loadWithCopies(id, { dir: root, rolesDir, snapshot: promoted });
@@ -336,13 +336,22 @@ test('CB4 fire: a copy whose root agent is gone at the next start (the fire land
await assert.rejects(f.prepare(), refuses('copy-unavailable'));
});
test('CB4 fire: the bus revokes a fired copy\'s binding before the fire is written, with its runs stopped', async (t) => {
test('CB4 fire through the owner\'s decision: the fire is written, the copy\'s runs are stopped and its binding is revoked after the write', async (t) => {
const w = await world(t);
const { f, r } = await live(t, w);
// The fire path (#stopCopyApproved) starts the retire revoke; the revoker is the one it calls.
await f.revoker.run(f.id, f.name, { cause: 'retire', actor: 'bus' });
// The root agent is still in this boot's file, so the copy itself still exists: unbound.
await assertRevoked(f, r, { cause: 'retire', code: 'credential-unbound' });
const f = await org(t, w, { name: 'rev.stack', project: { rev: { definition: 'research' } } });
f.rootAgents = {};
f.start();
await f.bind({ level: 'project', project: 'stack', name: 'rev' });
f.start();
await f.boot();
const r = await f.launch('run-1');
assert.equal((await f.use(r.cap)).status, 200);
// The fire decision itself must not be refused decision-stale by a revoke written under it.
const out = await f.call(f.owner, 'hire.start.console', { op: 'fire', level: 'project', project: 'stack', name: 'rev', reason: 'Fire the copy' });
assert.ok(out, 'the fire applied');
assert.equal(JSON.parse(readFileSync(f.file, 'utf8')).projects.stack.agents?.rev, undefined, 'the fire is written');
assert.ok(f.killed().includes(r.run), 'the run was told to stop before the write');
await assertRevoked(f, r, { cause: 'retire', code: 'copy-unavailable' });
});
test('CB4 a project copy removed from its project while the bus runs (no reload): the next call refuses and the binding is revoked', async (t) => {
@@ -521,16 +530,17 @@ for (const point of REVOKE_POINTS) {
});
}
test('CB4 archive discards the copy credential: tombstone, slot gone, receipt honest about the remote, restore brings nothing back', async (t) => {
test('CB4 archive with no remote adapter: slot gone, the copy stays revoking and fenced, the receipt claims no revoke, and the bus finishes it after restore', async (t) => {
const w = await world(t);
const { f, r } = await live(t, w);
const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision;
const out = await orgService.archiveOrg(f.ctx, f.id, { ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }) });
assert.deepEqual(out.copies, [{ agent: f.name, state: 'unbound', slot: 'removed', remote: { gitea: 'not-attempted' } }]);
assert.ok(tombstoned(f));
assert.deepEqual(out.copies, [{ agent: f.name, state: 'revoking', slot: 'removed', remote: { gitea: 'not-attempted' } }]);
assert.equal(f.rec().state, 'revoking');
assert.equal(f.rec().revoke.cause, 'archive');
assert.deepEqual(f.rec().revoke.failed, ['remote']);
assert.ok(!existsSync(f.slot));
// Not wired to the managed adapter here, so the token is still at the service and the record says so.
// Not attempted at the service: the token is still there and nothing says otherwise.
assert.equal(f.bot.tokens.length, 1);
const log = readFileSync(join(w.data, 'org-lifecycle.log'), 'utf8').trim().split('\n').map(JSON.parse);
assert.deepEqual(log.at(-1).copies, out.copies);
@@ -541,16 +551,21 @@ test('CB4 archive discards the copy credential: tombstone, slot gone, receipt ho
f.start();
await f.boot();
assert.deepEqual(f.gate.bound(), {});
await assert.rejects(f.prepare(), refuses('credential-unbound'));
await assert.rejects(f.prepare(), (e) => ['credential-revoking', 'credential-revoke-failed'].includes(e.code));
// The bus's resume at its start runs the remote step through the managed adapter.
await f.revoker.resume(f.id);
await assertRevoked(f, null, { cause: 'archive', code: 'credential-unbound' });
});
test('CB4 archive with the managed adapter wired revokes the token at Gitea', async (t) => {
const w = await world(t);
const { f } = await live(t, w);
const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision;
const copyRemote = giteaCopyRemote({ grant: f.grant, read: (x) => f.writer.read(x).document });
// The adapter the CLI and Console wire (org-bus.mjs copyRemoteFor), on the fake Gitea.
const copyRemote = managedCopyRemote({ configDir: f.root, dataRoot: w.data, transport: send, ca: w.api.ca });
const out = await orgService.archiveOrg(f.ctx, f.id, { ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }), copyRemote });
assert.deepEqual(out.copies[0].remote, { gitea: 'revoked' });
assert.deepEqual(out.copies, [{ agent: f.name, state: 'unbound', slot: 'removed', remote: { gitea: 'revoked' } }]);
assert.ok(tombstoned(f));
assert.deepEqual(f.bot.tokens, []);
});
@@ -561,7 +576,8 @@ test('CB4 archive rerun after a crash joins the pending op', async (t) => {
const begun = f.writer.revokeCopyBinding(f.id, { phase: 'begin', instance: f.name, cause: 'archive' }, { actor: 'bus', owner: true });
assert.equal(begun.state, 'revoking');
const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision;
const out = await orgService.archiveOrg(f.ctx, f.id, { ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }) });
const copyRemote = managedCopyRemote({ configDir: f.root, dataRoot: w.data, transport: send, ca: w.api.ca });
const out = await orgService.archiveOrg(f.ctx, f.id, { ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }), copyRemote });
assert.equal(out.copies[0].state, 'unbound');
assert.equal(f.rec().revoke.id, begun.op);
assert.ok(!existsSync(f.slot));
@@ -585,6 +601,89 @@ test('CB4 delete quarantines the copy slot and its receipt never claims a remote
await assert.rejects(f.prepare(), (e) => ['org-unavailable', 'copy-unavailable', 'credential-unbound'].includes(e.code));
});
test('CB4 delete with the managed adapter revokes each copy token at Gitea and says so in the receipt', async (t) => {
const w = await world(t);
const { f } = await live(t, w);
const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision;
const copyRemote = managedCopyRemote({ configDir: f.root, dataRoot: w.data, transport: send, ca: w.api.ca });
const out = await orgService.deleteOrg(f.ctx, f.id, { confirm: f.id, ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }), copyRemote });
assert.deepEqual(out.copies, [{ agent: f.name, service: 'gitea', slot: 'quarantined', remote: 'revoked' }]);
assert.equal(out.copiesUnreadable, undefined);
assert.deepEqual(f.bot.tokens, []);
});
test('CB4 delete with an unreadable organization file says so, never an empty copy list', async (t) => {
const w = await world(t);
const { f } = await live(t, w);
const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision;
// The file fails the private read when the copies step reads it (busStop runs just before).
const busStop = async () => { chmodSync(f.file, 0o644); return { stopped: true, reason: 'stopped' }; };
const out = await orgService.deleteOrg(f.ctx, f.id, { confirm: f.id, ifRevision: revision, busStop });
assert.equal(out.copiesUnreadable, true);
assert.deepEqual(out.copies, []);
assert.match(out.note, /copiesUnreadable/);
});
test('CB4 an owner update cannot change the repository of a project a copy binding still grants', async (t) => {
const w = await world(t);
const { f } = await live(t, w);
const { revision } = (await listOrgs(f.ctx)).find((o) => o.id === f.id);
const doc = { ...JSON.parse(readFileSync(f.file, 'utf8')), human: f.ctx.principal.sub };
const moved = structuredClone(doc); moved.projects.stack.remote = `${w.api.url}/elsewhere/stack`;
await assert.rejects(orgService.updateOrg(f.ctx, f.id, moved, { ifRevision: revision }), refuses('project-repo-bound'));
// Removed (with another project left, so the document itself is valid).
const dropped = structuredClone(doc);
dropped.projects.other = { ...structuredClone(doc.projects.stack), root: join(w.data, 'org-runtime', f.id, 'projects', 'other'), remote: `${w.api.url}/owner/other` };
delete dropped.projects.other.agents; delete dropped.projects.stack;
await assert.rejects(orgService.updateOrg(f.ctx, f.id, dropped, { ifRevision: revision }), refuses('project-repo-bound'));
// An update that leaves the bound project's repository as it is still goes through.
const other = structuredClone(doc); other.vars = { ...other.vars, 'bus.tester': false };
await orgService.updateOrg(f.ctx, f.id, other, { ifRevision: revision });
});
test('CB4 a remote step whose bound server cannot be read, or whose record names another incarnation, fails and the copy stays fenced', async (t) => {
const w = await world(t);
const { f } = await live(t, w);
const rec = f.rec();
const base = { grant: f.grant, read: (x) => f.writer.read(x).document };
// The bound project is not in the file any more: no origin to revoke against, never not-managed.
const gone = giteaCopyRemote({ ...base, read: (x) => { const d = structuredClone(f.writer.read(x).document); delete d.projects.stack; return d; } });
assert.equal(await gone(f.id, f.name, rec, 'gitea'), 'failed');
// Another incarnation, or none: the admin slot is never asked to delete.
const sent = f.api.calls().length;
for (const incarnation of [() => 'f'.repeat(32), () => null, () => { throw new Error('unreadable'); }])
assert.equal(await giteaCopyRemote({ ...base, incarnation })(f.id, f.name, rec, 'gitea'), 'failed');
assert.equal(f.bot.tokens.length, 1);
assert.equal(f.api.calls().length, sent, 'no request reached the server');
// Through the revoker: the record stays revoking with the remote step failed, and fenced.
const revoker = copyRevoker({ writer: f.writer, gate: f.gate, credentials: f.credentials, stopRuns: (...a) => f.stopRuns(...a),
remote: gone, definitions: () => f.snapshot.definitions ?? {} });
const done = await revoker.run(f.id, f.name, { cause: 'revoke', actor: 'jason', owner: true });
assert.equal(done.state, 'revoking');
assert.deepEqual(f.rec().revoke.failed, ['remote']);
assert.equal(f.bot.tokens.length, 1);
await assert.rejects(f.prepare(), refuses('credential-revoke-failed'));
});
// Build review P3-4: when the begin write refuses before any change, only an owner's verb on a copy
// that was open reopens it (to its parked proof); a loss the bus found, or a copy already closed,
// stays closed.
test('CB4 a refused begin reopens the gate only for an owner verb on an open copy; a loss the bus found stays closed', async () => {
const run = async ({ cause, closed }) => {
const calls = [];
const gate = { closed: () => closed, close: (b, i, c) => calls.push(['close', i, c ?? null]), open: (b, i, parked) => calls.push(['open', i, parked === true]), drain: async () => {} };
const writer = { read: () => ({ document: { copyBindings: {} } }), revokeCopyBinding: () => { throw new BusError('org-mismatch'); } };
const stopRuns = async () => { throw new Error('stop must not run'); };
const revoker = copyRevoker({ writer, gate, credentials: { discardCopy: () => { throw new Error('slot must not go'); } }, stopRuns });
await assert.rejects(revoker.run('acme', 'rev', { cause, owner: cause === 'revoke' }), { code: 'org-mismatch' });
return calls;
};
for (const cause of ['expired', 'unavailable', 'retire', 'shadowed', 'upstream'])
assert.deepEqual(await run({ cause, closed: false }), [['close', 'rev', null]], cause);
assert.deepEqual(await run({ cause: 'revoke', closed: true }), [['close', 'rev', null]]);
assert.deepEqual(await run({ cause: 'revoke', closed: false }), [['close', 'rev', null], ['open', 'rev', true]]);
});
test('CB4 a second organization launches and calls throughout the first one\'s revoke, archive and delete', async (t) => {
const w = await world(t);
const { f: demo, r: dr } = await live(t, w);
+17 -9
View File
@@ -1295,11 +1295,16 @@ organization bus, moves its local organization document, history, runtime and
organization-specific secret directories into the instance's private
`deleted/` directory, and removes it from the registry. The ID may then be
reused. The deleted folder contains a receipt; `deleted/deleted.log` is an
append-only index. Remote repositories and remote credentials are untouched.
Purging deleted data and revoking remote credentials are separate operations and
are not provided by this command. The receipt's `copies` lists each agent copy
that held a service grant, with its slot `quarantined` and its remote outcome
`not-attempted`: the copy's token at the service still has to be revoked there.
append-only index. Remote repositories are untouched. Purging deleted data is a
separate operation and is not provided by this command. The receipt's `copies`
lists each agent copy that held a service grant (#1873 CB4), with its slot
`quarantined` and its remote outcome: `revoked` or `absent` when the
organization's managed Gitea admin slot deleted the copy's token, `not-managed`
when the organization has no admin slot on that server, and `failed` when the
service could not be reached or refused. For any outcome but `revoked` and
`absent`, the copy's token at the service still has to be revoked there. When
the organization file could not be read, the receipt says `copiesUnreadable:
true` instead of listing copies; check the service for copy tokens by hand.
If deletion stops, the organization remains in `deleting` state and cannot
start a bus. Inspect the reported receipt and correct the refusal, then resume
@@ -1313,10 +1318,13 @@ require the source and deleted folder to be on the same filesystem.
`bus stop` they refuse inside an agent run (exit 3, agent-guard #1873), after
their usage checks and before any config is read. After the bus stops,
`mosaic org archive` discards every agent copy's credential (#1873 CB4): each
copy's binding becomes an unbound tombstone and its slot file is removed. The
receipt's `copies` lists each one; its remote outcome is `not-attempted`, so a
copy's token at the service still has to be revoked there. A restore brings no
copy credential back.
copy's slot file is removed and its token is revoked at the service through
the organization's managed Gitea admin slot. The receipt's `copies` lists each
one with its state and remote outcome. A copy whose token was revoked, was
already gone, or is on a server the organization does not manage (`not-managed`)
becomes an unbound tombstone. A copy whose remote revoke `failed` stays
`revoking`, fenced, and the bus finishes the revoke when it starts after a
restore. A restore brings no copy credential back.
`mosaic org restore ID --if-revision N` makes the organization active and starts
its bus, as `mosaic bus start ID --dev-local-owner --detach` does (E1, P1-4). It
+6
View File
@@ -77,6 +77,12 @@ export async function busStop(ctx, id) {
checkedState(org);
return stopHost(org.root, { expectedBusiness: id });
}
// #1873 CB4: the remote step archive and delete give each copy's grant: the organization's managed
// Gitea adapter (its admin slot), the one the bus's own revoke uses. Loaded only when needed.
export async function copyRemoteFor(ctx) {
const { managedCopyRemote } = await import('../../bus/src/copy-revoke.mjs');
return managedCopyRemote({ configDir: ctx.configDir, dataRoot: ctx.dataRoot });
}
// Deletion refuses stale or ambiguous host state and any listening socket.
export async function busStopForDelete(ctx, id) {
const org = await target(ctx, id, true);
+3 -3
View File
@@ -6,7 +6,7 @@ import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
import { loadSystem } from './config.mjs';
import { CliError } from './errors.mjs';
import { busStart, busStopForDelete } from './org-bus.mjs';
import { busStart, busStopForDelete, copyRemoteFor } from './org-bus.mjs';
import { assertBuildStart, assertDevInstance } from './deployment.mjs';
import { consoleBusRestart } from './org-console.mjs';
import { refuseInsideAgent } from './transport.mjs';
@@ -52,7 +52,7 @@ export async function orgCommand(args, io, deps = {}) {
else if (words[0] === 'lock' && ['show', 'clear'].includes(words[1]) && words.length === 3) { allowed([], []); result = await orgLock(ctx, words[2], { clear: words[1] === 'clear' }); }
else if (words[0] === 'create' && words.length === 2) { allowed([], ['--from']); result = await createOrg(ctx, words[1], await document(need('--from'))); }
else if (words[0] === 'update' && words.length === 2) { allowed([], ['--from', '--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await updateOrg(ctx, words[1], await document(need('--from')), { ifRevision: Number(r) }); }
else if (words[0] === 'archive' && words.length === 2) result = await archiveOrg(ctx, words[1], { ...lifecycle, busStop: busStopForDelete });
else if (words[0] === 'archive' && words.length === 2) result = await archiveOrg(ctx, words[1], { ...lifecycle, busStop: busStopForDelete, copyRemote: await (deps.copyRemoteFor ?? copyRemoteFor)(ctx) });
else if (words[0] === 'restore' && words.length === 2) {
const startBus = !p.flags.has('--no-bus');
if (startBus) restoreBusCheck(system, io.env);
@@ -70,7 +70,7 @@ export async function orgCommand(args, io, deps = {}) {
}
}
}
else if (words[0] === 'delete' && words.length === 2) result = await deleteOrg(ctx, words[1], { busStop: busStopForDelete, ...lifecycle });
else if (words[0] === 'delete' && words.length === 2) result = await deleteOrg(ctx, words[1], { busStop: busStopForDelete, copyRemote: await (deps.copyRemoteFor ?? copyRemoteFor)(ctx), ...lifecycle });
// U1 (#1873): the F3 bus control from a terminal, through the running Console's own route, which
// checks the owner and writes the action trail (org-console.mjs consoleBusRestart).
else if (words[0] === 'reload' && words.length === 2) { allowed([], []); result = await consoleBusRestart(ctx, words[1], deps.consoleRequest ?? {}); }
+16 -2
View File
@@ -2,8 +2,8 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fixture, tmp } from './helpers.mjs';
import { busStart, busStop, busStopForDelete } from '../src/org-bus.mjs';
import { fixture, orgFixture, tmp } from './helpers.mjs';
import { busStart, busStop, busStopForDelete, copyRemoteFor } from '../src/org-bus.mjs';
import { createOrg, deleteOrg, templateRender } from '../../organization/src/org-service.mjs';
test('deletion stops the recorded running bus before moving its runtime', { timeout: 60000 }, async t => {
@@ -19,3 +19,17 @@ test('deletion stops the recorded running bus before moving its runtime', { time
assert.equal(receipt.bus.stopped, true); assert.equal(receipt.bus.pid, started.pid);
assert.ok(receipt.moves.some(move => move.from === started.root));
});
// #1873 CB4: archive and delete from the CLI get the organization's managed Gitea remote step. With no
// admin slot and no bound repository it never claims a revoke: a malformed grant fails, another
// service is not managed, and a grant whose bound projects have no server fails (the copy stays fenced).
test('CB4 copyRemoteFor gives the managed remote step, which never claims a revoke it did not make', async t => {
const { root, f } = await orgFixture(t, 'a');
const ctx = { configDir: join(root, '.config', 'mosaic-test'), dataRoot: f.dataRoot, instance: 'test' };
const remote = await copyRemoteFor(ctx);
assert.equal(typeof remote, 'function');
const record = (grantId) => ({ orgIncarnation: 'x', copyId: 'c', services: { gitea: { grantId, resources: { projects: ['work'] } } } });
assert.equal(await remote('a', 'rev', record('nope'), 'gitea'), 'failed');
assert.equal(await remote('a', 'rev', record('gitea:1:2'), 'other'), 'not-managed');
assert.equal(await remote('a', 'rev', record('gitea:1:2'), 'gitea'), 'failed');
});
+31 -9
View File
@@ -195,6 +195,13 @@ export async function updateOrg(ctx, id, input, { ifRevision } = {}) {
authorize(ctx); return withRegistry(ctx, async registry => {
const current = await readCurrentUnlocked(ctx, id); if (!Number.isSafeInteger(ifRevision) || ifRevision !== current.revision) fail('revision-conflict', 'organization revision changed');
const doc = normalizeDoc(ctx, id, input, current.doc); if (doc.human !== ctx.principal.sub) fail('org-forbidden', 'document human must name the local owner'); await checked(doc);
// #1873 CB4 (F7): a project a copy binding still grants keeps its repository: its remote, its git
// binding and its presence change only after that binding is revoked, as in project repo set.
for (const [project, entry] of Object.entries(current.doc.projects ?? {})) {
const bound = projectBindings(current.doc, project); if (!bound.length) continue;
const next = doc.projects?.[project];
if (!next || canonicalJson(next.remote ?? null) !== canonicalJson(entry.remote ?? null) || canonicalJson(next.git ?? null) !== canonicalJson(entry.git ?? null)) fail(REPO_BOUND, repoBoundMessage(project, bound));
}
const raw = encode(doc), revision = current.revision + 1; await atomic(join(ctx.configDir, 'businesses', `${id}.json`), raw); registry.orgs[id].digest = digest(raw); registry.orgs[id].revision = revision;
return receipt(current.root, 'org.update', id, ctx.principal, { root: current.root, revision });
});
@@ -306,12 +313,23 @@ export async function projectRepoShow(ctx, id, project) { return showSetup(ctx,
// The bus has stopped, so no run of a copy is left; each binding takes the same two writes as the
// bus's revoke (`revoking` with an op id, then the tombstone), and a rerun joins the op a crash left.
// The slot file goes from the organization's agent store. The remote step is the caller's
// `copyRemote(id, instance, record, service)` (the CLI or Console wires the managed adapter, as it
// wires busStop); without one it is recorded `not-attempted`, never claimed. A slot that cannot be
// removed keeps the record revoking and refuses the archive. Returns the writes and one line per copy.
// `copyRemote(id, instance, record, service)` (the CLI and Console wire the managed adapter, as they
// wire busStop). A remote step that failed, or that was not attempted because no adapter was given,
// keeps the record revoking (`failed: ['remote']`): the copy stays fenced, and the bus's resume at its
// next start, after a restore, runs the remote step again. The copy's line says which, never
// claiming a revoke. A slot that cannot be removed also keeps the record revoking, and refuses the
// archive. Returns the writes and one line per copy.
const tombstoned = r => r?.state === 'unbound' && r.revoke?.done === true;
async function discardCopySlot(ctx, id, instance, service) {
const file = join(ctx.configDir, 'secrets', 'agents', id, instance, service);
// Each parent must be the stack user's own private directory, or missing (no slot below it).
let parent = join(ctx.configDir, 'secrets');
for (const part of ['', 'agents', id, instance]) {
if (part) parent = join(parent, part);
let st;
try { st = await fs.lstat(parent); } catch (e) { if (e.code === 'ENOENT') return 'absent'; return 'failed'; }
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) return 'failed';
}
const file = join(parent, service);
let removed = false;
for (const path of [file + '.tmp', file]) {
let st;
@@ -348,7 +366,9 @@ async function discardCopies(ctx, id, cause, copyRemote) {
try { remote[service] = await copyRemote(id, instance, record, service); } catch { remote[service] = 'failed'; }
if (!['revoked', 'absent', 'not-managed', 'failed'].includes(remote[service])) remote[service] = 'failed';
}
const done = writer.revokeCopyBinding(id, { phase: 'finish', instance, opId: begun.op, steps: { stop: 'confirmed', slot, remote } }, { actor: 'bus', owner: true });
// The bus is stopped (busStop answered before this), so the copy has no run left to stop.
const steps = Object.fromEntries(Object.entries(remote).map(([k, v]) => [k, v === 'not-attempted' ? 'failed' : v]));
const done = writer.revokeCopyBinding(id, { phase: 'finish', instance, opId: begun.op, steps: { stop: 'confirmed', slot, remote: steps } }, { actor: 'bus', owner: true });
if (done.revision !== undefined) writes++;
copies.push({ agent: instance, state: done.state, slot, remote });
}
@@ -436,10 +456,11 @@ async function deleteParents(base, relative) {
// #1873 CB4: the deletion quarantine moves every copy slot with the agent store, and the receipt
// lists each copy that held a service grant with its remote outcome. With no `copyRemote` from the
// caller that outcome is `not-attempted`: the receipt never claims a token was revoked at the service.
const DELETE_NOTE = 'Purging deleted data is out of scope. Remote-side revocation happened only where copies lists a copy with remote revoked.';
// An organization file that cannot be read is said so (`copiesUnreadable`), never an empty list.
const DELETE_NOTE = 'Purging deleted data is out of scope. Remote-side revocation happened only where copies lists a copy with remote revoked. When copiesUnreadable is true the organization file could not be read, so any copy grant still live at its service must be revoked there by hand.';
async function deleteCopies(ctx, id, copyRemote) {
let doc;
try { doc = JSON.parse(await privateRead(join(ctx.configDir, 'businesses', `${id}.json`))); } catch { return []; }
try { doc = JSON.parse(await privateRead(join(ctx.configDir, 'businesses', `${id}.json`))); } catch { return { copies: [], unreadable: true }; }
const out = [];
for (const instance of Object.keys(doc?.copyBindings ?? {}).sort()) {
const record = doc.copyBindings[instance];
@@ -454,7 +475,7 @@ async function deleteCopies(ctx, id, copyRemote) {
out.push({ agent: instance, service, slot: 'quarantined', remote });
}
}
return out;
return { copies: out, unreadable: false };
}
export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop, copyRemote = null } = {}) {
authorize(ctx); checkId(id);
@@ -490,7 +511,8 @@ export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop, copyRem
}
if (!Array.isArray(result.copies)) {
phase = 'copies';
result.copies = await deleteCopies(ctx, id, copyRemote);
const found = await deleteCopies(ctx, id, copyRemote);
result.copies = found.copies; if (found.unreadable) result.copiesUnreadable = true;
await atomic(receiptFile, encode(result));
}
phase = 'inspect';
+6 -3
View File
@@ -464,16 +464,18 @@ async function route(req, res, { path, search, org, admin, principal, write, log
if ((kind === 'archive' || kind === 'restore') && !sub && write) {
const body = await readBody(req); strictKeys(body, ['revision']);
if (!Number.isSafeInteger(body.revision) || body.revision < 1) return json(res, 422, { error: 'invalid', step: kind, message: 'A valid revision is required.' });
let busStop;
let busStop, copyRemote = null;
if (kind === 'archive') {
const bus = await need(admin, 'bus', res, kind); if (!bus) return;
if (typeof bus.busStopForDelete !== 'function') return json(res, 503, { error: 'not-configured', step: kind, message: 'bus control cannot stop a bus for archive on this Console yet' });
busStop = bus.busStopForDelete;
// #1873 CB4: archive revokes each copy's grant through the organization's managed adapter.
if (typeof bus.copyRemoteFor === 'function') copyRemote = await bus.copyRemoteFor(ctx);
}
try {
const fn = kind === 'archive' ? service.archiveOrg : service.restoreOrg;
if (typeof fn !== 'function') return json(res, 503, { error: 'not-configured', step: kind });
const receipt = await fn(ctx, id, { ifRevision: body.revision, ...(busStop ? { busStop } : {}) });
const receipt = await fn(ctx, id, { ifRevision: body.revision, ...(busStop ? { busStop } : {}), ...(copyRemote ? { copyRemote } : {}) });
const done = { outcome: 'done', step: kind, receipt: forReceipt(ctx, receipt), verb: verbFor(kind, id, { revision: body.revision }) };
if (kind === 'archive') return json(res, 200, done);
return json(res, 200, { ...done, bus: await restoreBus({ id, ctx, admin, log, trail, by }) });
@@ -485,7 +487,8 @@ async function route(req, res, { path, search, org, admin, principal, write, log
const bus = await need(admin, 'bus', res, 'delete'); if (!bus) return;
if (typeof bus.busStopForDelete !== 'function') return json(res, 503, { error: 'not-configured', step: 'delete', message: 'bus control cannot stop a bus for deletion on this Console yet' });
try {
const receipt = await service.deleteOrg(ctx, id, { busStop: bus.busStopForDelete, confirm: body.confirm, ifRevision: body.revision });
const copyRemote = typeof bus.copyRemoteFor === 'function' ? await bus.copyRemoteFor(ctx) : null;
const receipt = await service.deleteOrg(ctx, id, { busStop: bus.busStopForDelete, confirm: body.confirm, ifRevision: body.revision, ...(copyRemote ? { copyRemote } : {}) });
return json(res, 200, { outcome: 'done', step: 'delete', receipt: forReceipt(ctx, receipt), verb: verbFor('delete', id, { revision: body.revision }) });
} catch (e) { if (e.code === 'revision-conflict') return refusal(res, e, { step: 'delete', id }); return json(res, 409, { error: e.code || 'delete-refused', step: e.deleteStep || 'delete', message: plainMessage(ctx, e.message || 'Deletion stopped; inspect the receipt and resume with the CLI.'), receipt: forReceipt(ctx, e.deleteReceipt), verb: verbFor('delete', id, { revision: body.revision }) }); }
}
+12 -1
View File
@@ -13,6 +13,7 @@ import { actionTrail } from '../src/action-trail.mjs';
import { close } from './fixture.mjs';
class OrgError extends Error { constructor(code, message = code) { super(message); this.code = code; } }
const COPY_REMOTE = async () => 'revoked';
const template = id => ({ businessVersion: 1, id, human: 'local-dev-owner', arbiters: { delivery: 'chief', technical: 'chief' }, roles: { chief: { definition: 'chief-of-staff' }, assistant: { definition: 'assistant' } }, projects: { work: { root: `/__mosaic_org__/${id}/projects/work` } } });
// In-memory C1/C2/C3 fakes with the PLAN.md signatures. `fail` injects errors.
@@ -40,6 +41,8 @@ function fakes() {
busStatus: async (ctx, id) => { get(id); return { org: id, instance: 'test', root: `/data/org-runtime/${id}`, socket: '/run/s', healthy: running === id, pid: running === id ? 4242 : null }; },
busStart: async (ctx, id, opts) => { calls.push(['busStart', id, opts]); running = id; return { org: id, pid: 4242, socket: '/run/s', root: `/data/org-runtime/${id}` }; },
busStopForDelete: async (ctx, id) => { calls.push(['busStopForDelete', id]); running = null; return { stopped: false, reason: 'no host runs' }; },
// #1873 CB4: the managed remote step archive and delete hand to the organization service.
copyRemoteFor: async ctx => { assert.equal(ctx.configDir, '/cfg'); return COPY_REMOTE; },
busRestart: async (ctx, id) => { calls.push(['busRestart', id]); running = id; return { org: id, pid: 4343, socket: '/run/s', root: `/data/org-runtime/${id}` }; },
busStop: async (ctx, id) => { calls.push(['busStop', id]); const was = running === id; running = null; return was ? { stopped: true, business: id, pid: 4242 } : { stopped: false, reason: 'no host runs' }; },
};
@@ -589,6 +592,14 @@ test('organization delete route enforces write fence and validates confirmation
assert.equal((await c.call('POST', '/api/orgs/acme/delete', { confirm: 'acme', revision: 1, extra: true })).status, 400);
});
test('CB4 delete hands the managed copy remote to the organization service', async t => {
const c = await console_(t);
let seen = null;
c.service.deleteOrg = async (ctx, id, opts) => { seen = opts; return { org: id, bus: { stopped: false, reason: 'no host runs' }, moves: [], copies: [] }; };
const r = await c.call('POST', '/api/orgs/acme/delete', { confirm: 'acme', revision: 1 });
assert.equal(r.status, 200); assert.equal(seen.copyRemote, COPY_REMOTE); assert.equal(typeof seen.busStop, 'function');
});
test('delete refusal exposes progress and redacts receipt paths', async t => {
const c = await console_(t);
c.service.deleteOrg = async () => { const e = new OrgError('org-mismatch', 'unsafe path /data/org-runtime/acme'); e.deleteStep = 'inspect'; e.deleteReceipt = { bus: { stopped: false, reason: 'no host runs' }, moves: [], destination: '/data/deleted/acme-x' }; throw e; };
@@ -599,7 +610,7 @@ test('delete refusal exposes progress and redacts receipt paths', async t => {
test('archive and restore use fenced owner routes with strict revisions and injected bus stop', async t => {
const c = await console_(t);
c.service.archiveOrg = async (ctx, id, opts) => { assert.equal(typeof opts.busStop, 'function'); c.calls.push(['archiveOrg', id, opts.ifRevision]); return { org: id, revision: 2 }; };
c.service.archiveOrg = async (ctx, id, opts) => { assert.equal(typeof opts.busStop, 'function'); assert.equal(opts.copyRemote, COPY_REMOTE); c.calls.push(['archiveOrg', id, opts.ifRevision]); return { org: id, revision: 2 }; };
c.service.restoreOrg = async (ctx, id, opts) => { c.calls.push(['restoreOrg', id, opts.ifRevision]); return { org: id, revision: 3 }; };
for (const step of ['archive', 'restore']) {
const path = `/api/orgs/acme/${step}`;