diff --git a/packages/bus/README.md b/packages/bus/README.md index 7fa51f92b..82597eeed 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -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 diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 595ca2327..d73198833 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', @@ -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'); } diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index ebc07cf3b..e72f65d6f 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 2bffda6bc..a33c0faf0 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 000000000..f3f20142c --- /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 bb7fc399b..e2ffcb3bb 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -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=` 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=`, `/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 8fee21a6e..ca1bab895 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 a1861d5dc..89327c077 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 cf4770525..be677a5b2 100644 --- a/packages/webui/tests/bus.test.mjs +++ b/packages/webui/tests/bus.test.mjs @@ -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);