Files
stack/packages/webui/tests/agent-levels-edit-browser.test.mjs
jason.woltje a4eed1e219 fix(agents): A2 review round: config check, name rule, drafts and test gaps (#1873)
The CLI agent verbs validate the instance config through loadSystem before
any read or write, and print a fixed line for errors they do not own. The
root writer refuses a config directory with an ancestor another user can
write without the sticky bit. The project HTTP route checks agent names
with the organization ID_PATTERN and answers 400 naming the key.

The Settings level forms take their own class, so the role-instance
handlers no longer catch them, and keep both drafts in module state with
the revision each began at. The organization writer tests now reach the
file's own uid check and test the assistant protection with an assistant
in the fixture.
2026-11-10 18:25:54 -06:00

163 lines
15 KiB
JavaScript

// #1873 A2: Settings > Agents, the root and project edit forms against their grids, in a real
// browser on the real routes and writers (a temporary config directory with one organization).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
import { loadRootAgents } from '../../organization/src/index.mjs';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { agentLevelsWriteRoutes } from '../src/agent-levels-write.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
const SECRET = 'sk-test-NOT-A-SECRET-1234';
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
// orgSend gives the selected organization a send transport, so the role-instance Settings forms
// are writable too and their handlers are live beside the level forms.
async function open(t, { send = true, orgSend = false } = {}) {
const root = mkdtempSync(join(tmpdir(), 'ale-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
const file = writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
const doc = () => JSON.parse(readFileSync(file, 'utf8'));
const instances = { business: 'demo', revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {},
instances: { chief: { definition: 'chief-of-staff', vars: {}, launched: true, launcher: false, protected: false, kept: false, running: [], alive: [] } } };
const organizations = {
list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }],
members: async () => [{ id: 'demo', doc: doc() }],
resolve: async id => id !== 'demo' ? null : {
busHealthy: true, ...(orgSend ? { send: async () => ({}) } : {}),
reads: { business: async () => ({ business: { id: 'demo', human: 'jason', arbiters: ['chief'], projects: ['stack'], vars: {}, instances: [{ instance: 'chief', vars: {} }] } }),
settings: async () => ({ business: 'demo', release: 'fixture', notifier: {}, launch: { limits: {} } }), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: { instances: async () => instances, messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] },
chat: { projects: async () => [{ id: 'stack', name: 'Stack' }], threads: async () => [] },
},
};
const agentLevels = { loadRoot: () => loadRootAgents(root, { rolesDir }), rolesDir, ctx: { configDir: root, dataRoot: join(root, 'data'), instance: 'test' } };
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, agentLevels, send: send ? async () => ({}) : null, log: () => {},
routes: [agentLevelsWriteRoutes({ configDir: root, rolesDir })] });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/demo/settings/agents`);
return { b, wait: waitFor(b), file, root };
}
const fill = (b, form, values) => b.evaluate(`(() => { const f = document.getElementById(${JSON.stringify(form)}); for (const [k, v] of Object.entries(${JSON.stringify(values)})) { f.elements[k].value = v; } f.requestSubmit(); })()`);
// Typing: each value set, then input and change fired, as a person's edit fires them.
const type = (b, form, values) => b.evaluate(`(() => { const f = document.getElementById(${JSON.stringify(form)}); for (const [k, v] of Object.entries(${JSON.stringify(values)})) { const el = f.elements[k]; el.value = v; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } })()`);
const values = (b, form) => b.evaluate(`Object.fromEntries(new FormData(document.getElementById(${JSON.stringify(form)})))`);
const receipt = which => `(document.getElementById("agent-levels-${which}-receipt")?.textContent || "")`;
test('the root form adds a root agent and the grid repaints from a fresh read; a refusal names the key, keeps the draft and never shows the value', async t => {
const { b, wait, root } = await open(t);
await wait('document.getElementById("agent-levels-root-form")');
assert.match(await b.evaluate('document.getElementById("agent-levels-root-grid").textContent'), /does not exist yet/);
const before = await b.evaluate('document.getElementById("agent-levels-root-form").dataset.revision');
await fill(b, 'agent-levels-root-form', { name: 'researcher', definition: 'research', model: 'claude-opus-5-5', vars: 'limits.network=none', excludeOrgs: 'home' });
await wait('document.querySelector("[data-root-agent=\\"researcher\\"]")');
await wait(`${receipt('root')}.startsWith("Saved researcher at ")`);
const row = await b.evaluate('document.querySelector("[data-root-agent=\\"researcher\\"]").innerText');
assert.match(row, /research/); assert.match(row, /claude-opus-5-5/); assert.match(row, /limits\.network/); assert.match(row, /home/);
assert.doesNotMatch(row, /\bnone\b/, 'only the model value is shown');
assert.notEqual(await b.evaluate('document.getElementById("agent-levels-root-form").dataset.revision'), before, 'the form carries the new revision');
assert.equal(await b.evaluate('document.getElementById("agent-levels-root-form").elements.name.value'), '', 'a saved form is cleared');
// A value the root file can't take: the key is named, the value is not, the draft stays.
await fill(b, 'agent-levels-root-form', { name: 'researcher', definition: '', model: SECRET.repeat(10), vars: '', excludeOrgs: '' });
await wait(`${receipt('root')}.startsWith("Refused (agents-invalid)")`);
const page = await b.evaluate('document.getElementById("agent-levels-edit").innerText');
assert.match(page, /model/); assert.ok(!page.includes(SECRET), 'the value never shows outside the input');
assert.equal(await b.evaluate('document.getElementById("agent-levels-root-form").elements.model.value'), SECRET.repeat(10));
assert.doesNotMatch(readFileSync(join(root, 'agents.json'), 'utf8'), /NOT-A-SECRET/);
// Remove asks once more, then the row goes.
await b.evaluate('document.querySelector("[data-level-remove=root][data-level-name=researcher]").click()');
await wait('document.querySelector("[data-level-remove=root][data-level-name=researcher]").textContent === "Confirm remove"');
await b.evaluate('document.querySelector("[data-level-remove=root][data-level-name=researcher]").click()');
await wait(`!document.querySelector("[data-root-agent=\\"researcher\\"]") && ${receipt('root')}.startsWith("Removed researcher")`);
});
test('the project form defines, narrows and excludes, each row repainting with its form; a widening narrow is refused without the value', async t => {
const { b, wait, file } = await open(t);
await wait('document.getElementById("agent-levels-project-form")');
assert.equal(await b.evaluate('document.getElementById("agent-levels-org").value'), 'demo');
assert.equal(await b.evaluate('document.getElementById("agent-levels-project").value'), 'stack');
assert.match(await b.evaluate('document.getElementById("agent-levels-project-grid").textContent'), /No project entries/);
await fill(b, 'agent-levels-project-form', { name: 'checker', form: 'define', definition: 'fact-checker', vars: 'model=claude-sonnet-5-5' });
await wait('document.querySelector("[data-project-agent=checker][data-form=define]")');
await wait(`${receipt('project')}.startsWith("Saved checker in stack at ")`);
await fill(b, 'agent-levels-project-form', { name: 'coder', form: 'narrow', definition: '', vars: 'limits.network=none' });
await wait('document.querySelector("[data-project-agent=coder][data-form=narrow]")');
await fill(b, 'agent-levels-project-form', { name: 'reviewer', form: 'exclude', definition: '', vars: '' });
await wait('document.querySelector("[data-project-agent=reviewer][data-form=exclude]")');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-project-agent]")].map(r => r.dataset.projectAgent + ":" + r.dataset.form)'),
['checker:define', 'coder:narrow', 'reviewer:exclude']);
assert.deepEqual(Object.keys(JSON.parse(readFileSync(file, 'utf8')).projects.stack.agents).sort(), ['checker', 'coder', 'reviewer']);
const before = readFileSync(file, 'utf8');
await fill(b, 'agent-levels-project-form', { name: 'coder', form: 'narrow', definition: '', vars: `model=${SECRET}` });
await wait(`${receipt('project')}.startsWith("Refused (project-agents-invalid)")`);
assert.match(await b.evaluate(receipt('project')), /can only narrow/);
assert.equal(await b.evaluate('document.getElementById("agent-levels-project-form").elements.vars.value'), `model=${SECRET}`, 'a refused form keeps the draft');
assert.ok(!(await b.evaluate('document.getElementById("agent-levels-edit").innerText')).includes(SECRET));
assert.equal(readFileSync(file, 'utf8'), before);
// A client-side refusal names the line, never its text.
await fill(b, 'agent-levels-project-form', { name: 'coder', form: 'narrow', definition: '', vars: SECRET });
await wait(`${receipt('project')} === "Variables line 1 needs KEY=VALUE."`);
await b.evaluate('document.querySelector("[data-level-remove=project][data-level-name=checker]").click()');
await b.evaluate('document.querySelector("[data-level-remove=project][data-level-name=checker]").click()');
await wait(`!document.querySelector("[data-project-agent=checker]") && ${receipt('project')}.startsWith("Removed checker from stack")`);
});
test('without --send both levels read but offer no form', async t => {
const { b, wait } = await open(t, { send: false });
await wait('document.getElementById("agent-levels-edit")');
assert.match(await b.evaluate('document.getElementById("agent-levels-edit").innerText'), /started without writes/);
assert.equal(await b.evaluate('!!document.getElementById("agent-levels-root-form") || !!document.getElementById("agent-levels-project-form")'), false);
});
test('both drafts survive a refresh render and the other form\'s save; a draft keeps its revision; the role-instance handlers leave the level forms alone', async t => {
const { b, wait, root } = await open(t, { orgSend: true });
await wait('document.getElementById("agent-levels-root-form") && document.getElementById("agent-levels-project-form")');
assert.equal(await b.evaluate('!!document.getElementById("instance-add-form")'), true, 'the role-instance forms are writable in this fixture');
assert.equal(await b.evaluate('document.querySelectorAll("#agent-levels-edit .instance-form").length'), 0);
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#agent-levels-root-form .filters")).display'), 'grid', 'the level forms keep the Settings form layout');
await type(b, 'agent-levels-root-form', { name: 'drafted', vars: 'limits.network=none' });
await type(b, 'agent-levels-project-form', { name: 'helper', form: 'narrow', vars: 'limits.tools=read' });
assert.equal(await b.evaluate('!!document.querySelector("#settings-nav a[href$=\'settings/agents\'] .settings-dot")'), true, 'the nav marks unsaved changes');
// A full Settings render, as the ten-second poll does: the edit box is a new node, the drafts are in it.
await b.evaluate('document.getElementById("agent-levels-edit").dataset.old = "1"; document.getElementById("refresh").click()');
await wait('document.getElementById("agent-levels-edit") && !document.getElementById("agent-levels-edit").dataset.old');
assert.deepEqual(await values(b, 'agent-levels-root-form'), { name: 'drafted', definition: '', model: '', vars: 'limits.network=none', unset: '', excludeOrgs: '' });
assert.deepEqual(await values(b, 'agent-levels-project-form'), { name: 'helper', form: 'narrow', definition: '', vars: 'limits.tools=read' });
// The root form saves; the project draft stays as typed and the root draft clears.
await type(b, 'agent-levels-root-form', { name: 'researcher', definition: 'research', vars: '' });
await b.evaluate('document.getElementById("agent-levels-root-form").requestSubmit()');
await wait(`${receipt('root')}.startsWith("Saved researcher at ")`);
assert.equal(await b.evaluate('document.getElementById("agent-levels-root-form").elements.name.value'), '');
assert.deepEqual(await values(b, 'agent-levels-project-form'), { name: 'helper', form: 'narrow', definition: '', vars: 'limits.tools=read' });
// A locally refused submit keeps its draft; the role-instance handler takes no part.
await type(b, 'agent-levels-root-form', { name: 'second', vars: 'malformed' });
await b.evaluate('document.getElementById("agent-levels-root-form").requestSubmit()');
await wait(`${receipt('root')} === "Variables line 1 needs KEY=VALUE."`);
await new Promise(r => setTimeout(r, 300));
assert.equal(await b.evaluate('document.getElementById("agent-levels-root-form").elements.name.value'), 'second');
assert.equal(await b.evaluate('document.getElementById("agent-levels-root-form").elements.vars.value'), 'malformed');
assert.equal(await b.evaluate('!!document.querySelector("#instance-add-form .settings-actions.has-draft")'), false, 'the instance form took no draft');
// The file changes under a draft: the draft's save sends the revision it began at and is refused,
// then a second, deliberate save goes through against the file as now read.
await type(b, 'agent-levels-root-form', { name: 'later', definition: 'research', vars: '' });
const began = await b.evaluate('document.getElementById("agent-levels-root-form").dataset.revision');
writeFileSync(join(root, 'agents.json'), JSON.stringify({ agentsVersion: 1, agents: {} }) + '\n', { mode: 0o600 });
await b.evaluate('document.getElementById("agent-levels-edit").dataset.old = "1"; document.getElementById("refresh").click()');
await wait('document.getElementById("agent-levels-edit") && !document.getElementById("agent-levels-edit").dataset.old');
assert.equal(await b.evaluate('document.getElementById("agent-levels-root-form").dataset.revision'), began, 'the draft keeps the revision it began at');
await b.evaluate('document.getElementById("agent-levels-root-form").requestSubmit()');
await wait(`${receipt('root')}.startsWith("Refused: the file changed")`);
assert.equal(await b.evaluate('document.getElementById("agent-levels-root-form").elements.name.value'), 'later');
await b.evaluate('document.getElementById("agent-levels-root-form").requestSubmit()');
await wait(`${receipt('root')}.startsWith("Saved later at ")`);
assert.deepEqual(Object.keys(JSON.parse(readFileSync(join(root, 'agents.json'), 'utf8')).agents), ['later']);
});