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:
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user