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.
152 lines
11 KiB
JavaScript
152 lines
11 KiB
JavaScript
// #1873 A2: `mosaic agents root` and `mosaic project agents`, end to end through scripts/mosaic on a
|
|
// bootstrapped named instance (the org CLI's own setup, org.test.mjs).
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { join, resolve } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { spawnSync } from 'node:child_process';
|
|
|
|
const repo = resolve(import.meta.dirname, '../../..');
|
|
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
|
const ABSENT = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
|
|
|
|
function instance() {
|
|
const home = mkdtempSync(join(tmpdir(), 'a2cli-'));
|
|
const config = join(home, '.config/mosaic-atwo/config.json');
|
|
const env = { PATH: process.env.PATH, LANG: 'C.UTF-8', TMPDIR: process.env.TMPDIR ?? tmpdir(), HOME: home, MOSAIC_CONFIG: config };
|
|
const boot = spawnSync(join(repo, 'scripts/bootstrap.sh'), ['--instance', 'atwo'], { cwd: repo, env, encoding: 'utf8' });
|
|
assert.equal(boot.status, 0, boot.stderr);
|
|
const run = (args, extra = {}) => spawnSync(join(repo, 'scripts/mosaic'), args, { cwd: repo, env: { ...env, ...extra }, encoding: 'utf8' });
|
|
const ok = (...args) => { const r = run(args); assert.equal(r.status, 0, `${args.join(' ')}: ${r.stderr}`); return r.stdout; };
|
|
const file = join(home, 'alpha.json');
|
|
ok('org', 'template', 'render', 'alpha', '--fixture', 'local', '--output', file);
|
|
ok('org', 'create', 'alpha', '--from', file);
|
|
const project = Object.keys(JSON.parse(readFileSync(join(home, '.config/mosaic-atwo/businesses/alpha.json'), 'utf8')).projects)[0];
|
|
return { home, env, run, ok, project, configDir: join(home, '.config/mosaic-atwo') };
|
|
}
|
|
|
|
test('agents root list, set, remove: each write prints its receipt, values stay out of receipts and refusals', () => {
|
|
const { run, ok, configDir } = instance();
|
|
assert.deepEqual(JSON.parse(ok('agents', 'root', 'list', '--json')), { revision: ABSENT, state: 'absent', agents: [] });
|
|
assert.match(ok('agents', 'root', 'list'), /^root agents: none \(no file\)/);
|
|
const added = JSON.parse(ok('agents', 'root', 'set', 'researcher', '--definition', 'research', '--var', 'model=claude-opus-5-5'));
|
|
assert.equal(added.verb, 'root.agent.set'); assert.equal(added.agent, 'researcher'); assert.deepEqual(added.changed, ['added']);
|
|
assert.equal(added.revisionBefore, ABSENT);
|
|
const patched = run(['agents', 'root', 'set', 'researcher', '--var', 'limits.network=none', '--exclude-orgs', 'home']);
|
|
assert.equal(patched.status, 0, patched.stderr);
|
|
assert.deepEqual(JSON.parse(patched.stdout).changed, ['vars.limits.network', 'excludeOrgs']);
|
|
assert.ok(!patched.stdout.includes('none') && !patched.stdout.includes('claude-opus'));
|
|
const listed = JSON.parse(ok('agents', 'root', 'list', '--json'));
|
|
assert.equal(listed.state, 'present');
|
|
assert.deepEqual(listed.agents, [{ name: 'researcher', definition: 'research', model: 'claude-opus-5-5', varKeys: ['limits.network'], excludeOrgs: ['home'] }]);
|
|
// A stale --revision refuses and the file is unchanged.
|
|
const before = readFileSync(join(configDir, 'agents.json'), 'utf8');
|
|
const stale = run(['agents', 'root', 'set', 'researcher', '--var', 'limits.network=api-only', '--revision', added.revisionAfter]);
|
|
assert.equal(stale.status, 2); assert.match(stale.stderr, /revision-conflict/);
|
|
// An invalid value refuses with the key named and the value absent.
|
|
const bad = run(['agents', 'root', 'set', 'researcher', '--var', `model=${SECRET.repeat(10)}`]);
|
|
assert.equal(bad.status, 2); assert.match(bad.stderr, /agents-invalid: .*model/); assert.ok(!bad.stderr.includes(SECRET), bad.stderr);
|
|
const badJson = run(['agents', 'root', 'set', 'researcher', '--var-json', `limits.tools=${SECRET}`]);
|
|
assert.equal(badJson.status, 4); assert.match(badJson.stderr, /var limits\.tools is not valid JSON/); assert.ok(!badJson.stderr.includes(SECRET));
|
|
const chief = run(['agents', 'root', 'set', 'boss', '--definition', 'chief-of-staff']);
|
|
assert.equal(chief.status, 2); assert.match(chief.stderr, /chief-of-staff can't be a root agent/);
|
|
assert.equal(readFileSync(join(configDir, 'agents.json'), 'utf8'), before);
|
|
// An agent run is refused before anything is read.
|
|
for (const marker of ['MOSAIC_RUN_ID', 'MOSAIC_BUS_CAP', 'MOSAIC_AGENT_RUN']) {
|
|
const inside = run(['agents', 'root', 'remove', 'researcher'], { [marker]: '1' });
|
|
assert.equal(inside.status, 3, `${marker}: ${inside.stderr}`);
|
|
}
|
|
assert.equal(readFileSync(join(configDir, 'agents.json'), 'utf8'), before);
|
|
const removed = JSON.parse(ok('agents', 'root', 'remove', 'researcher'));
|
|
assert.equal(removed.verb, 'root.agent.remove'); assert.deepEqual(removed.changed, ['removed']);
|
|
const receipts = readFileSync(join(configDir, 'agents-receipts.jsonl'), 'utf8');
|
|
assert.equal(receipts.trim().split('\n').length, 3);
|
|
assert.ok(!receipts.includes('claude-opus') && !receipts.includes(SECRET));
|
|
// Usage refusals never echo option values.
|
|
const unknown = run(['agents', 'root', 'set', 'researcher', `--${SECRET}`]);
|
|
assert.equal(unknown.status, 4); assert.ok(!unknown.stderr.includes(SECRET));
|
|
});
|
|
|
|
test('a filesystem failure prints its code and a fixed line, never a host path', () => {
|
|
const { run, configDir, home } = instance();
|
|
// A directory where the receipt file goes: opening it fails EISDIR, whose message holds the path.
|
|
mkdirSync(join(configDir, 'agents-receipts.jsonl'));
|
|
const r = run(['agents', 'root', 'set', 'researcher', '--definition', 'research']);
|
|
assert.equal(r.status, 2, r.stderr);
|
|
assert.match(r.stderr, /EISDIR: the operation failed; the error text is not shown/);
|
|
assert.ok(!r.stderr.includes(home), r.stderr);
|
|
assert.equal(existsSync(join(configDir, 'agents.json')), false);
|
|
});
|
|
|
|
test('a missing, malformed or unclaimed instance config refuses before anything is read or written', () => {
|
|
const verbs = [['agents', 'root', 'set', 'researcher', '--definition', 'research'], ['agents', 'root', 'list'], ['project', 'agents', 'work', '--org', 'alpha', 'exclude', 'coder']];
|
|
const check = (env, dir, why) => {
|
|
for (const args of verbs) {
|
|
const r = spawnSync(join(repo, 'scripts/mosaic'), args, { cwd: repo, env, encoding: 'utf8' });
|
|
assert.equal(r.status, 3, `${why} ${args.join(' ')}: ${r.stderr}`); assert.match(r.stderr, /system config problem/);
|
|
}
|
|
assert.equal(existsSync(join(dir, 'agents.json')), false, why);
|
|
assert.equal(existsSync(join(dir, 'agents-receipts.jsonl')), false, why);
|
|
};
|
|
// Missing: the instance's config directory exists, its config.json does not.
|
|
const home = mkdtempSync(join(tmpdir(), 'a2cfg-'));
|
|
const dir = join(home, '.config/mosaic-atwo');
|
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
const env = { PATH: process.env.PATH, LANG: 'C.UTF-8', TMPDIR: process.env.TMPDIR ?? tmpdir(), HOME: home, MOSAIC_CONFIG: join(dir, 'config.json') };
|
|
check(env, dir, 'missing');
|
|
writeFileSync(join(dir, 'config.json'), '{', { mode: 0o600 });
|
|
check(env, dir, 'malformed');
|
|
// Unclaimed: a bootstrapped instance whose data root marker names another instance.
|
|
const booted = instance();
|
|
const { dataRoot } = JSON.parse(readFileSync(booted.env.MOSAIC_CONFIG, 'utf8'));
|
|
writeFileSync(join(dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: 'other' }));
|
|
check(booted.env, booted.configDir, 'marker');
|
|
});
|
|
|
|
test('the default instance refuses the agent level verbs', () => {
|
|
const home = mkdtempSync(join(tmpdir(), 'a2def-'));
|
|
const env = { PATH: process.env.PATH, LANG: 'C.UTF-8', TMPDIR: process.env.TMPDIR ?? tmpdir(), HOME: home };
|
|
for (const args of [['agents', 'root', 'list'], ['project', 'agents', 'work', '--org', 'alpha', 'list']]) {
|
|
const r = spawnSync(join(repo, 'scripts/mosaic'), args, { cwd: repo, env, encoding: 'utf8' });
|
|
assert.equal(r.status, 2, r.stderr); assert.match(r.stderr, /named instance/);
|
|
}
|
|
assert.equal(existsSync(join(home, '.config/mosaic-dev/agents.json')), false);
|
|
});
|
|
|
|
test('project agents: set, narrow, exclude and remove print receipts; a widening narrow and the chief refuse', () => {
|
|
const { run, ok, project, home } = instance();
|
|
ok('agents', 'root', 'set', 'researcher', '--definition', 'research');
|
|
ok('agents', 'root', 'set', 'scout', '--definition', 'research');
|
|
const p = (...args) => ['project', 'agents', project, '--org', 'alpha', ...args];
|
|
assert.deepEqual(JSON.parse(ok(...p('list', '--json'))).agents, []);
|
|
const defined = JSON.parse(ok(...p('set', 'checker', '--definition', 'fact-checker', '--var', 'model=claude-sonnet-5-5')));
|
|
assert.deepEqual({ ...defined, revisionBefore: undefined, revisionAfter: undefined, transaction: undefined },
|
|
{ verb: 'project.agents.set', organization: 'alpha', project, agent: 'checker', form: 'define', revisionBefore: undefined, revisionAfter: undefined, transaction: undefined });
|
|
assert.notEqual(defined.revisionBefore, defined.revisionAfter);
|
|
assert.ok(!JSON.stringify(defined).includes(home), 'no host path in the receipt');
|
|
assert.equal(JSON.parse(ok(...p('narrow', 'researcher', '--var', 'limits.network=none'))).form, 'narrow');
|
|
assert.equal(JSON.parse(ok(...p('exclude', 'scout'))).form, 'exclude');
|
|
assert.deepEqual(JSON.parse(ok(...p('list', '--json'))).agents, [
|
|
{ name: 'checker', form: 'define', definition: 'fact-checker', varKeys: ['model'] },
|
|
{ name: 'researcher', form: 'narrow', varKeys: ['limits.network'] },
|
|
{ name: 'scout', form: 'exclude' },
|
|
]);
|
|
const file = join(home, '.config/mosaic-atwo/businesses/alpha.json');
|
|
const before = readFileSync(file, 'utf8');
|
|
const widen = run(p('narrow', 'scout', '--var', `model=${SECRET}`));
|
|
assert.equal(widen.status, 2); assert.match(widen.stderr, /can only narrow limits\.tools, limits\.network, limits\.authority, not "model"/); assert.ok(!widen.stderr.includes(SECRET));
|
|
const chief = run(p('exclude', 'chief'));
|
|
assert.equal(chief.status, 2); assert.match(chief.stderr, /project-agents-invalid/);
|
|
const stale = run(p('remove', 'checker', '--revision', defined.revisionBefore));
|
|
assert.equal(stale.status, 2); assert.match(stale.stderr, /revision-conflict/);
|
|
const inside = run(p('remove', 'checker'), { MOSAIC_RUN_ID: 'r1' });
|
|
assert.equal(inside.status, 3);
|
|
assert.equal(readFileSync(file, 'utf8'), before);
|
|
for (const name of ['checker', 'researcher', 'scout']) assert.equal(JSON.parse(ok(...p('remove', name))).form, 'remove');
|
|
assert.deepEqual(JSON.parse(ok(...p('list', '--json'))).agents, []);
|
|
const missing = run(['project', 'agents', 'nope', '--org', 'alpha', 'exclude', 'scout']);
|
|
assert.equal(missing.status, 2); assert.match(missing.stderr, /project-not-found/);
|
|
});
|