The catalogue refusals no longer tell every user to press Disable model proxy catalogue. The server text names the proxy setup step. The Routing page adds the Disable step only when cliproxy is listed and every moved model comes from another listed provider, and sends a tester to the owner. The switch mismatch message no longer claims a setting changed. Tests: a fallback thinking-only change saves with no catalogue; a move between model-proxy models refuses before and after cliproxy is removed.
194 lines
13 KiB
JavaScript
194 lines
13 KiB
JavaScript
// Row 65 (#1558, REQ-PLUG-2, mos-0840): the Console's routing table read and write. The write has
|
|
// the Settings boundary and makes one human-transport call, `routes.set`; the route rules are the
|
|
// bus's and the business writer's, tested there. The first test runs a real broker over a temporary
|
|
// organization directory, so a save reaches the file and the log, and each refusal leaves both.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { request } from 'node:http';
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { startServer } from '../src/serve.mjs';
|
|
import { BusReadError } from '../src/bus.mjs';
|
|
import { BusError } from '../../bus/src/broker.mjs';
|
|
import { startBroker } from '../../bus/src/runtime.mjs';
|
|
import { systemFor } from '../../organization/tests/helpers.mjs';
|
|
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
|
|
import { createSsoSession } from './helpers/sso-session.mjs';
|
|
|
|
const REV = 'a'.repeat(64);
|
|
const ROUTE = { harness: 'pi', model: 'glm-5.3-flash', thinking: 'low', fallback: null };
|
|
const view = (extra = {}) => ({ business: 'acme', revision: 'b'.repeat(64), harnesses: ['pi'], models: ['glm-5.3-flash'], levels: { pi: ['low'] }, instances: { coder: ROUTE }, ...extra });
|
|
|
|
async function setup(t, { on = true, writer = on, write = async () => view(), bus = null } = {}) {
|
|
const calls = [], lines = [];
|
|
const auth = await createSsoSession(t, { host: 'console.example' });
|
|
const server = await startServer({
|
|
port: 0, sso: auth.sso, bus, log: line => lines.push(line), send: on ? async () => ({}) : null,
|
|
routesWrite: writer ? async args => { calls.push(args); return write(args); } : null,
|
|
});
|
|
t.after(() => new Promise(r => server.close(r)));
|
|
const session = await auth.signIn(server.address().port);
|
|
return { calls, lines, port: server.address().port, headers: auth.writeHeaders(session) };
|
|
}
|
|
const send = (s, path, data, hs = s.headers, method = 'POST') => new Promise((resolve, reject) => {
|
|
const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => {
|
|
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text ? JSON.parse(text) : null }));
|
|
});
|
|
req.on('error', reject);
|
|
req.end(data === undefined ? undefined : typeof data === 'string' ? data : JSON.stringify(data));
|
|
});
|
|
const get = (s, path) => send(s, path, undefined, s.headers, 'GET');
|
|
|
|
test('row 65 Console: the signed-in human saves a route through a real broker; each refusal says nothing changed and leaves the file and the log', async t => {
|
|
const root = tmp(t, 'webui-routes-');
|
|
const f = fixture(root, 'acme');
|
|
const dir = join(root, 'config');
|
|
const host = await startBroker({
|
|
dataRoot: f.dataRoot,
|
|
businesses: { acme: { id: 'acme', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
|
organization: { dir, rolesDir: f.roles, system: systemFor(root) },
|
|
});
|
|
t.after(() => host.close());
|
|
const human = host.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
|
const call = async (verb, args) => {
|
|
try { return structuredClone(host.broker.request(human, { verb, args })); } catch (e) {
|
|
if (e instanceof BusError) throw new BusReadError(e.code);
|
|
throw e;
|
|
}
|
|
};
|
|
const s = await setup(t, { bus: { routes: () => call('routes') }, write: args => call('routes.set', args) });
|
|
const file = join(dir, 'businesses', 'acme.json');
|
|
const log = () => readFileSync(join(dir, 'businesses', '.history', 'acme', 'changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
|
|
const read = await get(s, '/api/bus/routes');
|
|
assert.equal(read.status, 200);
|
|
const [current] = read.body.rows;
|
|
assert.deepEqual(current.harnesses, ['pi', 'claude-code']);
|
|
assert.deepEqual(current.levels['claude-code'], ['low', 'medium', 'high', 'xhigh', 'max']);
|
|
assert.deepEqual(current.instances.coder, { harness: 'pi', model: 'glm-5.3-flash', thinking: 'high', fallback: null });
|
|
|
|
const route = { harness: 'claude-code', model: 'claude-opus-5-5', thinking: 'xhigh', fallback: { harness: 'pi', model: 'glm-5.3-flash', thinking: 'off' } };
|
|
const saved = await send(s, '/api/routes', { revision: current.revision, routes: { coder: route } });
|
|
assert.equal(saved.status, 200, JSON.stringify(saved.body));
|
|
assert.deepEqual(saved.body.instances.coder, route);
|
|
assert.deepEqual(s.calls, [{ revision: current.revision, routes: { coder: route } }]);
|
|
assert.match(s.lines.at(-1), / routes user="jason" instances="coder" done$/);
|
|
const entry = log().at(-1);
|
|
assert.deepEqual([entry.actor, entry.state, entry.transaction], ['jason', 'committed', saved.body.transaction]);
|
|
const vars = JSON.parse(readFileSync(file, 'utf8')).roles.coder.vars;
|
|
assert.deepEqual(['harness', 'model', 'thinking', 'fallback.harness', 'fallback.model', 'fallback.thinking'].map(k => vars[k]), ['claude-code', 'claude-opus-5-5', 'xhigh', 'pi', 'glm-5.3-flash', 'off']);
|
|
|
|
const before = readFileSync(file, 'utf8'), entries = log().length;
|
|
const coder = more => ({ coder: { ...route, ...more } });
|
|
for (const [routes, revision, status, code, why] of [
|
|
[coder({ harness: 'cursor' }), saved.body.revision, 400, 'harness-off', /isn't switched on in the business file's plugins\.harness/],
|
|
[coder({ thinking: 'minimal' }), saved.body.revision, 400, 'thinking-unsupported', /doesn't apply that thinking level/],
|
|
[coder({ fallback: { harness: 'pi', model: 'glm-5.3-flash' } }), saved.body.revision, 400, 'organization-partial-fallback', /sets its harness, model and thinking together, or is cleared/],
|
|
[{ ghost: route }, saved.body.revision, 400, 'organization-unknown-instance', /no such instance/],
|
|
[{ coder: route }, saved.body.revision, 400, 'organization-no-change', /already says that/],
|
|
[coder({ thinking: 'low' }), current.revision, 409, 'revision-conflict', /changed since you read it/],
|
|
]) {
|
|
const r = await send(s, '/api/routes', { revision, routes });
|
|
assert.deepEqual([r.status, r.body.error, r.body.sent], [status, code, false], code);
|
|
assert.match(r.body.message, why, code);
|
|
assert.match(r.body.message, new RegExp(`^${code}: .*Nothing changed\\.$`), code);
|
|
}
|
|
assert.equal(readFileSync(file, 'utf8'), before);
|
|
assert.equal(log().length, entries);
|
|
});
|
|
|
|
test('row 65 Console: the routes write needs send on, a writer, the public name, the user, the exact Origin and same-origin Fetch Metadata', async t => {
|
|
const data = { revision: REV, routes: { coder: ROUTE } };
|
|
const off = await setup(t, { on: false });
|
|
assert.equal((await send(off, '/api/routes', data)).status, 403);
|
|
const unsent = await setup(t, { on: false, writer: true });
|
|
assert.equal((await send(unsent, '/api/routes', data)).status, 403);
|
|
assert.equal(unsent.calls.length, 0);
|
|
const unwritten = await setup(t, { writer: false });
|
|
assert.equal((await send(unwritten, '/api/routes', data)).status, 403);
|
|
const s = await setup(t);
|
|
for (const [key, value, status] of [['host', 'wrong.example', 403], ['cookie', '__Host-mosaic_session=invalid', 401], ['cookie', undefined, 401], ['origin', 'https://else.example', 403], ['origin', undefined, 403], ['sec-fetch-site', 'cross-site', 403], ['sec-fetch-site', undefined, 403]]) {
|
|
const hs = { ...s.headers };
|
|
if (value === undefined) delete hs[key]; else hs[key] = value;
|
|
assert.equal((await send(s, '/api/routes', data, hs)).status, status, key);
|
|
}
|
|
assert.equal((await send(s, '/api/routes', undefined, s.headers, 'GET')).status, 405);
|
|
assert.equal(s.calls.length, 0);
|
|
});
|
|
|
|
test('row 65 Console: the routes write takes JSON within 16 KiB with a revision and routes only; an actor is refused and routes are never logged', async t => {
|
|
const s = await setup(t);
|
|
const base = { revision: REV, routes: { coder: ROUTE } };
|
|
for (const value of [
|
|
{ ...base, actor: 'mos' }, { ...base, instances: { coder: 'x' } }, { revision: REV }, { routes: base.routes },
|
|
{ ...base, revision: 'stale' }, { ...base, revision: 7 }, { ...base, routes: {} }, { ...base, routes: [] }, { ...base, routes: null },
|
|
[], 'bad', { ...base, routes: { coder: { ...ROUTE, model: 'x'.repeat(17000) } } },
|
|
]) assert.equal((await send(s, '/api/routes', value)).status, 400, JSON.stringify(value).slice(0, 60));
|
|
assert.equal((await send(s, '/api/routes', base, { ...s.headers, 'content-type': 'text/plain' })).status, 400);
|
|
assert.equal(s.calls.length, 0);
|
|
// A route with a model the bus refuses: the log names the instance and the code, never the route.
|
|
const refused = await setup(t, { write: async () => { throw new BusReadError('unknown-model'); } });
|
|
await send(refused, '/api/routes', { revision: REV, routes: { coder: { ...ROUTE, model: 'secret-model-name' } } });
|
|
assert.deepEqual(refused.lines.map(l => l.replace(/^\S+ /, '')), ['routes user="jason" instances="coder" error=unknown-model']);
|
|
assert.ok([...s.lines, ...refused.lines].every(l => !/xxxx|secret-model-name|glm/.test(l)), 'routes are never logged');
|
|
});
|
|
|
|
test('row 65 Console: a bus refusal maps to its status and says nothing changed; a lost or odd answer says the change may have been made', async t => {
|
|
const fail = code => async () => { throw new BusReadError(code); };
|
|
for (const [write, status, error, sent] of [
|
|
[fail('harness-off'), 400, 'harness-off', false], [fail('harness-refused'), 400, 'harness-refused', false],
|
|
[fail('thinking-unsupported'), 400, 'thinking-unsupported', false], [fail('unknown-model'), 400, 'unknown-model', false],
|
|
[fail('organization-partial-fallback'), 400, 'organization-partial-fallback', false],
|
|
[fail('organization-unknown-instance'), 400, 'organization-unknown-instance', false],
|
|
[fail('organization-no-change'), 400, 'organization-no-change', false], [fail('revision-conflict'), 409, 'revision-conflict', false],
|
|
[fail('human-required'), 403, 'human-required', false], [fail('invalid-request'), 400, 'invalid-request', false],
|
|
[fail('outcome-unknown'), 503, 'outcome-unknown', 'unknown'], [async () => { throw new Error('lost'); }, 503, 'outcome-unknown', 'unknown'],
|
|
// Each part of the answer's shape wrong alone, and an answer without the instance sent.
|
|
[async () => view({ revision: 'x' }), 503, 'outcome-unknown', 'unknown'],
|
|
[async () => view({ harnesses: null }), 503, 'outcome-unknown', 'unknown'],
|
|
[async () => view({ models: {} }), 503, 'outcome-unknown', 'unknown'],
|
|
[async () => view({ levels: [] }), 503, 'outcome-unknown', 'unknown'],
|
|
[async () => view({ instances: null }), 503, 'outcome-unknown', 'unknown'],
|
|
[async () => view({ instances: {} }), 503, 'outcome-unknown', 'unknown'],
|
|
]) {
|
|
const s = await setup(t, { write });
|
|
const r = await send(s, '/api/routes', { revision: REV, routes: { coder: ROUTE } });
|
|
assert.deepEqual([r.status, r.body.error, r.body.sent], [status, error, sent], error);
|
|
if (sent === false) assert.match(r.body.message, new RegExp(`^${error}: .*Nothing changed\\.$`), error);
|
|
else assert.match(r.body.message, /may have been made/, error);
|
|
assert.equal(s.calls.length, 1);
|
|
assert.match(s.lines[0], new RegExp(` routes user="jason" instances="coder" error=${error}$`));
|
|
}
|
|
});
|
|
|
|
test('row 65 Console: the routes read takes no arguments, needs a bus and refuses an odd answer', async t => {
|
|
const none = await setup(t);
|
|
assert.equal((await get(none, '/api/bus/routes')).status, 503);
|
|
const s = await setup(t, { bus: { routes: async () => view() } });
|
|
assert.equal((await get(s, '/api/bus/routes?x=1')).status, 400);
|
|
assert.deepEqual((await get(s, '/api/bus/routes')).body.rows, [view()]);
|
|
for (const odd of [{ revision: 'x' }, { harnesses: 'pi' }, { models: null }, { levels: [] }, { instances: [] }]) {
|
|
const r = await setup(t, { bus: { routes: async () => view(odd) } });
|
|
assert.equal((await get(r, '/api/bus/routes')).status, 502, JSON.stringify(odd));
|
|
}
|
|
const refused = await setup(t, { bus: { routes: async () => { throw new BusReadError('human-required'); } } });
|
|
assert.equal((await get(refused, '/api/bus/routes')).status, 403);
|
|
});
|
|
|
|
// The Disable step is the Routing page's to add (bus.js catalogueStep), since only the page knows
|
|
// whether pressing it can help; the server text never names it.
|
|
test('D3 P0-4: a model refusal keeps its status and names the next step that makes the save apply', async t => {
|
|
for (const [code, step] of [
|
|
['model-catalogue-unavailable', /A change to thinking alone still saves; a model from the model proxy needs the proxy set up first\. Nothing changed\.$/],
|
|
['model-provider-unavailable', /A change to thinking alone still saves; otherwise pick a model the catalogue lists, or set up the model proxy when the model comes from it\. Nothing changed\.$/],
|
|
['model-harness-incompatible', /Pick a harness the model lists, or another model/],
|
|
['model-key-collision', /Pick the table's entry or another key/],
|
|
]) {
|
|
const s = await setup(t, { write: async () => { throw new BusReadError(code); } });
|
|
const r = await send(s, '/api/routes', { revision: REV, routes: { coder: ROUTE } });
|
|
assert.deepEqual([r.status, r.body.error, r.body.sent], [502, code, false], code);
|
|
assert.match(r.body.message, new RegExp(`^${code}: .*Nothing changed\\.$`), code);
|
|
assert.match(r.body.message, step, code);
|
|
assert.doesNotMatch(r.body.message, /refused by the bus|Disable/, code);
|
|
}
|
|
});
|