feat(bus,webui): bus read of credential status; the Console shows it (#1549, row 60)

The credentials read returns service, account, role, date kind, date and
state for the capability's business, to human and reader sessions only.
It never returns a token, file path or variable name. Author Filbert,
reviewers Dewey (27558) and Darkwing (27575), candidate manifest 13541657.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-11 19:37:06 -05:00
co-authored by Claude Opus 5.5
parent 6a0d43854a
commit 1771bf98b2
9 changed files with 313 additions and 15 deletions
+27 -1
View File
@@ -127,6 +127,8 @@ an operator inspect content and delivery evidence after an uncertain result.
| `launch.revoke`, `launch.restore` | No args; outside-agent human CLI only. |
| `inbox`, `tasks`, `agents` | No args; plain JSON projections scoped to capability's business. |
| `trail` | `{subject}`: task ref, decision UUID or message UUID. Linked records in broker write order. |
| `decisions` | `{list}`: `roles` or `recent`; human and reader sessions only. Open decisions routed to a role, or the last 50 closed. |
| `credentials` | No args; human and reader sessions only. `[{service,account,role,dateKind,date,state}]` for the capability's business, never a token, file path or variable name. |
Unknown keys, roles, references, actions and verbs refuse. If writing refusal
evidence fails, the caller receives the static `storage-unavailable` code. `human` is the reserved
@@ -196,7 +198,7 @@ must not invoke the human CLI or work around its proof.
Human resolutions and launch toggles append `human.input`. Agent capabilities
and read-only WebUI capabilities cannot reach these operations. Reader
capabilities expose the human inbox plus tasks/agents/trail/decisions for one
capabilities expose the human inbox plus tasks/agents/trail/decisions/credentials for one
business; they do not confer mutation or resolution authority.
`decisions` (row 57, #1546) is for human and reader sessions only; a role
@@ -208,6 +210,30 @@ each with `closed`, the closing `decision_events` row. Every row is the
decision as `trail` gives the same session for its id, so the lists add no
data a reader couldn't already read one id at a time; they add the listing.
`credentials` (row 60, #1549) is also for human and reader sessions only; a
role session gets `human-required`. It takes no args. Each row is one
credential the running host loaded for the session's business, in load order
(role instances, then the tracker sync identity):
- `service`: `gitea` or `vikunja`.
- `account`: the role's `tracker.bot` for Vikunja, the sync identity's `bot`
for its row, `null` for Gitea.
- `role`: the instance, or `tracker sync` for `business/@sync`.
- `dateKind` and `date`: `rotateBy` for Gitea, `expires` for Vikunja.
- `state`: `credentials.status()` against the host's clock at the request:
`valid`, `expiring` (under 7 days), `expired`, or `rotation-due` for Gitea.
The broker gets only that metadata function from the runtime, never
`credentials.use`, and the result passes the known-token check like every
other read. The rows match the Business view's credentials in the WebUI
(`packages/webui/src/reads.mjs`), which reads the business file instead: the
file says what the host should load, this read says what it did load. The
host takes the dates and accounts from the business file at startup, and
only the state moves with the clock. So the two differ only when the
business file changes after startup: a date, an account, or a credential
added or removed. Rotating a token file without editing its date changes
neither.
## Shared reads and adapter boundary
```js
+27 -2
View File
@@ -86,12 +86,15 @@ export class Broker {
#runs = new Map();
#ended = new Set();
#secretCheck;
#credentialStatus;
#clock = 0;
constructor({ store, businesses, secretCheck = () => {} }) {
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [] }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
this.#businesses = structuredClone(businesses);
this.#store = store;
this.#secretCheck = secretCheck;
this.#credentialStatus = credentialStatus;
for (const table of [
'events',
'role_claims',
@@ -489,7 +492,7 @@ export class Broker {
if (!object(args)) fail('invalid-request');
this.#secretCheck(args);
return this.#store.transaction(() => {
if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions'].includes(request.verb))
if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials'].includes(request.verb))
fail('read-only');
const result = this.#dispatch(s, request.verb, args);
this.#secretCheck(result);
@@ -875,6 +878,28 @@ export class Broker {
.map((d) => ({ ...this.#decisionView(d), closed: this.#closed(d.id) }));
fail('invalid-request');
}
// Row 60 (#1549): what the running host loaded, checked against its clock, for the session's
// business. Service, account, role, date and state only; no token, file path or variable name.
case 'credentials': {
if (!s.human && !s.reader) fail('human-required');
keys(a, [], []);
const prefix = s.business + '/';
return this.#credentialStatus()
.filter((c) => c.instance.startsWith(prefix))
.map(({ instance, service, date, state }) => {
const role = instance.slice(prefix.length),
sync = role === '@sync',
bot = sync ? b.tracker?.sync?.bot : service === 'vikunja' ? b.roles?.[role]?.tracker?.bot : null;
return {
service,
account: bot ?? null,
role: sync ? 'tracker sync' : role,
dateKind: service === 'gitea' ? 'rotateBy' : 'expires',
date,
state,
};
});
}
default:
fail('unknown-verb');
}
+6 -1
View File
@@ -31,7 +31,12 @@ export async function startBroker({
];
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot });
store = new Store(dataRoot);
const broker = new Broker({ store, businesses, secretCheck: (value) => credentials.assertClean(value) });
const broker = new Broker({
store,
businesses,
secretCheck: (value) => credentials.assertClean(value),
credentialStatus: () => credentials.status(),
});
// Process identities must be supplied for every managed run when using the human transport.
const launchRecords = [];
function bindLaunch(r) {
+1
View File
@@ -6,5 +6,6 @@ export function views(client) {
agents: () => client.call('agents'),
trail: (subject) => client.call('trail', { subject }),
decisions: (list) => client.call('decisions', { list }),
credentials: () => client.call('credentials'),
});
}
@@ -0,0 +1,212 @@
// Row 60 (#1549): `credentials`, the broker's credential status read. Human and reader
// sessions read service, account, role, date and state for their own business; a role
// session can't. No token, file path or variable name reaches the output or the host log.
import test from 'node:test';
import assert from 'node:assert/strict';
import { fork } from 'node:child_process';
import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { once } from 'node:events';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { Credentials } from '../src/credentials.mjs';
import { Client } from '../src/client.mjs';
const day = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
const authority = { withinRole: ['message.send'], crossRole: [] };
const TOKENS = {
pmGitea: 'fixture_pm_gitea_0123456789',
pmVikunja: 'fixture_pm_vikunja_0123456789',
coderGitea: 'fixture_coder_gitea_0123456789',
coderVikunja: 'fixture_coder_vikunja_0123456789',
sync: 'fixture_sync_vikunja_0123456789',
other: 'fixture_other_gitea_0123456789',
};
const ENV = 'FIXTURE_ROW60_PM_VIKUNJA';
// Two businesses, `demo` and `demox`: an instance prefix without its slash would leak one into the other.
function seed(root) {
const secrets = join(root, 'secrets');
mkdirSync(secrets);
const file = (name) => {
const path = join(secrets, name);
writeFileSync(path, TOKENS[name] + '\n', { mode: 0o600 });
return path;
};
const paths = { pmGitea: file('pmGitea'), coderGitea: file('coderGitea'), coderVikunja: file('coderVikunja') };
paths.sync = file('sync');
paths.other = file('other');
const businesses = {
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'cto', delivery: 'pm' },
roles: {
pm: {
authority,
tracker: { bot: 'bot-pm' },
credentials: {
gitea: { file: paths.pmGitea, rotateBy: '2099-01-01' },
vikunja: { env: ENV, expires: day(3) },
},
},
coder: {
authority,
tracker: { bot: 'bot-coder' },
credentials: {
gitea: { file: paths.coderGitea, rotateBy: '2000-01-01' },
vikunja: { file: paths.coderVikunja, expires: '2000-01-01' },
},
},
cto: { authority },
},
tracker: { sync: { bot: 'bot-sync', botId: 7, credentials: { vikunja: { file: paths.sync, expires: '2099-01-01' } } } },
},
demox: {
id: 'demox',
human: 'jason',
arbiters: { technical: 'cto', delivery: 'cto' },
roles: { cto: { authority, credentials: { gitea: { file: paths.other, rotateBy: '2099-01-01' } } } },
},
};
return { businesses, paths, secrets, env: { [ENV]: TOKENS.pmVikunja } };
}
// What the runtime builds: one reference per business/instance, plus business/@sync.
function references(businesses) {
const out = {};
for (const [business, b] of Object.entries(businesses)) {
for (const [role, r] of Object.entries(b.roles)) if (r.credentials) out[business + '/' + role] = r.credentials;
if (b.tracker?.sync?.credentials) out[business + '/@sync'] = b.tracker.sync.credentials;
}
return out;
}
const DEMO = [
{ service: 'gitea', account: null, role: 'pm', dateKind: 'rotateBy', date: '2099-01-01', state: 'valid' },
{ service: 'vikunja', account: 'bot-pm', role: 'pm', dateKind: 'expires', date: day(3), state: 'expiring' },
{ service: 'gitea', account: null, role: 'coder', dateKind: 'rotateBy', date: '2000-01-01', state: 'rotation-due' },
{ service: 'vikunja', account: 'bot-coder', role: 'coder', dateKind: 'expires', date: '2000-01-01', state: 'expired' },
{ service: 'vikunja', account: 'bot-sync', role: 'tracker sync', dateKind: 'expires', date: '2099-01-01', state: 'valid' },
];
// Nothing that names or holds a credential: the tokens, the token files and their directory, and the env name.
const forbidden = (secrets, paths) => [...Object.values(TOKENS), ...Object.values(paths), secrets, ENV];
const clean = (text, words) => words.filter((w) => text.includes(w));
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-cred-status-'));
const { businesses, paths, secrets, env } = seed(root);
const credentials = new Credentials({ references: references(businesses), repoRoots: [], dataRoot: '/nonexistent-data', env });
mkdirSync(join(root, 'data'));
const store = new Store(join(root, 'data'));
const b = new Broker({
store,
businesses,
secretCheck: (value) => credentials.assertClean(value),
credentialStatus: () => credentials.status(),
});
t.after(() => {
store.close();
credentials.close();
rmSync(root, { recursive: true, force: true });
});
return { b, secrets, paths };
}
test('credentials gives human and reader sessions service, account, role, date and state, and nothing else', (t) => {
const { b, secrets, paths } = setup(t);
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const reader = b.bindReader({ business: 'demo' });
for (const cap of [human, reader]) {
const rows = b.request(cap, { verb: 'credentials' });
assert.deepEqual(rows, DEMO);
for (const row of rows) assert.deepEqual(Object.keys(row), ['service', 'account', 'role', 'dateKind', 'date', 'state']);
assert.deepEqual(clean(JSON.stringify(rows), forbidden(secrets, paths)), []);
}
assert.deepEqual(b.request(b.bindReader({ business: 'demox' }), { verb: 'credentials' }), [
{ service: 'gitea', account: null, role: 'cto', dateKind: 'rotateBy', date: '2099-01-01', state: 'valid' },
]);
});
test('credentials refuses a role session and any argument', (t) => {
const { b } = setup(t);
const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
b.request(coder, { verb: 'role.claim' });
assert.throws(() => b.request(coder, { verb: 'credentials' }), { code: 'human-required' });
const reader = b.bindReader({ business: 'demo' });
assert.throws(() => b.request(reader, { verb: 'credentials', args: { role: 'coder' } }), { code: 'invalid-request' });
assert.throws(() => b.request(reader, { verb: 'credentials', args: { business: 'demox' } }), { code: 'invalid-request' });
});
test('credentials reads the status on each request, and a broker without one lists none', (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-cred-live-'));
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'cto' }, roles: { cto: { authority } } } };
const stores = ['a', 'b'].map((d) => (mkdirSync(join(root, d)), new Store(join(root, d))));
t.after(() => {
for (const s of stores) s.close();
rmSync(root, { recursive: true, force: true });
});
let state = 'valid';
const b = new Broker({
store: stores[0],
businesses,
credentialStatus: () => [{ instance: 'demo/cto', service: 'gitea', date: '2099-01-01', state }],
});
const reader = b.bindReader({ business: 'demo' });
assert.equal(b.request(reader, { verb: 'credentials' })[0].state, 'valid');
state = 'rotation-due';
assert.equal(b.request(reader, { verb: 'credentials' })[0].state, 'rotation-due');
const bare = new Broker({ store: stores[1], businesses });
assert.deepEqual(bare.request(bare.bindReader({ business: 'demo' }), { verb: 'credentials' }), []);
});
// The brief's acceptance: the broker process on a seeded config holding a file token, an env
// value and their paths and names. The op's output, the host log and the store hold none of them.
test('the broker process answers credentials without a token, path or variable in its output, log or store', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-cred-process-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const { businesses, paths, secrets, env } = seed(root);
const data = join(root, 'data');
mkdirSync(data);
const child = fork(new URL('../src/process.mjs', import.meta.url), [], {
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
env: { ...process.env, ...env },
});
let logs = '';
child.stdout.on('data', (b) => (logs += b));
child.stderr.on('data', (b) => (logs += b));
t.after(() => {
if (child.exitCode === null) child.kill('SIGKILL');
});
const reply = once(child, 'message');
child.send({
op: 'boot',
config: {
dataRoot: data,
businesses,
launches: [{ business: 'demo', role: 'coder', run: 'r1', harness: 'pi', pid: process.pid, startTime: '1' }],
readers: ['demo'],
},
});
const ready = (await reply)[0];
assert.equal(ready.ok, true);
const reader = new Client({ path: ready.path, cap: ready.readers[0].cap });
const rows = await reader.call('credentials');
assert.deepEqual(rows, DEMO);
const agent = new Client({ path: ready.path, cap: ready.launches[0].cap });
await agent.call('role.claim');
await assert.rejects(agent.call('credentials'), /human-required/);
const exit = once(child, 'exit');
child.send({ op: 'close' });
assert.equal((await exit)[0], 0);
const words = forbidden(secrets, paths);
assert.deepEqual(clean(JSON.stringify(rows), words), []);
assert.deepEqual(clean(logs, words), []);
const bus = join(data, 'bus');
const stored = readdirSync(bus)
.filter((f) => f.startsWith('bus.sqlite'))
.map((f) => readFileSync(join(bus, f), 'latin1'))
.join('');
assert.ok(stored.includes('human-required'), 'the refusal is in the store');
assert.deepEqual(clean(stored, words), []);
});