// #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)); }