From e7e6950819a22861fd40e2522676ffeea21dffa1 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 11 Oct 2026 16:57:51 -0500 Subject: [PATCH] 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 --- agents/filbert/work/queue-60/build.md | 165 ++++++ agents/filbert/work/queue-60/build.patch | 473 ++++++++++++++++++ .../work/queue-60/candidate-manifest.sha256 | 9 + 3 files changed, 647 insertions(+) create mode 100644 agents/filbert/work/queue-60/build.md create mode 100644 agents/filbert/work/queue-60/build.patch create mode 100644 agents/filbert/work/queue-60/candidate-manifest.sha256 diff --git a/agents/filbert/work/queue-60/build.md b/agents/filbert/work/queue-60/build.md new file mode 100644 index 000000000..b60581189 --- /dev/null +++ b/agents/filbert/work/queue-60/build.md @@ -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? diff --git a/agents/filbert/work/queue-60/build.patch b/agents/filbert/work/queue-60/build.patch new file mode 100644 index 000000000..7cda1cd15 --- /dev/null +++ b/agents/filbert/work/queue-60/build.patch @@ -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=` 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 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); diff --git a/agents/filbert/work/queue-60/candidate-manifest.sha256 b/agents/filbert/work/queue-60/candidate-manifest.sha256 new file mode 100644 index 000000000..41c50290c --- /dev/null +++ b/agents/filbert/work/queue-60/candidate-manifest.sha256 @@ -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