Files
stack/packages/bus/tests/credential-claim.test.mjs
jason.woltje b1fece6f0a row 235: machine grant row b, raise binding, executor, reconcile, revoke and sweep, creative roles refused at raise, recheck and host.run, N10 (#1741)
Lands Gizmoduck's round 1 candidate, ed4193316 on gizmoduck/row235-e (one commit on 62ddc96cc), approved
by Duckworth (duckworth-0089, REVIEW-r1 d9ecec514 on duckworth/reviews, #1741 comment 30022). 13 paths,
manifest dad377ca8fe4. No 235 path changed between 62ddc96cc and this commit's parent. Patch-id
c355d2fc457d, the same as 62ddc96cc..ed4193316. broker.mjs keeps row 213's import and decision.raise
creative refusal as landed.

Creative roles (mos-1163, mos-1164, sage-1908): #hostDefinition refuses host-creative-role at the raise
and at the step 1 recheck, and #hostAccess refuses it at host.run before reading the grant record, which
stays for hosts.revoke. Duckworth checked from the code that a creative role can't raise, approve or
spend a host grant, and ruled the upsert mutant equivalent.

Evidence: Gizmoduck's runs on my slot. B1-B37 on 5df4b52c5, and run 2 on 534f2254a (10:12:52-10:14:51Z):
grant-host 9/9, grant-executor 37/37 and grant-enroll 24/24, each run whole, with B16, B23, B27, B38 and
B39 killed. B1-B39: 38 killed, and B17 is equivalent (sage-1904).
Gate on my slot, 10:30:55-10:42:43Z on 29ce1e360 plus the 13 paths: bus 596/0 (its first whole run on
this head, duckworth-0089 N1), business 200/0, cli 666/0, and every scripts/test-*.sh green (auth 15,
conductor 17, config 31, discord 78, extension-package 18, foundation 44, land-work 86, queue 156 and 28,
release 14, task 98, all 0 failed).
2026-10-21 05:42:52 -05:00

153 lines
6.9 KiB
JavaScript

// Row 235 (#1741, #436 row b, r6 X4, X5): the grant executor's Credentials calls. register and
// unregister add and drop one store reference in the running bus, and claim, release and abortClaim
// hold the (agent, host.<id>) slot that host.run and revoke share. Keys are made by ssh-keygen in a
// scratch root, and nothing connects to any host.
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Credentials } from '../src/credentials.mjs';
const AGENT = 'demo/ops';
const SERVICE = 'host.web1';
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-claim-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const storeRoot = join(root, 'secrets', 'agents');
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
chmodSync(join(root, 'secrets'), 0o700);
const file = join(storeRoot, 'demo', 'ops', SERVICE);
const ref = { service: SERVICE, store: true, file, rotateBy: '2099-01-01' };
const key = (name) => {
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'test', '-f', join(root, name)]);
return readFileSync(join(root, name), 'utf8');
};
const c = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
t.after(() => c.close());
const state = () => c.status().find((e) => e.instance === AGENT && e.service === SERVICE)?.state;
const run = () => c.use(AGENT, SERVICE, async () => ({ ok: true }));
return { root, storeRoot, file, ref, key, c, state, run };
}
test('X4: register adds a store reference to the running bus, and issue then writes the key', async (t) => {
const s = setup(t);
assert.throws(() => s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01'), /credential-unknown/);
s.c.register(AGENT, SERVICE, s.ref);
assert.equal(s.state(), 'not-issued');
await assert.rejects(s.run(), /credential-unavailable/);
assert.equal(s.c.issue(AGENT, SERVICE, s.key('k2'), '2099-01-01').state, 'valid');
assert.deepEqual(await s.run(), { ok: true });
});
test('X4: register runs the constructor\'s reference checks', (t) => {
const s = setup(t);
const other = join(s.storeRoot, 'demo', 'pm', SERVICE);
for (const [ref, code] of [
[null, /credential-reference/],
[{ ...s.ref, rotateBy: '2099-02-30' }, /credential-date/],
[{ ...s.ref, file: other }, /credential-reference/],
[{ ...s.ref, extra: 1 }, /credential-reference/],
[{ ...s.ref, service: 'host.web2' }, /credential-reference/],
[{ ...s.ref, env: 'X' }, /credential-reference/],
])
assert.throws(() => s.c.register(AGENT, SERVICE, ref), code);
assert.throws(() => s.c.register(AGENT, 'nope', s.ref), /credential-reference/);
assert.throws(() => s.c.register('demo/@tester', SERVICE, { ...s.ref, file: join(s.storeRoot, 'demo', '@tester', SERVICE) }), /credential-reference/);
assert.equal(s.state(), undefined);
});
test('X4: register reads no file, so a key file left at the path stays unused, and a live token refuses', async (t) => {
const s = setup(t);
mkdirSync(join(s.storeRoot, 'demo', 'ops'), { recursive: true, mode: 0o700 });
writeFileSync(s.file, s.key('left'), { mode: 0o600 });
s.c.register(AGENT, SERVICE, s.ref);
assert.equal(s.state(), 'not-issued');
await assert.rejects(s.run(), /credential-unavailable/);
s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01');
assert.throws(() => s.c.register(AGENT, SERVICE, s.ref), /credential-reference/);
assert.equal(s.state(), 'valid');
// A revoked entry has no token, so a new grant registers again.
s.c.revoke(AGENT, SERVICE);
assert.equal(s.state(), 'revoked');
s.c.register(AGENT, SERVICE, s.ref);
assert.equal(s.state(), 'not-issued');
});
test('X3, X4: unregister drops an entry with no token and refuses one with a live token', (t) => {
const s = setup(t);
s.c.unregister(AGENT, SERVICE);
s.c.register(AGENT, SERVICE, s.ref);
s.c.unregister(AGENT, SERVICE);
assert.equal(s.state(), undefined);
assert.throws(() => s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01'), /credential-unknown/);
s.c.register(AGENT, SERVICE, s.ref);
s.c.issue(AGENT, SERVICE, s.key('k2'), '2099-01-01');
assert.throws(() => s.c.unregister(AGENT, SERVICE), /credential-reference/);
assert.equal(s.state(), 'valid');
s.c.revoke(AGENT, SERVICE);
assert.equal(existsSync(s.file), false);
s.c.unregister(AGENT, SERVICE);
assert.equal(s.state(), undefined);
});
test('X5: a claim holds the slot, so a second claim and a host.run refuse host-busy until release', async (t) => {
const s = setup(t);
s.c.register(AGENT, SERVICE, s.ref);
s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01');
const signal = s.c.claim(AGENT, SERVICE);
assert.equal(signal.aborted, false);
assert.equal(s.c.busy(AGENT, SERVICE), true);
assert.throws(() => s.c.claim(AGENT, SERVICE), /host-busy/);
await assert.rejects(s.run(), /host-busy/);
// A claim needs no reference: the executor claims before it registers a new key.
const other = s.c.claim('demo/pm', SERVICE);
s.c.release('demo/pm', SERVICE, other);
s.c.release(AGENT, SERVICE, signal);
assert.equal(s.c.busy(AGENT, SERVICE), false);
assert.deepEqual(await s.run(), { ok: true });
});
test('X5: a claim made while a host.run is in flight refuses host-busy', async (t) => {
const s = setup(t);
s.c.register(AGENT, SERVICE, s.ref);
s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01');
let finish;
const running = s.c.use(AGENT, SERVICE, () => new Promise((r) => (finish = r)));
assert.throws(() => s.c.claim(AGENT, SERVICE), /host-busy/);
finish({ ok: true });
assert.deepEqual(await running, { ok: true });
s.c.release(AGENT, SERVICE, s.c.claim(AGENT, SERVICE));
});
test('X5: a stale release leaves a later claim in place', (t) => {
const s = setup(t);
const first = s.c.claim(AGENT, SERVICE);
s.c.release(AGENT, SERVICE, first);
const second = s.c.claim(AGENT, SERVICE);
s.c.release(AGENT, SERVICE, first);
s.c.release(AGENT, SERVICE);
assert.equal(s.c.busy(AGENT, SERVICE), true);
s.c.release(AGENT, SERVICE, second);
assert.equal(s.c.busy(AGENT, SERVICE), false);
});
test('X5: abortClaim aborts a live claim and says pending, and revoke\'s abort reaches a claim too', (t) => {
const s = setup(t);
assert.deepEqual(s.c.abortClaim(AGENT, SERVICE), { pending: false });
const signal = s.c.claim(AGENT, SERVICE);
assert.deepEqual(s.c.abortClaim(AGENT, SERVICE), { pending: true });
assert.equal(signal.aborted, true);
// The slot stays held until the executor releases it.
assert.equal(s.c.busy(AGENT, SERVICE), true);
s.c.release(AGENT, SERVICE, signal);
s.c.register(AGENT, SERVICE, s.ref);
s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01');
const live = s.c.claim(AGENT, SERVICE);
s.c.revoke(AGENT, SERVICE);
assert.equal(live.aborted, true);
s.c.release(AGENT, SERVICE, live);
});