Files
stack/packages/webui/tests/decision-http.test.mjs
jason.woltje 23b5e24ebc fix(webui): E2E6 re-check round, consumed-decision wording, autonomy focus and read errors (#1873)
Re-check finding 1 (P2): the wording for refusals that can come after the
decision is consumed told the user to look for the proposal "as answered"
in the inbox and otherwise read "nothing was applied". The bus inbox lists
no closed decision, so a consumed one drops out of it and the user would
read the false claim. Those codes now say only that part of it may already
be applied and to check the Agents page. decision-http tests assert no
"nothing was applied" in any case and no inbox pointer for those codes.

Re-check P3-b: the autonomy controls (Save, Discard, notices, review
summary and buttons) carry data-focus-key, so a focused button in a clean
form gets its focus back after the poll repaints the section.

Re-check P3-c: a failed autonomy read keeps the last read only while the
page holds the form (dirty, or focus inside it). A clean form shows the
read error. org-live-edit-browser test 4 covers both.

Re-check P3-a (harness-off after the consume through a corrupt prior
settings file) is deferred: wording it exactly needs the bus to mark the
phase on the refusal.
2026-11-13 20:05:10 -06:00

485 lines
33 KiB
JavaScript

// Row 252 (#1753): the Console decision endpoint, driven with a fake request, response and bus
// transport. The real bus side is in packages/bus/tests/console-decide.test.mjs and the page side
// in the browser tests.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { BusReadError } from '../src/bus.mjs';
import { BINDING_NEXT, DECISION_REFUSALS, createConfirmations, decisionHTTP } from '../src/decision-http.mjs';
const ORIGIN = 'https://console.test';
const DIGEST = 'a'.repeat(64);
const PREVIEW = {
id: 'd1', business: 'demo', question: 'Enroll host?', digest: DIGEST,
approve: { key: 'yes', text: 'Yes, enroll it' }, deny: { key: 'no', text: 'No' },
grant: { method: 'host-enroll', host: 'h1' },
};
function request({ method = 'POST', body = {}, headers = {}, raw } = {}) {
const h = { origin: [ORIGIN], 'sec-fetch-site': ['same-origin'], 'content-type': ['application/json'], ...headers };
for (const k of Object.keys(h)) if (h[k] === null) delete h[k];
const data = Buffer.from(raw ?? JSON.stringify(body));
return { method, headersDistinct: h, async *[Symbol.asyncIterator]() { yield data; } };
}
function response() {
const r = { status: null, headers: null, text: '' };
r.writeHead = (s, h) => { r.status = s; r.headers = h; };
r.end = (t = '') => { r.text += t; };
r.json = () => JSON.parse(r.text);
return r;
}
function setup({ answer = () => ({ ...PREVIEW }), resolve = (a) => ({ id: a.id, op: 'resolved', choice: a.choice, grant: 'started' }), now } = {}) {
const session = { name: 'jason', provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' };
const calls = [], lines = [];
const s = {
session, calls, lines, current: session,
confirmations: createConfirmations(now ? { now } : {}),
decide: {
origin: ORIGIN,
call: async (verb, args, ctx) => {
calls.push({ verb, args, ctx });
return ['decision.preview.console','hire.preview.console'].includes(verb) ? answer(args) : resolve(args);
},
},
};
s.run = async (path, req) => {
const res = response();
await decisionHTTP(req, res, { path, decide: s.decide, session: s.session, live: () => s.current, confirmations: s.confirmations, log: (l) => lines.push(l) });
return res;
};
s.confirm = (choice = 'yes', id = 'd1') => s.run(`/api/decisions/${id}/confirmation`, request({ body: { choice } }));
s.resolve = (body, id = 'd1') => s.run(`/api/decisions/${id}/resolve`, request({ body }));
return s;
}
const refused = (res, code) => {
assert.equal(res.status, DECISION_REFUSALS[code][0], `${code}: ${res.text}`);
assert.deepEqual(res.json(), { error: code, message: DECISION_REFUSALS[code][1] });
assert.equal(res.headers['cache-control'], 'no-store');
};
test('a confirmation then a resolve answers once with the held choice and digest, and a second resolve refuses', async () => {
const s = setup();
const c = await s.confirm('yes');
assert.equal(c.status, 200);
const body = c.json();
assert.match(body.confirmation, /^[a-f0-9]{64}$/);
assert.deepEqual({ ...body, confirmation: 'x' }, { id: 'd1', question: 'Enroll host?', action: 'approve', choice: 'yes', text: 'Yes, enroll it', grant: PREVIEW.grant, confirmation: 'x' });
assert.deepEqual(s.calls[0], { verb: 'decision.preview.console', args: { id: 'd1' }, ctx: { subject: { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' } } });
const r = await s.resolve({ choice: 'yes', confirmation: body.confirmation });
assert.equal(r.status, 200);
assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'yes', grant: 'started' });
assert.deepEqual(s.calls[1], { verb: 'decision.resolve.console', args: { id: 'd1', choice: 'yes', digest: DIGEST }, ctx: { subject: { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' }, business: 'demo' } });
// J12: the confirmation is spent, so a repeat never reaches the bus.
refused(await s.resolve({ choice: 'yes', confirmation: body.confirmation }), 'confirmation-required');
assert.equal(s.calls.length, 2);
assert.equal(s.lines.length, 3);
assert.match(s.lines[0], / decide user="jason" decision="d1" step=confirmation action=approve$/);
assert.match(s.lines[1], / step=resolve choice="yes" grant=started$/);
assert.doesNotMatch(s.lines.join('\n'), new RegExp(body.confirmation));
});
test('deny resolves without a grant field', async () => {
const s = setup({ resolve: (a) => ({ id: a.id, op: 'resolved', choice: a.choice }) });
const c = (await s.confirm('no')).json();
assert.equal(c.action, 'deny');
assert.equal(c.text, 'No');
const r = await s.resolve({ choice: 'no', confirmation: c.confirmation });
assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'no' });
});
const ORDINARY = {
id: 'd1', business: 'demo', question: 'How should we handle this request?', digest: DIGEST, kind: 'decision',
options: [{ key: 'new/autonomous', text: 'New project — autonomous' }, { key: 'new/guided', text: 'New project — guided' }, { key: 'existing:project', text: 'Add to existing project' }],
};
test('ordinary confirmation carries the selected third key/text and decision context, then resolves once in its business', async () => {
const s = setup({ answer: () => ORDINARY, resolve: a => ({ id: a.id, op: 'resolved', choice: a.choice }) });
const c = await s.confirm('existing:project');
assert.equal(c.status, 200, c.text);
assert.deepEqual({ ...c.json(), confirmation: 'x' }, { id: 'd1', kind: 'decision', question: ORDINARY.question, action: 'answer', choice: 'existing:project', text: 'Add to existing project', confirmation: 'x' });
const body = { choice: 'existing:project', confirmation: c.json().confirmation };
assert.deepEqual((await s.resolve(body)).json(), { id: 'd1', op: 'resolved', choice: 'existing:project' });
assert.equal(s.calls[1].ctx.business, 'demo');
assert.equal(s.calls[1].args.digest, DIGEST);
refused(await s.resolve(body), 'confirmation-required');
assert.equal(s.calls.length, 2);
});
test('ordinary options are validated authoritatively at confirmation, including two and nine choices', async () => {
for (const options of [ORDINARY.options.slice(0, 2), Array.from({ length: 9 }, (_, i) => ({ key: `key:${i}`, text: `Answer ${i}` }))]) {
const s = setup({ answer: () => ({ ...ORDINARY, options }) });
assert.equal((await s.confirm(options.at(-1).key)).status, 200);
refused(await s.confirm('not-listed'), 'decision-stale');
}
for (const options of [[], ORDINARY.options.slice(0, 1), Array(10).fill(ORDINARY.options[0]), [ORDINARY.options[0], ORDINARY.options[0]], [{ key: 1, text: 'x' }, ORDINARY.options[1]], [{ key: 'x' }, ORDINARY.options[1]]]) {
const s = setup({ answer: () => ({ ...ORDINARY, options }) });
refused(await s.confirm('new/autonomous'), 'confirmation-unavailable');
assert.equal(s.confirmations.size, 0);
}
});
test('ordinary stale/closed and uncertain outcomes spend their confirmation and never replay', async () => {
for (const code of ['decision-stale', 'decision-closed', 'outcome-unknown']) {
const s = setup({ answer: () => ORDINARY, resolve: () => { throw new BusReadError(code); } });
const c = await s.confirm('new/guided');
assert.equal(c.status, 200, c.text);
const body = { choice: 'new/guided', confirmation: c.json().confirmation };
refused(await s.resolve(body), code);
refused(await s.resolve(body), 'confirmation-required');
assert.equal(s.calls.length, 2);
}
});
test('ordinary confirmations stay bound to the issuing session, choice, decision and business', async () => {
const s = setup({ answer: () => ORDINARY, resolve: a => ({ id: a.id, op: 'resolved', choice: a.choice }) });
let c = (await s.confirm('new/guided')).json();
refused(await s.resolve({ choice: 'existing:project', confirmation: c.confirmation }), 'confirmation-required');
c = (await s.confirm('new/guided')).json();
refused(await s.resolve({ choice: 'new/guided', confirmation: c.confirmation }, 'd2'), 'confirmation-required');
c = (await s.confirm('new/guided')).json();
s.current = null;
refused(await s.resolve({ choice: 'new/guided', confirmation: c.confirmation }), 'sign-in-required');
s.current = s.session;
refused(await s.resolve({ choice: 'new/guided', confirmation: c.confirmation }), 'confirmation-required');
const other = setup({ answer: () => ({ ...ORDINARY, business: 'other' }) });
other.confirmations = s.confirmations;
c = (await s.confirm('new/guided')).json();
refused(await other.resolve({ choice: 'new/guided', confirmation: c.confirmation }), 'confirmation-required');
assert.equal(s.calls.filter(x => x.verb === 'decision.resolve.console').length, 0);
assert.equal(other.calls.length, 0);
});
test('the gates before the body: method, read-only, sign-in and same origin', async () => {
const s = setup();
refused(await s.run('/api/decisions/d1/confirmation', request({ method: 'GET' })), 'method-not-allowed');
const res = response();
await decisionHTTP(request({ body: { choice: 'yes' } }), res, { path: '/api/decisions/d1/confirmation', decide: null, session: s.session, live: () => s.session, confirmations: s.confirmations, log: () => {} });
refused(res, 'read-only');
const out = response();
await decisionHTTP(request({ body: { choice: 'yes' } }), out, { path: '/api/decisions/d1/confirmation', decide: s.decide, session: null, live: () => null, confirmations: s.confirmations, log: () => {} });
refused(out, 'sign-in-required');
for (const headers of [{ origin: ['https://evil.test'] }, { origin: null }, { origin: [ORIGIN, ORIGIN] }, { 'sec-fetch-site': ['same-site'] }, { 'sec-fetch-site': null }]) {
refused(await s.run('/api/decisions/d1/confirmation', request({ body: { choice: 'yes' }, headers })), 'cross-origin');
}
assert.equal(s.calls.length, 0);
});
test('a bad body, id or content type refuses before the bus', async () => {
const s = setup();
const path = '/api/decisions/d1/confirmation';
refused(await s.run(path, request({ body: { choice: 'yes' }, headers: { 'content-type': ['text/plain'] } })), 'invalid-request');
refused(await s.run(path, request({ raw: '{' })), 'invalid-request');
refused(await s.run(path, request({ raw: '[]' })), 'invalid-request');
refused(await s.run(path, request({ body: { choice: 'yes', extra: 1 } })), 'invalid-request');
refused(await s.run(path, request({ body: { choice: 1 } })), 'invalid-request');
refused(await s.run(path, request({ raw: JSON.stringify({ choice: 'y'.repeat(5000) }) })), 'request-too-large');
refused(await s.run('/api/decisions/..%2Fx/confirmation', request({ body: { choice: 'yes' } })), 'decision-not-found');
refused(await s.run('/api/decisions/%E0%A4%A/confirmation', request({ body: { choice: 'yes' } })), 'decision-not-found');
refused(await s.resolve({ choice: 'yes' }), 'invalid-request');
refused(await s.resolve({ choice: 'yes', confirmation: 'a'.repeat(64), more: 1 }), 'invalid-request');
assert.equal(s.calls.length, 0);
});
test('a choice that is neither key is decision-stale, and a malformed preview is confirmation-unavailable', async () => {
refused(await setup().confirm('maybe'), 'decision-stale');
for (const bad of [{ ...PREVIEW, id: 'd2' }, { ...PREVIEW, digest: 'short' }, { ...PREVIEW, deny: { key: 'yes', text: 'No' } }, { ...PREVIEW, grant: null }, null]) {
const s = setup({ answer: () => bad });
refused(await s.confirm('yes'), 'confirmation-unavailable');
assert.equal(s.confirmations.size, 0);
}
});
test('bus refusals map by code, and an unknown or missing code is unavailable at preview and outcome-unknown at resolve', async () => {
const cases = [
['human-required', 'human-required'], ['agent-required', 'human-required'], ['access-revoked', 'human-required'],
['peercred-required', 'peercred-required'], ['decision-not-eligible', 'decision-not-eligible'],
['decision-closed', 'decision-closed'], ['decision-stale', 'decision-stale'], ['invalid-choice', 'decision-stale'],
['decision-not-found', 'decision-not-found'], ['outcome-unknown', 'confirmation-unavailable'], ['weird', 'confirmation-unavailable'],
];
for (const [bus, code] of cases) {
const s = setup({ answer: () => { throw new BusReadError(bus); } });
refused(await s.confirm('yes'), code);
}
refused(await setup({ answer: () => { throw new Error('boom'); } }).confirm('yes'), 'confirmation-unavailable');
for (const [bus, code] of [['decision-closed', 'decision-closed'], ['outcome-unknown', 'outcome-unknown'], ['weird', 'outcome-unknown']]) {
const s = setup({ resolve: () => { throw new BusReadError(bus); } });
const c = (await s.confirm('yes')).json();
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), code);
// Nothing retries, and the confirmation stays spent.
assert.equal(s.calls.length, 2);
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
}
for (const reply of [null, { id: 'd2', op: 'resolved', choice: 'yes' }, { id: 'd1', op: 'refused', choice: 'yes' }, { id: 'd1', op: 'resolved', choice: 'no' }]) {
const s = setup({ resolve: () => reply });
const c = (await s.confirm('yes')).json();
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'outcome-unknown');
}
});
test('a confirmation binds the session, decision and choice, and any mismatch spends it', async () => {
const s = setup();
let c = (await s.confirm('yes')).json();
refused(await s.resolve({ choice: 'no', confirmation: c.confirmation }), 'confirmation-required');
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
c = (await s.confirm('yes')).json();
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }, 'd2'), 'confirmation-required');
c = (await s.confirm('yes')).json();
const other = setup();
other.confirmations = s.confirmations;
other.session = { ...s.session };
other.current = other.session;
refused(await other.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
refused(await s.resolve({ choice: 'yes', confirmation: 'not-a-token' }), 'confirmation-required');
assert.equal(s.calls.filter((x) => x.verb === 'decision.resolve.console').length, 0);
assert.equal(other.calls.length, 0);
});
test('a session that ends between the steps refuses before the bus', async () => {
const s = setup();
s.current = null;
refused(await s.confirm('yes'), 'sign-in-required');
assert.equal(s.calls.length, 0);
s.current = s.session;
const c = (await s.confirm('yes')).json();
s.current = { ...s.session };
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'sign-in-required');
assert.equal(s.calls.length, 1);
// A sign-out during the preview also refuses, and no confirmation is issued.
const t = setup({ answer: () => { t.current = null; return { ...PREVIEW }; } });
refused(await t.confirm('yes'), 'sign-in-required');
assert.equal(t.confirmations.size, 0);
});
test('confirmations expire after the TTL, cap at the limit without dropping a live one, and are one use', () => {
let t = 0;
const store = createConfirmations({ ttlMs: 1000, max: 2, now: () => t });
const bind = { session: 's', business: 'demo', decision: 'd1', choice: 'yes', digest: DIGEST };
const a = store.issue(bind), b = store.issue(bind);
assert.equal(store.issue(bind), null);
assert.equal(store.size, 2);
assert.equal(store.consume(a, bind).digest, DIGEST);
assert.equal(store.consume(a, bind), null);
t = 1000;
assert.equal(store.consume(b, bind), null);
assert.equal(store.size, 0);
assert.match(store.issue(bind), /^[a-f0-9]{64}$/);
assert.equal(store.consume(undefined, bind), null);
assert.equal(store.consume('A'.repeat(64), bind), null);
});
test('A4 hire confirmation carries computed lines and resolves through org.hire, never decide',async()=>{
const flagged=[{field:'tools',value:'bash',reasons:['above-chief']}],credentialLines=[{service:'gitea',scope:'read:repository'}];
const s=setup({answer:()=>({id:'d1',business:'demo',kind:'hire',question:'hire scout?',digest:DIGEST,options:[{key:'yes',text:'Approve'},{key:'no',text:'Deny'}],flagged,credentialLines,expiresAt:'2099-01-01T00:00:00.000Z'}),resolve:a=>({id:a.id,op:'resolved',choice:a.choice})});
const c=await s.confirm('yes');assert.equal(c.status,200);
assert.deepEqual(c.json().flagged,flagged);assert.deepEqual(c.json().credentialLines,credentialLines);
const r=await s.resolve({choice:'yes',confirmation:c.json().confirmation});assert.equal(r.status,200);
assert.equal(s.calls[1].verb,'hire.resolve.console');
});
test('A4 Console start carries the signed-in subject and org, records once and never retries',async()=>{
const s=setup({resolve:()=>({id:'new-hire',op:'resolved',choice:'yes'})});
const hire={op:'hire',level:'org',name:'scout',reason:'Need help',role:{template:'coder',sha256:DIGEST},route:{harness:'pi',model:'claude-opus-5-5'}};
const r=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire}}));
assert.equal(r.status,200,r.text);assert.equal(s.calls.length,1);
assert.equal(s.calls[0].verb,'hire.start.console');assert.deepEqual(s.calls[0].args,hire);assert.equal(s.calls[0].ctx.business,'demo');
refused(await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire},headers:{origin:['https://elsewhere.test']}})),'cross-origin');
assert.equal(s.calls.length,1);
});
test('A4 confirmation holds the exact human adjustment, so resolve cannot substitute it',async()=>{
const adjust={role:{tools:['read']},credentials:[]};
const s=setup({answer:()=>({...ORDINARY,kind:'hire',flagged:[],credentialLines:[],expiresAt:'2099-01-01T00:00:00.000Z'}),resolve:a=>({id:a.id,op:'resolved',choice:a.choice})});
const c=await s.run('/api/decisions/d1/confirmation',request({body:{choice:'new/autonomous',adjust}}));
assert.equal(c.status,200,c.text);assert.deepEqual(s.calls[0].args,{id:'d1',adjust});
assert.equal(s.calls[0].verb,'hire.preview.console');
const r=await s.resolve({choice:'new/autonomous',confirmation:c.json().confirmation});
assert.equal(r.status,200,r.text);assert.deepEqual(s.calls[1].args.adjust,adjust);
});
// D4 (#1873, P1-2): a hire start the bus definitely refused before it recorded the decision keeps its
// own code and a next step; every other code, a lost or unreadable reply among them, is still
// outcome-unknown, and the other steps keep their mapping.
const START_HIRE={op:'hire',level:'org',name:'scout',reason:'Need help',role:{template:'coder',sha256:DIGEST},route:{harness:'pi',model:'gone-model'}};
const startWith=async err=>{
const s=setup({resolve:()=>{throw err;}});
const r=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:START_HIRE}}));
assert.equal(s.calls.length,1,'one bus call, never retried');
return {r,s};
};
test('D4 hire start: model-provider-unavailable keeps its code and names Settings > Model proxy',async()=>{
const {r,s}=await startWith(new BusReadError('model-provider-unavailable'));
assert.equal(r.status,409,r.text);
const body=r.json();
assert.equal(body.error,'model-provider-unavailable');
assert.match(body.message,/Settings > Model proxy/);
assert.match(body.message,/Nothing was applied/);
assert.equal(r.headers['cache-control'],'no-store');
assert.match(s.lines.at(-1),/step=start error=model-provider-unavailable$/);
});
test('D4 hire start: the other model route codes keep their code with a next step',async()=>{
for(const [code,status] of [['model-catalogue-unavailable',503],['model-harness-incompatible',409],['thinking-unsupported',409]]) {
const {r}=await startWith(new BusReadError(code));
assert.equal(r.status,status,`${code}: ${r.text}`);assert.equal(r.json().error,code);assert.match(r.json().message,/Nothing was applied/);
}
});
test('D4 hire start: the other proposal checks that run before the decision insert keep their code',async()=>{
for(const code of ['definition-not-hireable','definition-shared','no-chief-of-staff']) {
const {r}=await startWith(new BusReadError(code));
assert.equal(r.status,409,`${code}: ${r.text}`);assert.equal(r.json().error,code);assert.match(r.json().message,/Nothing was applied/);
assert.doesNotMatch(r.json().message,/\bhire was\b/,'worded for hire, fire and change alike');
}
});
// E2E6 P1-2: every definite refusal of a start keeps its own code and a next step. Only a reply that
// never came, could not be read, or the bus server's request codes stay outcome-unknown.
test('E2E6 hire start: an unknown project, an unknown agent on fire and an unknown harness keep their code',async()=>{
for(const [code,re] of [['project-agents-invalid',/code project/],['organization-unknown-instance',/Agents page/],['harness-off',/Settings > Routing/]]) {
const {r,s}=await startWith(new BusReadError(code));
assert.equal(r.status,409,`${code}: ${r.text}`);
assert.equal(r.json().error,code);assert.match(r.json().message,re);
// harness-off comes only from prepare()'s route check; the other two can also come from apply().
if(code==='harness-off')assert.match(r.json().message,/ Nothing was applied\.$/);
else {assert.match(r.json().message,/ Part of it may already be applied: check the Agents page before trying again\.$/);assert.doesNotMatch(r.json().message,/nothing was applied/i);}
assert.match(s.lines.at(-1),new RegExp(`step=start error=${code}$`));
}
});
test('E2E6 hire start: the other proposal and apply codes keep their code; apply-phase ones do not claim nothing changed',async()=>{
for(const code of ['unknown-model','harness-refused']) {
const {r}=await startWith(new BusReadError(code));
assert.equal(r.json().error,code,`${code}: ${r.text}`);assert.match(r.json().message,/ Nothing was applied\.$/);
}
// Review finding 1: apply() runs check() again and the writers run after the consume, so these
// never claim nothing changed, in any case or clause. The inbox lists no closed decision, so they
// do not send the user there to tell which side of the consume the refusal came from (re-check).
for(const code of ['organization-instance-exists','instance-kept','root-fire-shared','definition-rev-conflict','definition-missing','credential-leak','decision-stale','organization-instance-retired','organization-instance-referenced','organization-refused','definition-secret-check-required','not-configured','invalid-request']) {
const {r}=await startWith(new BusReadError(code));
assert.equal(r.json().error,code,`${code}: ${r.text}`);
assert.doesNotMatch(r.json().message,/nothing was applied/i,`${code} does not claim nothing changed`);
assert.doesNotMatch(r.json().message,/inbox/,`${code} does not point at the inbox, which drops a consumed decision`);
assert.match(r.json().message,/Part of it may already be applied: check the Agents page/);
}
for(const code of ['organization-busy','fire-in-progress','credential-revoking']) {
const {r}=await startWith(new BusReadError(code));
assert.equal(r.status,409);assert.equal(r.json().error,code);
assert.doesNotMatch(r.json().message,/Nothing was applied/);assert.match(r.json().message,/Agents page and the inbox/);
}
const {r}=await startWith(new BusReadError('some-future-code'));
assert.equal(r.status,409);assert.equal(r.json().error,'some-future-code');assert.match(r.json().message,/Agents page and the inbox/);
});
test('E2E6 hire start: an apply-phase decision-stale on a fire does not say nothing was applied',async()=>{
// #hireConsole consumes the decision and stops a project agent's runs before apply() checks again.
const s=setup({resolve:()=>{throw new BusReadError('decision-stale');}});
const r=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:{op:'fire',level:'project',project:'site',name:'coder',reason:'done'}}}));
assert.equal(s.calls.length,1);assert.equal(r.status,409,r.text);assert.equal(r.json().error,'decision-stale');
assert.doesNotMatch(r.json().message,/nothing was applied/i);
assert.doesNotMatch(r.json().message,/inbox|answer it there/,'a consumed decision is gone from the inbox');
assert.match(r.json().message,/check the Agents page/);
});
test('E2E6 hire start: invalid-request from the bus says the values, not the shape, were refused',async()=>{
const {r}=await startWith(new BusReadError('invalid-request'));
assert.equal(r.status,400);assert.equal(r.json().error,'invalid-request');assert.match(r.json().message,/Settings > Model proxy/);
});
test('E2E6 hire start: the bus server request codes and organization-uncertain stay outcome-unknown',async()=>{
for(const code of ['revision-conflict-revoked','request-timeout','invalid-envelope','incomplete-request','organization-uncertain'])
refused((await startWith(new BusReadError(code))).r,'outcome-unknown');
});
// E2E6 P2-4: a malformed proposal names its field and the rule, and never reaches the bus.
test('E2E6 hire start: a malformed proposal names the field and the rule before the bus',async()=>{
const cases=[
[{...START_HIRE,name:'Bad Name'},'name',/lowercase/],
[(({route,...h})=>h)(START_HIRE),'route',/harness and a model/],
[{...START_HIRE,route:{harness:'pi'}},'route.model',/required/],
[{...START_HIRE,level:'project'},'project',/code project/],
[{...START_HIRE,project:'north'},'project',/only for project level/],
[{...START_HIRE,reason:' '},'reason',/blank/],
[{...START_HIRE,op:'promote'},'op',/hire, fire or change/],
[{op:'fire',level:'org',name:'scout',reason:'r',route:START_HIRE.route},'route',/not used by fire/],
[{...START_HIRE,role:{template:'coder',sha256:'x'}},'role.sha256',/digest/],
[{...START_HIRE,role:{template:'coder',sha256:DIGEST,definition:'x'}},'role',/exactly one/],
[{...START_HIRE,colour:'blue'},'colour',/not a hire field/],
];
for(const [hire,field,rule] of cases) {
const s=setup({resolve:()=>{throw new Error('must not be called');}});
const r=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire}}));
assert.equal(r.status,400,`${field}: ${r.text}`);
const body=r.json();
assert.equal(body.error,'invalid-request');assert.equal(body.field,field);assert.match(body.rule,rule);
assert.match(body.message,/Nothing was sent to the bus/);assert.ok(body.message.toLowerCase().startsWith(field.toLowerCase()),body.message);
assert.equal(s.calls.length,0,`${field}: no bus call`);
}
});
test('D4 hire start: a lost or unreadable reply is still outcome-unknown',async()=>{
for(const err of [new BusReadError('outcome-unknown'),new BusReadError('invalid-response'),new BusReadError('response-too-large'),new BusReadError('outcome-unknown-revoked'),new BusReadError('Not A Code'),new BusReadError(''),new Error('socket closed'),Object.assign(new Error('x'),{code:'model-provider-unavailable'})])
refused((await startWith(err)).r,'outcome-unknown');
// A reply that does not say the hire resolved yes is not a refusal either.
const s=setup({resolve:()=>({id:'new-hire',op:'resolved',choice:'no'})});
refused(await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:START_HIRE}})),'outcome-unknown');
});
test('D4 hire start: the bus codes the endpoint already names keep their refusal',async()=>{
refused((await startWith(new BusReadError('unauthenticated'))).r,'human-required');
refused((await startWith(new BusReadError('right-required'))).r,'right-required');
refused((await startWith(new BusReadError('hire-owner-required'))).r,'hire-owner-required');
});
test('D4 templates, confirmation and resolve keep their open mapping for a definite unnamed code',async()=>{
const t=setup({resolve:()=>{throw new BusReadError('model-provider-unavailable');}});
refused(await t.run('/api/decisions/hire/templates',request({body:{business:'demo'}})),'confirmation-unavailable');
const c=setup({answer:()=>{throw new BusReadError('model-provider-unavailable');}});
refused(await c.confirm('yes'),'confirmation-unavailable');
const s=setup({resolve:()=>{throw new BusReadError('model-provider-unavailable');}});
const ok=await s.confirm('yes');assert.equal(ok.status,200);
refused(await s.resolve({choice:'yes',confirmation:ok.json().confirmation}),'outcome-unknown');
});
// #1873 CB5: a copy credential binding decision on the Console. The confirmation carries the bus's
// computed scope lines, the copy's name and whether it binds or unbinds, and nothing else of the
// binding; the approved answer carries the owner's fixed next step. The sentinel stands for a token
// the bus should never send: placed in every binding field the bus could add, it never reaches a
// response or a log line.
const SENTINEL='sentinel-0123456789abcdef0123456789abcdef01234567';
const bindingPreview=(after)=>({id:'d1',business:'demo',kind:'hire',question:'bind credentials for copy researcher?',digest:DIGEST,
options:[{key:'yes',text:'Approve'},{key:'no',text:'Deny'}],flagged:[],credentialLines:[{service:'gitea',scope:'read:repository'}],expiresAt:'2099-01-01T00:00:00.000Z',
binding:{instance:'researcher',source:{level:'root',name:SENTINEL,project:null},copyId:SENTINEL,fresh:true,orgIncarnation:SENTINEL,definition:{ref:SENTINEL,sha256:SENTINEL},
before:{gitea:{slot:SENTINEL,grantId:SENTINEL,scopes:['read:repository'],resources:{projects:['stack']}}},after,generation:0}});
const leaks=(s,...texts)=>texts.filter(t=>t.includes(SENTINEL)||t.includes(SENTINEL.slice(0,16))).length;
test('CB5 binding confirmation names the copy and the change with the computed scope lines, and the approval names the next step; nothing else of the binding leaves',async()=>{
const after={gitea:{slot:'self',scopes:['read:repository'],resources:{projects:['stack']},token:SENTINEL}};
const s=setup({answer:()=>bindingPreview(after),resolve:a=>({id:a.id,op:'resolved',choice:a.choice,transaction:SENTINEL,binding:{token:SENTINEL}})});
const c=await s.confirm('yes');assert.equal(c.status,200,c.text);
const body=c.json();
assert.deepEqual({...body,confirmation:'x'},{id:'d1',question:'bind credentials for copy researcher?',action:'answer',choice:'yes',text:'Approve',kind:'hire',
flagged:[],credentialLines:[{service:'gitea',scope:'read:repository'}],expiresAt:'2099-01-01T00:00:00.000Z',binding:{instance:'researcher',change:'bind'},confirmation:'x'});
const r=await s.resolve({choice:'yes',confirmation:body.confirmation});assert.equal(r.status,200,r.text);
assert.deepEqual(r.json(),{id:'d1',op:'resolved',choice:'yes',next:BINDING_NEXT.bind});
assert.equal(s.calls[1].verb,'hire.resolve.console');
assert.equal(leaks(s,c.text,r.text,...s.lines),0);
assert.match(s.lines[0],/ step=confirmation action=answer$/);assert.match(s.lines[1],/ step=resolve choice="yes"$/);
});
test('CB5 an unbind says the bus revokes; a denial carries no next step; a malformed binding is not echoed',async()=>{
const u=setup({answer:()=>bindingPreview(null),resolve:a=>({id:a.id,op:'resolved',choice:a.choice})});
const uc=await u.confirm('yes');assert.deepEqual(uc.json().binding,{instance:'researcher',change:'unbind'});
const ur=await u.resolve({choice:'yes',confirmation:uc.json().confirmation});
assert.deepEqual(ur.json(),{id:'d1',op:'resolved',choice:'yes',next:BINDING_NEXT.unbind});
const d=setup({answer:()=>bindingPreview(null),resolve:a=>({id:a.id,op:'resolved',choice:a.choice})});
const dc=await d.confirm('no');
assert.deepEqual((await d.resolve({choice:'no',confirmation:dc.json().confirmation})).json(),{id:'d1',op:'resolved',choice:'no'});
// An instance name the subject check refuses, or an after that is neither null nor an object, sends
// no binding field (the copy name is the only binding value the page shows).
for(const bad of [{instance:'../'+SENTINEL,after:null},{instance:'researcher',after:[SENTINEL]},{instance:'researcher',after:SENTINEL}]){
const b=setup({answer:()=>({...bindingPreview(null),binding:{...bindingPreview(null).binding,...bad}}),resolve:a=>({id:a.id,op:'resolved',choice:a.choice})});
const bc=await b.confirm('yes');assert.equal(bc.status,200,bc.text);
assert.equal(Object.hasOwn(bc.json(),'binding'),false,bc.text);
const br=await b.resolve({choice:'yes',confirmation:bc.json().confirmation});
assert.equal(Object.hasOwn(br.json(),'next'),false,br.text);
assert.equal(leaks(b,bc.text,br.text,...b.lines),0);
}
assert.equal(leaks(u,uc.text,ur.text,...u.lines,...d.lines),0);
});
test('CB5 a binding recorded and approved through the Console start names the next step by its shape, and only a binding start does',async()=>{
const s=setup({resolve:()=>({id:'b1',op:'resolved',choice:'yes',binding:{token:SENTINEL}})});
const bind={op:'change',level:'root',name:'researcher',reason:'token '+SENTINEL,binding:{services:{gitea:{scopes:['read:repository'],projects:['stack']}}}};
const r=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:bind}}));
assert.equal(r.status,200,r.text);assert.deepEqual(r.json(),{id:'b1',op:'resolved',choice:'yes',next:BINDING_NEXT.bind});
const u=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:{...bind,binding:{services:null}}}}));
assert.deepEqual(u.json(),{id:'b1',op:'resolved',choice:'yes',next:BINDING_NEXT.unbind});
const h=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:START_HIRE}}));
assert.deepEqual(h.json(),{id:'b1',op:'resolved',choice:'yes'});
assert.equal(leaks(s,r.text,u.text,h.text,...s.lines),0);
for(const t of Object.values(BINDING_NEXT))assert.equal(leaks(s,t),0);
});