Row 286 C2-03b: fold dewey-0864 (T7/T9 through grants.revoke and instance.fire, race fetch takes the sync guard, two-chief code, id -u shim note)
This commit is contained in:
1 parent
bb29ee2d95
commit
90b70a6a6c
5 files changed
+235
-66
No files matched your search
@@ -172,7 +172,7 @@ N3: `fetchCredential` reads the store, so it is the bus's choice function, not a
|
|||||||
through C2-21".
|
through C2-21".
|
||||||
- N1: as above.
|
- N1: as above.
|
||||||
|
|
||||||
## Build notes (code written; nothing run yet)
|
## Build notes (code written; nothing run yet; dewey-0864 folded)
|
||||||
|
|
||||||
Files changed:
|
Files changed:
|
||||||
- `packages/library/src/sync.mjs`: `syncCommand` takes `push` with a full-sha `commit`; `push()`;
|
- `packages/library/src/sync.mjs`: `syncCommand` takes `push` with a full-sha `commit`; `push()`;
|
||||||
@@ -193,26 +193,40 @@ Choices made without a ruling. Each is small and reversible:
|
|||||||
source runtime's `slotDir` uses.
|
source runtime's `slotDir` uses.
|
||||||
- `BusError` carries only a code, so the sage-2928 fix text can't ride on the verb's refusal. It
|
- `BusError` carries only a code, so the sage-2928 fix text can't ride on the verb's refusal. It
|
||||||
goes in the timer's log line (`fix`) and in the README. The verb answers `library-fetch-no-grant`.
|
goes in the timer's log line (`fix`) and in the README. The verb answers `library-fetch-no-grant`.
|
||||||
- T7 stages revoke and fire as the state those verbs leave: row deleted plus `grant.revoked`; for a
|
- T7 and T9 run the real verbs (dewey-0864, replacing the staged state in cefebf65b). The chief's
|
||||||
fire, the name delisted with `roles.chief` kept and the row revoked (row 281's fire revokes grants).
|
grant goes through decision.raise and the human's yes against fake-gitea-api.mjs. The test's own
|
||||||
A delisted chief with its row still there, and a `fire.stopped` with no `fire.done`, also refuse. The chief has no Gitea
|
definitions give chief-of-staff a gitea credential, as gitea-grant.test.mjs gives coder one. The
|
||||||
grant path before C2-21, so the evidence and the row go in by SQL, as gitea-grant.test.mjs does for
|
real roles/chief-of-staff.json has `credentials: []`, so a live chief can't get one before C2-21.
|
||||||
decisions. The verbs' own effects are pinned in gitea-grant.test.mjs and fire-revokes-grants.test.mjs.
|
- Revoked: `grants.revoke`, then the row is gone and the fetch refuses.
|
||||||
This departs from the brief's "through grants.revoke" and "through instance.fire". For Dewey.
|
- Fired: `instance.fire` can't fire the launch.by chief. Only the launch.by role calls it, and it
|
||||||
|
refuses its own name (`organization-instance-referenced`, broker.mjs `#chiefLifecycle`). So the
|
||||||
|
test's launch.by is `boss` (definition pm, the chief's authority), and boss fires the one listed
|
||||||
|
chief-of-staff instance. Step G deletes the row (row 281), `roles.chief` stays, the fetch refuses.
|
||||||
|
- T9's revoked case runs `grants.revoke` on the runtime's own data root and store, between starts.
|
||||||
|
- Delisted with the row still there, a `fire.stopped` with no `fire.done`, held, two chiefs and
|
||||||
|
another project stay unit checks on the choice function with SQL fixtures.
|
||||||
- "Another uid" in T5: the suite can't chown, so a PATH `stat` shim reports uid+1 for the open file.
|
- "Another uid" in T5: the suite can't chown, so a PATH `stat` shim reports uid+1 for the open file.
|
||||||
- The race fetch inside `push()` isn't serialized with the timer's sync. Two fetches of the same
|
The real `id -u` compare is covered only through that shim (dewey-0864).
|
||||||
ref race on git's ref lock, and the loser fails (`library-push-failed` or `library-fetch-failed`),
|
- Two listed chiefs, or none listed, refuse `library-fetch-no-grant` (T8).
|
||||||
never a wrong ref. C2-02 owns serializing a write with sync. This is a bounded follow-up, not this row.
|
- The race fetch inside `push()` takes sync's `running` guard (dewey-0864 R1). While a sync of the
|
||||||
|
mirror runs it refuses `library-sync-busy`, fetches nothing, and the caller writes again. Test:
|
||||||
|
"race fetch: while a sync of the mirror runs".
|
||||||
|
- The slot at 0000: T9 covers the revoked case at runtime. Fired and held refuse inside the choice
|
||||||
|
function, which takes no path and opens no file. `sync` with any `{ refused }` runs no git (library
|
||||||
|
test, valid slot present).
|
||||||
|
- First-run risks: T9's runtime rebuilds definitions from rolesDir, where chief-of-staff has no gitea
|
||||||
|
credential. If giteaStart or reconcile then holds or drops the chief's grant, T9 part 1 fails. The
|
||||||
|
fix would be in the test's setup, not in the product.
|
||||||
- The chief's evidence is any gitea grant event naming the chief, of any action (grant.applied,
|
- The chief's evidence is any gitea grant event naming the chief, of any action (grant.applied,
|
||||||
grant.revoked and the rest), per the plan. The spend event names no agent, so it doesn't count.
|
grant.revoked and the rest), per the plan. The spend event names no agent, so it doesn't count.
|
||||||
|
|
||||||
Test map:
|
Test map:
|
||||||
- library sync.test.mjs: argv (push), T1, T2+T3, T4 (dead remote, hook, wrong token), refused or
|
- library sync.test.mjs: argv (push), T1, T2+T3, T4 (dead remote, hook, wrong token), refused or
|
||||||
unsafe credential runs no git, T10, fetch credential and state with N1/T12, T5+T5b.
|
unsafe credential runs no git, T10, fetch credential and state with N1/T12, T5+T5b.
|
||||||
- bus library-credential.test.mjs: T6, T7, T8, departure, T9 runtime (chief file and user via timer
|
- library sync.test.mjs also: race fetch busy (R1).
|
||||||
and verb; revoked with slot 0000: no git, log `fix`, state `credential: null`).
|
- bus library-credential.test.mjs: T6, T7 (real revoke and fire), T7 choice checks, T8, departure,
|
||||||
- T9 at runtime covers the revoked case with the slot at 0000. Fired and held are unit-level in the
|
T9 runtime (real grant, chief file and user via timer and verb; real revoke, slot 0000: no git,
|
||||||
broker; `sync` with any `{ refused }` opens no file and runs no git (library test, valid slot present).
|
log `fix`, state `credential: null`).
|
||||||
- webby-0151 R3: T9 reads `library.status` for `credential` and `lastError`.
|
- webby-0151 R3: T9 reads `library.status` for `credential` and `lastError`.
|
||||||
- T11 token search: T1 scans the data root and every file but the seat's; T9 scans the log lines.
|
- T11 token search: T1 scans the data root and every file but the seat's; T9 scans the log lines.
|
||||||
The gate's output search uses the fixture tokens `fixture-token-*-made-up-00{42..49}`.
|
The gate's output search uses the fixture tokens `fixture-token-*-made-up-00{42..49}`.
|
||||||
|
|||||||
@@ -1,29 +1,48 @@
|
|||||||
// C2-03b (#1791, design 7.2): the credential the library fetch runs with. The chief's grant store file
|
// C2-03b (#1791, design 7.2): the credential the library fetch runs with. The chief's grant store file
|
||||||
// once a chief has Gitea grant evidence, slot library-gitea before that, and a refusal otherwise. The
|
// once a chief has Gitea grant evidence, slot library-gitea before that, and a refusal otherwise.
|
||||||
// chief has no Gitea grant path before C2-21, so its evidence and gitea_grants row go in by SQL as
|
// T7's revoked and fired cases and T9's revoked case run the broker's real paths (dewey-0864): the
|
||||||
// fixtures, as gitea-grant.test.mjs does for decisions. A revoke and a fire are staged as the state
|
// chief's grant through decision.raise and the human's yes against the fake Gitea in fake-gitea-api.mjs
|
||||||
// those verbs leave: gitea-grant.test.mjs and fire-revokes-grants.test.mjs pin that a revoke deletes
|
// on 127.0.0.1, then grants.revoke or instance.fire. roles/chief-of-staff.json names no gitea credential,
|
||||||
// the row and writes grant.revoked, and that a fire delists the name and keeps roles[name] (dewey-0860).
|
// so these tests give chief-of-staff one in their own definitions, as gitea-grant.test.mjs gives coder
|
||||||
// Tokens are made up and only ever compared.
|
// one. The choice function's other checks (listed, a fire under way, held, two chiefs, another project)
|
||||||
|
// use fixtures: the evidence and the gitea_grants row go in by SQL, as gitea-grant.test.mjs does for
|
||||||
|
// decisions. Tokens are made up or the fake's, and only ever compared.
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { Store } from '../src/store.mjs';
|
import { Store } from '../src/store.mjs';
|
||||||
import { Broker, LIBRARY_FETCH_FIX, writeGrantEvent } from '../src/broker.mjs';
|
import { Broker, GRANT_EVIDENCE, LIBRARY_FETCH_FIX, writeGrantEvent } from '../src/broker.mjs';
|
||||||
|
import { Credentials } from '../src/credentials.mjs';
|
||||||
|
import { giteaGrant, giteaKeys, giteaStart } from '../src/gitea-grant.mjs';
|
||||||
import { startBroker } from '../src/runtime.mjs';
|
import { startBroker } from '../src/runtime.mjs';
|
||||||
import { gitEnv } from '../../seat/src/checkout.mjs';
|
import { gitEnv } from '../../seat/src/checkout.mjs';
|
||||||
import { mappingEntry, repo } from '../../library/tests/helpers.mjs';
|
import { mappingEntry, repo } from '../../library/tests/helpers.mjs';
|
||||||
import * as library from '../../library/src/index.mjs';
|
import * as library from '../../library/src/index.mjs';
|
||||||
|
import { fakeGiteaApi } from './fake-gitea-api.mjs';
|
||||||
import { grantDecision } from './host-fixtures.mjs';
|
import { grantDecision } from './host-fixtures.mjs';
|
||||||
import { rolesCopy, systemFor } from '../../business/tests/helpers.mjs';
|
import { rolesCopy, systemFor } from '../../business/tests/helpers.mjs';
|
||||||
|
|
||||||
const AT = '2026-10-22T00:00:00.000Z';
|
const AT = '2026-10-22T00:00:00.000Z';
|
||||||
|
const REV = 'b'.repeat(64);
|
||||||
const SLOT_TOKEN = 'fixture-token-2c90-made-up-0048';
|
const SLOT_TOKEN = 'fixture-token-2c90-made-up-0048';
|
||||||
const CHIEF_TOKEN = 'fixture-token-e413-made-up-0049';
|
|
||||||
const role = { authority: { withinRole: [], crossRole: [] } };
|
const role = { authority: { withinRole: [], crossRole: [] } };
|
||||||
|
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||||
|
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||||
|
const chiefAuthority = JSON.parse(readFileSync(fileURLToPath(new URL('../../../roles/chief-of-staff.json', import.meta.url)), 'utf8')).authority;
|
||||||
|
const SWITCH = { version: 1, halted: false, generation: '0b6e7f6e-4c1d-4a8e-9a51-6d2f3c4b5a69' };
|
||||||
|
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
async function until(fn, ms = 10000) {
|
||||||
|
const end = Date.now() + ms;
|
||||||
|
while (!fn()) {
|
||||||
|
if (Date.now() > end) throw new Error('timed out');
|
||||||
|
await sleep(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const makeBusiness = ({ chief = true, instances = null } = {}) => ({
|
const makeBusiness = ({ chief = true, instances = null } = {}) => ({
|
||||||
id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||||
@@ -44,12 +63,6 @@ function grant(store, agent, { user = agent + '-bot', project = 'books', row = t
|
|||||||
'acme', agent, user, 11, 'mosaic-' + agent, JSON.stringify([project]), '2099-01-01', decision);
|
'acme', agent, user, 11, 'mosaic-' + agent, JSON.stringify([project]), '2099-01-01', decision);
|
||||||
return decision;
|
return decision;
|
||||||
}
|
}
|
||||||
// The state a revoke leaves: no gitea_grants row and grant.revoked.
|
|
||||||
function revoke(store, agent) {
|
|
||||||
store.run('DELETE FROM gitea_grants WHERE business=? AND agent=?', 'acme', agent);
|
|
||||||
writeGrantEvent(store, AT, 'acme', { action: 'grant.revoked', method: 'gitea', agent });
|
|
||||||
assert.equal(store.get('SELECT 1 FROM gitea_grants WHERE business=? AND agent=?', 'acme', agent), undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function world(t, options) {
|
function world(t, options) {
|
||||||
const root = mkdtempSync(join(tmpdir(), 'bus-library-credential-')), store = new Store(root);
|
const root = mkdtempSync(join(tmpdir(), 'bus-library-credential-')), store = new Store(root);
|
||||||
@@ -60,6 +73,108 @@ function world(t, options) {
|
|||||||
return { store, business, broker, held, choose: () => broker.libraryFetchCredential('acme') };
|
return { store, business, broker, held, choose: () => broker.libraryFetchCredential('acme') };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The business for the real paths. `boss` is launch.by: instance.fire is the launch.by role's verb and
|
||||||
|
// refuses its own name (organization-instance-referenced), so the fired chief is the one listed
|
||||||
|
// chief-of-staff instance, fired by boss. The grant targets project app; books is the library.
|
||||||
|
function verbDoc(url) {
|
||||||
|
return {
|
||||||
|
id: 'acme', human: 'jason', arbiters: { technical: 'boss', delivery: 'boss' },
|
||||||
|
roles: {
|
||||||
|
boss: { definition: 'pm', authority: chiefAuthority },
|
||||||
|
chief: { definition: 'chief-of-staff', ...none, identity: { name: 'Chief', email: '[email protected]', gitea: { url, user: 'acme-chief' } }, publish: ['app'] },
|
||||||
|
coder: { definition: 'coder', ...none },
|
||||||
|
},
|
||||||
|
definitions: {
|
||||||
|
'chief-of-staff': { roleVersion: 2, credentials: [{ service: 'gitea' }] },
|
||||||
|
pm: { roleVersion: 2, credentials: [] },
|
||||||
|
coder: { roleVersion: 2, credentials: [] },
|
||||||
|
},
|
||||||
|
projects: { app: { remote: `${url}/acme/app` }, books: { ...mappingEntry(), user: 'books-bot' } },
|
||||||
|
launch: { by: 'boss', instances: ['chief', 'coder'] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bare broker on the real grant paths, as gitea-grant.test.mjs and fire-revokes-grants.test.mjs run
|
||||||
|
// it. A second one on the same data root and store picks the grant up as runtime.mjs does: giteaStart,
|
||||||
|
// then Credentials with its references and held list. The admin slot sits outside storeRoot's config
|
||||||
|
// dir, so a runtime started on that dir finds no admin slot.
|
||||||
|
async function verbWorld(t, opts = {}) {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'bus-library-verbs-'));
|
||||||
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||||
|
let gitea = opts.gitea;
|
||||||
|
if (!gitea) {
|
||||||
|
gitea = await fakeGiteaApi(t, join(dir, 'tls'));
|
||||||
|
gitea.addUser('acme');
|
||||||
|
gitea.addRepo('acme', 'app');
|
||||||
|
}
|
||||||
|
const adminDir = join(dir, 'admin');
|
||||||
|
mkdirSync(adminDir, { mode: 0o700 });
|
||||||
|
const dataRoot = opts.dataRoot ?? join(dir, 'data');
|
||||||
|
mkdirSync(dataRoot, { recursive: true, mode: 0o700 });
|
||||||
|
const storeRoot = opts.storeRoot ?? join(dir, 'secrets', 'agents');
|
||||||
|
const doc = opts.doc ?? verbDoc(gitea.url);
|
||||||
|
const store = new Store(dataRoot);
|
||||||
|
const references = {};
|
||||||
|
const { held } = giteaStart(store, storeRoot, { acme: doc }, references);
|
||||||
|
const credentials = new Credentials({ references, storeRoot, dataRoot, held });
|
||||||
|
let open = true;
|
||||||
|
const close = () => {
|
||||||
|
if (!open) return;
|
||||||
|
open = false;
|
||||||
|
credentials.close();
|
||||||
|
store.close();
|
||||||
|
};
|
||||||
|
t.after(close);
|
||||||
|
const grantApi = giteaGrant({ configDir: adminDir, ca: gitea.ca, timeoutMs: 5000 });
|
||||||
|
grantApi.writeAdmin('acme', gitea.adminToken, { url: gitea.url, account: 'mosaic-grant', rotateBy: dayFrom(60), setAt: AT });
|
||||||
|
let broker;
|
||||||
|
const organization = (business, op, args) => {
|
||||||
|
if (op === 'instance.fire') {
|
||||||
|
doc.launch.instances = doc.launch.instances.filter((n) => n !== args.name);
|
||||||
|
broker.reloadBusiness('acme', doc);
|
||||||
|
return { revision: REV, transaction: 't-fire' };
|
||||||
|
}
|
||||||
|
return { revision: REV };
|
||||||
|
};
|
||||||
|
broker = new Broker({
|
||||||
|
store, businesses: { acme: structuredClone(doc) }, secretCheck: (value) => credentials.assertClean(value), organization,
|
||||||
|
processing: { read: () => SWITCH }, giteaGrant: grantApi, giteaKeys: giteaKeys({ credentials, storeRoot }),
|
||||||
|
});
|
||||||
|
const boss = broker.bindLaunch({ business: 'acme', role: 'boss', run: 'boss-run', harness: 'pi', address: 'boss' });
|
||||||
|
broker.request(boss, { verb: 'role.claim' });
|
||||||
|
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||||
|
const events = () => store.all(`SELECT body FROM events WHERE business='acme' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((e) => JSON.parse(e.body));
|
||||||
|
const ended = (id) => events().findLast((e) => e.grantDecision === id && ['grant.applied', 'grant.failed'].includes(e.action));
|
||||||
|
const row = () => store.get("SELECT 1 FROM gitea_grants WHERE business='acme' AND agent='chief'");
|
||||||
|
const approveChief = async () => {
|
||||||
|
const d = await broker.request(boss, {
|
||||||
|
verb: 'decision.raise',
|
||||||
|
args: {
|
||||||
|
action: 'credential.mint', question: 'Grant chief Gitea?', options, recommendation: 'yes', blocking: false,
|
||||||
|
grant: { method: 'gitea', agent: 'chief', target: { projects: ['app'] }, rotateBy: dayFrom(30) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
broker.request(human, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
|
||||||
|
await until(() => ended(d.id));
|
||||||
|
await broker.giteaIdle();
|
||||||
|
assert.equal(ended(d.id).action, 'grant.applied', 'the chief\'s grant landed');
|
||||||
|
assert.ok(row(), 'with its gitea_grants row');
|
||||||
|
};
|
||||||
|
const revoke = async () => {
|
||||||
|
await broker.request(human, { verb: 'grants.revoke', args: { service: 'gitea', agent: 'chief', reason: 'test' } });
|
||||||
|
await broker.giteaIdle();
|
||||||
|
assert.equal(row(), undefined, 'grants.revoke deleted the row');
|
||||||
|
assert.ok(events().some((e) => e.action === 'grant.revoked' && e.agent === 'chief'));
|
||||||
|
};
|
||||||
|
const fire = async () => {
|
||||||
|
await broker.request(boss, { verb: 'instance.fire', args: { name: 'chief', reason: 'the work is done' } });
|
||||||
|
await until(() => !doc.launch.instances.includes('chief'));
|
||||||
|
await broker.grantsIdle();
|
||||||
|
assert.equal(row(), undefined, 'the fire\'s step G deleted the row (row 281)');
|
||||||
|
};
|
||||||
|
return { gitea, doc, store, broker, storeRoot, row, approveChief, revoke, fire, close, choose: () => broker.libraryFetchCredential('acme') };
|
||||||
|
}
|
||||||
|
|
||||||
const REFUSED = { refused: 'library-fetch-no-grant', fix: LIBRARY_FETCH_FIX };
|
const REFUSED = { refused: 'library-fetch-no-grant', fix: LIBRARY_FETCH_FIX };
|
||||||
|
|
||||||
test('C2-03b T6: the slot before any chief evidence, then the listed chief\'s live grant', t => {
|
test('C2-03b T6: the slot before any chief evidence, then the listed chief\'s live grant', t => {
|
||||||
@@ -75,23 +190,27 @@ test('C2-03b T6: the slot before any chief evidence, then the listed chief\'s li
|
|||||||
assert.equal(LIBRARY_FETCH_FIX, 'add the chief of staff, or grant the library through C2-21');
|
assert.equal(LIBRARY_FETCH_FIX, 'add the chief of staff, or grant the library through C2-21');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('C2-03b T7: a revoked, fired or held chief each refuse library-fetch-no-grant, and the fallback stays ended', t => {
|
test('C2-03b T7: a chief revoked through grants.revoke or fired through instance.fire refuses, and the fallback stays ended', { timeout: 60000 }, async t => {
|
||||||
const revoked = world(t);
|
const revoked = await verbWorld(t);
|
||||||
grant(revoked.store, 'chief');
|
assert.deepEqual(revoked.choose(), { kind: 'slot' });
|
||||||
revoke(revoked.store, 'chief');
|
await revoked.approveChief();
|
||||||
|
assert.deepEqual(revoked.choose(), { kind: 'chief', agent: 'chief', user: 'acme-chief' });
|
||||||
|
await revoked.revoke();
|
||||||
assert.deepEqual(revoked.choose(), REFUSED, 'revoked');
|
assert.deepEqual(revoked.choose(), REFUSED, 'revoked');
|
||||||
|
|
||||||
// A fire delists the name and, since row 281, revokes its grants; roles[chief] stays, so its
|
const fired = await verbWorld(t);
|
||||||
// evidence still ends the fallback.
|
await fired.approveChief();
|
||||||
const fired = world(t, { instances: ['coder'] });
|
assert.deepEqual(fired.choose(), { kind: 'chief', agent: 'chief', user: 'acme-chief' });
|
||||||
grant(fired.store, 'chief');
|
await fired.fire();
|
||||||
revoke(fired.store, 'chief');
|
assert.equal(fired.doc.launch.instances.includes('chief'), false, 'delisted');
|
||||||
assert.ok(fired.business.roles.chief, 'roles[chief] remains after a fire');
|
assert.ok(fired.doc.roles.chief, 'roles[chief] remains after a fire, so its evidence still ends the fallback');
|
||||||
assert.deepEqual(fired.choose(), REFUSED, 'fired');
|
assert.deepEqual(fired.choose(), REFUSED, 'fired');
|
||||||
// The listed check alone: delisted with the row still there, then a fire under way.
|
});
|
||||||
|
|
||||||
|
test('C2-03b T7 choice checks: delisted, a fire under way and held each refuse; a rehire lists the chief again', t => {
|
||||||
const delisted = world(t, { instances: ['coder'] });
|
const delisted = world(t, { instances: ['coder'] });
|
||||||
grant(delisted.store, 'chief');
|
grant(delisted.store, 'chief');
|
||||||
assert.deepEqual(delisted.choose(), REFUSED, 'delisted');
|
assert.deepEqual(delisted.choose(), REFUSED, 'delisted with the row still there');
|
||||||
delisted.business.launch.instances.push('chief');
|
delisted.business.launch.instances.push('chief');
|
||||||
assert.deepEqual(delisted.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' }, 'a rehire lists it again');
|
assert.deepEqual(delisted.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' }, 'a rehire lists it again');
|
||||||
delisted.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('f1',?,?,'action.allowed','chief','run','chief',?)",
|
delisted.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('f1',?,?,'action.allowed','chief','run','chief',?)",
|
||||||
@@ -104,14 +223,17 @@ test('C2-03b T7: a revoked, fired or held chief each refuse library-fetch-no-gra
|
|||||||
assert.deepEqual(held.choose(), REFUSED, 'held');
|
assert.deepEqual(held.choose(), REFUSED, 'held');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('C2-03b T8: a chief grant on another project ends the fallback, and two listed chiefs refuse', t => {
|
test('C2-03b T8: a chief grant on another project ends the fallback, and two listed chiefs refuse library-fetch-no-grant', t => {
|
||||||
const w = world(t);
|
const w = world(t);
|
||||||
grant(w.store, 'chief', { project: 'other-project', row: false });
|
grant(w.store, 'chief', { project: 'other-project', row: false });
|
||||||
assert.deepEqual(w.choose(), REFUSED);
|
assert.deepEqual(w.choose(), REFUSED);
|
||||||
const two = world(t);
|
const two = world(t);
|
||||||
two.business.roles.second = { definition: 'chief-of-staff', ...role };
|
|
||||||
grant(two.store, 'chief');
|
grant(two.store, 'chief');
|
||||||
assert.deepEqual(two.choose(), REFUSED, 'one chief only');
|
assert.deepEqual(two.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' });
|
||||||
|
two.business.roles.second = { definition: 'chief-of-staff', ...role };
|
||||||
|
assert.deepEqual(two.choose(), REFUSED, 'two chief-of-staff entries, both listed: one chief only');
|
||||||
|
two.business.launch = { instances: ['coder'] };
|
||||||
|
assert.deepEqual(two.choose(), REFUSED, 'neither listed');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('C2-03b departure (sage-2928): with no chief entry, any Gitea grant evidence refuses', t => {
|
test('C2-03b departure (sage-2928): with no chief entry, any Gitea grant evidence refuses', t => {
|
||||||
@@ -122,20 +244,23 @@ test('C2-03b departure (sage-2928): with no chief entry, any Gitea grant evidenc
|
|||||||
});
|
});
|
||||||
|
|
||||||
// T9 and the runtime wiring. The timer and library.sync both take the broker's choice: the chief's
|
// T9 and the runtime wiring. The timer and library.sync both take the broker's choice: the chief's
|
||||||
// store file, the slot, or a refusal that runs no git even with the slot at 0000.
|
// store file, the slot, or a refusal that runs no git even with the slot at 0000. The grant and its
|
||||||
test('C2-03b T9 runtime: the chief\'s store file and user, and a refusal that runs no git with the slot at 0000', { timeout: 30000 }, async t => {
|
// revoke run through the real paths on the runtime's own data root and store.
|
||||||
|
test('C2-03b T9 runtime: the chief\'s store file and user, then a real revoke that runs no git with the slot at 0000', { timeout: 60000 }, async t => {
|
||||||
const f = repo(t), root = f.root, dataRoot = join(root, 'data');
|
const f = repo(t), root = f.root, dataRoot = join(root, 'data');
|
||||||
mkdirSync(dataRoot, { mode: 0o700 });
|
mkdirSync(dataRoot, { mode: 0o700 });
|
||||||
|
const storeRoot = join(root, 'secrets', 'agents');
|
||||||
const rolesDir = rolesCopy(root);
|
const rolesDir = rolesCopy(root);
|
||||||
const slot = join(root, 'secrets', 'acme', library.SLOT);
|
const slot = join(root, 'secrets', 'acme', library.SLOT);
|
||||||
mkdirSync(join(root, 'secrets', 'acme'), { recursive: true, mode: 0o700 });
|
mkdirSync(join(root, 'secrets', 'acme'), { recursive: true, mode: 0o700 });
|
||||||
writeFileSync(slot, SLOT_TOKEN + '\n', { mode: 0o600 });
|
writeFileSync(slot, SLOT_TOKEN + '\n', { mode: 0o600 });
|
||||||
const chiefFile = join(root, 'secrets', 'agents', 'acme', 'chief', 'gitea');
|
const granted = await verbWorld(t, { dataRoot, storeRoot });
|
||||||
mkdirSync(join(root, 'secrets', 'agents', 'acme', 'chief'), { recursive: true, mode: 0o700 });
|
await granted.approveChief();
|
||||||
writeFileSync(chiefFile, CHIEF_TOKEN + '\n', { mode: 0o600 });
|
granted.close();
|
||||||
const seed = new Store(dataRoot);
|
const gitea = granted.gitea;
|
||||||
grant(seed, 'chief', { user: 'chief-bot' });
|
const chiefFile = join(storeRoot, 'acme', 'chief', 'gitea');
|
||||||
seed.close();
|
assert.ok(existsSync(chiefFile), 'the grant wrote the chief\'s store file');
|
||||||
|
const tokens = () => [SLOT_TOKEN, ...gitea.issued];
|
||||||
const calls = [], lines = [], write = process.stderr.write;
|
const calls = [], lines = [], write = process.stderr.write;
|
||||||
const command = (kind, url, opts) => { calls.push({ kind, opts }); return { argv: ['--version'], env: gitEnv() }; };
|
const command = (kind, url, opts) => { calls.push({ kind, opts }); return { argv: ['--version'], env: gitEnv() }; };
|
||||||
process.stderr.write = function (chunk, ...rest) {
|
process.stderr.write = function (chunk, ...rest) {
|
||||||
@@ -143,30 +268,30 @@ test('C2-03b T9 runtime: the chief\'s store file and user, and a refusal that ru
|
|||||||
return write.call(this, chunk, ...rest);
|
return write.call(this, chunk, ...rest);
|
||||||
};
|
};
|
||||||
t.after(() => { process.stderr.write = write; });
|
t.after(() => { process.stderr.write = write; });
|
||||||
const start = () => startBroker({ dataRoot, businesses: { acme: makeBusiness() }, organization: { dir: root, rolesDir, system: systemFor(root) },
|
const start = () => startBroker({ dataRoot, businesses: { acme: verbDoc(gitea.url) }, organization: { dir: root, rolesDir, system: systemFor(root) },
|
||||||
librarySync: { command, intervalMs: 50 } });
|
librarySync: { command, intervalMs: 50 } });
|
||||||
const until = async (fn) => { const end = Date.now() + 10000; while (!fn() && Date.now() < end) await new Promise(r => setTimeout(r, 10)); };
|
const untilAsync = async (fn) => { const end = Date.now() + 10000; while (!fn() && Date.now() < end) await sleep(10); };
|
||||||
|
|
||||||
let runtime = await start();
|
let runtime = await start();
|
||||||
await until(() => calls.length >= 1);
|
await untilAsync(() => calls.length >= 1);
|
||||||
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||||
await runtime.broker.request(human, { verb: 'library.sync', args: { project: 'books' } }).catch(e => e);
|
await runtime.broker.request(human, { verb: 'library.sync', args: { project: 'books' } }).catch(e => e);
|
||||||
// webby-0151 R3: library.status names the kind and user of the last fetch, never the file or value.
|
// webby-0151 R3: library.status names the kind and user of the last fetch, never the file or value.
|
||||||
const shown = await runtime.broker.request(human, { verb: 'library.status', args: { project: 'books' } });
|
const shown = await runtime.broker.request(human, { verb: 'library.status', args: { project: 'books' } });
|
||||||
assert.deepEqual(shown.credential, { kind: 'chief', user: 'chief-bot' });
|
assert.deepEqual(shown.credential, { kind: 'chief', user: 'acme-chief' });
|
||||||
assert.equal(JSON.stringify(shown).includes(chiefFile) || JSON.stringify(shown).includes(CHIEF_TOKEN), false);
|
assert.equal(JSON.stringify(shown).includes(chiefFile) || tokens().some(tok => JSON.stringify(shown).includes(tok)), false);
|
||||||
await runtime.close();
|
await runtime.close();
|
||||||
assert.ok(calls.length >= 2, 'the timer and the verb');
|
assert.ok(calls.length >= 2, 'the timer and the verb');
|
||||||
assert.ok(calls.every(c => c.kind === 'fetch' && c.opts.slot === chiefFile && c.opts.user === 'chief-bot'), 'the chief\'s file and user, never the slot');
|
assert.ok(calls.every(c => c.kind === 'fetch' && c.opts.slot === chiefFile && c.opts.user === 'acme-chief'), 'the chief\'s file and user, never the slot');
|
||||||
|
|
||||||
// Revoked: the next start refuses before git, and the log names the fix.
|
// Revoked through grants.revoke: the next start refuses before git, and the log names the fix.
|
||||||
const again = new Store(dataRoot);
|
const revoking = await verbWorld(t, { dataRoot, storeRoot, gitea });
|
||||||
revoke(again, 'chief');
|
await revoking.revoke();
|
||||||
again.close();
|
revoking.close();
|
||||||
chmodSync(slot, 0o000);
|
chmodSync(slot, 0o000);
|
||||||
calls.length = 0; lines.length = 0;
|
calls.length = 0; lines.length = 0;
|
||||||
runtime = await start();
|
runtime = await start();
|
||||||
await until(() => lines.length >= 1);
|
await untilAsync(() => lines.length >= 1);
|
||||||
const human2 = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
const human2 = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||||
await assert.rejects(runtime.broker.request(human2, { verb: 'library.sync', args: { project: 'books' } }), { code: 'library-fetch-no-grant' });
|
await assert.rejects(runtime.broker.request(human2, { verb: 'library.sync', args: { project: 'books' } }), { code: 'library-fetch-no-grant' });
|
||||||
const after = await runtime.broker.request(human2, { verb: 'library.status', args: { project: 'books' } });
|
const after = await runtime.broker.request(human2, { verb: 'library.status', args: { project: 'books' } });
|
||||||
@@ -177,8 +302,8 @@ test('C2-03b T9 runtime: the chief\'s store file and user, and a refusal that ru
|
|||||||
assert.equal(statSync(slot).mode & 0o777, 0, 'the slot was never fixed up');
|
assert.equal(statSync(slot).mode & 0o777, 0, 'the slot was never fixed up');
|
||||||
assert.deepEqual(JSON.parse(lines[0].slice('library: '.length)),
|
assert.deepEqual(JSON.parse(lines[0].slice('library: '.length)),
|
||||||
{ event: 'library-sync-held', business: 'acme', project: 'books', code: 'library-fetch-no-grant', fix: LIBRARY_FETCH_FIX });
|
{ event: 'library-sync-held', business: 'acme', project: 'books', code: 'library-fetch-no-grant', fix: LIBRARY_FETCH_FIX });
|
||||||
for (const line of lines) assert.equal(line.includes(SLOT_TOKEN) || line.includes(CHIEF_TOKEN), false);
|
for (const line of lines) assert.equal(tokens().some(tok => line.includes(tok)), false);
|
||||||
const mirror = library.mirrorPath({ business: makeBusiness(), project: 'books', dataRoot });
|
const mirror = library.mirrorPath({ business: verbDoc(gitea.url), project: 'books', dataRoot });
|
||||||
const state = await library.readSyncState(mirror, { branch: 'main' });
|
const state = await library.readSyncState(mirror, { branch: 'main' });
|
||||||
assert.equal(state.lastError.code, 'library-fetch-no-grant');
|
assert.equal(state.lastError.code, 'library-fetch-no-grant');
|
||||||
assert.equal(state.credential, null);
|
assert.equal(state.credential, null);
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ entry's `branch` as `push --porcelain --no-verify --no-follow-tags --end-of-opti
|
|||||||
| the push landed | `{ pushed: true }` |
|
| the push landed | `{ pushed: true }` |
|
||||||
| `[rejected] (fetch first)` or `(non-fast-forward)` | one fetch with the same credential, then `{ pushed: false, raced: true, tip }` |
|
| `[rejected] (fetch first)` or `(non-fast-forward)` | one fetch with the same credential, then `{ pushed: false, raced: true, tip }` |
|
||||||
| the same, with `retried: true` | `stale-base` |
|
| the same, with `retried: true` | `stale-base` |
|
||||||
|
| the same, while a sync of the mirror runs | `library-sync-busy`, with no fetch |
|
||||||
| anything else, `[remote rejected]` (a hook) included | `library-push-failed` |
|
| anything else, `[remote rejected]` (a hook) included | `library-push-failed` |
|
||||||
|
|
||||||
Push moves no local ref; C2-02 re-applies and moves `served`. git's output is never kept.
|
Push moves no local ref; C2-02 re-applies and moves `served`. git's output is never kept.
|
||||||
|
|||||||
@@ -206,7 +206,8 @@ export async function sync(context, { slot, credential = null, accept = null, co
|
|||||||
// C2-03b: push `commit`, already in the mirror, to her branch with the writer's own credential. It
|
// C2-03b: push `commit`, already in the mirror, to her branch with the writer's own credential. It
|
||||||
// resolves { pushed: true } or, when the remote tip moved, { pushed: false, raced: true, tip } after
|
// resolves { pushed: true } or, when the remote tip moved, { pushed: false, raced: true, tip } after
|
||||||
// one fetch with the same credential; the caller re-applies once and calls again with retried: true,
|
// one fetch with the same credential; the caller re-applies once and calls again with retried: true,
|
||||||
// and a second race refuses stale-base. A hook refusal ([remote rejected]) or any other failure
|
// and a second race refuses stale-base. The fetch refuses library-sync-busy while a sync of the same
|
||||||
|
// mirror runs. A hook refusal ([remote rejected]) or any other failure
|
||||||
// refuses library-push-failed, and git's output is never kept. It moves no local ref: C2-02 moves the
|
// refuses library-push-failed, and git's output is never kept. It moves no local ref: C2-02 moves the
|
||||||
// remote-tracking and served refs after a push.
|
// remote-tracking and served refs after a push.
|
||||||
export async function push(context, { commit, credential, retried = false, command = syncCommand, limits = GIT_LIMITS } = {}) {
|
export async function push(context, { commit, credential, retried = false, command = syncCommand, limits = GIT_LIMITS } = {}) {
|
||||||
@@ -230,6 +231,11 @@ export async function push(context, { commit, credential, retried = false, comma
|
|||||||
if (!line?.startsWith('!\t') || !/^\[rejected\] \((fetch first|non-fast-forward)\)$/.test(summary))
|
if (!line?.startsWith('!\t') || !/^\[rejected\] \((fetch first|non-fast-forward)\)$/.test(summary))
|
||||||
fail('library-push-failed', 'push refused');
|
fail('library-push-failed', 'push refused');
|
||||||
if (retried) fail('stale-base', 'her branch moved again during the write');
|
if (retried) fail('stale-base', 'her branch moved again during the write');
|
||||||
|
// The race fetch writes the mirror, so it takes sync's guard (dewey-0864 R1). A held guard refuses
|
||||||
|
// library-sync-busy and the caller tries the write again.
|
||||||
|
if (running.has(mirror)) fail('library-sync-busy', 'a sync of this library is running');
|
||||||
|
running.add(mirror);
|
||||||
|
try {
|
||||||
const fetch = command('fetch', mapping.remote, opts);
|
const fetch = command('fetch', mapping.remote, opts);
|
||||||
if (!(await runGit(fetch.argv, fetch.env, limits)).ok) fail('library-push-failed', 'fetch after a moved tip failed');
|
if (!(await runGit(fetch.argv, fetch.env, limits)).ok) fail('library-push-failed', 'fetch after a moved tip failed');
|
||||||
let tip;
|
let tip;
|
||||||
@@ -237,4 +243,5 @@ export async function push(context, { commit, credential, retried = false, comma
|
|||||||
catch { fail('library-push-failed', 'fetch after a moved tip found no branch'); }
|
catch { fail('library-push-failed', 'fetch after a moved tip found no branch'); }
|
||||||
if (!SHA.test(tip)) fail('library-push-failed', 'fetch after a moved tip found no branch');
|
if (!SHA.test(tip)) fail('library-push-failed', 'fetch after a moved tip found no branch');
|
||||||
return { pushed: false, raced: true, tip };
|
return { pushed: false, raced: true, tip };
|
||||||
|
} finally { running.delete(mirror); }
|
||||||
}
|
}
|
||||||
@@ -370,6 +370,28 @@ test('C2-03b T2 and T3: a moved tip reports the race after one fetch, and a seco
|
|||||||
assert.equal(w.remoteTip(), again);
|
assert.equal(w.remoteTip(), again);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// dewey-0864 R1: the race fetch takes sync's guard. A sync holds it (its fetch is a 3 s sleep, started
|
||||||
|
// only once the guard is taken), so the push's fetch refuses busy and the mirror's ref stays put.
|
||||||
|
test('C2-03b race fetch: while a sync of the mirror runs, the race refuses library-sync-busy', async t => {
|
||||||
|
const w = await writeWorld(t);
|
||||||
|
const moved = w.push({ 'Inbox Note.md': 'she wrote more\n' });
|
||||||
|
const before = git(w.mirror(), 'rev-parse', 'refs/remotes/library/main');
|
||||||
|
let inside;
|
||||||
|
const held = new Promise(resolve => { inside = resolve; });
|
||||||
|
const slow = (kind, url, opts) => {
|
||||||
|
const real = w.command(kind, url, opts);
|
||||||
|
inside();
|
||||||
|
return { argv: ['-c', 'alias.hold=!sleep 3', 'hold'], env: real.env };
|
||||||
|
};
|
||||||
|
const syncing = sync(w.context, { slot: w.slot, command: slow });
|
||||||
|
await held;
|
||||||
|
await assert.rejects(w.write({ commit: w.child(w.commit) }), { code: 'library-sync-busy' });
|
||||||
|
assert.equal(w.remoteTip(), moved, 'her commit stands');
|
||||||
|
await syncing.catch(() => {});
|
||||||
|
assert.equal(git(w.mirror(), 'rev-parse', 'refs/remotes/library/main'), before, 'the refused race fetched nothing');
|
||||||
|
assert.deepEqual(await w.write({ commit: w.child(w.commit) }), { pushed: false, raced: true, tip: moved }, 'free again afterwards');
|
||||||
|
});
|
||||||
|
|
||||||
test('C2-03b T4: a dead remote, a hook refusal and a wrong token each refuse library-push-failed', async t => {
|
test('C2-03b T4: a dead remote, a hook refusal and a wrong token each refuse library-push-failed', async t => {
|
||||||
const w = await writeWorld(t);
|
const w = await writeWorld(t);
|
||||||
// N2: [remote rejected] is a hook, not a race.
|
// N2: [remote rejected] is a hook, not a race.
|
||||||
|
|||||||
Reference in new issue
Block a user