From a18fc6cf8cf5dd97bb940da069e65f6fa64e886c Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 20 Oct 2026 14:59:45 -0500 Subject: [PATCH] tasks: row 199 request calls use useFor keyed from the session; the host's start probe keeps use; a D5 test pins the use( call sites (#1697) --- packages/bus/tests/credential-store.test.mjs | 29 +++++++++++++++++++- packages/tasks/src/adapter.mjs | 8 ++++-- packages/tasks/src/startup.mjs | 4 +-- packages/tasks/src/verbs.mjs | 17 ++++++------ 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/packages/bus/tests/credential-store.test.mjs b/packages/bus/tests/credential-store.test.mjs index eaade5140..3712abd02 100644 --- a/packages/bus/tests/credential-store.test.mjs +++ b/packages/bus/tests/credential-store.test.mjs @@ -2,7 +2,7 @@ // /secrets/agents///. Fake tokens in a scratch root only. import test from 'node:test'; import assert from 'node:assert/strict'; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Credentials } from '../src/credentials.mjs'; @@ -153,3 +153,30 @@ test('T5: delete refuses an agent dir holding anything but tokens', (t) => { assert.throws(() => c.remove('acme/coder'), /credential-file/); assert.equal(existsSync(join(s.storeRoot, 'acme', 'coder', 'notes')), true); }); + +// Design D5: the name-keyed use() is the host's own, for @sync and the tracker's start check of each +// role's token. Every other credential use goes through useFor, keyed from the broker's session. A +// new use( call site outside this list fails here. +test('D5: every credentials use( call site is on the allowlist', () => { + const packages = new URL('../../', import.meta.url); + const allowed = new Map([ + ['bus/src/credentials.mjs', 1], // useFor's own call + ['tasks/src/adapter.mjs', 1], // the host's use(instance) for @sync and probe + ]); + const found = new Map(); + const walk = (dir, rel) => { + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name === 'tests') continue; + const path = join(dir, name); + if (statSync(path).isDirectory()) walk(path, `${rel}${name}/`); + else if (name.endsWith('.mjs') || name.endsWith('.js')) { + const code = readFileSync(path, 'utf8').split('\n').filter((line) => !/^\s*\/\//.test(line)).join('\n'); + const n = (code.match(/(?:credentials|this)\.use\(/g) ?? []).length; + if (n) found.set(`${rel}${name}`, n); + } + } + }; + for (const pkg of readdirSync(packages)) if (statSync(new URL(`${pkg}/src`, packages), { throwIfNoEntry: false })?.isDirectory()) + walk(new URL(`${pkg}/src`, packages).pathname, `${pkg}/src/`); + assert.deepEqual(Object.fromEntries([...found].sort()), Object.fromEntries([...allowed].sort())); +}); diff --git a/packages/tasks/src/adapter.mjs b/packages/tasks/src/adapter.mjs index 6a1f77e97..a78062048 100644 --- a/packages/tasks/src/adapter.mjs +++ b/packages/tasks/src/adapter.mjs @@ -80,7 +80,11 @@ export function tasksAdapter({ tree: null, bots: new Set([businesses[name].tracker.sync.botId, ...Object.values(s.roles).map((r) => r.botId)]), sync: use('@sync'), - as: (role, method, path, opts) => use(role)(method, path, opts), + // Row 199 (#1697, design D5): a request's call keys from the broker's session for its cap, + // never from a role name. probe is the host's own start check of each role's token. + as: (session, method, path, opts) => + credentials.useFor(session, 'vikunja', (token) => call(token, method, path, opts)), + probe: (role, method, path, opts) => use(role)(method, path, opts), state: 'starting', refused: null, queue: Promise.resolve(), @@ -96,7 +100,7 @@ export function tasksAdapter({ timers: [], }; ctx.syncCall = (_token, method, path, opts) => ctx.sync(method, path, opts); - ctx.asCall = (role) => (_token, method, path, opts) => ctx.as(role, method, path, opts); + ctx.asCall = (role) => (_token, method, path, opts) => ctx.probe(role, method, path, opts); ctx.handle = verbs(ctx); contexts.set(name, ctx); } diff --git a/packages/tasks/src/startup.mjs b/packages/tasks/src/startup.mjs index f3bdc3d5d..8eaeccb00 100644 --- a/packages/tasks/src/startup.mjs +++ b/packages/tasks/src/startup.mjs @@ -41,7 +41,7 @@ export async function startup(ctx) { if (probe.status === 404 || probe.status === 403 || outcome(probe) === 'ok') refuse('scope-too-broad'); expect(probe, 401, 'tracker-unexpected'); for (const [role, r] of Object.entries(ctx.roles)) { - const control = await ctx.as(role, 'GET', `/tasks/${MISSING}`); + const control = await ctx.probe(role, 'GET', `/tasks/${MISSING}`); if (control.status !== 404 || control.json?.code !== 4002) { expect(control, 404, 'tracker-unexpected'); refuse('tracker-unexpected'); @@ -49,7 +49,7 @@ export async function startup(ctx) { const probes = [['DELETE', `/tasks/${MISSING}`, undefined]]; if (r.definition !== 'pm') probes.push(['POST', `/tasks/${MISSING}/labels`, { label_id: 1 }]); for (const [method, path, body] of probes) { - const p = await ctx.as(role, method, path, { body }); + const p = await ctx.probe(role, method, path, { body }); if (p.status === 404 || p.status === 403 || outcome(p) === 'ok') refuse('scope-too-broad'); if (p.status !== 401) refuse(p.status === 0 || p.status >= 500 ? 'tracker-unavailable' : 'tracker-unexpected'); } diff --git a/packages/tasks/src/verbs.mjs b/packages/tasks/src/verbs.mjs index dfd6bb1d3..fc3191535 100644 --- a/packages/tasks/src/verbs.mjs +++ b/packages/tasks/src/verbs.mjs @@ -122,8 +122,9 @@ export function verbs(ctx) { const relatedTo = (task, kind, other) => Array.isArray(task.related_tasks?.[kind]) && task.related_tasks[kind].some((t) => t?.id === other); // One write with the acting role's token. An uncertain answer is settled by a re-read. - async function step(role, method, path, body, at, done) { - const r = await ctx.as(role, method, path, { body }); + // `me` is the broker's session for the caller's cap (design D5). + async function step(me, method, path, body, at, done) { + const r = await ctx.as(me, method, path, { body }); const o = outcome(r); if (o === 'ok') return r; if (o === 'uncertain' && at !== null && done) { @@ -144,7 +145,7 @@ export function verbs(ctx) { if (!mine) fail('tracker-role'); if (PM_VERBS.has(verb) && mine.definition !== 'pm') fail('field-writer'); const plan = prepare(verb, args, role, mine); - if (verb === 'task.create') return create(cap, role, plan); + if (verb === 'task.create') return create(cap, me, plan); const at = target(args.task_ref); const before = await read(at); if (args.expect !== undefined && args.expect !== before.digest) { @@ -165,7 +166,7 @@ export function verbs(ctx) { let landed = 0; try { for (const [method, path, body, done] of work.steps) { - await step(role, method, path, body, at, done); + await step(me, method, path, body, at, done); landed++; } } catch (e) { @@ -385,12 +386,12 @@ export function verbs(ctx) { fail('unknown-verb'); } } - async function create(cap, role, plan) { + async function create(cap, me, plan) { if (!ctx.broker.taskView(ctx.business, 'input', plan.request)) fail('request-not-found'); await located(plan.others); ctx.broker.authorize(cap, 'task.create', { decision: plan.decision, target: null }); // No re-read can find a create whose answer was lost, so an uncertain create is reported as such. - const r = await step(role, 'POST', `/projects/${plan.at.project}/tasks`, plan.body, null, null); + const r = await step(me, 'POST', `/projects/${plan.at.project}/tasks`, plan.body, null, null); if (r.status !== 201 && r.status !== 200) fail('tracker-unexpected'); checkTask(r.json); const id = r.json.id; @@ -399,9 +400,9 @@ export function verbs(ctx) { let failure = null; try { for (const l of plan.labels) - await step(role, 'POST', `/tasks/${id}/labels`, { label_id: l }, at, (a) => a.fields.labels.includes(l)); + await step(me, 'POST', `/tasks/${id}/labels`, { label_id: l }, at, (a) => a.fields.labels.includes(l)); for (const x of plan.relations) - await step(role, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at, (a) => + await step(me, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at, (a) => relatedTo(a.task, x.kind, x.other), ); } catch (e) {