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:
1 parent
609dc9364d
commit
5043367978
3 files changed
+62
-1
No files matched your search
@@ -1055,6 +1055,12 @@ export class Broker {
|
|||||||
#publish(ticket) {
|
#publish(ticket) {
|
||||||
return (typeof ticket === 'string' && this.#publishes.get(ticket)) || fail('publish-unknown');
|
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
|
// 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
|
// 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.
|
// check is not repeated (as recordLand). Trusted host only.
|
||||||
|
|||||||
@@ -86,7 +86,9 @@ export async function startBroker({
|
|||||||
...repoRoots,
|
...repoRoots,
|
||||||
...Object.values(businesses).flatMap((b) => Object.values(b.projects ?? {}).map((p) => p.root)),
|
...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);
|
store = new Store(dataRoot);
|
||||||
// vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs
|
// 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.
|
// 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.
|
// 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.
|
// 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);
|
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.
|
// Metadata only (service, date, state); the token stays in Credentials.
|
||||||
function credentialStatus(business, role) {
|
function credentialStatus(business, role) {
|
||||||
return credentials
|
return credentials
|
||||||
@@ -237,6 +245,10 @@ export async function startBroker({
|
|||||||
checkLand,
|
checkLand,
|
||||||
recordLand: (ticket, land) => broker.recordLand(ticket, land),
|
recordLand: (ticket, land) => broker.recordLand(ticket, land),
|
||||||
landRefused: (ticket, args) => broker.landRefused(ticket, args),
|
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),
|
landHeld: (record) => broker.landHeld(record),
|
||||||
// Row 156 (#1651): the launcher's landing pass.
|
// Row 156 (#1651): the launcher's landing pass.
|
||||||
landHolders: (business) => broker.landHolders(business),
|
landHolders: (business) => broker.landHolders(business),
|
||||||
|
|||||||
@@ -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')))
|
for (const name of readdirSync(dir).filter((n) => n.endsWith('.json')))
|
||||||
assert.ok(!readFileSync(new URL(name, dir), 'utf8').includes('git.push.remote'), name);
|
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/);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user