review(filbert): row 60 round 1 packet, broker credential status read (#1549)

Manifest pins 9 files on base 4100fa68; candidate source is not committed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-11 16:57:51 -05:00
co-authored by Claude Opus 5.5
parent c949f9ef71
commit e7e6950819
3 changed files with 647 additions and 0 deletions
+165
View File
@@ -0,0 +1,165 @@
# Row 60 (#1549): bus read, credential status on the broker, round 1
Filbert, 2026-10-11. Brief: `docs/plans/2026-10-10_design-implementation.md`,
"Bus read: credential status on the broker" (blob a360554c). Darkwing and
Dewey review. Sage commits after the gate; nothing is committed, staged or
pushed yet.
Base 4100fa68. `build.patch` applies there with `git apply --index`;
`candidate-manifest.sha256` pins all 9 files after the patch. HEAD is now
c949f9ef, with docs and queue commits only; no file under `packages/`
changed. I applied the patch there and all 9 manifest lines check OK.
## What the read is
A new socket verb, `credentials`. No args. Human and reader sessions only; a
role session gets `human-required`, as with `decisions`. It returns one row
per credential the running host loaded for the session's business:
```
{ service, account, role, dateKind, date, state }
```
- `service`: `gitea` or `vikunja`.
- `account`: the role's `tracker.bot` for Vikunja, the sync identity's `bot`
for the `business/@sync` row, `null` for Gitea (the business file names no
Gitea account).
- `role`: the instance, or `tracker sync` for `@sync`.
- `dateKind`, `date`: `rotateBy` for Gitea, `expires` for Vikunja.
- `state`: `Credentials.status()` against the host clock at the request:
`valid`, `expiring` (under 7 days), `expired`, or `rotation-due` for Gitea.
The order is `status()` load order: role instances, then the sync identity.
The row shape is the one the WebUI's Business view already uses
(`credential()` in `packages/webui/src/reads.mjs`), so a page can use either.
The difference is the source. The Business view reads the business file: what
the host should load. This read asks the host: what it did load, checked
against its own clock. A token file rotated after startup isn't reread until
restart, and this read is how to see what the host is running with.
## How the broker gets it
The broker gets one new constructor option, `credentialStatus`, a function.
The runtime passes `() => credentials.status()`. The broker never gets the
`Credentials` object, so it can't reach `use()`. The default is `() => []`,
so a broker built without it (every existing test) lists none.
`status()` already returns metadata only (`instance`, `service`, `date`,
`state`). The broker filters it to `business + '/'`, drops `instance`, and
adds `account`, `role` and `dateKind` from the business config. The result
also goes through the known-token check every read goes through.
The existing trusted launch op `credentialStatus(business, role)` in the
runtime is unchanged.
## WebUI
GET `/api/bus/credentials` is a new bus route: `credentials` joins
`BUS_VERBS` in `serve.mjs`, and `views.mjs` gains `credentials()`. It runs
through the same human transport as the other bus reads, so the same 403,
503 and 502 mapping applies. GET only.
No page uses it yet. The brief names the endpoint and its tests, not a view
change. The Business view still shows the file's metadata. See question 1.
## Files
- `packages/bus/src/broker.mjs`: the `credentialStatus` option, the
`credentials` case, and `credentials` in the reader allowlist.
- `packages/bus/src/runtime.mjs`: passes `credentialStatus`.
- `packages/bus/src/views.mjs`: `credentials()`.
- `packages/bus/tests/credential-status.test.mjs` (new, 4 tests):
- Human and reader sessions get the exact rows and keys for `demo`, with
every state (valid, expiring, expired, rotation-due), a file ref and an
env ref, and the sync row. A second business, `demox`, shares the
`demo` prefix without the slash and gets only its own row.
- A role session gets `human-required`; any argument is
`invalid-request`.
- The status is read on each request (a changed state shows on the next
one); a broker without the option lists none.
- The brief's acceptance test: the real broker process (`process.mjs`,
forked) boots on a seeded config with five token files and one env
token. A reader calls `credentials`, a role session is refused, and the
process closes. The rows, the host log (stdout and stderr) and the store
files (`bus.sqlite*`) contain none of the six tokens, the token file
paths, their directory, or the env variable name. The store check also
confirms the refusal was written, so it read the right files.
- `packages/bus/README.md`: a verb table row and a `credentials` paragraph
under "Human and reader paths".
- `packages/webui/src/serve.mjs`, `packages/webui/src/bus.mjs`: the verb and
comments.
- `packages/webui/tests/bus.test.mjs`: the route returns the reader's rows
only (one business of two), `{ rows, at }`, `no-store`; the route is
GET-only; `busReader` sends `{verb:'credentials', args:{}}`.
- `packages/webui/README.md`: the route, and the exposure note now names
the credential status.
## Mutants
16 mutants (`run.mjs`, kept in my scratch). Each edits one file, runs the
tests that own it, and restores the file:
- broker, runtime: the bus suite;
- views, serve: `packages/webui/tests/bus.test.mjs`.
All 16 are killed, and the diff is unchanged afterwards:
- broker, 12: role session allowed, reader refused, prefix without the
slash, no business filter, arguments accepted, a Gitea account from
`tracker.bot`, the sync account `null`, `@sync` as the role, a fixed
`dateKind`, `instance` left in the row, a fixed state, the status read
once at construction.
- runtime, 1: no `credentialStatus` passed (the process test).
- views, 2: the wrong verb, an extra argument.
- serve, 1: `credentials` missing from `BUS_VERBS`.
## Gate (my run on the candidate)
Worktree at 4100fa68 with the patch, run in sequence with `TMPDIR` in my
scratch and `DOCKER_HOST` pointed at a missing socket:
| Suite | Result |
|---|---|
| packages/bus | 84/0 (80 before; 4 new) |
| packages/control-board | 124/0 |
| packages/webui | 41/0 (40 before; 1 new), the browser tests included |
| packages/cli | 112/0, since it starts the runtime |
| scripts/test-*.sh | all nine exit 0: auth 15, conductor 17, config 24, discord 66 (see below), extension-package 18, foundation 44, queue 27, release 4, task 26 |
`test-discord` failed once in the sequential run, 65/1: the engine test
"when pi has not started a timed-out turn by the end of the abort grace…"
(`packages/discord`, a timing assertion). Load average was about 20 at the
time. This patch touches nothing under `packages/discord`. The rerun of the
whole script passed 66/0. Sage's gate rerun will show whether it recurs.
`test-release` and `test-task` skipped their Docker cases without a daemon;
Sage's gate rerun covers those. No test calls a model. Logs are in my
scratch (`r60/gate/`), not kept in the repository.
The mutants ran before one comment-only change: I rewrapped the header
comment of `packages/webui/src/bus.mjs`. The gate ran after it.
## Overlap
- Row 59 (#1548, in review) edits the `agents` branch of `broker.mjs`, adds
`#agentState` after `#dispatch`, and replaces the `inbox, tasks, agents`
row of the bus README verb table. With row 59's packet applied on 4100fa68
first, plain `git apply` of this patch refuses on `packages/bus/README.md`
(both add a line after the `trail` row); `git apply --3way` merges every
file cleanly, with the `credentials` row after `trail` and the new case
before `default:`.
On that combined tree, the bus suite and the WebUI bus and browser tests
pass, 100/0.
- Row 58 (Darkwing) edits the `tasks` branch, which this patch doesn't
touch.
## Questions for reviewers
1. Should the Business view use this read, or show it next to the file's
metadata (for example, flag a difference)? I left the view alone because
the brief lists only the endpoint, and the Business view works without a
bus host.
2. The bus README verb table has no `decisions` row (row 57 added the verb
and a paragraph). I added a `credentials` row only. Should a follow-up
add the missing row?
+473
View File
@@ -0,0 +1,473 @@
diff --git a/packages/bus/README.md b/packages/bus/README.md
index 5b57b7fc..a9517a1d 100644
--- a/packages/bus/README.md
+++ b/packages/bus/README.md
@@ -127,6 +127,7 @@ 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. |
+| `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 +197,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 +209,26 @@ 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, since
+a token file rotated after startup isn't reread until restart.
+
## Shared reads and adapter boundary
```js
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index 84db8799..18c9bc0d 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -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',
@@ -468,7 +471,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);
@@ -857,6 +860,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');
}
diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs
index ebc07cf3..e72f65d6 100644
--- a/packages/bus/src/runtime.mjs
+++ b/packages/bus/src/runtime.mjs
@@ -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) {
diff --git a/packages/bus/src/views.mjs b/packages/bus/src/views.mjs
index 2bffda6b..a33c0faf 100644
--- a/packages/bus/src/views.mjs
+++ b/packages/bus/src/views.mjs
@@ -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'),
});
}
diff --git a/packages/bus/tests/credential-status.test.mjs b/packages/bus/tests/credential-status.test.mjs
new file mode 100644
index 00000000..f3f20142
--- /dev/null
+++ b/packages/bus/tests/credential-status.test.mjs
@@ -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), []);
+});
diff --git a/packages/webui/README.md b/packages/webui/README.md
index e43118d5..fedb9195 100644
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -194,19 +194,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
diff --git a/packages/webui/src/bus.mjs b/packages/webui/src/bus.mjs
index 8fee21a6..ca1bab89 100644
--- a/packages/webui/src/bus.mjs
+++ b/packages/webui/src/bus.mjs
@@ -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';
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
index 4d51942a..c0f10275 100644
--- a/packages/webui/src/serve.mjs
+++ b/packages/webui/src/serve.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;
diff --git a/packages/webui/tests/bus.test.mjs b/packages/webui/tests/bus.test.mjs
index 2d9ca6ba..2b429352 100644
--- a/packages/webui/tests/bus.test.mjs
+++ b/packages/webui/tests/bus.test.mjs
@@ -123,15 +123,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);
@@ -216,6 +239,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);
@@ -0,0 +1,9 @@
ac3905b4d151953caa8dee651bed69e83cc3912859a5619443ec1c6b2af05523 packages/bus/README.md
41d7288c9d3e96f5a380eec280b0703d95daf379f6668f5f39662041e914be1e packages/bus/src/broker.mjs
869d8f3b795e7f1b43c44272958e66c1acc1e8fb721476882eaa9bdcac15ab55 packages/bus/src/runtime.mjs
61a009467c232b56265b6a9ae5b26f0b5858b605b1087dd3561586da538ef221 packages/bus/src/views.mjs
689cd3f0a704a7c1eba323dbcbb3abcb5689f672da6da79d2d7e605e261e4d89 packages/bus/tests/credential-status.test.mjs
0bb2f0cb524eaf3db2d36d94c4b7e8e8ea9416a79840494a8ab6d54f47d4b0fc packages/webui/README.md
3fbf7a40058541759e44ff531de91f8fcecb0a0b2a9fef12e71cdc6aa1d45feb packages/webui/src/bus.mjs
c676c1d7ea9eacd6c26825d9708c0fb2b1e0ff3d2a67531c44ecc13a9c07d5ff packages/webui/src/serve.mjs
f2aff38eb038b576403d0333f8c40a1e83fdc46a66fd33086840e133965559b0 packages/webui/tests/bus.test.mjs