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), []);
});
+8 -4
View File
@@ -264,19 +264,23 @@ session reader or transport is implemented here. The session reader is
`packages/conversation`, served by the board.
GET `/api/bus/inbox`, `/api/bus/tasks`, `/api/bus/agents`,
`/api/bus/trail?subject=<id>` and `/api/bus/decisions?list=roles|recent` are
the bus reads (`src/bus.mjs`). Any other method is 405. The subject must match
`/api/bus/trail?subject=<id>`, `/api/bus/decisions?list=roles|recent` and
`/api/bus/credentials` are the bus reads (`src/bus.mjs`). Any other method is 405. The subject must match
the broker's identifier rule, and the list must be `roles` or `recent`, else
400. `decisions` (row 57, #1546) is for the Inbox tabs "Routed to roles" and
"Recent"; "Human and reader paths" in `packages/bus/README.md` has what each
list holds.
list holds. `credentials` (row 60, #1549) is the bus host's credential
status: service, account, role, date and state for each credential it
loaded, never a token, file path or variable name. No page uses it yet; the
Business view still shows the business file's metadata.
Each read runs the S4 human transport (`packages/bus/src/human-cli.mjs`) as
a child process, so a slow read doesn't stall the server. The bus checks
that child's ancestry, which ends at the Console process; it never sees the
browser or any other client of the port. So while the Console runs,
anything that can connect to its port (7330 by default) reads what a reader
capability reads ("Human and reader paths" in `packages/bus/README.md`):
the inbox, tasks, agents, trails and the two decision lists. That includes
the inbox, tasks, agents, trails, the two decision lists and the credential
status. That includes
a T3 seat using `curl` and a managed S6 session, since S6 doesn't confine
the network. No route
writes (Q4), so the exposure is reads only. Slice 1 doesn't change this
+4 -4
View File
@@ -1,10 +1,10 @@
// Slice 1 S5 (#1522): the Console's read of the bus. It runs the S4 human
// transport (packages/bus/src/human-cli.mjs, lead decision 70) as a child
// with spawn, not spawnSync, so one slow read never stalls the HTTP server.
// Only the four verbs of the Q1 module (lead decision 56) and row 57's
// `decisions` read are reachable: the WebUI writes nothing, not even seen
// (Q4). The bus does the proof; a
// server started inside an agent run gets `human-required` from the broker.
// Only the four verbs of the Q1 module (lead decision 56), row 57's
// `decisions` and row 60's `credentials` reads are reachable: the WebUI
// writes nothing, not even seen (Q4). The bus does the proof; a server
// started inside an agent run gets `human-required` from the broker.
import { spawn } from 'node:child_process';
import { HUMAN_CLI } from '../../cli/src/transport.mjs';
+3 -2
View File
@@ -46,8 +46,9 @@ async function body(req) {
return bytes;
}
// Slice 1 S5 (#1522): GET only, the four Q1 verbs, nothing else (lead decision 56, Q4).
// Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs.
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions'];
// Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs. Row 60
// (#1549) adds `credentials`: what the bus host loaded, metadata only.
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials'];
const DECISION_LISTS = ['roles', 'recent'];
async function busRead(res, bus, verb, search) {
let arg;
+25 -1
View File
@@ -126,15 +126,38 @@ test('decisions route serves the roles and recent lists from the reader view', a
assert.equal(calls.length, before);
});
// Row 60 (#1549): what the bus host loaded, metadata only, for the reader's business.
test('credentials route serves the broker credential status from the reader view', async t => {
const store = new Store(tmp(t, 'webui-bus-credentials-'));
t.after(() => store.close());
const status = [
{ instance: 'demo/coder', service: 'gitea', date: '2000-01-01', state: 'rotation-due' },
{ instance: 'other/coder', service: 'gitea', date: '2099-01-01', state: 'valid' },
];
const b = new Broker({ store, businesses, credentialStatus: () => status });
const reader = b.bindReader({ business: 'demo' });
const calls = [];
const bus = views({ call: async (verb, args = {}) => { calls.push([verb, args]); return structuredClone(b.request(reader, { verb, args })); } });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
t.after(() => close(web));
const r = await fetch(`http://127.0.0.1:${web.address().port}/api/bus/credentials`);
assert.equal(r.status, 200); assert.equal(r.headers.get('cache-control'), 'no-store');
const body = await r.json();
assert.match(body.at, /^\d{4}-\d\d-\d\dT/);
assert.deepEqual(body.rows, [{ service: 'gitea', account: null, role: 'coder', dateKind: 'rotateBy', date: '2000-01-01', state: 'rotation-due' }]);
assert.deepEqual(calls, [['credentials', {}]]);
});
test('bus routes are GET-only, have no write verb, and keep the same-origin checks', async t => {
const calls = [];
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => { calls.push(v); return []; }]));
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials'].map(v => [v, async () => { calls.push(v); return []; }]));
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
t.after(() => close(web));
const base = `http://127.0.0.1:${web.address().port}`;
for (const method of ['POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS']) {
assert.equal((await fetch(base + '/api/bus/inbox', { method })).status, 405, method);
assert.equal((await fetch(base + '/api/bus/decisions?list=roles', { method })).status, 405, method);
assert.equal((await fetch(base + '/api/bus/credentials', { method })).status, 405, method);
}
for (const path of ['/api/bus/decide', '/api/bus/seen', '/api/bus/', '/api/bus/inbox/x', '/api/bus/request', '/api/bus/raise']) {
assert.equal((await fetch(base + path)).status, 404, path);
@@ -219,6 +242,7 @@ test('busReader takes --business, else the live bus host, else refuses', async t
const pinned = busReader({ dataRoot, socket: 'sock', business: 'pinned', cli });
assert.equal((await pinned.inbox()).request.business, 'pinned');
assert.deepEqual((await pinned.decisions('recent')).request, { business: 'pinned', verb: 'decisions', args: { list: 'recent' } });
assert.deepEqual((await pinned.credentials()).request, { business: 'pinned', verb: 'credentials', args: {} });
const reader = busReader({ dataRoot, socket: 'sock', cli });
const code = () => reader.agents().then(() => 'resolved', e => e.code);