bus: row 199 runtime wiring, the store root is <configDir>/secrets/agents, and the op table gets checkPublish, withGitea, recordPublish and publishRefused; withGitea keys from the open publish ticket (#1697)

Deviation from D9b, recorded for BUILD.md: withGitea takes the ticket, not a cap. The key is the
session checkPublish checked as an agent holding its claim (broker.publishSession), so a tester,
reader or human cap never reaches a token, and the token is usable only between checkPublish and
recordPublish or publishRefused. Its refusals are filed once, by publishRefused.
This commit is contained in:
jason.woltje committed 2026-10-20 14:56:03 -05:00
1 parent 609dc9364d
commit 5043367978
3 files changed
+62 -1

No files matched your search

+6
View File
@@ -1055,6 +1055,12 @@ export class Broker {
#publish(ticket) {
return (typeof ticket === 'string' && this.#publishes.get(ticket)) || fail('publish-unknown');
}
// Design D5: the only key the git socket's gitea token use gets is the agent checkPublish checked
// (#agent: a run holding its claim, never a tester, reader or human). Trusted host only.
publishSession(ticket) {
const { s } = this.#publish(ticket);
return Object.freeze({ business: s.business, role: s.role });
}
// Step 6: records the push the git socket made as the action.allowed for git.push.remote, which
// spends the decision. The checks run again; the run may have ended while git ran, so the holder
// check is not repeated (as recordLand). Trusted host only.
+13 -1
View File
@@ -86,7 +86,9 @@ export async function startBroker({
...repoRoots,
...Object.values(businesses).flatMap((b) => Object.values(b.projects ?? {}).map((p) => p.root)),
];
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot });
// Row 199 (#1697, design D1): the per-agent store is <configDir>/secrets/agents.
const storeRoot = organization ? join(organization.dir, 'secrets', 'agents') : null;
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot });
store = new Store(dataRoot);
// vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs
// plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused.
@@ -212,6 +214,12 @@ export async function startBroker({
// Row 137 (#1631): the git socket's land op. checkLand's refusal is the run's, like checkPush's.
// A landing then ends in recordLand or landRefused under its ticket, which outlive the run's cap.
const checkLand = (cap, args) => refusing(cap, () => broker.checkLand(cap, args), args?.task_ref);
// Row 199 (#1697, design D9b): the git socket's publish op. checkPublish's refusal is the run's.
// The gitea token is used only under the open ticket, keyed from the agent checkPublish checked,
// and the op table returns no token. A publish then ends in recordPublish or publishRefused,
// which files any refusal once, withGitea's included.
const checkPublish = (cap, args) => refusing(cap, () => broker.checkPublish(cap, args), args?.task_ref);
const withGitea = async (ticket, fn) => credentials.useFor(broker.publishSession(ticket), 'gitea', fn);
// Metadata only (service, date, state); the token stays in Credentials.
function credentialStatus(business, role) {
return credentials
@@ -237,6 +245,10 @@ export async function startBroker({
checkLand,
recordLand: (ticket, land) => broker.recordLand(ticket, land),
landRefused: (ticket, args) => broker.landRefused(ticket, args),
checkPublish,
withGitea,
recordPublish: (ticket, pushed) => broker.recordPublish(ticket, pushed),
publishRefused: (ticket, args) => broker.publishRefused(ticket, args),
landHeld: (record) => broker.landHeld(record),
// Row 156 (#1651): the launcher's landing pass.
landHolders: (business) => broker.landHolders(business),
+43
View File
@@ -199,3 +199,46 @@ test('git.push.remote is in neither ROUTED list and no shipped role grants it',
for (const name of readdirSync(dir).filter((n) => n.endsWith('.json')))
assert.ok(!readFileSync(new URL(name, dir), 'utf8').includes('git.push.remote'), name);
});
// D1 and D9b through the real startBroker: the store is <configDir>/secrets/agents, and the gitea
// token is used only under an open publish ticket, keyed from the agent checkPublish checked.
test('runtime: withGitea keys from the open ticket only, and the store root is the organization dir', async (t) => {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { chmodSync, mkdirSync } = await import('node:fs');
const root = mkdtempSync(join(tmpdir(), 'bus-publish-rt-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
// The runtime reads the harness switch from the organization file as it stands.
writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
chmodSync(join(root, 'secrets'), 0o700);
const demo = businesses().demo;
demo.roles.coder.credentials = { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: '2099-01-01' } };
const organization = { dir: root, rolesDir, system: systemFor(root) };
// No organization, no store root: a store reference refuses the start.
await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo } }), /credential-reference/);
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization });
t.after(() => bus.close());
assert.deepEqual(bus.credentialStatus('demo', 'coder'), [{ service: 'gitea', date: '2099-01-01', state: 'not-issued' }]);
const coder = bus.broker.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' });
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
bus.broker.request(coder, { verb: 'role.claim', args: {} });
bus.checkPush(coder, TASK);
bus.recordPush(coder, { task_ref: TASK, project: 'stack', branch: 'feat', ref: 'work/vikunja-stack-2', commit: A, previous: null });
const d = bus.broker.request(coder, { verb: 'decision.raise', args: {
action: 'git.push.remote', target: TASK, task_ref: TASK, question: 'Publish?', blocking: false, recommendation: 'yes',
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }] } });
bus.broker.request(human, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
assert.throws(() => bus.checkPublish(coder, { task_ref: TASK, project: 'stack', branch: 'feat' }), /decision-required/);
const ticket = bus.checkPublish(coder, { task_ref: TASK, project: 'stack', branch: 'feat', decision: d.id }).ticket;
await assert.rejects(bus.withGitea(ticket, async () => 1), /credential-unavailable/);
bus.credentials.issue('demo/coder', 'gitea', 'fake_publish_token_0123456789', '2099-01-01');
assert.equal(await bus.withGitea(ticket, async (token) => token === 'fake_publish_token_0123456789'), true);
await assert.rejects(bus.withGitea(ticket, async (token) => ({ echo: token })), /credential-leak/);
await assert.rejects(bus.withGitea('not-a-ticket', async () => 1), /publish-unknown/);
await assert.rejects(bus.withGitea(coder, async () => 1), /publish-unknown/);
bus.recordPublish(ticket, { ref: 'refs/heads/acme-coder/feat', commit: A, previous: null });
await assert.rejects(bus.withGitea(ticket, async () => 1), /publish-unknown/);
});