- broker: the hire.raise answer, the inbox console preview and the trail read carry a
binding as {instance, change} only; the raise event keeps the full proposal the resolve reads.
- copy-launch status(): a copy whose role needs a service a copy cannot bind reads
copy-service-unbindable (new fixed status text) instead of a gitea binding step.
- cli instances: an unbound copy's line names its missing services.
- webui serve: the instances verb rebuilds each copy binding from the fixed status table.
- bus.js reads the binding change field only.
- browser tests: system Chromium starts with --disable-extensions; the A3 booted-bus test passes it.
215 lines
16 KiB
JavaScript
215 lines
16 KiB
JavaScript
// #797 / #1839 extends row 252's signed-in Console answers to ordinary decisions. Two POSTs.
|
|
// /confirmation asks the bus for authoritative answers and digest and returns a one-use
|
|
// confirmation bound to this session, business,
|
|
// decision, choice and digest. /resolve takes only that choice and confirmation, consumes the
|
|
// confirmation before it awaits the bus, and asks the bus to resolve with the digest. The bus
|
|
// rechecks everything in the resolve transaction. Nothing here retries.
|
|
import { createHash, randomBytes } from 'node:crypto';
|
|
import { BusReadError, subjectOk } from './bus.mjs';
|
|
|
|
export const DECISION_ROUTE = /^\/api\/decisions\/([^/]*)\/(confirmation|resolve|start|templates)$/;
|
|
// The endpoint contract, design section 4. Each refusal is one of these, never a 200.
|
|
export const DECISION_REFUSALS = Object.freeze({
|
|
'sign-in-required': [401, 'Sign in again before answering.'],
|
|
'read-only': [403, 'Console decision answers are not enabled. Use mosaic decide.'],
|
|
'cross-origin': [403, 'Answer from the signed-in Console page.'],
|
|
'not-found': [404, 'Not found.'],
|
|
'human-required': [403, 'The bus refused this human identity.'],
|
|
'peercred-required': [403, 'The bus could not verify this connection.'],
|
|
'method-not-allowed': [405, 'Use POST.'],
|
|
'invalid-request': [400, 'Invalid decision answer.'],
|
|
'request-too-large': [413, 'Decision answer is too large.'],
|
|
'decision-not-found': [404, 'This decision is unavailable.'],
|
|
'decision-not-eligible': [403, 'Answer this decision with mosaic decide.'],
|
|
'decision-closed': [409, 'This decision is no longer open. Refresh the inbox.'],
|
|
'hire-owner-required': [403, 'Only the owner may approve these grants or a root agent.'],
|
|
'right-required': [403, 'This decision needs org.hire.'],
|
|
'decision-expired': [409, 'This decision expired. Ask for a fresh proposal.'],
|
|
'hire-resolution-required': [403, 'Use the hire approval path.'],
|
|
'decision-stale': [409, 'The choices changed. Review the decision again.'],
|
|
'confirmation-required': [409, 'Review and confirm this answer again.'],
|
|
'confirmation-unavailable': [503, 'Confirmation is unavailable. Try again later.'],
|
|
'outcome-unknown': [503, 'The answer may have reached the bus. Refresh before trying again.'],
|
|
});
|
|
// Bus codes by name. A bus that refuses this caller is human-required, whatever its code. Any
|
|
// other code, or no answer, is confirmation-unavailable at the preview and outcome-unknown at the
|
|
// resolve, which the page reads as "refresh first".
|
|
const BUS_CODES = {
|
|
'human-required': 'human-required', unauthenticated: 'human-required', 'unknown-business': 'human-required',
|
|
'access-refused': 'human-required', 'access-revoked': 'human-required', 'agent-required': 'human-required',
|
|
'peercred-required': 'peercred-required',
|
|
'decision-not-found': 'decision-not-found', 'decision-not-eligible': 'decision-not-eligible',
|
|
'decision-closed': 'decision-closed', 'decision-stale': 'decision-stale', 'invalid-choice': 'decision-stale',
|
|
'invalid-request': 'invalid-request', 'hire-owner-required':'hire-owner-required','right-required':'right-required','decision-expired':'decision-expired','hire-resolution-required':'hire-resolution-required',
|
|
};
|
|
// D4 (#1873, P1-2): a hire, fire or change start the bus definitely refused keeps its own code,
|
|
// but only for codes the broker raises before it records the decision: the proposal checks in
|
|
// hire-decisions.mjs prepare(), which #raiseHire runs before its INSERT. hire.start.console then
|
|
// resolves the decision and applies it in #hireConsole, and those steps can raise other codes (and
|
|
// some of the same generic ones) after the decision exists or is consumed, so every code not listed
|
|
// here stays outcome-unknown. Review P3: an allowlist, not a pass-through of any well-formed code.
|
|
const HIRE_START_REFUSALS = {
|
|
'model-provider-unavailable': [409, 'The model proxy does not offer this model, or its catalogue is not ready. Pick a model it offers, or ask the owner to check Settings > Model proxy. Nothing was applied.'],
|
|
'model-catalogue-unavailable': [503, 'The model catalogue could not be read, so this model could not be checked. Check Settings > Model proxy, then try again. Nothing was applied.'],
|
|
'model-harness-incompatible': [409, 'This model does not run on the chosen harness. Pick another model or harness. Nothing was applied.'],
|
|
'thinking-unsupported': [409, 'The chosen harness does not apply this thinking level. Clear Thinking or pick a level it applies. Nothing was applied.'],
|
|
'definition-not-hireable': [409, 'This organization does not hire agents from this role template. Pick another template. Nothing was applied.'],
|
|
'definition-shared': [409, 'Another agent uses the same role definition, so this change cannot replace it. Nothing was applied.'],
|
|
'no-chief-of-staff': [409, 'This organization has no chief of staff, so a hire, fire or change cannot be checked against it. Nothing was applied.'],
|
|
};
|
|
// #1873 CB5: the owner's next step once a copy credential binding decision is approved, fixed text
|
|
// (design section 6 and its three steps). Nothing in it comes from the decision, the bus or a token.
|
|
export const BINDING_NEXT = Object.freeze({
|
|
bind: "Binding approved. Issue the copy's token with mosaic credentials issue, then restart the organization bus. The copy's status on the Agents grid names each next step.",
|
|
unbind: "Unbind approved. The bus stops the copy's runs and revokes its credential. The copy's status on the Agents grid shows when the revoke finishes.",
|
|
});
|
|
// A binding decision's preview names its copy and whether it binds or unbinds. Only those two leave
|
|
// this server: the copy id, incarnation, definition pin and services stay with the bus. The owner's
|
|
// preview verb is the one bus answer that carries them; the inbox and the trail send the same two.
|
|
const bindingOf = (v) => (v?.binding && typeof v.binding === 'object' && subjectOk(v.binding.instance) &&
|
|
(v.binding.after === null || (typeof v.binding.after === 'object' && !Array.isArray(v.binding.after)))
|
|
? { instance: v.binding.instance, change: v.binding.after === null ? 'unbind' : 'bind' } : null);
|
|
const BODY_MAX = 4096;
|
|
const TOKEN = /^[a-f0-9]{64}$/;
|
|
const DIGEST = /^[a-f0-9]{64}$/;
|
|
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
|
|
const hash = (token) => createHash('sha256').update(token).digest('hex');
|
|
export function decisionRefuse(res, code) {
|
|
const [status, message] = DECISION_REFUSALS[code];
|
|
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
res.end(JSON.stringify({ error: code, message }));
|
|
}
|
|
|
|
// The confirmations, in this process's memory only and stored by hash. 120 seconds, at most 1024
|
|
// live. A full store refuses a new one rather than drop a live one. An expired one is swept.
|
|
export function createConfirmations({ ttlMs = 120000, max = 1024, now = Date.now } = {}) {
|
|
const live = new Map();
|
|
const sweep = () => { const t = now(); for (const [k, v] of live) if (v.expires <= t) live.delete(k); };
|
|
return {
|
|
issue(binding) {
|
|
sweep();
|
|
if (live.size >= max) return null;
|
|
const token = randomBytes(32).toString('hex');
|
|
live.set(hash(token), { ...binding, expires: now() + ttlMs });
|
|
return token;
|
|
},
|
|
// One use: a token found is gone, whether or not it matches.
|
|
consume(token, { session, decision, choice }) {
|
|
sweep();
|
|
if (typeof token !== 'string' || !TOKEN.test(token)) return null;
|
|
const k = hash(token), v = live.get(k);
|
|
if (!v) return null;
|
|
live.delete(k);
|
|
return v.session === session && v.decision === decision && v.choice === choice ? v : null;
|
|
},
|
|
get size() { sweep(); return live.size; },
|
|
};
|
|
}
|
|
|
|
async function readBody(req, max = BODY_MAX) {
|
|
if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return { code: 'invalid-request' };
|
|
const chunks = [];
|
|
let size = 0;
|
|
for await (const chunk of req) {
|
|
size += chunk.length;
|
|
if (size > max) return { code: 'request-too-large' };
|
|
chunks.push(chunk);
|
|
}
|
|
try {
|
|
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
return value && typeof value === 'object' && !Array.isArray(value) ? { value } : { code: 'invalid-request' };
|
|
} catch { return { code: 'invalid-request' }; }
|
|
}
|
|
const option = o => o && typeof o.key === 'string' && o.key.length > 0 && typeof o.text === 'string' && o.text.length > 0;
|
|
const options = list => Array.isArray(list) && list.length >= 2 && list.length <= 9 && list.every(option) && new Set(list.map(o => o.key)).size === list.length;
|
|
function preview(v, id) {
|
|
if (!v || v.id !== id || typeof v.business !== 'string' || !DIGEST.test(v.digest ?? '')) return false;
|
|
if(v.kind==='hire')return options(v.options) && Array.isArray(v.flagged) && Array.isArray(v.credentialLines) && typeof v.expiresAt==='string';
|
|
if (v.kind === 'decision') return options(v.options) && !['grant', 'approve', 'deny'].some(k => k in v);
|
|
// Retain the existing grant preview fields, including previews from an older pinned broker.
|
|
return (v.kind === undefined || v.kind === 'grant') && option(v.approve) && option(v.deny) &&
|
|
v.approve.key !== v.deny.key && v.grant && typeof v.grant.method === 'string' &&
|
|
(v.options === undefined || (options(v.options) && v.options.length === 2 &&
|
|
[v.approve, v.deny].every(o => v.options.some(x => x.key === o.key && x.text === o.text))));
|
|
}
|
|
|
|
// `session` is the live sign-in session object this request passed, and `live()` reads it again
|
|
// right before each spawn. `call(verb, args, { subject, business })` is the console transport.
|
|
export async function decisionHTTP(req, res, { path, decide, session, live, confirmations, log }) {
|
|
const [, raw, step] = DECISION_ROUTE.exec(path);
|
|
let id = null;
|
|
try { id = decodeURIComponent(raw); } catch { /* refused below */ }
|
|
if (req.method !== 'POST') return decisionRefuse(res, 'method-not-allowed');
|
|
if (!decide) return decisionRefuse(res, 'read-only');
|
|
if (!session) return decisionRefuse(res, 'sign-in-required');
|
|
if (one(req, 'origin') !== decide.origin || one(req, 'sec-fetch-site') !== 'same-origin') return decisionRefuse(res, 'cross-origin');
|
|
const line = (outcome) => log(`${new Date().toISOString()} decide user=${JSON.stringify(String(session.name ?? '').slice(0, 64))} decision=${JSON.stringify(String(id ?? '').slice(0, 64))} step=${step} ${outcome}`);
|
|
const refuse = (code) => { line(`error=${code}`); return decisionRefuse(res, code); };
|
|
const { value, code } = await readBody(req, step==='start'?32768:BODY_MAX);
|
|
if (code) return refuse(code);
|
|
if (!subjectOk(id)) return refuse('decision-not-found');
|
|
const subject = { provider: session.provider, iss: session.iss, sub: session.sub };
|
|
const busCode = (err, open) => {
|
|
const c = err instanceof BusReadError ? BUS_CODES[err.code] : undefined;
|
|
return c ?? open;
|
|
};
|
|
if(step==='start' || step==='templates') {
|
|
const allowed=step==='start'?['business','hire']:['business'];
|
|
if(id!=='hire' || Object.keys(value).length!==allowed.length || Object.keys(value).some(k=>!allowed.includes(k)) || !subjectOk(value.business))return refuse('invalid-request');
|
|
if(live()!==session)return refuse('sign-in-required');
|
|
try {
|
|
const result=await decide.call(step==='start'?'hire.start.console':'hire.templates.console',step==='start'?value.hire:{},{subject,business:value.business});
|
|
if(step==='start' && (typeof result?.id!=='string'||result.op!=='resolved'||result.choice!=='yes'))return refuse('outcome-unknown');
|
|
if(step==='templates' && !Array.isArray(result?.templates))return refuse('confirmation-unavailable');
|
|
line(step==='start'?'hire=recorded':'templates=read');
|
|
// CB5: an approved binding start says what the owner does next, fixed text by its shape.
|
|
const change=step==='start' && value.hire?.binding && typeof value.hire.binding==='object' && 'services' in value.hire.binding
|
|
? (value.hire.binding.services===null?'unbind':'bind') : null;
|
|
return json(res,200,step==='start'?{id:result.id,op:result.op,choice:result.choice,...(change?{next:BINDING_NEXT[change]}:{})}:result);
|
|
} catch(err) {
|
|
if(step!=='start' || !(err instanceof BusReadError) || !Object.hasOwn(HIRE_START_REFUSALS,err.code))
|
|
return refuse(busCode(err,step==='start'?'outcome-unknown':'confirmation-unavailable'));
|
|
const [status,message]=HIRE_START_REFUSALS[err.code];
|
|
line(`error=${err.code}`);
|
|
return json(res,status,{error:err.code,message});
|
|
}
|
|
}
|
|
if (step === 'confirmation') {
|
|
if (Object.keys(value).some(k=>!['choice','adjust'].includes(k)) || typeof value.choice !== 'string') return refuse('invalid-request');
|
|
if (live() !== session) return refuse('sign-in-required');
|
|
let v;
|
|
try { v = await decide.call(value.adjust===undefined?'decision.preview.console':'hire.preview.console', { id,...(value.adjust===undefined?{}:{adjust:value.adjust}) }, { subject }); } catch (err) { return refuse(busCode(err, 'confirmation-unavailable')); }
|
|
if (!preview(v, id)) return refuse('confirmation-unavailable');
|
|
const ordinary = v.kind === 'decision' || v.kind === 'hire';
|
|
const chosen = (ordinary ? v.options : [v.approve, v.deny]).find(o => o.key === value.choice);
|
|
if (!chosen) return refuse('decision-stale');
|
|
const action = ordinary ? 'answer' : value.choice === v.approve.key ? 'approve' : 'deny';
|
|
if (live() !== session) return refuse('sign-in-required');
|
|
if(value.adjust!==undefined && v.kind!=='hire')return refuse('invalid-request');
|
|
const binding = v.kind === 'hire' ? bindingOf(v) : null;
|
|
const confirmation = confirmations.issue({ session, business: v.business, decision: id, choice: value.choice, digest: v.digest, ...(v.kind==='hire'?{kind:'hire',...(value.adjust===undefined?{}:{adjust:structuredClone(value.adjust)}),...(binding?{change:binding.change}:{})}:{}) });
|
|
if (!confirmation) return refuse('confirmation-unavailable');
|
|
line(`action=${action}`);
|
|
return json(res, 200, { id, question: typeof v.question === 'string' ? v.question : '', action, choice: value.choice, text: chosen.text,
|
|
...(v.kind==='hire'?{kind:'hire',flagged:v.flagged,credentialLines:v.credentialLines,expiresAt:v.expiresAt,...(binding?{binding}:{})}:ordinary ? { kind: 'decision' } : { grant: v.grant }), confirmation });
|
|
}
|
|
const keys = Object.keys(value);
|
|
if (keys.length !== 2 || !keys.includes('choice') || !keys.includes('confirmation') || typeof value.choice !== 'string') return refuse('invalid-request');
|
|
// Consumed before any await on the bus, so a second click finds nothing (J12).
|
|
const held = confirmations.consume(value.confirmation, { session, decision: id, choice: value.choice });
|
|
if (!held) return refuse('confirmation-required');
|
|
if (live() !== session) return refuse('sign-in-required');
|
|
let r;
|
|
try {
|
|
r = await decide.call(held.kind==='hire'?'hire.resolve.console':'decision.resolve.console', { id, choice: held.choice, digest: held.digest,...(held.adjust===undefined?{}:{adjust:held.adjust}) }, { subject, business: held.business });
|
|
} catch (err) { return refuse(busCode(err, 'outcome-unknown')); }
|
|
if (r?.id !== id || r.op !== 'resolved' || r.choice !== held.choice) return refuse('outcome-unknown');
|
|
line(`choice=${JSON.stringify(held.choice.slice(0, 64))}${r.grant ? ` grant=${r.grant}` : ''}`);
|
|
return json(res, 200, { id, op: 'resolved', choice: r.choice, ...(r.grant === 'started' ? { grant: 'started' } : {}),
|
|
...(held.change && r.choice === 'yes' ? { next: BINDING_NEXT[held.change] } : {}) });
|
|
}
|
|
function json(res, status, body) {
|
|
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
res.end(JSON.stringify(body));
|
|
}
|