Compare commits

..
6 Commits
Author SHA1 Message Date
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
jason.woltje 1e1bbaa6dd fix(webui): A2 agent level grids scroll inside their card on phones (#1873) 2026-11-10 18:25:54 -06:00
jason.woltje 78bda9d6a4 test(organization): A2 refusal patterns follow var-redact's key-only text (#1873) 2026-11-10 18:25:54 -06:00
jason.woltje 6b39af7888 feat(agents): A2 root and project agent level writers, CLI and Settings forms (#1873)
Root writer for <configDir>/agents.json (owner only, revision-checked, atomic, A1-validated,
receipts name agents and keys only), the project.agents writer verb on the organization
writer, `mosaic agents root` and `mosaic project agents` verbs, and owner-only Settings >
Agents edit forms on GET/POST hooks gated like /api/agents/levels.
2026-11-10 18:25:54 -06:00
jason.woltje 96c900d210 fix: preserve read error codes and launcher fixtures (#1873) 2026-11-10 17:54:33 -06:00
jason.woltje 307fe928ac fix: redact remaining file and config refusal echoes (#1873) 2026-11-10 17:06:54 -06:00
48 changed files with 2320 additions and 75 deletions

No files matched your search

@@ -0,0 +1,2 @@
// Old path of packages/organization/src/agent-levels-write.mjs (rename design r2, stage B).
export * from "../../organization/src/agent-levels-write.mjs";
+195
View File
@@ -0,0 +1,195 @@
// #1873 Phase A slice A2: the owner's terminal verbs for the root and project agent levels (design
// docs/plans/2026-11-10_agent-levels-design.md sections 6, 7 and 9 item 1).
//
// mosaic agents root list [--json]
// mosaic agents root set NAME [--definition ROLE] [--var KEY=VALUE]... [--var-json KEY=JSON]...
// [--unset-var KEY]... [--exclude-orgs ID,ID | --exclude-orgs none] [--revision R]
// mosaic agents root remove NAME [--revision R]
// mosaic project agents PROJECT --org ID list [--json]
// mosaic project agents PROJECT --org ID set NAME --definition ROLE [--var KEY=VALUE]... [--var-json KEY=JSON]... [--revision R]
// mosaic project agents PROJECT --org ID narrow NAME --var KEY=VALUE... [--var-json KEY=JSON]... [--revision R]
// mosaic project agents PROJECT --org ID exclude NAME [--revision R]
// mosaic project agents PROJECT --org ID remove NAME [--revision R]
//
// Every write is the owner's: an agent run is refused before anything is read (exit 3), and the
// verbs run only on a named dev instance, as `mosaic org` does, with the local dev owner as actor.
// Without --revision a write reads the revision just before it writes, so a concurrent change
// between that read and the write still refuses, but one made before the command started does
// not. Pass --revision from a `list` to make the write conditional on what was listed.
// A write prints its receipt; refusals name keys, never values.
import { configDir } from '../../organization/src/organization-config.mjs';
import { OrgError } from '../../organization/src/org-registry.mjs';
import { OrganizationError, organizationWriter } from '../../organization/src/organization.mjs';
import { changeRootAgent, patchRootEntry, projectAgentSummary, readRootAgentsFile, rootAgentSummary } from '../../organization/src/agent-levels-write.mjs';
import { loadSystem, rolesDir } from './config.mjs';
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
import { CliError } from './errors.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const AGENTS_ROOT_USAGE = 'mosaic agents root list [--json] | set NAME [--definition ROLE] [--var KEY=VALUE]... [--var-json KEY=JSON]... [--unset-var KEY]... [--exclude-orgs ID,ID|none] [--revision R] | remove NAME [--revision R]';
export const PROJECT_AGENTS_USAGE = 'mosaic project agents PROJECT --org ID list [--json] | set NAME --definition ROLE [--var KEY=VALUE]... [--revision R] | narrow NAME --var KEY=VALUE... [--revision R] | exclude NAME [--revision R] | remove NAME [--revision R]';
const usage = (text, line) => new CliError(`${text}\nusage: ${line}`, 4);
const REPEATED = ['--var', '--var-json', '--unset-var'];
const SINGLE = ['--definition', '--exclude-orgs', '--revision', '--org'];
// Option values are never echoed back: a refusal names the option only.
function parse(args, line) {
const positional = [], single = {}, repeated = { '--var': [], '--var-json': [], '--unset-var': [] }, flags = new Set();
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--json') { if (flags.has(a)) throw usage('duplicate --json', line); flags.add(a); }
else if (REPEATED.includes(a) || SINGLE.includes(a)) {
if (i + 1 >= args.length) throw usage(`${a} needs one value`, line);
const value = args[++i];
if (REPEATED.includes(a)) repeated[a].push(value);
else if (Object.hasOwn(single, a)) throw usage(`duplicate ${a}`, line);
else single[a] = value;
} else if (a.startsWith('--')) throw usage('unknown option', line);
else positional.push(a);
}
return { positional, single, repeated, flags };
}
function only(parsed, allowed, line) {
for (const key of [...Object.keys(parsed.single), ...REPEATED.filter(k => parsed.repeated[k].length), ...parsed.flags])
if (!allowed.includes(key)) throw usage(`unexpected ${key}`, line);
}
// KEY=VALUE pairs; a --var-json value is parsed as JSON. A malformed pair names its key at most.
function vars(parsed, line) {
const out = {};
const put = (key, value) => {
if (!key) throw usage('a var needs KEY=VALUE', line);
if (Object.hasOwn(out, key)) throw usage(`var ${key} is given twice`, line);
out[key] = value;
};
for (const pair of parsed.repeated['--var']) {
const at = pair.indexOf('=');
if (at < 1) throw usage('a var needs KEY=VALUE', line);
put(pair.slice(0, at), pair.slice(at + 1));
}
for (const pair of parsed.repeated['--var-json']) {
const at = pair.indexOf('=');
if (at < 1) throw usage('a var needs KEY=JSON', line);
const key = pair.slice(0, at);
try { put(key, JSON.parse(pair.slice(at + 1))); }
catch (e) { if (e instanceof CliError) throw e; throw usage(`var ${key} is not valid JSON`, line); }
}
return out;
}
const revisionOf = (parsed, read) => parsed.single['--revision'] ?? read();
function owner(io) {
refuseInsideAgent(io.env);
// Its refusal is one fixed line naming no path.
let instance;
try { instance = devOwnerInstance(io.env); } catch (error) { throw error instanceof CliError ? error : new CliError(String(error?.message), 2); }
// The instance's system config must exist and validate, marker included, as `mosaic org` checks:
// a missing or broken config refuses (exit 3) before anything is read or written.
loadSystem({ env: io.env, instance });
return { dir: configDir(io.env), roles: rolesDir(io.env) };
}
// Only the writers' own coded refusals print their text (it names keys and ids, never a value).
// Anything else, a filesystem error above all, prints its code and a fixed line: its message can
// hold a host path. So does a coded refusal whose text happens to hold an absolute path.
const ABSOLUTE_PATH = /(?:^|[\s('"=:])\/[^\s/]/;
function refusal(error) {
if (error instanceof CliError) return error;
const code = typeof error?.code === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(error.code) ? error.code : null;
const ours = (error instanceof OrgError || error instanceof OrganizationError || error?.name === 'BusinessError') && code !== null;
const text = ours && typeof error.message === 'string' && !ABSOLUTE_PATH.test(error.message) ? error.message : 'the operation failed; the error text is not shown';
// A refusal after the replace says the change landed; the receipt may be missing.
const landed = error?.committed ? ' (the change was written; its receipt may be missing)' : '';
return new CliError(`${code ? `${code}: ` : ''}${text}${landed}`, 2);
}
export async function agentsRootCommand(args, io) {
const line = AGENTS_ROOT_USAGE;
const parsed = parse(args, line);
const [op, name, ...extra] = parsed.positional;
try {
const { dir, roles } = owner(io);
if (op === 'list') {
if (name !== undefined) throw usage('list takes no name', line);
only(parsed, ['--json'], line);
const read = await readRootAgentsFile(dir);
const listing = { revision: read.revision, state: read.exists ? 'present' : 'absent', agents: rootAgentSummary(read.document) };
io.stdout.write(parsed.flags.has('--json') ? `${JSON.stringify(listing, null, 2)}\n` : formatRoot(listing));
return;
}
if (!['set', 'remove'].includes(op) || !name || extra.length) throw usage('root takes list, set NAME or remove NAME', line);
only(parsed, op === 'set' ? ['--definition', '--var', '--var-json', '--unset-var', '--exclude-orgs', '--revision'] : ['--revision'], line);
let entry = null;
if (op === 'set') {
const patch = {};
if (Object.hasOwn(parsed.single, '--definition')) patch.definition = parsed.single['--definition'];
const set = vars(parsed, line);
for (const key of parsed.repeated['--unset-var']) {
if (Object.hasOwn(set, key)) throw usage(`var ${key} is both set and unset`, line);
set[key] = null;
}
if (Object.keys(set).length) patch.vars = set;
if (Object.hasOwn(parsed.single, '--exclude-orgs')) {
const text = parsed.single['--exclude-orgs'];
patch.excludeOrgs = text === 'none' ? [] : text.split(',');
}
if (!Object.keys(patch).length) throw usage('set needs --definition, a var or --exclude-orgs', line);
entry = old => patchRootEntry(old, patch);
}
const revision = await revisionOf(parsed, async () => (await readRootAgentsFile(dir)).revision);
const receipt = await changeRootAgent(dir, { revision, name, entry }, { actor: DEV_OWNER, owner: DEV_OWNER, rolesDir: roles });
io.stdout.write(`${JSON.stringify(receipt)}\n`);
} catch (error) { throw refusal(error); }
}
export async function projectAgentsCommand(args, io) {
const line = PROJECT_AGENTS_USAGE;
const parsed = parse(args, line);
const [project, op, name, ...extra] = parsed.positional;
try {
const { dir, roles } = owner(io);
const org = parsed.single['--org'];
if (!project || !org) throw usage('project agents needs PROJECT and --org ID', line);
const writer = organizationWriter({ dir, rolesDir: roles });
if (op === 'list') {
if (name !== undefined) throw usage('list takes no name', line);
only(parsed, ['--org', '--json'], line);
const { document, revision } = writer.read(org);
if (!Object.hasOwn(document.projects ?? {}, project)) throw new OrgError('project-not-found', `project ${project} is not in organization ${org}`);
const listing = { organization: org, project, revision, agents: projectAgentSummary(document.projects[project].agents ?? {}) };
io.stdout.write(parsed.flags.has('--json') ? `${JSON.stringify(listing, null, 2)}\n` : formatProject(listing));
return;
}
if (!['set', 'narrow', 'exclude', 'remove'].includes(op) || !name || extra.length) throw usage('project agents takes list, set, narrow, exclude or remove NAME', line);
const allowed = { set: ['--definition', '--var', '--var-json'], narrow: ['--var', '--var-json'], exclude: [], remove: [] }[op];
only(parsed, ['--org', '--revision', ...allowed], line);
let entry = null;
if (op === 'set') {
if (!Object.hasOwn(parsed.single, '--definition')) throw usage('set needs --definition', line);
const v = vars(parsed, line);
entry = { definition: parsed.single['--definition'], ...(Object.keys(v).length ? { vars: v } : {}) };
} else if (op === 'narrow') {
const v = vars(parsed, line);
if (!Object.keys(v).length) throw usage('narrow needs at least one --var', line);
entry = { vars: v };
} else if (op === 'exclude') entry = { exclude: true };
const revisionBefore = revisionOf(parsed, () => writer.read(org).revision);
const result = writer.setProjectAgent(org, { revision: revisionBefore, project, name, entry }, DEV_OWNER.sub);
// The receipt: names and revisions only. The writer's `previous` is a host path; it isn't printed.
const receipt = { verb: 'project.agents.set', organization: org, project, agent: name, form: op === 'set' ? 'define' : op, revisionBefore, revisionAfter: result.revision, transaction: result.transaction };
io.stdout.write(`${JSON.stringify(receipt)}\n`);
} catch (error) { throw refusal(error); }
}
function formatRoot({ revision, state, agents }) {
if (!agents.length) return `root agents: none (${state === 'absent' ? 'no file' : 'empty'}), revision ${revision}\n`;
return `root agents (revision ${revision}):\n` + agents.map(a =>
` ${a.name}: ${a.definition}${a.model ? `, model ${a.model}` : ''}${a.varKeys.length ? `, vars ${a.varKeys.join(', ')}` : ''}${a.excludeOrgs.length ? `, not in ${a.excludeOrgs.join(', ')}` : ''}\n`).join('');
}
function formatProject({ organization, project, revision, agents }) {
if (!agents.length) return `project ${project} in ${organization}: no project agent entries, revision ${revision}\n`;
return `project ${project} in ${organization} (revision ${revision}):\n` + agents.map(a =>
` ${a.name}: ${a.form}${a.definition ? ` ${a.definition}` : ''}${a.varKeys?.length ? `, vars ${a.varKeys.join(', ')}` : ''}\n`).join('');
}
+6
View File
@@ -48,6 +48,7 @@ import { consoleRestart, consoleStart, consoleStatus, consoleStop } from "./org-
import { usageCommand, USAGE_USAGE } from "./usage.mjs";
import { orgCommand, ORG_USAGE } from "./org.mjs";
import { agentsRootCommand, AGENTS_ROOT_USAGE, projectAgentsCommand, PROJECT_AGENTS_USAGE } from "./agent-levels.mjs";
import { ORG_CONFIG, runConfigCommand } from "../../organization/src/config-command.mjs";
import { devCommand, DEV_USAGE, fixtureLaunchEnv, fixtureLauncherOptions } from "./dev-fixture.mjs";
import { runCommand, RUN_USAGE } from "./run-evidence.mjs";
@@ -65,6 +66,8 @@ export const USAGE = `usage:
${ORG_INSTANCES_USAGE}
mosaic business validate|resolve|instances|instance|veto: the old spelling of mosaic org config validate|resolve and mosaic org instances|instance|veto, same arguments
${PROJECT_USAGE}
${PROJECT_AGENTS_USAGE}
${AGENTS_ROOT_USAGE}
${PROJECT_BUS_USAGE}
${NORTH_STAR_USAGE}
${INTERVIEW_USAGE}
@@ -731,6 +734,9 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (verb === 'org' && rest[0] === 'config') return orgConfig(rest.slice(1), io);
if (verb === 'org' && ["instances", "instance", "veto"].includes(rest[0])) return orgInstancesCommand(rest, io, (p) => callFor(p, io, deps));
if (verb === 'org') return orgCommand(rest, io, deps);
// A2 (#1873): the owner's root and project agent levels. `mosaic agents` without `root` stays the bus read.
if (verb === "agents" && rest[0] === "root") return agentsRootCommand(rest.slice(1), io);
if (verb === "project" && rest[0] === "agents") return projectAgentsCommand(rest.slice(1), io);
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
if (verb === "project" && rest[0] === "north-star") return northStarCommand(rest.slice(1), io, (p) => callFor(p, io, deps));
if (verb === "project" && rest[0] === "interview") return interviewCommand(rest.slice(1), io);
@@ -0,0 +1,151 @@
// #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/);
});
+9 -3
View File
@@ -100,9 +100,15 @@ function sessionPairs(pairs) {
// Safety net for a removed-outer-bound change. It is fixture cleanup, never
// product supervision. The guard reads only fixture records and checks sid
// immediately before every kill. Normal outer-bound cases return before it.
export function fixtureGuard(t,files,milliseconds=9500) {
const program=`const fs=require('node:fs');setTimeout(()=>{for(const file of JSON.parse(process.argv[1])){let rows=[];try{rows=fs.readFileSync(file,'utf8').trim().split(String.fromCharCode(10))}catch{}for(const row of rows){const [pid,sid]=row.trim().split(' ').map(Number);if(!(pid>0&&sid>0))continue;try{const raw=fs.readFileSync('/proc/'+pid+'/stat','utf8'),f=raw.slice(raw.lastIndexOf(') ')+2).split(' ');if(Number(f[3])===sid&&f[0]!=='Z')process.kill(pid,'SIGKILL')}catch{}}}},Number(process.argv[2]));`;
const child=spawn(process.execPath,['-e',program,JSON.stringify(files),String(milliseconds)],{stdio:'ignore'});
export function fixtureGuard(t,files,milliseconds=9500,startFile) {
// P10 already records the instant its observed probe starts. Arm its safety
// net there, so unrelated host-fixture setup cannot consume the probe budget.
// Callers without an observer retain the original immediate-start behavior.
const program=`const fs=require('node:fs');
function cleanup(){for(const file of JSON.parse(process.argv[1])){let rows=[];try{rows=fs.readFileSync(file,'utf8').trim().split(String.fromCharCode(10))}catch{}for(const row of rows){const [pid,sid]=row.trim().split(' ').map(Number);if(!(pid>0&&sid>0))continue;try{const raw=fs.readFileSync('/proc/'+pid+'/stat','utf8'),f=raw.slice(raw.lastIndexOf(') ')+2).split(' ');if(Number(f[3])===sid&&f[0]!=='Z')process.kill(pid,'SIGKILL')}catch{}}}}
function arm(){setTimeout(cleanup,Number(process.argv[2]));}
if(!process.argv[3])arm();else{const poll=setInterval(()=>{let text='';try{text=fs.readFileSync(process.argv[3],'utf8')}catch{}if(/^begin [0-9]+[.][0-9]{2}\\n/.test(text)){clearInterval(poll);arm();}},10);}`;
const child=spawn(process.execPath,['-e',program,JSON.stringify(files),String(milliseconds),...(startFile?[startFile]:[])],{stdio:'ignore'});
const stop=async()=>{
if(child.exitCode!==null||child.signalCode!==null)return;
const exited=new Promise(resolve=>child.once('exit',resolve));
@@ -0,0 +1,33 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { fixtureGuard } from './case-sudo-fixture.mjs';
test('fixture cleanup guard starts its budget at the probe marker', async t => {
const dir = mkdtempSync(join(tmpdir(), 'guard-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const records = join(dir, 'pids'), startFile = join(dir, 'probe');
writeFileSync(startFile, '');
const child = spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { stdio: 'ignore' });
const exited = once(child, 'exit');
t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); });
const raw = readFileSync(`/proc/${child.pid}/stat`, 'utf8');
const sid = Number(raw.slice(raw.lastIndexOf(') ') + 2).split(' ')[3]);
writeFileSync(records, `${child.pid} ${sid}\n`);
const stop = fixtureGuard(t, [records], 100, startFile);
// An empty marker is setup, not the start of the observed probe. This wait
// exceeds the cleanup budget deliberately; no production bound is changed.
await new Promise(resolve => setTimeout(resolve, 500));
assert.equal(child.signalCode, null, 'setup must not consume the probe cleanup budget');
writeFileSync(startFile, 'begin 100.00\n');
const terminal = await Promise.race([exited, new Promise((_, reject) => {
const timer = setTimeout(() => reject(new Error('armed fixture guard did not clean up')), 3000);
timer.unref();
})]);
assert.deepEqual(terminal, [null, 'SIGKILL']);
await stop();
});
+2 -1
View File
@@ -429,7 +429,7 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
assert.equal(baseline.status,0,baseline.out+baseline.err);
assert.deepEqual(await pollFixtureGone(files),[],'baseline leaves no session');
}
const capture=cleanupFixture(t,files);const stop=fixtureGuard(t,files,7000);
const capture=cleanupFixture(t,files);
if(fault==='inner')put(join(h.hc,'peer-hang'),'');
if(fault==='outer'){put(join(h.hc,'stall-probe'),'');put(join(h.hc,'stall-output'),'200 997 997\n');}
if(fault==='overflow'){put(join(h.hc,'peer-output'),'200 997 997\n'+'X'.repeat(256));put(join(h.hc,'peer-status'),'0');}
@@ -440,6 +440,7 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
put(join(h.dir,'selector.sh'),read(join(REPO,'scripts','selector.sh')),0o644);
assert.equal(removeP10Observers(read(observedScript)),read(SCRIPT),'observer reversal equals a fresh shipped script read');
}
const stop=fixtureGuard(t,files,7000,timingFile);
const start=performance.now(),r=run(h,timingFile?{ROW299_TIMING_FILE:timingFile}:{},undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
assert.equal(r.status,0,r.out+r.err);
const reason=fault==='inner'?'inner-timeout':fault==='outer'?'outer-timeout':'output-limit';
@@ -0,0 +1,60 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
const secret = 'sk-test-NOT-A-SECRET-1234';
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
const malformed = secret;
function scratch(t) {
const root = mkdtempSync(join(tmpdir(), 'r3-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
return root;
}
function write(file, text = malformed) {
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, text, { mode: 0o600 });
return file;
}
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { fixture } from './helpers.mjs';
const repo = join(import.meta.dirname, '../../..');
test('task file JSON refusal preserves exit 2 and file without parser bytes', t => {
const file = write(join(scratch(t), 'task.json'));
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-task.mjs'), 'validate', file], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
assert.equal(r.status, 2, r.stderr);
assert.ok(r.stderr.includes(file), r.stderr);
assert.match(r.stderr, /task file is not valid JSON/);
assert.doesNotMatch(r.stdout + r.stderr, /sk-test|Unexpected token/);
});
test('task file read refusal retains exit 2, file and filesystem code', t => {
const file = write(join(scratch(t), 'task.json'), '{}');
// A directory is refused by lstat before the read. Inject a read failure
// after that unchanged regular-file gate, including on root-run CI hosts.
const preload = `import fs from 'node:fs';
const read = fs.readFileSync;
fs.readFileSync = function(path, ...args) {
if (path === ${JSON.stringify(file)}) throw Object.assign(new Error(${JSON.stringify(secret)}), { code: 'EIO' });
return read.call(this, path, ...args);
};`;
const r = spawnSync(process.execPath, ['--import', 'data:text/javascript,' + encodeURIComponent(preload), join(repo, 'scripts/mosaic-task.mjs'), 'validate', file], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
assert.equal(r.status, 2, r.stderr);
assert.ok(r.stderr.includes(file), r.stderr);
assert.match(r.stderr, /task file is not valid JSON.*\(EIO\)/);
assert.doesNotMatch(r.stderr, /sk-test/);
});
test('case2 precheck business helper omits parser bytes in its BUSINESS fact', t => {
const root = scratch(t), f = fixture(root);
const file = write(join(root, 'config/businesses/acme.json'));
// Execute the production facts helper, without host or credential checks.
const shell = readFileSync(join(repo, 'scripts/case2-precheck.sh'), 'utf8');
const start = shell.indexOf('facts="$(cd "$REPO" && node --input-type=module -e \'') + 'facts="$(cd "$REPO" && node --input-type=module -e \''.length;
assert.ok(start > 100);
const end = shell.indexOf("' \"$REPO\" \"$BUSINESS\"", start);
assert.ok(end > start);
const r = spawnSync(process.execPath, ['--input-type=module', '-e', shell.slice(start, end), repo, 'acme'], { encoding: 'utf8', env: { ...f.env, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, PATH: process.env.PATH } });
assert.equal(r.status, 0, r.stderr);
assert.ok(r.stdout.includes(`BUSINESS\tbad\tcannot read ${file}:`), r.stdout);
assert.doesNotMatch(r.stdout + r.stderr, /sk-test|Unexpected token/);
});
+7 -6
View File
@@ -13,6 +13,7 @@
// exception is <boardDir>/seen.json, which holds Jason's "seen" marks and is
// only changed when he clicks; a scan reads it and never rewrites it.
import { parseJsonFileText } from "../../organization/src/util.mjs";
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, renameSync } from "node:fs";
import { join, basename, isAbsolute, resolve } from "node:path";
import { homedir } from "node:os";
@@ -35,9 +36,9 @@ export function loadConfig(path = defaultConfigPath()) {
if (!existsSync(path)) throw new ConfigError(`config not found: ${path}`);
let raw;
try {
raw = JSON.parse(readFileSync(path, "utf8"));
} catch (err) {
throw new ConfigError(`config is not valid JSON: ${path} (${err.message})`);
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
} catch (error) {
throw new ConfigError(`config is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new ConfigError(`config is not an object: ${path}`);
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) {
@@ -227,9 +228,9 @@ export function loadSeen(boardDir) {
if (!existsSync(path)) return {};
let parsed;
try {
parsed = JSON.parse(readFileSync(path, "utf8"));
} catch (err) {
throw new ConfigError(`seen marks file is not valid JSON: ${path} (${err.message})`);
parsed = parseJsonFileText(readFileSync(path, "utf8"), path);
} catch (error) {
throw new ConfigError(`seen marks file is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new ConfigError(`seen marks file must be a JSON object: ${path}`);
for (const [k, v] of Object.entries(parsed)) {
@@ -0,0 +1,44 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
const secret = 'sk-test-NOT-A-SECRET-1234';
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
const malformed = secret;
function scratch(t) {
const root = mkdtempSync(join(tmpdir(), 'r3-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
return root;
}
function write(file, text = malformed) {
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, text, { mode: 0o600 });
return file;
}
function safe(label, ErrorType, properties = {}) {
return error => {
assert.ok(error instanceof ErrorType);
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
assert.ok(error.message.includes(label), error.message);
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
assert.doesNotMatch(error.stack, /sk-test/);
assert.equal(error.cause, undefined);
return true;
};
}
import { ConfigError, loadConfig, loadSeen } from '../src/scan.mjs';
for (const kind of ['config', 'seen']) test(`${kind} JSON refusal names only the file`, t => {
const root = scratch(t), file = write(join(root, kind === 'config' ? 'config.json' : 'seen.json'));
assert.throws(() => kind === 'config' ? loadConfig(file) : loadSeen(root), safe(file, ConfigError));
});
for (const kind of ['config', 'seen']) test(`${kind} read refusal retains the filesystem code`, t => {
const root = scratch(t), file = join(root, kind === 'config' ? 'config.json' : 'seen.json');
mkdirSync(file);
assert.throws(() => kind === 'config' ? loadConfig(file) : loadSeen(root), error => {
safe(file, ConfigError)(error);
assert.match(error.message, /\(EISDIR\)$/);
assert.doesNotMatch(error.message, /illegal operation/);
return true;
});
});
+4 -3
View File
@@ -15,6 +15,7 @@
// The per-message budget resets on agent_start, the event pi emits once per
// prompt run, so a follow-up message gets a fresh budget.
import { parseJsonFileText } from "../../organization/src/util.mjs";
import { Type } from "typebox";
import { TOOLS_ENV, TOOL_DESCRIPTIONS, READ_MAX_LINES, loadToolsConfig, createToolSet, enabledToolNames } from "../src/tools.mjs";
import { COMMIT_MESSAGE_MAX, COMMIT_PATHS_MAX, VAULT_PREFIXES } from "../src/git.mjs";
@@ -141,9 +142,9 @@ export default function (pi) {
if (typeof raw !== "string" || raw.length === 0) throw new Error(`${TOOLS_ENV} is not set; the connector sets it from the binding's tools key`);
let parsed;
try {
parsed = JSON.parse(raw);
} catch (err) {
throw new Error(`${TOOLS_ENV} is not valid JSON: ${err.message}`);
parsed = parseJsonFileText(raw, TOOLS_ENV);
} catch {
throw new Error(`${TOOLS_ENV} is not valid JSON`);
}
const config = loadToolsConfig(parsed);
const tools = createToolSet(config);
+4 -3
View File
@@ -19,6 +19,7 @@
// Ids in this file are private to the binding's data directory (0600),
// like every other journal.
import { parseJsonFileText } from "../../organization/src/util.mjs";
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { DiscordError } from "./errors.mjs";
@@ -104,9 +105,9 @@ export function readApprovals(dir) {
for (const [i, line] of readFileSync(path, "utf8").split("\n").entries()) {
if (line.length === 0) continue;
try {
out.push(JSON.parse(line));
} catch (err) {
throw new DiscordError(`${path}:${i + 1}: not valid JSON (${err.message})`);
out.push(parseJsonFileText(line, `${path}:${i + 1}`));
} catch {
throw new DiscordError(`${path}:${i + 1}: not valid JSON`);
}
}
return out;
+7 -6
View File
@@ -18,6 +18,7 @@
// Absent means no tools and a launch exactly as before. It is a fixed
// key: the extension reads it at pi start.
import { parseJsonFileText } from "../../organization/src/util.mjs";
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
import { loadWebConfig } from "./web.mjs";
import { loadSetsparkConfig } from "./setspark.mjs";
@@ -59,9 +60,9 @@ export function loadDataRoot(path = defaultConfigPath()) {
if (!existsSync(path)) throw new DiscordError(`config not found: ${path}`);
let raw;
try {
raw = JSON.parse(readFileSync(path, "utf8"));
} catch (err) {
throw new DiscordError(`config is not valid JSON: ${path} (${err.message})`);
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
} catch (error) {
throw new DiscordError(`config is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new DiscordError(`config is not an object: ${path}`);
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new DiscordError(`config.dataRoot must be an absolute path: ${path}`);
@@ -292,9 +293,9 @@ export function loadBinding(path) {
checkPrivateFile(path, "binding");
let raw;
try {
raw = JSON.parse(readFileSync(path, "utf8"));
} catch (err) {
throw new DiscordError(`binding is not valid JSON: ${path} (${err.message})`);
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
} catch (error) {
throw new DiscordError(`binding is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
}
return validateBinding(raw, `binding ${path}`);
}
+4 -3
View File
@@ -20,6 +20,7 @@
// supervised form for a service unit's pre-start
// Directories are 0700, files 0600. Lines are appended, never rewritten.
import { parseJsonFileText } from "../../organization/src/util.mjs";
import {
appendFileSync, closeSync, constants, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmdirSync, rmSync,
unlinkSync, writeFileSync, writeSync,
@@ -47,9 +48,9 @@ function readLines(path) {
for (const [i, line] of text.split("\n").entries()) {
if (line.length === 0) continue;
try {
out.push(JSON.parse(line));
} catch (err) {
throw new DiscordError(`${path}:${i + 1}: not valid JSON (${err.message})`);
out.push(parseJsonFileText(line, `${path}:${i + 1}`));
} catch {
throw new DiscordError(`${path}:${i + 1}: not valid JSON`);
}
}
return out;
@@ -0,0 +1,59 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
const secret = 'sk-test-NOT-A-SECRET-1234';
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
const malformed = secret;
function scratch(t) {
const root = mkdtempSync(join(tmpdir(), 'r3-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
return root;
}
function write(file, text = malformed) {
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, text, { mode: 0o600 });
return file;
}
function safe(label, ErrorType, properties = {}) {
return error => {
assert.ok(error instanceof ErrorType);
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
assert.ok(error.message.includes(label), error.message);
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
assert.doesNotMatch(error.stack, /sk-test/);
assert.equal(error.cause, undefined);
return true;
};
}
import { DiscordError } from '../src/errors.mjs';
import { loadDataRoot, loadBinding } from '../src/binding.mjs';
import { readApprovals } from '../src/approvals.mjs';
import { readInboxIds } from '../src/journal.mjs';
import extension from '../extension/tools.mjs';
import { TOOLS_ENV } from '../src/tools.mjs';
for (const [name, read, relative] of [
['config', (file) => loadDataRoot(file), 'config.json'],
['binding', (file) => loadBinding(file), 'binding.json'],
['approvals', (_file, root) => readApprovals(root), 'approvals.jsonl'],
['journal', (_file, root) => readInboxIds(root), 'inbox.jsonl'],
]) test(`${name} JSON refusal omits parser bytes`, t => {
const root = scratch(t), ledger = name === 'approvals' || name === 'journal';
const file = write(join(root, relative), ledger ? '{}\n' + malformed : malformed);
assert.throws(() => read(file, root), safe(ledger ? `${file}:2` : file, DiscordError, { exitCode: 2 }));
});
test('tools environment JSON refusal names the config key without its bytes', () => {
const previous = process.env[TOOLS_ENV];
process.env[TOOLS_ENV] = malformed;
try { assert.throws(() => extension({}), safe(TOOLS_ENV, Error)); }
finally { if (previous === undefined) delete process.env[TOOLS_ENV]; else process.env[TOOLS_ENV] = previous; }
});
test('config read refusal retains the filesystem code', t => {
const file = scratch(t);
assert.throws(() => loadDataRoot(file), error => {
safe(file, DiscordError, { exitCode: 2 })(error);
assert.match(error.message, /\(EISDIR\)$/);
return true;
});
});
+1 -1
View File
@@ -36,7 +36,7 @@ export function webPlan(resolved, harness, builtinWeb) {
throw new BundleError(`instance ${resolved.instance}: a ${resolved.definition} role fetches only through the broker, not search-source "harness-builtin"`, "web-source-not-allowed");
}
if (granted && source === "harness-builtin" && !builtinWeb) {
throw new BundleError(`instance ${resolved.instance}: search-source "harness-builtin" has no built-in web tool on ${harness}`);
throw new BundleError(`instance ${resolved.instance}: search-source "harness-builtin" has no built-in web tool on the configured harness`);
}
return { source, builtin: granted && source === "harness-builtin" };
}
+1 -1
View File
@@ -440,7 +440,7 @@ test("search-source harness-builtin: claude-code gets WebSearch and WebFetch and
test("search-source harness-builtin is refused for a granted pi role, which has no built-in web tool", (t) => {
const r = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "pi", model: "claude-sonnet-5-5" }));
assert.throws(() => build(t, withSource(granted(r), "harness-builtin")), (e) => e instanceof BundleError && /no built-in web tool on pi/.test(e.message));
assert.throws(() => build(t, withSource(granted(r), "harness-builtin")), (e) => e instanceof BundleError && /no built-in web tool on the configured harness/.test(e.message));
// Without the grant the source doesn't matter on pi.
assert.ok(!build(t, withSource(r, "harness-builtin")).manifest.builtins.some((x) => /web/i.test(x)));
});
@@ -0,0 +1,19 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
const secret = 'sk-test-NOT-A-SECRET-1234';
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
function safe(label, ErrorType, properties = {}) {
return error => {
assert.ok(error instanceof ErrorType);
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
assert.ok(error.message.includes(label), error.message);
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
assert.doesNotMatch(error.stack, /sk-test/);
assert.equal(error.cause, undefined);
return true;
};
}
import { BundleError, webPlan } from '../src/bundle.mjs';
test('harness web refusal names the harness field without the configured value', () => {
assert.throws(() => webPlan({ instance: 'builder', definition: 'coder', webSource: 'harness-builtin', limits: { tools: ['web'] } }, secret, false), safe('harness', BundleError, { code: 'bundle-refused' }));
});
+3
View File
@@ -65,6 +65,9 @@ function fixture(t) {
cpSync(source, path.join(root, 'packages/ledger/src'), { recursive: true });
// The queue section imports the queue validator and the seat registry.
for (const pkg of ['queue', 'seat']) cpSync(path.join(source, '../..', pkg, 'src'), path.join(root, 'packages', pkg, 'src'), { recursive: true });
mkdirSync(path.join(root, 'packages/organization/src'), { recursive: true });
for (const file of ['util.mjs', 'errors.mjs', 'vocabulary.mjs'])
cpSync(path.join(source, '../../organization/src', file), path.join(root, 'packages/organization/src', file));
// The fake serves ISSUES for every page, or page N of the ISSUE_PAGES array
// (an empty page past its end). API_FAIL fails every call, or only FAIL_PAGE.
const api = put('bin/gitea-api.sh', `#!/usr/bin/env node
@@ -337,6 +337,9 @@ function cliFixture(t, rows = MAP) {
const repo = scratchRepo(t, { map: mapText(rows, { retired: [], highWater: Math.max(...rows.map(r => r.id)) }) });
genesisCommitted(repo);
for (const pkg of ['ledger', 'seat']) cpSync(path.join(PACKAGES, pkg, 'src'), path.join(repo.root, 'packages', pkg, 'src'), { recursive: true });
mkdirSync(path.join(repo.root, 'packages/organization/src'), { recursive: true });
for (const file of ['util.mjs', 'errors.mjs', 'vocabulary.mjs'])
cpSync(path.join(PACKAGES, 'organization/src', file), path.join(repo.root, 'packages/organization/src', file));
const dataRoot = path.join(repo.base, 'data');
const config = path.join(repo.base, 'config.json');
writeFileSync(config, JSON.stringify({ dataRoot }));
+3 -3
View File
@@ -112,10 +112,10 @@ function checkScope(value, where, check) {
function checkExpires(value, where) {
if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z (got ${JSON.stringify(value)})`);
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z`);
}
const iso = new Date(value).toISOString();
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time: ${value}`);
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time`);
}
function checkGrant(grant, at, ctx, users, seenGrants) {
@@ -133,7 +133,7 @@ function checkGrant(grant, at, ctx, users, seenGrants) {
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID (got ${JSON.stringify(p)})`);
});
checkScope(grant.instances, `${at}.instances`, (x, where) => {
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of ${grant.business} (got ${JSON.stringify(x)})`);
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of the grant's business`);
});
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")} (got ${JSON.stringify(grant.level)})`);
if (grant.expires !== undefined) checkExpires(grant.expires, `${at}.expires`);
@@ -0,0 +1,312 @@
// #1873 Phase A slice A2: the root agent file's writer (design
// docs/plans/2026-11-10_agent-levels-design.md sections 6, 7 and 9 item 1).
//
// One instance-wide file, <configDir>/agents.json. Only the signed-in owner writes it: the caller
// passes the actor and the owner principal its own trusted context established (the CLI's local
// owner, the Console's owner session), and anything else refuses `human-required`. The write
// follows M2's locked store (accounts.mjs): a lock file, a re-read and revision check under it, the
// whole new document checked with A1's checkRootAgents, a 0600 temp file, a re-check, then rename
// (or link for a new file), a directory sync and one receipt line. Nothing here launches an agent.
//
// No refusal and no receipt carries a value from the file or the request: a var value can be a
// secret typed into the wrong place. Messages name the file (agents.json) and the key, never a
// host path.
import { constants, lstatSync } from 'node:fs';
import * as fs from 'node:fs/promises';
import { createHash, randomBytes } from 'node:crypto';
import { join, parse, resolve } from 'node:path';
import { OrgError } from './org-registry.mjs';
import { ROOT_FILE, checkProjectAgents, checkRootAgents, loadRootAgents, projectUnion, rootAgentsPath } from './agent-levels.mjs';
import { canonicalJson, isPlainObject } from './util.mjs';
import { ID_PATTERN } from './vocabulary.mjs';
import { REGISTRY } from './vars.mjs';
export const ROOT_LOCK = 'agents.lock';
export const ROOT_RECEIPTS = 'agents-receipts.jsonl';
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
// The revision of a root file that does not exist yet: the digest of no bytes.
export const ABSENT_REVISION = sha256('');
const REVISION = /^[a-f0-9]{64}$/;
const PRINCIPAL = ['provider', 'iss', 'sub'];
const fail = (code, message = code) => { throw new OrgError(code, message); };
// The only actor that writes: the owner principal the caller's trusted context names. The tester
// identity never counts as the owner, whatever a context says.
export function requireRootOwner(actor, owner) {
const ok = p => isPlainObject(p) && PRINCIPAL.every(k => typeof p[k] === 'string' && p[k] !== '');
if (!ok(actor) || !ok(owner) || PRINCIPAL.some(k => actor[k] !== owner[k]) || actor.sub === 'tester' || actor.provider === 'tester')
fail('human-required', 'only the signed-in owner writes the root agent file');
}
// Every ancestor a real directory that only root or this uid can change: owned by one of them, and
// group or other writable only with the sticky bit (as /tmp is), so no other user can rename or
// replace configDir between these checks and the path-based lock, temp file and rename. configDir
// itself is this uid's and not group or other writable.
function assertConfigDir(configDir, uid) {
if (typeof configDir !== 'string' || resolve(configDir) !== configDir) fail('agents-unsafe', 'config directory must be an absolute normalized path');
let current = parse(configDir).root;
const trusted = st => (st.uid === 0 || st.uid === uid) && (!(st.mode & 0o022) || (st.mode & 0o1000));
try { if (!trusted(lstatSync(current))) fail('agents-unsafe', `a config directory ancestor must belong to root or uid ${uid} and not be writable by others without the sticky bit`); }
catch (error) { if (error instanceof OrgError) throw error; fail('agents-unsafe', 'config directory is missing'); }
for (const part of configDir.slice(current.length).split('/').filter(Boolean)) {
current = join(current, part);
let st;
try { st = lstatSync(current); } catch { fail('agents-unsafe', 'config directory is missing'); }
if (!st.isDirectory() || st.isSymbolicLink()) fail('agents-unsafe', 'config path contains a symbolic link or non-directory');
if (current !== configDir && !trusted(st)) fail('agents-unsafe', `a config directory ancestor must belong to root or uid ${uid} and not be writable by others without the sticky bit`);
}
const st = lstatSync(configDir);
if (st.uid !== uid) fail('agents-unsafe', `config directory must belong to uid ${uid}`);
if (st.mode & 0o022) fail('agents-unsafe', 'config directory must not be writable by group or other');
}
// The reader's descriptor checks (loadRootAgents), plus one link: a regular file, no symbolic link,
// this uid, no group or other write. Missing is an empty root level at ABSENT_REVISION.
async function readCurrent(configDir, uid, statFile = fd => fd.stat()) {
const file = rootAgentsPath(configDir);
let fd;
try { fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
catch (error) {
if (error.code === 'ENOENT') return { exists: false, revision: ABSENT_REVISION, document: { agentsVersion: 1, agents: {} } };
fail('agents-unsafe', `root agent file must be a regular file, not a symbolic link (${ROOT_FILE})`);
}
let bytes;
try {
const st = await statFile(fd);
if (!st.isFile()) fail('agents-unsafe', `root agent file must be a regular file, not a symbolic link (${ROOT_FILE})`);
if (st.uid !== uid) fail('agents-unsafe', `root agent file must belong to uid ${uid} (${ROOT_FILE})`);
if (st.mode & 0o022) fail('agents-unsafe', `root agent file must not be writable by group or other (${ROOT_FILE})`);
if (st.nlink !== 1) fail('agents-unsafe', `root agent file must have exactly one link (${ROOT_FILE})`);
bytes = await fd.readFile();
} finally { await fd.close(); }
let document;
// The parser's text can quote the file's bytes: say only that it is not JSON.
try { document = JSON.parse(bytes.toString('utf8')); } catch { fail('agents-invalid', `root agent file is not valid JSON (${ROOT_FILE})`); }
return { exists: true, revision: sha256(bytes), document };
}
// The file's document and revision, for a form or a CLI list. A file that is not a root agent
// document at all refuses; one with a bad entry reads, so the owner can fix or remove that entry.
// statFile is a test seam for the descriptor stat; it grants nothing a caller's uid does not.
export async function readRootAgentsFile(configDir, { uid = process.geteuid(), statFile } = {}) {
assertConfigDir(configDir, uid);
const current = await readCurrent(configDir, uid, statFile);
shape(current.document);
return current;
}
function shape(document) {
if (!isPlainObject(document) || document.agentsVersion !== 1 || !isPlainObject(document.agents))
fail('agents-invalid', `root agent file must be {"agentsVersion": 1, "agents": {...}} (${ROOT_FILE})`);
}
// Every string a request put in an entry, as a message could quote it bare or as JSON.
// What a refusal must not show. Field names, var names, role ids and org ids may appear; every
// var value, every unknown key and any other string that isn't an id is caller text.
const FIELDS = new Set(['definition', 'vars', 'excludeOrgs', 'exclude', 'credentials', 'holder', 'tracker', 'identity', 'publish', 'hosts']);
function all(value, out) {
if (typeof value === 'string') { if (value.length >= 3) out.push(value, JSON.stringify(value)); }
else if (Array.isArray(value)) value.forEach(v => all(v, out));
else if (isPlainObject(value)) for (const [key, v] of Object.entries(value)) { all(key, out); all(v, out); }
return out;
}
const unlessId = (value, out) => (typeof value === 'string' && ID_PATTERN.test(value) ? out : all(value, out));
function strings(entry, out = []) {
if (!isPlainObject(entry)) return all(entry, out);
for (const [key, value] of Object.entries(entry)) {
if (!FIELDS.has(key)) all(key, out);
if (key === 'definition') unlessId(value, out);
else if (key === 'excludeOrgs' && Array.isArray(value)) value.forEach(v => unlessId(v, out));
else if (key === 'exclude' && typeof value === 'boolean') continue;
else if (key === 'vars' && isPlainObject(value)) for (const [name, v] of Object.entries(value)) { if (!Object.hasOwn(REGISTRY, name)) all(name, out); all(v, out); }
else all(value, out);
}
return out;
}
// A refusal's message with its key path and without any value: an id check's "(got ...)" is cut,
// and a message that still holds a string from the entry is replaced by one naming the key only.
export function withoutValues(message, entry, prefix) {
const cut = String(message).replace(/ \(got [\s\S]*\)$/, '');
if (!strings(entry).some(text => cut.includes(text))) return cut;
const at = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\.[^\\s:]+)*`).exec(cut)?.[0] ?? prefix;
return `${at}: refused (the value is not shown)`;
}
// A1's checks on the whole new document.
function validate(document, rolesDir, entry) {
try { return checkRootAgents(document, { rolesDir }); }
catch (error) {
if (error?.name !== 'BusinessError') throw error;
fail('agents-invalid', withoutValues(error.message, entry, 'root agents'));
}
}
// What changed in one entry, by key name only.
function changedKeys(before, after) {
if (!before && !after) return [];
if (!before) return ['added'];
if (!after) return ['removed'];
const out = [];
if (before.definition !== after.definition) out.push('definition');
const bv = isPlainObject(before.vars) ? before.vars : {}, av = isPlainObject(after.vars) ? after.vars : {};
for (const key of [...new Set([...Object.keys(bv), ...Object.keys(av)])].sort()) {
if (canonicalJson(bv[key]) !== canonicalJson(av[key])) out.push(`vars.${key}`);
}
if (canonicalJson(before.excludeOrgs ?? []) !== canonicalJson(after.excludeOrgs ?? [])) out.push('excludeOrgs');
return out;
}
// One entry changed by a patch: `definition` replaces, `vars` sets each key it names (null removes
// that key), `excludeOrgs` replaces the list ([] clears it). Keys the patch leaves out are kept.
export function patchRootEntry(old, { definition, vars, excludeOrgs } = {}) {
const next = old ? structuredClone(old) : {};
if (definition !== undefined) next.definition = definition;
if (vars !== undefined) {
if (!isPlainObject(vars)) fail('agents-invalid', 'vars must be an object of keys');
const merged = isPlainObject(next.vars) ? { ...next.vars } : {};
for (const [key, value] of Object.entries(vars)) { if (value === null) delete merged[key]; else merged[key] = value; }
if (Object.keys(merged).length) next.vars = merged; else delete next.vars;
}
if (excludeOrgs !== undefined) { if (Array.isArray(excludeOrgs) && excludeOrgs.length === 0) delete next.excludeOrgs; else next.excludeOrgs = excludeOrgs; }
return next;
}
async function syncDirectory(dir) {
const fd = await fs.open(dir, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
try { await fd.sync(); } finally { await fd.close(); }
}
// Change one root agent. `entry` is the new entry, null to remove it, or a function of the old
// entry (undefined when there is none) returning either. Returns the receipt. An error after the
// rename carries committed: true, so a caller never retries a write that landed.
export async function changeRootAgent(configDir, { revision, name, entry }, { actor, owner, rolesDir, uid = process.geteuid(), beforeReplace, statFile, syncDirectory: syncDir = syncDirectory } = {}) {
requireRootOwner(actor, owner);
if (typeof name !== 'string' || !ID_PATTERN.test(name)) fail('agents-invalid', 'agent name must be an id: lowercase letters, digits, dot, dash or underscore, at most 64');
if (typeof revision !== 'string' || !REVISION.test(revision)) fail('revision-conflict', 'a root agent change needs the revision it was read at');
assertConfigDir(configDir, uid);
const lock = join(configDir, ROOT_LOCK);
let lockFd;
for (let n = 0; n < 200; n++) {
try { lockFd = await fs.open(lock, 'wx', 0o600); break; }
catch (error) { if (error.code !== 'EEXIST') throw error; await new Promise(r => setTimeout(r, 10)); }
}
if (!lockFd) fail('agents-locked', 'the root agent file is locked by another write; inspect agents.lock before retrying');
const file = rootAgentsPath(configDir);
let temp, receiptFd, committed = false;
try {
assertConfigDir(configDir, uid);
const before = await readCurrent(configDir, uid, statFile);
if (before.revision !== revision) fail('revision-conflict', 'the root agent file changed since it was read; read it again');
shape(before.document);
const old = Object.hasOwn(before.document.agents, name) ? before.document.agents[name] : undefined;
const value = typeof entry === 'function' ? entry(old === undefined ? undefined : structuredClone(old)) : entry;
if (value === null && old === undefined) fail('agent-not-found', `root agents.${name} does not exist`);
if (value !== null && !isPlainObject(value)) fail('agents-invalid', `root agents.${name} must be an object`);
const next = structuredClone(before.document);
if (value === null) delete next.agents[name]; else next.agents[name] = value;
const changed = changedKeys(old, value ?? undefined);
if (changed.length === 0) fail('no-change', `root agents.${name} is already that`);
validate(next, rolesDir, value);
const bytes = JSON.stringify(next, null, 2) + '\n';
// Check what lands on disk, not only the object built: JSON can drop or reshape a value.
validate(JSON.parse(bytes), rolesDir, value);
const after = sha256(bytes);
const line = {
at: new Date().toISOString(), uid: process.geteuid(), actor: 'owner',
verb: value === null ? 'root.agent.remove' : 'root.agent.set', agent: name,
...(value === null ? {} : { definition: value.definition }),
revisionBefore: before.revision, revisionAfter: after, changed,
};
// Refuse an unsafe receipt destination before publishing the change.
receiptFd = await fs.open(join(configDir, ROOT_RECEIPTS), constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
const st = await receiptFd.stat();
if (!st.isFile() || st.uid !== uid || (st.mode & 0o022) || st.nlink !== 1) fail('agents-unsafe', `unsafe root agent receipt file (${ROOT_RECEIPTS})`);
temp = join(configDir, `.agents-${randomBytes(12).toString('hex')}`);
const fd = await fs.open(temp, 'wx', 0o600);
try { await fd.writeFile(bytes); await fd.sync(); } finally { await fd.close(); }
await beforeReplace?.();
// A writer that ignores the lock still loses: the bytes must be the ones read.
const again = await readCurrent(configDir, uid, statFile);
if (again.exists !== before.exists || again.revision !== before.revision) fail('revision-conflict', 'the root agent file changed before the write; read it again');
// A new file is published with link, which fails rather than replace a file made meanwhile.
if (before.exists) await fs.rename(temp, file);
else await fs.link(temp, file);
committed = true;
if (!before.exists) await fs.unlink(temp);
await syncDir(configDir);
await receiptFd.writeFile(JSON.stringify(line) + '\n'); await receiptFd.sync();
return line;
} catch (error) { if (committed && error instanceof Error) error.committed = true; throw error; }
finally {
if (temp) await fs.rm(temp, { force: true });
if (receiptFd) await receiptFd.close();
await lockFd.close(); await fs.unlink(lock);
}
}
// The entries as a form or a list shows them: names, definition, the model, the other var keys and
// the opted-out organizations. No var value other than the model.
export function rootAgentSummary(document) {
return Object.entries(isPlainObject(document?.agents) ? document.agents : {}).map(([name, entry]) => {
const vars = isPlainObject(entry?.vars) ? entry.vars : {};
return {
name, definition: typeof entry?.definition === 'string' ? entry.definition : null,
model: typeof vars.model === 'string' ? vars.model : null,
varKeys: Object.keys(vars).filter(k => k !== 'model').sort(),
excludeOrgs: Array.isArray(entry?.excludeOrgs) ? entry.excludeOrgs.filter(x => typeof x === 'string') : [],
};
}).sort((a, b) => a.name.localeCompare(b.name));
}
// One row per entry: its form and what it names, never a var value.
export function projectAgentSummary(agents) {
if (!isPlainObject(agents)) return [];
return Object.keys(agents).sort().map(name => {
const entry = isPlainObject(agents[name]) ? agents[name] : {};
if (entry.exclude === true) return { name, form: 'exclude' };
if (entry.definition !== undefined) return { name, form: 'define', definition: typeof entry.definition === 'string' ? entry.definition : null, varKeys: Object.keys(isPlainObject(entry.vars) ? entry.vars : {}).sort() };
return { name, form: 'narrow', varKeys: Object.keys(isPlainObject(entry.vars) ? entry.vars : {}).sort() };
});
}
// The project-agent verb's guard (design section 7, the projects fence's one exception for agents):
// `next` may differ from `old` only in projects.<project>.agents.<name>, the project must exist and
// not be a library, and the project's entries and its union must pass A1's checks. Returns
// [code, message] or null. `root` is the root level as loadRootAgents reads it.
export function projectAgentProblem(old, next, { orgId, project, name, entry, rolesDir, root }) {
const target = old?.projects?.[project];
if (!isPlainObject(target)) return ['project-not-found', `projects.${project} does not exist`];
if (target.kind === 'library') return ['project-agents-library', `projects.${project} is a library project; it takes no agents`];
const without = doc => {
const copy = structuredClone(doc);
const p = copy?.projects?.[project];
if (isPlainObject(p) && isPlainObject(p.agents)) {
delete p.agents[name];
if (Object.keys(p.agents).length === 0) delete p.agents;
}
return canonicalJson(copy);
};
if (without(old) !== without(next)) return ['organization-projects-protected', `only projects.${project}.agents.${name} may change`];
const after = next.projects[project].agents?.[name];
if (canonicalJson(after ?? null) !== canonicalJson(entry ?? null)) return ['organization-projects-protected', `projects.${project}.agents.${name} is not the entry asked for`];
if (canonicalJson(after ?? null) === canonicalJson(target.agents?.[name] ?? null)) return ['no-change', `projects.${project}.agents.${name} is already that`];
const where = `projects.${project}.agents`;
let entries;
try { entries = checkProjectAgents(next.projects[project].agents ?? {}, { rolesDir, where }); }
catch (error) { if (error?.name !== 'BusinessError') throw error; return ['project-agents-invalid', withoutValues(error.message, entry, where)]; }
const org = Object.fromEntries(Object.entries(old.roles ?? {}).map(([n, r]) => [n, { definition: r?.definition, vars: r?.vars ?? {} }]));
try { projectUnion({ orgId, root, org, project: entries, projectId: project }); }
catch (error) { if (error?.name !== 'BusinessError') throw error; return ['project-agents-invalid', withoutValues(error.message, entry, where)]; }
return null;
}
// The root level a project union is checked against. A root file that refuses refuses the project
// write too: an exclusion or narrowing checked against a root level that did not parse could be wrong.
export function rootForProjectCheck(configDir, rolesDir) {
try { return loadRootAgents(configDir, { rolesDir }); }
catch (error) { if (error?.name !== 'BusinessError') throw error; fail('project-agents-invalid', 'the root agent file refuses; fix it before changing project agents'); }
}
// A project entry's form, for the change log and the receipt.
export const entryForm = entry => entry === null ? 'remove' : Object.hasOwn(entry, 'exclude') ? 'exclude' : Object.hasOwn(entry, 'definition') ? 'define' : 'narrow';
+37 -3
View File
@@ -13,6 +13,7 @@ import { checkReplyLimits } from "../../library/src/limits.mjs";
import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
import { remoteProblem, remoteTarget } from "./project-remotes.mjs";
import { withRegistryWriter } from "./org-registry.mjs";
import { entryForm, projectAgentProblem, rootForProjectCheck } from "./agent-levels-write.mjs";
export class OrganizationError extends Error {
constructor(code, message = code) {
@@ -272,7 +273,9 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
// The one commit path. `guard(old, document)` is what a caller may do to `projects`: write() keeps
// them whole (C2-04 M9) and addProject() allows one exact addition. `document` may be a function of
// the old document, for a writer that builds what it publishes from what it read under the lock.
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, guard }) {
// #1873 A2: `pathsOnly` logs each change's path and presence without its values, for a verb whose
// values can hold a var a person typed (a secret pasted in the wrong place must not reach evidence).
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, guard, pathsOnly = false }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
let revoked = false;
@@ -320,7 +323,8 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
syncDir(tx);
const event = {
transaction, actor, ...(context === undefined ? {} : { context }), business: id, before, after,
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
changes: pathsOnly ? changes(old, written).map(({ path, oldPresent, newPresent }) => ({ path, oldPresent, newPresent })) : changes(old, written),
...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
};
log(id, { ...event, state: "prepared" });
syncDir(home);
@@ -382,6 +386,36 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
}, { revision, actor, guard, detail: { verb: "project.remote.set", project } });
}
// #1873 A2 (design section 7): the only writer of `projects.<p>.agents`, one entry per call. `entry`
// is a define ({definition, vars?}), a narrow ({vars}), an exclude ({exclude: true}) or null to
// remove the entry. The human owner only: every agent actor, the tester included, refuses
// human-required, and so does any actor that is not the organization's human. The guard checks the
// candidate and the serialized bytes: nothing but that entry changes, the project exists and is not
// a library, and the project's entries and union pass A1's checks against the current root level.
// The change log names paths only; the detail names the project, the agent and the form.
function setProjectAgent(id, { revision, project, name, entry }, actor) {
requireId(id, "organization id");
requireId(project, "project id");
requireId(name, "agent name");
if (entry !== null && (entry === undefined || typeof entry !== "object" || Array.isArray(entry))) fail("project-agents-invalid", "an entry is an object, or null to remove it");
const root = rootForProjectCheck(dir, rolesDir);
const guard = (old, document) => {
const agents = Object.entries(old.roles ?? {}).flatMap(([n, role]) => [n, `${role?.definition}.${n}`]);
if (actor !== old.human || actor === "tester" || agents.includes(actor)) fail("human-required");
if (entry === null && old.projects?.[project]?.agents?.[name] === undefined) fail("agent-not-found", `projects.${project}.agents.${name} does not exist`);
const problem = projectAgentProblem(old, document, { orgId: id, project, name, entry, rolesDir, root });
if (problem) fail(...problem);
};
return commit(id, old => {
const target = old.projects?.[project];
if (target === null || typeof target !== "object" || Array.isArray(target)) return old;
const agents = { ...(target.agents ?? {}) };
if (entry === null) delete agents[name]; else agents[name] = entry;
const { agents: _drop, ...rest } = target;
return { ...old, projects: { ...old.projects, [project]: Object.keys(agents).length ? { ...rest, agents } : rest } };
}, { revision, actor, guard, pathsOnly: true, detail: { verb: "project.agents.set", project, agent: name, form: entryForm(entry) } });
}
// Runs `fn({ revision, document })` under the writer lock, after recovery, and nothing else may
// write this business meanwhile. `fn` must be synchronous: the lock is a directory held for its
// duration, and an awaited call would leave a half-checked world to whoever finds the lock gone.
@@ -449,5 +483,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
});
}
return { logFile, read, readRaw, write, addProject, setProjectRemote, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
}
+4 -4
View File
@@ -20,7 +20,7 @@ export function rejectUnknownKeys(object, allowed, where) {
export function requireId(value, where) {
if (typeof value !== "string" || !ID_PATTERN.test(value)) {
refuse(`${where} must match ${ID_PATTERN} (got ${JSON.stringify(value)})`);
refuse(`${where} must match ${ID_PATTERN}`);
}
return value;
}
@@ -33,7 +33,7 @@ export function requireString(value, where, { max = 200 } = {}) {
}
export function requirePositiveInt(value, where) {
if (!Number.isSafeInteger(value) || value < 1) refuse(`${where} must be a positive integer (got ${JSON.stringify(value)})`);
if (!Number.isSafeInteger(value) || value < 1) refuse(`${where} must be a positive integer`);
return value;
}
@@ -52,9 +52,9 @@ export function requireDistinctList(value, where, check, { nonEmpty = false } =
// Calendar date YYYY-MM-DD that exists (2026-02-30 refuses).
export function requireDate(value, where) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) refuse(`${where} must be a date YYYY-MM-DD (got ${JSON.stringify(value)})`);
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) refuse(`${where} must be a date YYYY-MM-DD`);
const d = new Date(`${value}T00:00:00Z`);
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value) refuse(`${where} is not a real date: ${value}`);
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value) refuse(`${where} is not a real date`);
return value;
}
+1 -1
View File
@@ -161,7 +161,7 @@ test("grants: projects is [\"*\"] or distinct lowercase UUIDs", () => {
});
test("grants: instances is [\"*\"] or distinct declared instances of that business", () => {
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["coder"]; }), context()), /grants\[0\]\.instances\[0\] must be "\*" or a declared instance of creative-hub \(got "coder"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["coder"]; }), context()), /grants\[0\]\.instances\[0\] must be "\*" or a declared instance of the grant's business/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["*", "writer"]; }), context()), /grants\[0\]\.instances must be \["\*"\] alone/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["writer", "writer"]; }), context()), /duplicate entry in grants\[0\]\.instances/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = []; }), context()), /grants\[0\]\.instances must not be empty/);
@@ -0,0 +1,366 @@
// #1873 Phase A slice A2: the root agent file's writer and the project-agent writer verb (design
// docs/plans/2026-11-10_agent-levels-design.md sections 6, 7 and 9 item 1).
import test from "node:test";
import assert from "node:assert/strict";
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { loadRootAgents } from "../src/agent-levels.mjs";
import {
ABSENT_REVISION, ROOT_LOCK, ROOT_RECEIPTS, changeRootAgent, patchRootEntry, projectAgentProblem, readRootAgentsFile, rootAgentSummary,
} from "../src/agent-levels-write.mjs";
import * as oldPath from "../../business/src/agent-levels-write.mjs";
import { organizationWriter } from "../src/organization.mjs";
import { businessDoc, REPO_ROLES, rolesCopy, tmp, writeJson } from "./helpers.mjs";
const rolesDir = REPO_ROLES;
const SECRET = "sk-test-NOT-A-SECRET-1234";
const OWNER = Object.freeze({ provider: "local-dev", iss: "local-dev", sub: "local-dev-owner" });
const as = (actor = OWNER) => ({ actor, owner: OWNER, rolesDir });
function config() {
const dir = join(tmp(), "config");
mkdirSync(dir, { recursive: true, mode: 0o700 });
chmodSync(dir, 0o700);
return dir;
}
const file = dir => join(dir, "agents.json");
const bytes = dir => existsSync(file(dir)) ? readFileSync(file(dir), "utf8") : null;
const receipts = dir => existsSync(join(dir, ROOT_RECEIPTS))
? readFileSync(join(dir, ROOT_RECEIPTS), "utf8").trim().split("\n").filter(Boolean).map(line => JSON.parse(line)) : [];
const seeded = async dir => {
await changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research", vars: { model: "claude-opus-5-5" } } }, as());
return readRootAgentsFile(dir);
};
const refuses = (code, check = () => true) => error => error.code === code && check(error);
test("the old business path re-exports the writer", () => {
assert.equal(oldPath.changeRootAgent, changeRootAgent);
});
test("root writer: an owner write lands, reads back through A1's loader, and its receipt names the change", async () => {
const dir = config();
const receipt = await changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research", vars: { model: "claude-opus-5-5" } } }, as());
assert.equal(receipt.verb, "root.agent.set");
assert.equal(receipt.agent, "researcher");
assert.equal(receipt.definition, "research");
assert.deepEqual(receipt.changed, ["added"]);
assert.equal(receipt.revisionBefore, ABSENT_REVISION);
const st = lstatSync(file(dir));
assert.equal(st.mode & 0o777, 0o600);
assert.equal(st.nlink, 1);
const loaded = loadRootAgents(dir, { rolesDir });
assert.equal(loaded.agents.researcher.vars.model, "claude-opus-5-5");
const read = await readRootAgentsFile(dir);
assert.equal(read.revision, receipt.revisionAfter);
// A patch keeps the keys it leaves out and names, by key, what it changed.
const second = await changeRootAgent(dir, { revision: read.revision, name: "researcher", entry: old => patchRootEntry(old, { vars: { "limits.network": "api-only" }, excludeOrgs: ["home"] }) }, as());
assert.deepEqual(second.changed, ["vars.limits.network", "excludeOrgs"]);
assert.deepEqual(rootAgentSummary((await readRootAgentsFile(dir)).document), [{ name: "researcher", definition: "research", model: "claude-opus-5-5", varKeys: ["limits.network"], excludeOrgs: ["home"] }]);
const removed = await changeRootAgent(dir, { revision: second.revisionAfter, name: "researcher", entry: null }, as());
assert.equal(removed.verb, "root.agent.remove");
assert.deepEqual(removed.changed, ["removed"]);
assert.deepEqual(loadRootAgents(dir, { rolesDir }).agents, {});
const lines = receipts(dir);
assert.equal(lines.length, 3);
// Receipts carry names and key paths only: no var value, no host path.
const text = readFileSync(join(dir, ROOT_RECEIPTS), "utf8");
assert.ok(!text.includes("claude-opus-5-5") && !text.includes("api-only") && !text.includes(dir));
assert.ok(lines.every(line => line.actor === "owner" && line.uid === process.geteuid()));
assert.equal(lstatSync(join(dir, ROOT_RECEIPTS)).mode & 0o777, 0o600);
assert.equal(existsSync(join(dir, "agents.lock")), false);
assert.deepEqual(readdirSync(dir).filter(n => n.startsWith(".agents-")), []);
});
test("root writer: every actor but the owner refuses human-required and nothing is written", async () => {
const dir = config();
const others = [
{ provider: "local-dev", iss: "local-dev", sub: "chief" },
{ provider: "local-dev", iss: "local-dev", sub: "chief-of-staff.chief" },
{ provider: "local-dev", iss: "local-dev", sub: "tester" },
{ ...OWNER, provider: "tester" },
{ provider: "https-idp", iss: "https://idp.example", sub: "local-dev-owner" },
"local-dev-owner", "pm", null, undefined, {},
];
for (const actor of others) {
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research" } }, { actor, owner: OWNER, rolesDir }), refuses("human-required"), JSON.stringify(actor ?? null));
}
// An owner context that names the tester is not an owner either.
const tester = { provider: "local-dev", iss: "local-dev", sub: "tester" };
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research" } }, { actor: tester, owner: tester, rolesDir }), refuses("human-required"));
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research" } }, { actor: OWNER, rolesDir }), refuses("human-required"));
assert.equal(bytes(dir), null);
assert.deepEqual(receipts(dir), []);
});
test("root writer: a stale revision refuses and the file is unchanged", async () => {
const dir = config();
const first = await seeded(dir);
const before = bytes(dir);
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "reviewer", entry: { definition: "reviewer" } }, as()), refuses("revision-conflict"));
await changeRootAgent(dir, { revision: first.revision, name: "reviewer", entry: { definition: "reviewer" } }, as());
const after = bytes(dir);
// The first revision is now stale too.
await assert.rejects(changeRootAgent(dir, { revision: first.revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("revision-conflict"));
assert.notEqual(before, after);
assert.equal(bytes(dir), after);
await assert.rejects(changeRootAgent(dir, { revision: "nope", name: "pm", entry: { definition: "pm" } }, as()), refuses("revision-conflict"));
});
test("root writer: a symbolic link, a group-writable file and a foreign uid refuse and the file is unchanged", async () => {
const dir = config();
const { revision } = await seeded(dir);
const original = bytes(dir);
// A symbolic link in place of the file: the target is never followed or written.
const target = join(dir, "elsewhere.json");
writeFileSync(target, original); chmodSync(target, 0o600);
const real = join(dir, "real.json");
writeFileSync(real, original); chmodSync(real, 0o600);
const moved = readFileSync(file(dir));
writeFileSync(real, moved);
const { renameSync } = await import("node:fs");
renameSync(file(dir), join(dir, "kept.json"));
symlinkSync(target, file(dir));
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => !e.message.includes(dir)));
assert.equal(readFileSync(target, "utf8"), original);
assert.ok(lstatSync(file(dir)).isSymbolicLink());
await assert.rejects(readRootAgentsFile(dir), refuses("agents-unsafe"));
// Restore, then make it group-writable.
const { unlinkSync } = await import("node:fs");
unlinkSync(file(dir)); renameSync(join(dir, "kept.json"), file(dir));
chmodSync(file(dir), 0o620);
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => /group or other/.test(e.message)));
assert.equal(bytes(dir), original);
assert.equal(lstatSync(file(dir)).mode & 0o777, 0o620);
chmodSync(file(dir), 0o600);
// A second link to the file refuses: a write through one name would not be the file another reads.
const { linkSync } = await import("node:fs");
linkSync(file(dir), join(dir, "alias.json"));
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => /one link/.test(e.message)));
unlinkSync(join(dir, "alias.json"));
// Not this process's uid: the directory checks refuse first (an ancestor this uid owns is foreign).
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, { ...as(), uid: process.geteuid() + 1 }), refuses("agents-unsafe", e => /config directory/.test(e.message) && !/root agent file/.test(e.message)));
await assert.rejects(readRootAgentsFile(dir, { uid: process.geteuid() + 1 }), refuses("agents-unsafe", e => /config directory/.test(e.message) && !/root agent file/.test(e.message)));
assert.equal(bytes(dir), original);
// A file another uid owns, in a directory that is this uid's: the file's own owner check refuses.
// Changing a file's owner needs root, so the descriptor stat reports a foreign uid through the seam.
const foreign = async fd => { const st = await fd.stat(); return Object.assign(Object.create(Object.getPrototypeOf(st)), st, { uid: st.uid + 1 }); };
const receiptsBefore = existsSync(join(dir, ROOT_RECEIPTS)) ? readFileSync(join(dir, ROOT_RECEIPTS), "utf8") : null;
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, { ...as(), statFile: foreign }), refuses("agents-unsafe", e => /root agent file must belong to uid/.test(e.message)));
await assert.rejects(readRootAgentsFile(dir, { statFile: foreign }), refuses("agents-unsafe", e => /root agent file must belong to uid/.test(e.message)));
assert.equal(bytes(dir), original);
assert.equal(lstatSync(file(dir)).mode & 0o777, 0o600);
assert.equal(existsSync(join(dir, ROOT_RECEIPTS)) ? readFileSync(join(dir, ROOT_RECEIPTS), "utf8") : null, receiptsBefore);
assert.equal(existsSync(join(dir, ROOT_LOCK)), false, "the lock is released");
// A group-writable config directory refuses before any lock or read.
chmodSync(dir, 0o770);
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => /config directory/.test(e.message)));
chmodSync(dir, 0o700);
// An ancestor another user can write without the sticky bit refuses: that user could rename the
// config directory between the checks and the write. With the sticky bit, as /tmp has, it can't.
const parent = dirname(dir);
const mode = lstatSync(parent).mode & 0o7777;
chmodSync(parent, 0o777);
try {
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => /ancestor/.test(e.message) && !e.message.includes(dir)));
await assert.rejects(readRootAgentsFile(dir), refuses("agents-unsafe", e => /ancestor/.test(e.message)));
chmodSync(parent, 0o1777);
assert.equal((await readRootAgentsFile(dir)).revision, revision);
} finally { chmodSync(parent, mode); }
assert.equal(bytes(dir), original);
assert.equal(receipts(dir).length, 1);
assert.equal(existsSync(join(dir, "agents.lock")), false);
});
test("root writer: an invalid document refuses with the key named and the value absent", async () => {
const dir = config();
const { revision } = await seeded(dir);
const original = bytes(dir);
const cases = [
[{ definition: "research", vars: { model: SECRET.repeat(10) } }, /variable model/],
[{ definition: "research", vars: { "limits.network": SECRET } }, /variable limits\.network/],
[{ definition: SECRET }, /root agents\.leaky\.definition/],
[{ definition: "research", excludeOrgs: [SECRET.toUpperCase()] }, /root agents\.leaky\.excludeOrgs/],
[{ definition: "research", vars: { [SECRET]: "x" } }, /root agents\.leaky\.vars/],
[{ definition: "research", credentials: { gitea: SECRET } }, /root agents\.leaky\.credentials/],
[{ definition: "chief-of-staff" }, /chief-of-staff can't be a root agent/],
[{ definition: "assistant" }, /assistant definition is the root agent named assistant only/],
];
for (const [entry, pattern] of cases) {
await assert.rejects(changeRootAgent(dir, { revision, name: "leaky", entry }, as()),
refuses("agents-invalid", e => pattern.test(e.message) && !e.message.includes(SECRET) && !e.message.includes(SECRET.toUpperCase()) && !e.message.includes(dir)),
JSON.stringify(Object.keys(entry)));
}
await assert.rejects(changeRootAgent(dir, { revision, name: SECRET.toUpperCase(), entry: { definition: "research" } }, as()), refuses("agents-invalid", e => !e.message.includes(SECRET.toUpperCase())));
await assert.rejects(changeRootAgent(dir, { revision, name: "absent", entry: null }, as()), refuses("agent-not-found"));
await assert.rejects(changeRootAgent(dir, { revision, name: "researcher", entry: { definition: "research", vars: { model: "claude-opus-5-5" } } }, as()), refuses("no-change"));
assert.equal(bytes(dir), original);
assert.equal(receipts(dir).length, 1);
});
test("root writer: a crash between the temp file and the rename leaves the old file whole", async () => {
const dir = config();
const { revision } = await seeded(dir);
const original = bytes(dir);
await assert.rejects(changeRootAgent(dir, { revision, name: "reviewer", entry: { definition: "reviewer" } }, { ...as(), beforeReplace: () => { throw new Error("crash"); } }), /crash/);
assert.equal(bytes(dir), original);
assert.deepEqual(readdirSync(dir).filter(n => n.startsWith(".agents-")), []);
assert.equal(existsSync(join(dir, "agents.lock")), false);
assert.equal(receipts(dir).length, 1);
// A writer that ignores the lock and changes the file between the temp and the rename loses.
await assert.rejects(changeRootAgent(dir, { revision, name: "reviewer", entry: { definition: "reviewer" } }, { ...as(), beforeReplace: () => writeFileSync(file(dir), original.replace("research\"", "research\" ")) }), refuses("revision-conflict"));
// A new file is never published over one created meanwhile.
const fresh = config();
await assert.rejects(changeRootAgent(fresh, { revision: ABSENT_REVISION, name: "pm", entry: { definition: "pm" } }, { ...as(), beforeReplace: () => writeJson(file(fresh), { agentsVersion: 1, agents: {} }) }), refuses("revision-conflict"));
assert.deepEqual(JSON.parse(bytes(fresh)), { agentsVersion: 1, agents: {} });
// A failure after the rename says the change landed.
const landed = config();
await assert.rejects(changeRootAgent(landed, { revision: ABSENT_REVISION, name: "pm", entry: { definition: "pm" } }, { ...as(), syncDirectory: async () => { throw new Error("disk"); } }), e => e.committed === true);
assert.ok(loadRootAgents(landed, { rolesDir }).agents.pm);
});
test("root writer: a held lock refuses agents-locked", async () => {
const dir = config();
writeFileSync(join(dir, "agents.lock"), "");
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-locked"));
assert.equal(bytes(dir), null);
});
// The project-agent verb: the organization file's projects.<p>.agents, one entry per call.
function org(mutate = () => {}) {
const root = tmp();
const doc = businessDoc(root);
mutate(doc);
const dir = join(root, "config");
const orgFile = writeJson(join(dir, "businesses/acme.json"), doc);
chmodSync(dir, 0o700);
const roles = rolesCopy(root);
return { dir, orgFile, roles, writer: organizationWriter({ dir, rolesDir: roles }) };
}
const agentsOf = (writer, project = "stack") => writer.read("acme").document.projects[project].agents;
const history = (writer) => readFileSync(writer.logFile("acme"), "utf8");
test("project verb: each entry form lands, the log names paths only, and remove drops the key", () => {
const { writer } = org();
let { revision } = writer.read("acme");
const step = (name, entry) => ({ revision } = writer.setProjectAgent("acme", { revision, project: "stack", name, entry }, "jason"));
step("checker", { definition: "fact-checker", vars: { model: "claude-sonnet-5-5" } });
assert.deepEqual(agentsOf(writer), { checker: { definition: "fact-checker", vars: { model: "claude-sonnet-5-5" } } });
step("coder", { vars: { "limits.network": "none" } });
step("reviewer", { exclude: true });
assert.deepEqual(Object.keys(agentsOf(writer)).sort(), ["checker", "coder", "reviewer"]);
step("checker", null);
step("coder", null);
step("reviewer", null);
assert.equal(writer.read("acme").document.projects.stack.agents, undefined);
const events = history(writer).trim().split("\n").map(line => JSON.parse(line)).filter(e => e.state === "committed");
assert.deepEqual(events.map(e => e.detail.form), ["define", "narrow", "exclude", "remove", "remove", "remove"]);
assert.ok(events.every(e => e.detail.verb === "project.agents.set" && e.detail.project === "stack" && e.actor === "jason"));
assert.ok(events.every(e => e.changes.every(c => !("old" in c) && !("new" in c))));
assert.ok(!history(writer).includes("claude-sonnet-5-5"));
});
test("project verb: a narrow that widens refuses, and refusals never quote a value", () => {
const { writer, orgFile } = org();
const { revision } = writer.read("acme");
const before = readFileSync(orgFile, "utf8");
const refusals = [
[{ vars: { model: SECRET } }, "project-agents-invalid", /can only narrow/],
[{ vars: { "limits.network": SECRET } }, "project-agents-invalid", /variable limits\.network/],
[{ definition: "fact-checker", vars: { model: SECRET.repeat(10) } }, "project-agents-invalid", /variable model/],
[{ definition: SECRET }, "project-agents-invalid", /projects\.stack\.agents\.coder\.definition/],
[{ definition: "fact-checker", credentials: { gitea: SECRET } }, "project-agents-invalid", /credentials/],
[{ exclude: false }, "project-agents-invalid", /exclude entry/],
[{}, "project-agents-invalid", /defines \(definition\), narrows \(vars\) or excludes/],
];
for (const [entry, code, pattern] of refusals) {
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "coder", entry }, "jason"),
e => e.code === code && pattern.test(e.message) && !e.message.includes(SECRET), JSON.stringify(entry));
}
// A narrow or exclude that names nothing it could act on refuses (the union check).
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "ghost", entry: { exclude: true } }, "jason"), e => e.code === "project-agents-invalid" && /names no root or organization agent/.test(e.message));
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "ghost", entry: null }, "jason"), { code: "agent-not-found" });
assert.equal(readFileSync(orgFile, "utf8"), before);
});
test("project verb: the chief and the assistant refuse", () => {
const { writer, orgFile } = org(doc => { doc.roles.assistant = { definition: "assistant" }; });
const { document, revision } = writer.read("acme");
assert.equal(document.roles.assistant.definition, "assistant", "the fixture has an organization assistant");
assert.equal(document.roles.chief.definition, "chief-of-staff", "the fixture has a chief");
const before = readFileSync(orgFile, "utf8");
const protectedAgent = /a project can't change the organization's (chief-of-staff|assistant)/;
const notProjectAgent = /can't be a project agent/;
for (const [name, entry, reason] of [
["chief", { exclude: true }, protectedAgent], ["chief", { vars: { "limits.network": "none" } }, protectedAgent], ["chief", { definition: "reviewer" }, protectedAgent],
["assistant", { exclude: true }, protectedAgent], ["assistant", { vars: { "limits.network": "none" } }, protectedAgent], ["assistant", { definition: "reviewer" }, protectedAgent],
["helper", { definition: "chief-of-staff" }, notProjectAgent], ["helper", { definition: "assistant" }, notProjectAgent],
]) {
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name, entry }, "jason"), e => e.code === "project-agents-invalid" && reason.test(e.message), `${name} ${JSON.stringify(entry)}`);
}
assert.equal(readFileSync(orgFile, "utf8"), before);
});
test("project verb: every agent actor and every non-owner refuses human-required", () => {
const { writer, orgFile } = org();
const { revision } = writer.read("acme");
const before = readFileSync(orgFile, "utf8");
for (const actor of ["pm", "pm.pm", "chief", "chief-of-staff.chief", "coder", "tester", "local-dev-owner", "someone"]) {
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "checker", entry: { definition: "fact-checker" } }, actor), { code: "human-required" }, actor);
}
assert.equal(readFileSync(orgFile, "utf8"), before);
});
test("project verb: only projects.<p>.agents.<name> may change; the projects fence keeps every other writer out", () => {
const { writer, orgFile, roles } = org();
const { document, revision } = writer.read("acme");
const before = readFileSync(orgFile, "utf8");
// The general writer can't touch project agents, or anything else under projects.
const withAgents = structuredClone(document);
withAgents.projects.stack.agents = { checker: { definition: "fact-checker" } };
assert.throws(() => writer.write("acme", withAgents, { revision, actor: "jason" }), { code: "organization-projects-protected" });
// The verb's guard refuses any candidate that changes another projects key.
const root = { state: "absent", agents: {}, assistant: null };
const args = { orgId: "acme", project: "stack", name: "checker", entry: { definition: "fact-checker" }, rolesDir: roles, root };
const good = structuredClone(document);
good.projects.stack.agents = { checker: { definition: "fact-checker" } };
assert.equal(projectAgentProblem(document, good, args), null);
const mutations = [
d => { d.projects.stack.root = "/elsewhere"; },
d => { d.projects.other = { root: "/elsewhere" }; },
d => { d.projects.stack.agents.extra = { definition: "reviewer" }; },
d => { d.human = "someone"; },
d => { d.projects.stack.agents.checker = { definition: "reviewer" }; },
];
for (const mutate of mutations) {
const next = structuredClone(good);
mutate(next);
assert.equal(projectAgentProblem(document, next, args)?.[0], "organization-projects-protected", mutate.toString());
}
// A missing project and a library project refuse.
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "nope", name: "checker", entry: { definition: "fact-checker" } }, "jason"), { code: "project-not-found" });
const library = structuredClone(document);
library.projects.stack.kind = "library";
assert.equal(projectAgentProblem(library, good, args)?.[0], "project-agents-library");
// A stale revision refuses.
writer.setProjectAgent("acme", { revision, project: "stack", name: "checker", entry: { definition: "fact-checker" } }, "jason");
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "other", entry: { definition: "reviewer" } }, "jason"), { code: "revision-conflict" });
assert.notEqual(readFileSync(orgFile, "utf8"), before);
});
test("project verb: a root file that refuses refuses the project write", () => {
const { writer, dir, orgFile } = org();
writeJson(join(dir, "agents.json"), { agentsVersion: 2 });
const { revision } = writer.read("acme");
const before = readFileSync(orgFile, "utf8");
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "checker", entry: { definition: "fact-checker" } }, "jason"), e => e.code === "project-agents-invalid" && /root agent file/.test(e.message));
assert.equal(readFileSync(orgFile, "utf8"), before);
});
test("project verb: a narrow of a root agent checks against the root level", async () => {
const { writer, dir, roles } = org();
await changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research" } }, { actor: OWNER, owner: OWNER, rolesDir: roles });
const { revision } = writer.read("acme");
writer.setProjectAgent("acme", { revision, project: "stack", name: "researcher", entry: { vars: { "limits.network": "none" } } }, "jason");
assert.deepEqual(agentsOf(writer).researcher, { vars: { "limits.network": "none" } });
});
@@ -0,0 +1,39 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
const secret = 'sk-test-NOT-A-SECRET-1234';
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
function safe(label, ErrorType, properties = {}) {
return error => {
assert.ok(error instanceof ErrorType);
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
assert.ok(error.message.includes(label), error.message);
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
assert.doesNotMatch(error.stack, /sk-test/);
assert.equal(error.cause, undefined);
return true;
};
}
import { BusinessError } from '../src/errors.mjs';
import { requireId, requirePositiveInt, requireDate } from '../src/util.mjs';
import { checkAccess } from '../src/access.mjs';
for (const [field, check, value] of [
['id', requireId, secret], ['revision', requirePositiveInt, secret], ['rotateBy', requireDate, secret],
// Arbitrary sentinel bytes cannot pass the date-shape check to reach this branch.
['rotateBy', requireDate, '2026-02-30'],
]) test(`${field} rejects ${check.name} input without its value`, () => {
assert.throws(() => check(value, field), error => {
safe(field, BusinessError, { exitCode: 2 })(error);
assert.ok(!error.message.includes(value), error.message);
return true;
});
});
function access() { return { accessVersion: 1, revision: 1, users: [{ id: 'bean', home: 'local', subjects: [{ provider: 'test', sub: 'user' }] }], grants: [{ id: 'grant', user: 'bean', business: 'acme', projects: ['*'], instances: ['coder'], level: 'viewer' }] }; }
const context = { providers: ['test'], peers: [], businesses: { acme: { human: 'jason', instances: ['coder'] } }, ssoSubjects: [] };
for (const [field, value] of [['expires', secret], ['expires', '2026-02-30T00:00:00Z'], ['instances', [secret]]]) test(`grant ${field} refusal omits its value ${JSON.stringify(value)}`, () => {
const doc = access(); doc.grants[0][field] = value;
assert.throws(() => checkAccess(doc, context), error => {
safe(`grants[0].${field}`, BusinessError, { exitCode: 2 })(error);
assert.ok(!error.message.includes(Array.isArray(value) ? value[0] : value), error.message);
return true;
});
});
+1 -1
View File
@@ -27,7 +27,7 @@ function jsonProblems(kind, text, relative, problems) {
const value = JSON.parse(text);
for (const problem of validateRecord(kind, value)) problems.push(`${relative}: ${problem}`);
return value;
} catch (e) { problems.push(`${relative}: invalid JSON (${e.message})`); return null; }
} catch { problems.push(`${relative}: invalid JSON`); return null; }
}
async function checkLayout(context) {
const repo = projectRepo(context), problems = [];
@@ -0,0 +1,28 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
const secret = 'sk-test-NOT-A-SECRET-1234';
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
const malformed = secret;
function scratch(t) {
const root = mkdtempSync(join(tmpdir(), 'r3-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
return root;
}
function write(file, text = malformed) {
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, text, { mode: 0o600 });
return file;
}
import { checkProject, initProject } from '../src/index.mjs';
test('project record JSON problems name the file without parser bytes', async t => {
const root = scratch(t), context = { business: { id: 'acme', human: 'jason', projects: { stack: { root: join(root, 'code') } } }, project: 'stack', dataRoot: join(root, 'data') };
const { repo } = await initProject(context);
write(join(repo, 'requirements/REQ-1.json'));
const result = await checkProject(context);
assert.equal(result.ok, false);
assert.ok(result.problems.some(p => p.includes('requirements/REQ-1.json: invalid JSON')), JSON.stringify(result.problems));
assert.doesNotMatch(JSON.stringify(result), /sk-test|Unexpected token/);
});
+2 -2
View File
@@ -1025,7 +1025,7 @@ export function parseMigrationMap(text) {
const blocks = [...text.matchAll(/^```json queue-map\n([\s\S]*?)^```$/gm)];
if (blocks.length !== 1) throw refuse("the migration map must hold exactly one ```json queue-map block");
let map;
try { map = JSON.parse(blocks[0][1]); } catch (err) { throw refuse(`the migration map's queue-map block is not JSON: ${err.message}`); }
try { map = JSON.parse(blocks[0][1]); } catch { throw refuse(`the migration map's queue-map block is not JSON`); }
keysExactly(map, ["rows", "retired", "highWater"], "migration map");
if (!Array.isArray(map.rows)) throw refuse("migration map rows must be a list");
map.rows.forEach((r) => {
@@ -1126,7 +1126,7 @@ export function loadDoc(bytes) {
throw refuse("queue.json is not valid UTF-8");
}
let doc;
try { doc = JSON.parse(text); } catch (err) { throw refuse(`queue.json is not JSON: ${err.message}`); }
try { doc = JSON.parse(text); } catch { throw refuse(`queue.json is not JSON`); }
keysExactly(doc, DOC_KEYS, "queue.json");
if (serialize(doc) !== text) throw refuse("queue.json does not re-serialize byte for byte (a hand edit?)");
if (doc.version !== VERSION) throw refuse(`queue.json version ${doc.version} is not ${VERSION}`);
+4 -1
View File
@@ -1,6 +1,6 @@
// Scratch repositories for the queue tests. Every test works under
// os.tmpdir(): a fresh repository holding a copy of packages/queue/src and
// the two discord helpers it imports, so the code-under-root check (8.3)
// the discord helpers and safe JSON parser they import, so the code-under-root check (8.3)
// passes there and nothing touches this checkout's .git.
import { spawnSync } from "node:child_process";
import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statfsSync, writeFileSync } from "node:fs";
@@ -91,6 +91,9 @@ export function scratchRepo(t, { map = mapText(), commitMap = true, name = "repo
cpSync(PKG_SRC, join(root, "packages/queue/src"), { recursive: true });
mkdirSync(join(root, "packages/discord/src"), { recursive: true });
for (const f of ["journal.mjs", "errors.mjs"]) cpSync(join(DISCORD_SRC, f), join(root, "packages/discord/src", f));
mkdirSync(join(root, "packages/organization/src"), { recursive: true });
for (const f of ["util.mjs", "errors.mjs", "vocabulary.mjs"])
cpSync(join(DISCORD_SRC, "../../organization/src", f), join(root, "packages/organization/src", f));
for (const s of ["darkwing", "dewey", "filbert", "rocko", "sage"]) {
mkdirSync(join(root, "agents", s, "work"), { recursive: true });
writeFileSync(join(root, "agents", s, ".keep"), "");
@@ -0,0 +1,24 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
const secret = 'sk-test-NOT-A-SECRET-1234';
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
const malformed = secret;
function safe(label, ErrorType, properties = {}) {
return error => {
assert.ok(error instanceof ErrorType);
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
assert.ok(error.message.includes(label), error.message);
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
assert.doesNotMatch(error.stack, /sk-test/);
assert.equal(error.cause, undefined);
return true;
};
}
import { loadDoc, parseMigrationMap } from '../src/queue.mjs';
import { QueueError } from '../src/errors.mjs';
for (const [label, parse] of [
['queue.json', () => loadDoc(Buffer.from(malformed))],
["migration map's queue-map block", () => parseMigrationMap('```json queue-map\n' + malformed + '\n```')],
]) test(`${label} refusal omits parser bytes`, () => {
assert.throws(parse, safe(label, QueueError, { code: 2 }));
});
+2 -2
View File
@@ -31,8 +31,8 @@ export function readActivePointer(dataRoot) {
let pointer;
try {
pointer = JSON.parse(state.text);
} catch (error) {
throw new RunsError(`release pointer is not valid JSON (${state.file}): ${error.message}`, 2);
} catch {
throw new RunsError(`release pointer is not valid JSON (${state.file})`, 2);
}
const invalid = (why) => new RunsError(`release pointer ${why} (${state.file})`, 2);
if (typeof pointer !== "object" || pointer === null || Array.isArray(pointer)) throw invalid("must be a JSON object");
@@ -0,0 +1,34 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
const secret = 'sk-test-NOT-A-SECRET-1234';
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
const malformed = secret;
function scratch(t) {
const root = mkdtempSync(join(tmpdir(), 'r3-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
return root;
}
function write(file, text = malformed) {
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, text, { mode: 0o600 });
return file;
}
function safe(label, ErrorType, properties = {}) {
return error => {
assert.ok(error instanceof ErrorType);
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
assert.ok(error.message.includes(label), error.message);
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
assert.doesNotMatch(error.stack, /sk-test/);
assert.equal(error.cause, undefined);
return true;
};
}
import { RunsError, readActivePointer } from '../src/index.mjs';
test('release pointer JSON refusal names only the file', t => {
const root = scratch(t), file = write(join(root, 'state/active.json'));
assert.throws(() => readActivePointer(root), safe(file, RunsError, { exitCode: 2 }));
});
+7 -6
View File
@@ -15,6 +15,7 @@
// Sessions live in <seatDir>/.pi/agent/sessions.
// Anything else is layout "unknown" and records nulls; nothing is guessed.
import { parseJsonFileText } from "../../organization/src/util.mjs";
import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, statSync, realpathSync } from "node:fs";
import { join, basename, dirname, isAbsolute, resolve } from "node:path";
import { homedir } from "node:os";
@@ -53,9 +54,9 @@ export function loadDataRoot(path = defaultConfigPath()) {
if (!existsSync(path)) throw new SeatError(`config not found: ${path}`);
let raw;
try {
raw = JSON.parse(readFileSync(path, "utf8"));
} catch (err) {
throw new SeatError(`config is not valid JSON: ${path} (${err.message})`);
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
} catch (error) {
throw new SeatError(`config is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new SeatError(`config is not an object: ${path}`);
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new SeatError(`config.dataRoot must be an absolute path: ${path}`);
@@ -202,9 +203,9 @@ export function readRegistration(seats, seat, layout) {
if (!existsSync(path)) return null;
let parsed;
try {
parsed = JSON.parse(readFileSync(path, "utf8"));
} catch (err) {
throw new SeatError(`registration is not valid JSON: ${path} (${err.message})`);
parsed = parseJsonFileText(readFileSync(path, "utf8"), path);
} catch (error) {
throw new SeatError(`registration is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
}
try {
const record = validateRegistration(parsed);
@@ -0,0 +1,43 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
const secret = 'sk-test-NOT-A-SECRET-1234';
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
const malformed = secret;
function scratch(t) {
const root = mkdtempSync(join(tmpdir(), 'r3-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
return root;
}
function write(file, text = malformed) {
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, text, { mode: 0o600 });
return file;
}
function safe(label, ErrorType, properties = {}) {
return error => {
assert.ok(error instanceof ErrorType);
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
assert.ok(error.message.includes(label), error.message);
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
assert.doesNotMatch(error.stack, /sk-test/);
assert.equal(error.cause, undefined);
return true;
};
}
import { SeatError, loadDataRoot, readRegistration, registrationPath } from '../src/seat.mjs';
for (const kind of ['config', 'registration']) test(`${kind} JSON refusal names only the file`, t => {
const root = scratch(t), file = write(kind === 'config' ? join(root, 'config.json') : registrationPath(root, 'builder', 'repo'));
assert.throws(() => kind === 'config' ? loadDataRoot(file) : readRegistration(root, 'builder', 'repo'), safe(file, SeatError, { exitCode: 2 }));
});
for (const kind of ['config', 'registration']) test(`${kind} read refusal retains the filesystem code`, t => {
const root = scratch(t), file = kind === 'config' ? join(root, 'config.json') : registrationPath(root, 'builder', 'repo');
mkdirSync(file, { recursive: true });
assert.throws(() => kind === 'config' ? loadDataRoot(file) : readRegistration(root, 'builder', 'repo'), error => {
safe(file, SeatError, { exitCode: 2 })(error);
assert.match(error.message, /\(EISDIR\)$/);
return true;
});
});
+187
View File
@@ -0,0 +1,187 @@
// #1873 Phase A slice A2: the owner's Settings > Agents writes for the root and project agent levels
// (design docs/plans/2026-11-10_agent-levels-design.md sections 6, 7 and 9 item 1).
//
// GET /api/agents/root root entries, the file's revision, writable
// POST /api/agents/root {revision, name, entry}: entry is a patch
// {definition?, vars?, excludeOrgs?} (a var
// set to null is removed) or null to remove
// GET /api/orgs/<org>/agent-levels/projects/<project> the project's entries and the org revision
// POST /api/orgs/<org>/agent-levels/projects/<project> {revision, name, entry}: entry is a define
// {definition, vars?}, a narrow {vars}, an
// exclude {exclude: true} or null to remove
//
// These are registered hooks, so serve.mjs's hook fence runs first, as it does for
// /api/agents/levels: a signed-in session on the page's name, no granted user on a write, and on a
// POST the exact Origin and Sec-Fetch-Site: same-origin. An org path is resolved for the session
// before the hook runs. Here: the owner's own session only (owner-only otherwise), and a POST only
// when the Console was started with writes on (read-only otherwise). The writers then check again:
// the root writer takes the session's principal as the owner, and the project writer acts as the
// organization's human, as the bus's human transport does.
//
// A response or trail entry names agents, keys and revisions, never a var value or a host path.
import { organizationWriter } from '../../organization/src/organization.mjs';
import { changeRootAgent, patchRootEntry, projectAgentSummary, readRootAgentsFile, rootAgentSummary } from '../../organization/src/agent-levels-write.mjs';
import { ID_PATTERN } from '../../organization/src/vocabulary.mjs';
import { actor } from './action-trail.mjs';
export const ROOT_ROUTE = '/api/agents/root';
export const PROJECT_ROUTE = /^\/api\/orgs\/([^/]+)\/agent-levels\/projects\/([^/]+)$/;
// routeTable takes GET and POST only; any other method never reaches these hooks (serve.mjs answers it).
const METHODS = ['GET', 'POST'];
const REVISION = /^[a-f0-9]{64}$/;
// Agent and project names are the writers' ids (requireId): checked here so a bad one is a 400
// naming the field, not a writer error the Console can't classify.
const NAME = ID_PATTERN;
const NAME_RULE = 'lowercase letters, digits, dot, dash or underscore, at most 64';
const LIMIT = 4096;
const ROOT_PATCH = ['definition', 'vars', 'excludeOrgs'];
const PROJECT_ENTRY = ['definition', 'vars', 'exclude'];
const STATUS = {
'invalid-request': 400, 'agents-invalid': 400, 'project-agents-invalid': 400, 'project-agents-library': 400, 'no-change': 400,
'human-required': 403, 'owner-only': 403, 'read-only': 403,
'project-not-found': 404, 'agent-not-found': 404,
'revision-conflict': 409, 'agents-locked': 409, 'organization-busy': 409,
'agents-unsafe': 503, 'organization-uncertain': 503,
};
// The writers' own text for these codes names keys and ids only (A1's checks and the A2 scrubber).
const SAYS = new Set(['agents-invalid', 'project-agents-invalid', 'project-agents-library', 'no-change', 'human-required',
'project-not-found', 'agent-not-found', 'revision-conflict', 'agents-locked', 'organization-busy', 'agents-unsafe']);
const FIXED = {
'outcome-unknown': 'the change may have been made; nothing was retried. Read the agents again before trying',
committed: 'the change was written; its receipt may be missing. Read the agents again before trying',
};
const one = (req, key) => req.headersDistinct[key]?.length === 1 ? req.headersDistinct[key][0] : null;
function json(res, status, value) {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
res.end(JSON.stringify(value));
}
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
// Every string a var value carries, and each non-string value as JSON: a refusal that quotes any of
// them gets a fixed line instead.
function values(entry) {
const out = [];
const walk = v => {
if (typeof v === 'string') { if (v.length >= 3) out.push(v); }
else if (Array.isArray(v)) v.forEach(walk);
else if (plain(v)) Object.entries(v).forEach(([k, x]) => { if (k.length >= 3) out.push(k); walk(x); });
else if (v !== null && v !== undefined) out.push(JSON.stringify(v));
};
if (plain(entry?.vars)) Object.values(entry.vars).forEach(walk);
return out.filter(s => s.length >= 3);
}
// A coded refusal's own text when it names no value and no path; a fixed line otherwise.
function refusal(error, entry) {
const code = typeof error?.code === 'string' && Object.hasOwn(STATUS, error.code) ? error.code : 'outcome-unknown';
if (error?.committed) return { status: 503, body: { error: 'receipt-missing', committed: true, message: FIXED.committed } };
const text = typeof error?.message === 'string' ? error.message : '';
const quoted = values(entry).some(v => text.includes(v));
const message = SAYS.has(code) && text && text.length <= 400 && !text.includes('/') && !quoted ? text : (FIXED[code] ?? 'refused');
return { status: STATUS[code] ?? 503, body: { error: code, message } };
}
async function body(req) {
if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return null;
const chunks = []; let size = 0;
for await (const chunk of req) { size += chunk.length; if (size > LIMIT) return 'too-large'; chunks.push(chunk); }
let value;
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { return null; }
const keys = ['revision', 'name', 'entry'];
if (!plain(value) || Object.keys(value).length !== keys.length || !keys.every(k => Object.hasOwn(value, k))) return null;
if (typeof value.revision !== 'string' || !REVISION.test(value.revision) || typeof value.name !== 'string') return null;
if (!NAME.test(value.name)) return 'bad-name';
return value;
}
const only = (entry, keys) => plain(entry) && Object.keys(entry).length > 0 && Object.keys(entry).every(k => keys.includes(k));
// `configDir` and `rolesDir` are the instance's; `trail` is the Console action trail.
export function agentLevelsWriteRoutes({ configDir, rolesDir, trail = null, log = () => {} }) {
const writer = organizationWriter({ dir: configDir, rolesDir });
const note = (session, entry) => {
if (!trail) return;
try { trail({ by: actor(session), ...entry }); }
catch { log(`${new Date().toISOString()} trail write failed for ${entry.action}`); }
};
// The fence every request takes after serve.mjs's: method, owner, writes on, then the body.
async function gate(req, res, { session, owner, send }) {
if (!['GET', 'POST'].includes(req.method)) { json(res, 405, { error: 'method-not-allowed' }); return null; }
if (!owner || !session) { json(res, 403, { error: 'owner-only', message: 'agent levels are the owner\'s' }); return null; }
if (req.method === 'GET') return {};
if (!send) { json(res, 403, { error: 'read-only', message: 'this Console was started without writes' }); return null; }
const value = await body(req);
if (value === 'too-large') { json(res, 413, { error: 'invalid-request', message: `a change is at most ${LIMIT} bytes` }); return null; }
if (value === 'bad-name') { json(res, 400, { error: 'invalid-request', message: `name must be an id: ${NAME_RULE}` }); return null; }
if (!value) { json(res, 400, { error: 'invalid-request', message: 'this takes revision, name and entry, nothing else' }); return null; }
return value;
}
async function root(req, res, context) {
const value = await gate(req, res, context);
if (!value) return;
const { session } = context;
if (req.method === 'GET') {
try {
const read = await readRootAgentsFile(configDir);
return json(res, 200, { revision: read.revision, state: read.exists ? 'present' : 'absent', agents: rootAgentSummary(read.document), writable: Boolean(context.send) });
} catch (error) { const r = refusal(error, null); return json(res, r.status, r.body); }
}
const { revision, name, entry } = value;
if (entry !== null && !only(entry, ROOT_PATCH)) return json(res, 400, { error: 'invalid-request', message: 'entry is null or takes definition, vars and excludeOrgs' });
if (entry?.vars !== undefined && !plain(entry.vars)) return json(res, 400, { error: 'invalid-request', message: 'vars is an object of keys' });
const action = entry === null ? 'agents.root.remove' : 'agents.root.set';
const principal = { provider: session.provider, iss: session.iss, sub: session.sub };
let receipt;
try {
receipt = await changeRootAgent(configDir, { revision, name, entry: entry === null ? null : old => patchRootEntry(old, entry) }, { actor: principal, owner: principal, rolesDir });
} catch (error) {
const r = refusal(error, entry);
note(session, { action, agent: name, outcome: r.body.error });
return json(res, r.status, r.body);
}
note(session, { action, agent: name, changed: receipt.changed, outcome: 'done' });
return json(res, 200, { outcome: 'done', receipt });
}
async function project(req, res, context) {
const value = await gate(req, res, context);
if (!value) return;
const { session, orgId } = context;
let projectId;
try { projectId = decodeURIComponent(PROJECT_ROUTE.exec(context.path)?.[2] ?? ''); } catch { projectId = ''; }
if (typeof orgId !== 'string' || !NAME.test(projectId)) return json(res, 404, { error: 'project-not-found', message: 'no such project' });
if (req.method === 'GET') {
try {
const { document, revision } = writer.read(orgId);
const target = document.projects?.[projectId];
if (!plain(target)) return json(res, 404, { error: 'project-not-found', message: `projects.${projectId} does not exist` });
return json(res, 200, { organization: orgId, project: projectId, revision, library: target.kind === 'library', agents: projectAgentSummary(target.agents ?? {}), writable: Boolean(context.send) });
} catch (error) { const r = refusal(error, null); return json(res, r.status, r.body); }
}
const { revision, name, entry } = value;
if (entry !== null && !only(entry, PROJECT_ENTRY)) return json(res, 400, { error: 'invalid-request', message: 'entry is null or takes definition, vars or exclude' });
const form = entry === null ? 'remove' : Object.hasOwn(entry, 'exclude') ? 'exclude' : Object.hasOwn(entry, 'definition') ? 'define' : 'narrow';
const base = { org: orgId, action: 'project.agents.set', project: projectId, agent: name, form };
let result;
// The Console's owner is each organization's human, as the bus binds it (bindConsole with the
// document's human; request-bootstrap's addProject passes document.human the same way). The
// owner gate above is the identity check; the writer re-reads under its lock and refuses if the
// human changed since this read.
try { result = writer.setProjectAgent(orgId, { revision, project: projectId, name, entry }, writer.read(orgId).document.human); }
catch (error) {
const r = refusal(error, entry);
note(session, { ...base, outcome: r.body.error });
return json(res, r.status, r.body);
}
note(session, { ...base, outcome: 'done' });
return json(res, 200, { outcome: 'done', receipt: { verb: 'project.agents.set', organization: orgId, project: projectId, agent: name, form, revisionBefore: revision, revisionAfter: result.revision, transaction: result.transaction } });
}
return table => {
for (const method of METHODS) {
table.add(method, ROOT_ROUTE, root);
table.add(method, PROJECT_ROUTE, project);
}
};
}
+4 -1
View File
@@ -26,6 +26,7 @@ import { readFileSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { isAbsolute, join } from 'node:path';
import { actionTrail } from './action-trail.mjs';
import { agentLevelsWriteRoutes } from './agent-levels-write.mjs';
import { RESTART_EXIT } from '../../cli/src/org-console.mjs';
import { ID_PATTERN } from '../../organization/src/vocabulary.mjs';
import { selectorScope } from '../../organization/src/selector.mjs';
@@ -189,8 +190,10 @@ try {
server.closeAllConnections();
} : null;
const trail = system ? actionTrail(join(system.dataRoot, 'console')) : null;
// #1873 A2: Settings > Agents writes for the root and project levels, beside the levels read.
const agentLevelRoutes = organizations ? [agentLevelsWriteRoutes({ configDir: configDir(), rolesDir: rolesDir(), trail })] : [];
try {
server = await startServer({ restart, trail, ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, agentLevels, preselect: options.business ?? null });
server = await startServer({ restart, trail, ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, agentLevels, routes: agentLevelRoutes, preselect: options.business ?? null });
} catch (err) { sso?.close(); throw err; }
if (testerSocket) {
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
+213
View File
@@ -230,3 +230,216 @@
window.MosaicAgents = Object.freeze({ section, restartMarkup, isThread: definition => threadDefinitions.has(definition) });
})();
/* #1873 A2: Settings > Agents, the root and project agent levels. bus.js awaits section() for the
Agents page and puts it under the organization's own agents. Each grid comes from a fresh read:
GET /api/agents/root for the root file and GET /api/orgs/<org>/agent-levels/projects/<project>
for a project, each with the revision a write sends back. After a write the section reads
everything again and repaints. The server checks the owner, the exact Origin and Fetch Metadata,
the revision and the documents; this file only asks and reports. It never shows a var value
other than the model, and its own messages name keys only. */
(() => {
'use strict';
const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const clock = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const enc = encodeURIComponent;
const UNKNOWN = 'Outcome unknown. Nothing was retried. The grid below was read again.';
let root = null, levels = null, project = null, selected = { org: '', project: '' };
let receipts = { root: '', project: '' }, busy = false, confirming = null;
// What each form holds, kept here so a polling render, a picker change or the other form's save
// repaints it as typed. A draft keeps the revision it began at, so a save sends that and a file
// changed meanwhile refuses rather than being overwritten unseen. The project draft belongs to its
// organization and project. Only the submitted form's own success clears it.
const drafts = { root: null, project: null };
const read = url => fetch(url, { cache: 'no-store', redirect: 'manual' }).then(async r => {
if (r.type === 'opaqueredirect') return { signedOut: true };
let body = null; try { body = await r.json(); } catch { /* unreadable */ }
return r.ok ? body : { refused: body?.error ?? 'unreadable', status: r.status };
}).catch(() => null);
async function load() {
[levels, root] = await Promise.all([read('/api/agents/levels'), read('/api/agents/root')]);
const orgs = orgsOf();
if (!orgs.some(o => o.id === selected.org)) selected = { org: window.MosaicOrg?.id && orgs.some(o => o.id === window.MosaicOrg.id) ? window.MosaicOrg.id : (orgs[0]?.id ?? ''), project: '' };
const projects = orgs.find(o => o.id === selected.org)?.projects ?? [];
if (!projects.includes(selected.project)) selected.project = projects[0] ?? '';
project = selected.org && selected.project ? await read(`/api/orgs/${enc(selected.org)}/agent-levels/projects/${enc(selected.project)}`) : null;
}
const orgsOf = () => (Array.isArray(levels?.orgs) ? levels.orgs : []).filter(o => o?.state === 'ok')
.map(o => ({ id: o.id, projects: [...new Set([...Object.keys(o.projects ?? {}), ...Object.keys(o.defined ?? {})])].sort() }));
const writable = () => root?.writable === true;
// KEY=VALUE lines. A value that reads as JSON other than a string (a number, true, false, a list
// or an object) is sent as JSON. A malformed line is named by its number, never by its text.
function parseVars(text) {
const vars = {};
const lines = String(text ?? '').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const at = line.indexOf('=');
if (at < 1) throw new Error(`Variables line ${i + 1} needs KEY=VALUE.`);
const key = line.slice(0, at).trim(), raw = line.slice(at + 1).trim();
if (Object.hasOwn(vars, key)) throw new Error(`Variable ${key} is given twice.`);
let value = raw;
try { const parsed = JSON.parse(raw); if (typeof parsed !== 'string') value = parsed; } catch { /* a plain string */ }
vars[key] = value;
}
return vars;
}
const list = text => String(text ?? '').split(',').map(s => s.trim()).filter(Boolean);
function rootGrid() {
if (!root || root.refused || root.signedOut) return `<p class="small warn" id="agent-levels-root-grid">The root agent file could not be read${root?.refused ? ` (${esc(root.refused)})` : ''}. Nothing can be changed until it reads.</p>`;
if (!root.agents.length) return `<p class="muted" id="agent-levels-root-grid">No root agents${root.state === 'absent' ? '; the root agent file does not exist yet' : ''}.</p>`;
const off = !writable() || busy ? ' disabled' : '';
return `<div class="table-wrap" role="region" aria-label="Root agents"><table id="agent-levels-root-grid"><thead><tr><th scope="col">Agent</th><th scope="col">Definition</th><th scope="col">Model</th><th scope="col">Other variables</th><th scope="col">Not in</th><th scope="col">Actions</th></tr></thead><tbody>${root.agents.map(a => `<tr data-root-agent="${esc(a.name)}"><th scope="row">${esc(a.name)}</th><td>${esc(a.definition ?? '')}</td><td>${a.model ? `<code>${esc(a.model)}</code>` : '<span class="muted">Inherited</span>'}</td><td>${a.varKeys.length ? a.varKeys.map(k => `<code>${esc(k)}</code>`).join(' ') : '<span class="muted">None</span>'}</td><td>${a.excludeOrgs.length ? a.excludeOrgs.map(esc).join(', ') : '<span class="muted">Every organization</span>'}</td><td>${writable() ? `<button type="button" data-level-remove="root" data-level-name="${esc(a.name)}"${off}>${confirming === `root:${a.name}` ? 'Confirm remove' : 'Remove'}</button>` : ''}</td></tr>`).join('')}</tbody></table></div>`;
}
function projectGrid() {
if (!selected.project) return '<p class="muted" id="agent-levels-project-grid">This organization has no projects.</p>';
if (!project || project.refused || project.signedOut) return `<p class="small warn" id="agent-levels-project-grid">The project could not be read${project?.refused ? ` (${esc(project.refused)})` : ''}.</p>`;
if (project.library) return '<p class="muted" id="agent-levels-project-grid">This is a library project; it takes no agents.</p>';
if (!project.agents.length) return '<p class="muted" id="agent-levels-project-grid">No project entries. The project has the organization\'s agents and the root agents.</p>';
const off = !writable() || busy ? ' disabled' : '';
const what = a => a.form === 'define' ? `Defines ${esc(a.definition ?? '')}` : a.form === 'narrow' ? 'Narrows an inherited agent' : 'Excludes an inherited agent';
return `<div class="table-wrap" role="region" aria-label="Project agent entries"><table id="agent-levels-project-grid"><thead><tr><th scope="col">Agent</th><th scope="col">Entry</th><th scope="col">Variables</th><th scope="col">Actions</th></tr></thead><tbody>${project.agents.map(a => `<tr data-project-agent="${esc(a.name)}" data-form="${esc(a.form)}"><th scope="row">${esc(a.name)}</th><td>${what(a)}</td><td>${a.varKeys?.length ? a.varKeys.map(k => `<code>${esc(k)}</code>`).join(' ') : '<span class="muted">None</span>'}</td><td>${writable() ? `<button type="button" data-level-remove="project" data-level-name="${esc(a.name)}"${off}>${confirming === `project:${a.name}` ? 'Confirm remove' : 'Remove'}</button>` : ''}</td></tr>`).join('')}</tbody></table></div>`;
}
const receiptLine = which => {
const text = receipts[which];
return `<p role="status" id="agent-levels-${which}-receipt" class="${text.startsWith('Saved') || text.startsWith('Removed') ? 'settings-success' : text ? 'banner err' : ''}">${esc(text)}</p>`;
};
const projectDraft = () => drafts.project && drafts.project.org === selected.org && drafts.project.project === selected.project ? drafts.project : null;
const shownDraft = which => which === 'root' ? drafts.root : projectDraft();
const field = (which, key) => esc(shownDraft(which)?.values?.[key] ?? '');
const filled = d => !!d && Object.entries(d.values).some(([k, v]) => k !== 'form' && String(v).trim());
function markup() {
if (!levels || levels.refused || levels.signedOut) return '';
const off = !writable() || busy ? ' disabled' : '';
const at = (which, current) => esc(shownDraft(which)?.revision ?? current);
const key = (which, name) => ` data-focus-key="level-${which}-${name}"`;
const chosen = v => (projectDraft()?.values?.form ?? 'define') === v ? ' selected' : '';
const readOnly = writable() ? '' : '<p class="small">This Console was started without writes, so these levels are read only here. <code>mosaic agents root</code> and <code>mosaic project agents</code> change them from a terminal.</p>';
const rootForm = writable() && root && !root.refused ? `<form id="agent-levels-root-form" class="level-form" data-revision="${at('root', root.revision)}"><h4>Add or change a root agent</h4><div class="filters"><label>Name <input name="name" maxlength="64" required autocomplete="off" value="${field('root', 'name')}"${key('root', 'name')}${off}></label><label>Definition <input name="definition" maxlength="64" autocomplete="off" placeholder="Unchanged" value="${field('root', 'definition')}"${key('root', 'definition')}${off}></label><label>Model <input name="model" maxlength="200" autocomplete="off" placeholder="Unchanged" value="${field('root', 'model')}"${key('root', 'model')}${off}></label></div><div class="filters"><label>Other variables, one KEY=VALUE per line <textarea name="vars" rows="2"${key('root', 'vars')}${off}>${field('root', 'vars')}</textarea></label><label>Remove variables, comma separated <input name="unset" autocomplete="off" value="${field('root', 'unset')}"${key('root', 'unset')}${off}></label><label>Not in organizations, comma separated, or none <input name="excludeOrgs" autocomplete="off" placeholder="Unchanged" value="${field('root', 'excludeOrgs')}"${key('root', 'excludeOrgs')}${off}></label></div><div class="settings-actions${filled(drafts.root) ? ' has-draft' : ''}"><button type="submit"${off}>Save root agent</button></div></form>` : '';
const orgs = orgsOf(), projects = orgs.find(o => o.id === selected.org)?.projects ?? [];
const pick = `<div class="filters"><label>Organization <select id="agent-levels-org"${busy ? ' disabled' : ''}>${orgs.map(o => `<option value="${esc(o.id)}"${o.id === selected.org ? ' selected' : ''}>${esc(o.id)}</option>`).join('')}</select></label><label>Project <select id="agent-levels-project"${busy ? ' disabled' : ''}>${projects.map(p => `<option value="${esc(p)}"${p === selected.project ? ' selected' : ''}>${esc(p)}</option>`).join('')}</select></label></div>`;
const projectForm = writable() && project && !project.refused && !project.library ? `<form id="agent-levels-project-form" class="level-form" data-revision="${at('project', project.revision)}"><h4>Add or change a project entry</h4><div class="filters"><label>Agent <input name="name" maxlength="64" required autocomplete="off" value="${field('project', 'name')}"${key('project', 'name')}${off}></label><label>Entry <select name="form"${key('project', 'form')}${off}><option value="define"${chosen('define')}>Define a project agent</option><option value="narrow"${chosen('narrow')}>Narrow an inherited agent</option><option value="exclude"${chosen('exclude')}>Exclude an inherited agent</option></select></label><label>Definition, for a defined agent <input name="definition" maxlength="64" autocomplete="off" value="${field('project', 'definition')}"${key('project', 'definition')}${off}></label></div><label>Variables, one KEY=VALUE per line. A narrow takes limits.tools, limits.network and limits.authority only. <textarea name="vars" rows="2"${key('project', 'vars')}${off}>${field('project', 'vars')}</textarea></label><div class="settings-actions${filled(projectDraft()) ? ' has-draft' : ''}"><button type="submit"${off}>Save project entry</button></div></form>` : '';
return `<div id="agent-levels-edit" data-agent-levels-edit><section class="panel" aria-labelledby="agent-levels-root-title"><h3 id="agent-levels-root-title">Root agents</h3><p>Root agents are in every organization unless a row excludes it. They are read from the instance's root agent file and are not launched yet.</p>${readOnly}${rootGrid()}${receiptLine('root')}${rootForm}</section><section class="panel" aria-labelledby="agent-levels-project-title"><h3 id="agent-levels-project-title">Project agents</h3><p>A project can define its own agents, narrow an inherited agent's limits, or exclude one. Project agents are not launched yet.</p>${orgs.length ? pick : '<p class="muted">No organization to show.</p>'}${orgs.length ? projectGrid() : ''}${receiptLine('project')}${projectForm}</section></div>`;
}
const repaint = () => { const box = $('agent-levels-edit'); if (box) box.outerHTML = markup(); };
async function section() {
await load();
return markup();
}
async function post(url, payload) {
const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), cache: 'no-store', redirect: 'manual' });
if (res.type === 'opaqueredirect') return { signedOut: true };
let body = null; try { body = await res.json(); } catch { /* unknown below */ }
return { ok: res.ok, body };
}
const refusedText = body => body?.error === 'revision-conflict' ? 'Refused: the file changed since this page read it. The grid below was read again; check it and save again.'
: typeof body?.message === 'string' ? `Refused (${body.error}): ${body.message}` : UNKNOWN;
// fromForm: the submitted form's draft is cleared on success. A row's Remove leaves it alone.
async function write(which, url, payload, done, focus, fromForm = false) {
busy = true; receipts[which] = ''; repaint();
let answer;
try { answer = await post(url, payload); } catch { answer = null; }
busy = false; confirming = null;
if (answer?.signedOut) { receipts[which] = 'Sign in again, then retry.'; repaint(); return false; }
const ok = answer?.ok && answer.body?.outcome === 'done';
receipts[which] = ok ? done(answer.body.receipt) : answer ? refusedText(answer.body) : UNKNOWN;
const conflict = answer?.body?.error === 'revision-conflict';
await load();
if (ok && fromForm) drafts[which] = null;
// After a conflict the grid is read again and the receipt asks for a check: the next save is a
// deliberate one against what is shown now.
if (conflict && fromForm && shownDraft(which)) shownDraft(which).revision = which === 'root' ? root?.revision : project?.revision;
repaint();
(focus && $(focus))?.focus?.();
return ok;
}
// Take what a form holds into its draft, keeping the revision the draft began at.
function capture(form) {
const which = form.id === 'agent-levels-root-form' ? 'root' : 'project';
const values = Object.fromEntries(new FormData(form));
const old = shownDraft(which);
drafts[which] = { ...(which === 'project' ? { org: selected.org, project: selected.project } : {}), revision: old?.revision ?? form.dataset.revision, values };
form.querySelector('.settings-actions')?.classList.toggle('has-draft', filled(drafts[which]));
return which;
}
async function saveRoot(form) {
const f = Object.fromEntries(new FormData(form));
const name = String(f.name ?? '').trim(), entry = {};
try {
if (String(f.definition ?? '').trim()) entry.definition = String(f.definition).trim();
const vars = parseVars(f.vars);
if (String(f.model ?? '').trim()) { if (Object.hasOwn(vars, 'model')) throw new Error('Variable model is given twice.'); vars.model = String(f.model).trim(); }
for (const key of list(f.unset)) { if (Object.hasOwn(vars, key)) throw new Error(`Variable ${key} is both set and removed.`); vars[key] = null; }
if (Object.keys(vars).length) entry.vars = vars;
if (String(f.excludeOrgs ?? '').trim()) entry.excludeOrgs = String(f.excludeOrgs).trim() === 'none' ? [] : list(f.excludeOrgs);
if (!name) throw new Error('A root agent needs a name.');
if (!Object.keys(entry).length) throw new Error('Nothing to change: give a definition, a variable or the organizations it is not in.');
} catch (error) { receipts.root = error.message; capture(form); repaint(); return; }
await write('root', '/api/agents/root', { revision: form.dataset.revision, name, entry },
r => `Saved ${r.agent} at ${clock()}: ${r.changed.join(', ')}.`, 'agent-levels-root-title', true);
}
async function saveProject(form) {
const f = Object.fromEntries(new FormData(form));
const name = String(f.name ?? '').trim();
let entry;
try {
if (!name) throw new Error('A project entry needs an agent name.');
const vars = parseVars(f.vars);
if (f.form === 'exclude') { if (Object.keys(vars).length || String(f.definition ?? '').trim()) throw new Error('An exclude takes no definition and no variables.'); entry = { exclude: true }; }
else if (f.form === 'narrow') { if (String(f.definition ?? '').trim()) throw new Error('A narrow takes no definition.'); if (!Object.keys(vars).length) throw new Error('A narrow needs at least one variable.'); entry = { vars }; }
else { if (!String(f.definition ?? '').trim()) throw new Error('A defined agent needs a definition.'); entry = { definition: String(f.definition).trim(), ...(Object.keys(vars).length ? { vars } : {}) }; }
} catch (error) { receipts.project = error.message; capture(form); repaint(); return; }
await write('project', `/api/orgs/${enc(selected.org)}/agent-levels/projects/${enc(selected.project)}`, { revision: form.dataset.revision, name, entry },
r => `Saved ${r.agent} in ${r.project} at ${clock()}: ${r.form}.`, 'agent-levels-project-title', true);
}
async function remove(which, name) {
if (confirming !== `${which}:${name}`) { confirming = `${which}:${name}`; repaint(); document.querySelector(`[data-level-remove="${which}"][data-level-name="${CSS.escape(name)}"]`)?.focus(); return; }
if (which === 'root') await write('root', '/api/agents/root', { revision: root.revision, name, entry: null }, r => `Removed ${r.agent} at ${clock()}.`, 'agent-levels-root-title');
else await write('project', `/api/orgs/${enc(selected.org)}/agent-levels/projects/${enc(selected.project)}`, { revision: project.revision, name, entry: null }, r => `Removed ${r.agent} from ${r.project} at ${clock()}.`, 'agent-levels-project-title');
}
document.addEventListener('submit', e => {
const form = e.target;
if (form.id !== 'agent-levels-root-form' && form.id !== 'agent-levels-project-form') return;
e.preventDefault();
if (busy) return;
capture(form);
(form.id === 'agent-levels-root-form' ? saveRoot : saveProject)(form);
});
for (const kind of ['input', 'change']) document.addEventListener(kind, e => {
const form = e.target?.closest?.('.level-form');
if (form && e.target !== form) capture(form);
});
document.addEventListener('click', e => {
const button = e.target.closest?.('[data-level-remove]');
if (!button || button.disabled || busy || !writable()) return;
remove(button.dataset.levelRemove, button.dataset.levelName);
});
document.addEventListener('change', async e => {
if (e.target.id !== 'agent-levels-org' && e.target.id !== 'agent-levels-project') return;
if (e.target.id === 'agent-levels-org') selected = { org: e.target.value, project: '' };
else selected.project = e.target.value;
receipts.project = ''; confirming = null;
const id = e.target.id;
await load(); repaint(); $(id)?.focus();
});
// hasDraft feeds the Settings nav's unsaved-changes dot (bus.js settingsShell and its input listener).
window.MosaicAgentLevels = Object.freeze({ section, hasDraft: () => filled(drafts.root) || filled(drafts.project) });
})();
+7 -2
View File
@@ -1047,6 +1047,8 @@
const unknownInstance='Outcome unknown. Read the role instances again before trying. Draft kept.';
// The answer replaces the view the next render draws, so it must have the read's whole shape.
const instanceAnswer=r=>/^[a-f0-9]{64}$/.test(r?.revision??'')&&typeof r.restart==='boolean'&&[r.instances,r.definitions].every(o=>o&&typeof o==='object'&&!Array.isArray(o));
// #1873 A2: the level forms keep their own drafts (agents.js); here they only mark the nav.
for(const kind of ['input','change'])document.addEventListener(kind,e=>{if(e.target?.closest?.('.level-form'))markSettingsDraft('agents',!!window.MosaicAgentLevels?.hasDraft());});
for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=e.target?.closest?.('.instance-form');if(form){instanceDrafts[form.dataset.op]=instanceFields(form);markSettingsDraft('agents',true);form.querySelector('.settings-actions')?.classList.add('has-draft');const discard=form.querySelector('[data-instance-discard]');if(discard)discard.hidden=false;}});
document.addEventListener('click',e=>{if(e.target.id!=='instances-reload' && e.target.dataset?.instanceDiscard===undefined)return;instanceDrafts={add:null,change:null};instanceView=null;instanceReceipt='';instanceReceipts=Object.create(null);instanceReceiptOp=null;last.delete('instances');render(true);});
document.addEventListener('submit',async e=>{
@@ -1200,7 +1202,7 @@
function settingsShell(page, body, helper='') {
const pages=isTester ? [['appearance','Appearance'],['business','Business'],['routing','Routing'],['agents','Agents'],['names','Seat names']] : settingsPages;
const label=pages.find(([id])=>id===page)?.[1] || 'Appearance';
const isDirty=id=>(id==='routing'&&routesDraft)||(id==='limits'&&settingsInputs)||(id==='assistant'&&(Object.keys(assistantDrafts).length||Object.keys(nameDrafts).length))||(id==='agents'&&(instanceDrafts.add||instanceDrafts.change));
const isDirty=id=>(id==='routing'&&routesDraft)||(id==='limits'&&settingsInputs)||(id==='assistant'&&(Object.keys(assistantDrafts).length||Object.keys(nameDrafts).length))||(id==='agents'&&(instanceDrafts.add||instanceDrafts.change||window.MosaicAgentLevels?.hasDraft()));
const dot='<span class="settings-dot"><span class="sr-only">unsaved changes</span></span>';
const links=pages.map(([id,name],i)=>`${i===0?'<div class="settings-group">Your browser</div>':i===1?'<div class="settings-group">This instance</div>':''}<a href="#/settings/${id}"${id===page?' aria-current="page"':''}>${name}${isDirty(id)?dot:''}</a>`).join('') + (!isTester ? '<div class="settings-group">Members and invites <span class="muted">not built</span></div>' : '');
return `${head('Settings','Your browser and this instance.')}<div class="settings-layout"><div><button type="button" id="settings-disclosure" aria-expanded="false" aria-controls="settings-nav"><small>Section</small><strong>${label}${isDirty(page)?dot:''}</strong><span aria-hidden="true">⌄</span></button><nav id="settings-nav" aria-label="Settings sections">${links}</nav></div><div class="settings-content settings-${page}${readonlyPage(page)?' readonly':''}"><h2 id="settings-page-title" tabindex="-1">${label}</h2><p class="settings-helper">${helper}</p>${readonlyPage(page)?'<p class="settings-readonly">Read only. Saving requires signed-in send mode.</p>':''}${body}</div></div>`;
@@ -1217,10 +1219,13 @@
if (page==='tokens') { try { [usage] = await get('usage'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; } try { limitRows = await get('limits'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; limitsRefused = true; } }
if (page==='assistant'||page==='names') { try { names = await get('names'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; } }
if (page==='agents'||page==='names'||page==='routing') { try { [instances] = await get('instances'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; } }
// #1873 A2: the root and project agent levels (agents.js MosaicAgentLevels), the owner's only.
let agentLevels='';
if (page==='agents' && window.MosaicOrg && window.MosaicAgentLevels && !isTester) agentLevels = await window.MosaicAgentLevels.section().catch(()=>'');
if (page==='routing') { try { [routes] = await get('routes'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; } }
if (page==='tokens' && s.launch && !s.launch.refused) takeSnapshot(s.launch);
const notify = typeof n.binding==='string' ? `<code>${txt(n.binding)}</code>` : n.refused ? `Could not be read: <code>${txt(n.refused)}</code>` : '<span class="muted">None set</span>';
const contents={limits:()=>launchForm(s.launch),routing:()=>routesSection(routes,instances),agents:()=>agentsSection(instances),assistant:()=>assistantSection(s.launch,names),tokens:()=>tokenLimits(limitRows,limitsRefused)+tokenUsage(usage),names:()=>testerNames(names,instances),about:()=>`<section class="panel"><dl class="kv"><dt>Address</dt><dd><code>${txt(s.address)||'unknown'}</code></dd><dt>Release</dt><dd>${txt(s.release)||'unknown'}</dd><dt>Business</dt><dd>${txt(s.business)||none}</dd><dt>Board</dt><dd><code>${txt(s.board)||'unknown'}</code></dd><dt>Notifier binding</dt><dd>${notify}</dd></dl></section>`, 'model-proxy':()=>'<section class="panel"><a href="#/settings/model-proxy">Open model proxy settings</a></section>'};
const contents={limits:()=>launchForm(s.launch),routing:()=>routesSection(routes,instances),agents:()=>agentsSection(instances)+agentLevels,assistant:()=>assistantSection(s.launch,names),tokens:()=>tokenLimits(limitRows,limitsRefused)+tokenUsage(usage),names:()=>testerNames(names,instances),about:()=>`<section class="panel"><dl class="kv"><dt>Address</dt><dd><code>${txt(s.address)||'unknown'}</code></dd><dt>Release</dt><dd>${txt(s.release)||'unknown'}</dd><dt>Business</dt><dd>${txt(s.business)||none}</dd><dt>Board</dt><dd><code>${txt(s.board)||'unknown'}</code></dd><dt>Notifier binding</dt><dd>${notify}</dd></dl></section>`, 'model-proxy':()=>'<section class="panel"><a href="#/settings/model-proxy">Open model proxy settings</a></section>'};
const helpers={limits:'Leave a limit blank for no limit. Zero stops new launches. Running sessions keep running when limits or models change.',routing:"Each agent’s harness, model and thinking, and the fallback a launch takes when the primary harness fails its preflight. A fallback sets all three or none. Running sessions keep their route. The next launch reads the change.",agents:'Each agent with its definition, launch membership and runs. A launch refuses until the agent has a route in Routing.',assistant:'The definition stays an assistant one. Holder and credentials stay hand edits.',tokens:'Token limits the bus checks, and token use read from session transcripts.'};
return settingsShell(page,contents[page](),`${get.oldest()?`Read ${when(get.oldest())}.`: 'Reading…'} ${helpers[page]||''}`);
}
+1 -1
View File
@@ -35,7 +35,7 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
// /api/workspace reads and writes across organizations (U1, U4b), so it keeps its own path.
window.fetch = (input, init) => {
if (typeof input === 'string' && input.startsWith('/api/') && !/^\/api\/(?:workspace(?:\/|\?|$)|orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$)|agents\/levels(?:\?|$))/.test(input))
if (typeof input === 'string' && input.startsWith('/api/') && !/^\/api\/(?:workspace(?:\/|\?|$)|orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$)|agents\/(?:levels|root)(?:\?|$))/.test(input))
input = `/api/orgs/${id}/${input.slice('/api/'.length)}`;
return nativeFetch(input, init);
};
+9 -5
View File
@@ -294,7 +294,7 @@ button:disabled,[aria-disabled=true]{cursor:not-allowed}
#agents-table tbody th,#routes-table tbody th{text-transform:none;font-size:14px;font-weight:600;color:var(--strong)}
@media(max-width:759px){.settings-content .routing-panel{border:0;background:transparent;padding:0}.routing-panel .table-wrap{border:0;overflow:visible}.routing-panel #routes-table tr{display:grid;grid-template-columns:1fr 1fr;gap:0 10px;background:var(--surface)}.routing-panel #routes-table th,.routing-panel #routes-table td:first-of-type,.routing-panel #routes-table .fallback-cell:first-of-type{grid-column:1/-1}.routing-panel #routes-table td:nth-of-type(4){grid-column:1/-1}.routing-panel #routes-table td:nth-of-type(4)::before{content:'Fallback: None'}.routing-panel #routes-table .fallback-open td:nth-of-type(4)::before{content:'Fallback harness'}}
.settings-content label:has(>input[type=checkbox]){display:inline-flex;align-items:center;gap:8px;min-height:44px;padding:0 8px}
.settings-content .instance-form textarea,.settings-content .assistant-form textarea{display:block;width:100%}
.settings-content :is(.instance-form,.level-form) textarea,.settings-content .assistant-form textarea{display:block;width:100%}
.route-definition{display:none}.settings-spinner{display:inline-block;width:16px;height:16px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;vertical-align:middle;animation:settings-spin .8s linear infinite}@keyframes settings-spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.settings-spinner{animation:none}}
@media(max-width:759px){#routes-table .route-definition{display:inline;float:right;color:var(--muted);font-size:12px;font-weight:400}}
/* Settings forms: align controls across rows without changing their write paths. */
@@ -302,15 +302,15 @@ button:disabled,[aria-disabled=true]{cursor:not-allowed}
.settings-limits-table,.settings-model-table{width:100%;table-layout:fixed;border-collapse:collapse}
.settings-limits-table col:first-child{width:45%}.settings-limits-table col:nth-child(2){width:144px}.settings-model-table col:first-child{width:25%}.settings-model-table col:nth-child(2),.settings-model-table col:nth-child(3){width:37.5%}
.settings-limits-table :is(th,td),.settings-model-table :is(th,td){padding:8px;text-align:left;vertical-align:middle}.settings-limits-table tbody th,.settings-model-table tbody th{text-transform:none;font-size:14px;font-weight:500;color:var(--text)}.settings-limits-table .limit-group th{padding-top:14px;color:var(--muted);font-size:12px;font-weight:600}.settings-model-table input{width:100%}
.settings-content .instance-form .filters{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;gap:var(--gap);align-items:end}.settings-content .instance-form .filters>label:not(:has(input[type=checkbox])){display:flex;flex-direction:column;gap:6px}.settings-content .instance-form .filters>label:not(:has(input[type=checkbox])) :is(input,select){width:100%}.settings-content .instance-form .filters>label:has(input[type=checkbox]){display:flex;gap:8px;align-items:center;min-height:44px;white-space:nowrap}.settings-content .instance-form details{margin:var(--gap) 0}.hiring-grid>div{justify-content:flex-start;gap:var(--gap)}
@media(max-width:759px){.settings-content .instance-form .filters{grid-template-columns:1fr}.settings-limits-table,.settings-limits-table tbody,.settings-limits-table tr,.settings-limits-table th,.settings-limits-table td,.settings-model-table,.settings-model-table tbody,.settings-model-table tr,.settings-model-table th,.settings-model-table td{display:block;width:100%}.settings-limits-table colgroup,.settings-model-table colgroup,.settings-limits-table thead,.settings-model-table thead{display:none}.settings-limits-table tbody tr:not(.limit-group){display:grid;grid-template-columns:120px 1fr;gap:0 12px;border-bottom:1px solid var(--line)}.settings-limits-table tbody tr:not(.limit-group)>th{grid-column:1/-1}.settings-limits-table :is(th,td),.settings-model-table :is(th,td){padding:6px 0}.settings-model-table tbody tr{padding:8px 0;border-bottom:1px solid var(--line)}.settings-model-table td:before{display:block;color:var(--muted);font-size:12px}.settings-model-table td:nth-of-type(1):before{content:'Provider'}.settings-model-table td:nth-of-type(2):before{content:'Model'}#routes-table .route-row:not(.fallback-open) td.fallback-cell:nth-of-type(n+5){display:none}}
.settings-content :is(.instance-form,.level-form) .filters{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;gap:var(--gap);align-items:end}.settings-content :is(.instance-form,.level-form) .filters>label:not(:has(input[type=checkbox])){display:flex;flex-direction:column;gap:6px}.settings-content :is(.instance-form,.level-form) .filters>label:not(:has(input[type=checkbox])) :is(input,select){width:100%}.settings-content :is(.instance-form,.level-form) .filters>label:has(input[type=checkbox]){display:flex;gap:8px;align-items:center;min-height:44px;white-space:nowrap}.settings-content :is(.instance-form,.level-form) details{margin:var(--gap) 0}.hiring-grid>div{justify-content:flex-start;gap:var(--gap)}
@media(max-width:759px){.settings-content :is(.instance-form,.level-form) .filters{grid-template-columns:1fr}.settings-limits-table,.settings-limits-table tbody,.settings-limits-table tr,.settings-limits-table th,.settings-limits-table td,.settings-model-table,.settings-model-table tbody,.settings-model-table tr,.settings-model-table th,.settings-model-table td{display:block;width:100%}.settings-limits-table colgroup,.settings-model-table colgroup,.settings-limits-table thead,.settings-model-table thead{display:none}.settings-limits-table tbody tr:not(.limit-group){display:grid;grid-template-columns:120px 1fr;gap:0 12px;border-bottom:1px solid var(--line)}.settings-limits-table tbody tr:not(.limit-group)>th{grid-column:1/-1}.settings-limits-table :is(th,td),.settings-model-table :is(th,td){padding:6px 0}.settings-model-table tbody tr{padding:8px 0;border-bottom:1px solid var(--line)}.settings-model-table td:before{display:block;color:var(--muted);font-size:12px}.settings-model-table td:nth-of-type(1):before{content:'Provider'}.settings-model-table td:nth-of-type(2):before{content:'Model'}#routes-table .route-row:not(.fallback-open) td.fallback-cell:nth-of-type(n+5){display:none}}
/* Agent menus must extend beyond the table without being clipped by its scroll region. */
.settings-agents .table-wrap{overflow:visible}
/* Review fixes: preserve readable actions, labels and targets at every width. */
.settings-content .instance-form .filters>label:not(:has(input[type=checkbox])){align-items:flex-start}
.settings-content .instance-form .filters>label:has(input[type=checkbox]){grid-column:1/-1;justify-self:start}
.settings-content :is(.instance-form,.level-form) .filters>label:not(:has(input[type=checkbox])){align-items:flex-start}
.settings-content :is(.instance-form,.level-form) .filters>label:has(input[type=checkbox]){grid-column:1/-1;justify-self:start}
.settings-content .settings-actions .banner:not(:empty){order:-1;flex:1 0 100%;margin:0}
.agent-mobile-label{display:none}
#bus-view .page-head:has(+ .settings-layout) p{font-size:16px}
@@ -337,6 +337,10 @@ button:disabled,[aria-disabled=true]{cursor:not-allowed}
.s1-agents, .s1-authority, .s1-creds, #token-usage-table, #token-limits-table,
.table-wrap:has(#proxy-accounts) table, .table-wrap:has(#proxy-keys) table { min-width:650px; }
.s1-authority :is(th,td), .s1-creds :is(th,td) { white-space:nowrap; }
/* #1873 A2: the root and project agent level grids scroll inside their card. */
.table-wrap:has(#agent-levels-root-grid), .table-wrap:has(#agent-levels-project-grid) { overflow-x:scroll; max-width:100%; }
#agent-levels-root-grid { min-width:650px; }
#agent-levels-project-grid { min-width:480px; }
}
body:not(:has(.project-chat)) .content { padding-bottom:calc(22px + var(--foot-h)); }
@media (max-width:759px) {
@@ -0,0 +1,162 @@
// #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']);
});
@@ -0,0 +1,186 @@
// #1873 A2: the owner's Settings > Agents writes, POST /api/agents/root and
// POST /api/orgs/<org>/agent-levels/projects/<project>, through serve.mjs's hook fence with the real
// root and organization writers on a temporary config directory.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { request } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
import { startServer } from '../src/serve.mjs';
import { agentLevelsWriteRoutes } from '../src/agent-levels-write.mjs';
import { close } from './fixture.mjs';
const SECRET = 'sk-test-NOT-A-SECRET-1234';
const ABSENT = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
// `who` is the session the fence sees: the owner, a granted user (the hook fence's owner flag
// false), or nobody. `send` is the Console's --send.
async function fixture(t, { send = true, granted = false } = {}) {
const root = mkdtempSync(join(tmpdir(), 'alw-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
const file = writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
const trail = [];
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner-sub', name: 'jason' };
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => session, handle: () => {}, health: () => ({}) };
const organizations = {
list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }],
resolve: async id => id === 'demo' ? { busHealthy: true } : null,
members: async () => [],
...(granted ? { grantFor: () => null } : {}),
};
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, send: send ? async () => ({}) : null, organizations, log: () => {},
routes: [agentLevelsWriteRoutes({ configDir: root, rolesDir, trail: entry => trail.push(entry) })] });
t.after(() => close(web));
const base = `http://127.0.0.1:${web.address().port}`;
sso.publicOrigin = base; sso.publicHost = new URL(base).host;
return { base, root, file, trail };
}
function call(f, path, method = 'GET', body, headers = {}) {
return new Promise((resolve, reject) => {
const data = body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body);
const req = request(f.base + path, { method, headers: { ...(data !== undefined ? { origin: f.base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}), ...headers } }, res => {
let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text), text]));
});
req.on('error', reject); req.setTimeout(10000, () => req.destroy(new Error('fixture request timed out'))); req.end(data);
});
}
const ROOT = '/api/agents/root';
const PROJECT = '/api/orgs/demo/agent-levels/projects/stack';
test('the owner adds, patches and removes a root agent; each write answers a receipt and a trail entry with no value', async t => {
const f = await fixture(t);
const [s0, read0] = await call(f, ROOT);
assert.equal(s0, 200); assert.deepEqual(read0, { revision: ABSENT, state: 'absent', agents: [], writable: true });
const [s1, added, text1] = await call(f, ROOT, 'POST', { revision: ABSENT, name: 'researcher', entry: { definition: 'research', vars: { model: 'claude-opus-5-5' } } });
assert.equal(s1, 200, text1);
assert.equal(added.outcome, 'done'); assert.equal(added.receipt.verb, 'root.agent.set'); assert.deepEqual(added.receipt.changed, ['added']);
assert.equal(added.receipt.revisionBefore, ABSENT);
assert.doesNotMatch(text1, /claude-opus/);
const [, read1] = await call(f, ROOT);
assert.equal(read1.revision, added.receipt.revisionAfter);
assert.deepEqual(read1.agents, [{ name: 'researcher', definition: 'research', model: 'claude-opus-5-5', varKeys: [], excludeOrgs: [] }]);
const [s2, patched] = await call(f, ROOT, 'POST', { revision: read1.revision, name: 'researcher', entry: { vars: { model: null, 'limits.network': 'none' }, excludeOrgs: ['home'] } });
assert.equal(s2, 200); assert.deepEqual([...patched.receipt.changed].sort(), ['excludeOrgs', 'vars.limits.network', 'vars.model']);
const [s3, removed] = await call(f, ROOT, 'POST', { revision: patched.receipt.revisionAfter, name: 'researcher', entry: null });
assert.equal(s3, 200); assert.equal(removed.receipt.verb, 'root.agent.remove');
assert.deepEqual(f.trail.map(e => [e.action, e.agent, e.outcome]), [['agents.root.set', 'researcher', 'done'], ['agents.root.set', 'researcher', 'done'], ['agents.root.remove', 'researcher', 'done']]);
assert.ok(f.trail.every(e => e.by?.sub === 'owner-sub' && e.by.iss === 'https://issuer.example'));
const trailText = JSON.stringify(f.trail) + readFileSync(join(f.root, 'agents-receipts.jsonl'), 'utf8');
assert.doesNotMatch(trailText, /claude-opus|"none"|home"/);
});
test('refusals: wrong method, cross-site, missing Origin, non-owner, read-only, oversized, bad keys, stale revision', async t => {
const f = await fixture(t);
const ok = { revision: ABSENT, name: 'researcher', entry: { definition: 'research' } };
// Only GET and POST are registered: serve.mjs answers any other method before a writer runs.
for (const method of ['PUT', 'PATCH', 'DELETE']) {
for (const path of [ROOT, PROJECT]) {
const [status, body] = await call(f, path, method, ok);
assert.ok(status === 403 || status === 404 || status === 405, `${method} ${path}: ${status}`); assert.ok(body.error, method);
}
}
assert.equal((await call(f, ROOT, 'POST', ok, { 'sec-fetch-site': 'cross-site' }))[0], 403);
assert.equal((await call(f, ROOT, 'POST', ok, { origin: 'http://evil.example' }))[0], 403);
assert.equal((await call(f, PROJECT, 'POST', ok, { 'sec-fetch-site': 'same-site' }))[0], 403);
const [big, bigBody] = await call(f, ROOT, 'POST', { ...ok, entry: { definition: 'research', vars: { pad: 'x'.repeat(5000) } } });
assert.equal(big, 413); assert.equal(bigBody.error, 'invalid-request');
for (const bad of [{ ...ok, extra: 1 }, { revision: 'nope', name: 'researcher', entry: null }, { ...ok, name: '../x' }, { ...ok, entry: { definition: 'research', root: '/srv' } }, { ...ok, entry: { vars: [] } }]) {
const [status, body] = await call(f, ROOT, 'POST', bad);
assert.equal(status, 400, JSON.stringify(bad)); assert.equal(body.error, 'invalid-request');
}
assert.equal((await call(f, ROOT, 'POST', 'not json'))[0], 400);
assert.equal(existsSync(join(f.root, 'agents.json')), false, 'no refusal wrote the file');
const [, made] = await call(f, ROOT, 'POST', ok);
const before = readFileSync(join(f.root, 'agents.json'), 'utf8');
const [stale, staleBody] = await call(f, ROOT, 'POST', { ...ok, entry: { definition: 'reviewer' } });
assert.equal(stale, 409); assert.equal(staleBody.error, 'revision-conflict');
// An invalid value: the key is named, the value never travels.
const [invalid, invalidBody, invalidText] = await call(f, ROOT, 'POST', { revision: made.receipt.revisionAfter, name: 'researcher', entry: { vars: { model: SECRET.repeat(10) } } });
assert.equal(invalid, 400); assert.equal(invalidBody.error, 'agents-invalid'); assert.match(invalidBody.message, /model/);
assert.ok(!invalidText.includes(SECRET), invalidText);
const [chief, chiefBody] = await call(f, ROOT, 'POST', { revision: made.receipt.revisionAfter, name: 'boss', entry: { definition: 'chief-of-staff' } });
assert.equal(chief, 400); assert.match(chiefBody.message, /chief-of-staff/);
assert.equal(readFileSync(join(f.root, 'agents.json'), 'utf8'), before);
assert.ok(!JSON.stringify(f.trail).includes(SECRET));
const readOnly = await fixture(t, { send: false });
const [ro, roBody] = await call(readOnly, ROOT, 'POST', ok);
assert.equal(ro, 403); assert.equal(roBody.error, 'read-only');
const [, roRead] = await call(readOnly, ROOT);
assert.equal(roRead.writable, false);
});
test('a non-owner session never reaches the writers', async t => {
const f = await fixture(t);
const ok = { revision: ABSENT, name: 'researcher', entry: { definition: 'research' } };
// The fence: a Host other than the named one.
assert.equal((await call(f, ROOT, 'POST', ok, { host: 'other.example' }))[0], 403);
// The route itself, as the fence calls it for a granted user or the tester listener (owner false),
// and for no session at all.
const handlers = [];
agentLevelsWriteRoutes({ configDir: f.root, rolesDir: join(f.root, 'roles') })({ add: (method, route, handler) => handlers.push([method, route, handler]) });
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner-sub' };
for (const [method, route, handler] of handlers.filter(([m]) => m === 'POST')) {
const path = typeof route === 'string' ? route : PROJECT;
for (const context of [{ path, orgId: 'demo', session, owner: false, send: true }, { path, orgId: 'demo', session: null, owner: true, send: true }]) {
const res = { status: 0, body: null, writeHead(status) { this.status = status; }, end(text) { this.body = JSON.parse(text); } };
await handler({ method, headersDistinct: {}, [Symbol.asyncIterator]: async function* () {} }, res, context);
assert.equal(res.status, 403, `${path} ${JSON.stringify(context)}`); assert.equal(res.body.error, 'owner-only');
}
}
assert.equal(existsSync(join(f.root, 'agents.json')), false);
});
test('project entries: define, narrow, exclude and remove repaint the read; a widening narrow, the chief and a stale revision refuse', async t => {
const f = await fixture(t);
const [, r0] = await call(f, ROOT);
await call(f, ROOT, 'POST', { revision: r0.revision, name: 'scout', entry: { definition: 'research' } });
const [s0, p0] = await call(f, PROJECT);
assert.equal(s0, 200); assert.deepEqual({ ...p0, revision: undefined }, { organization: 'demo', project: 'stack', revision: undefined, library: false, agents: [], writable: true });
const set = async (name, entry) => { const [, now] = await call(f, PROJECT); return call(f, PROJECT, 'POST', { revision: now.revision, name, entry }); };
const [d, define, defineText] = await set('checker', { definition: 'fact-checker', vars: { model: 'claude-sonnet-5-5' } });
assert.equal(d, 200, defineText);
assert.equal(define.receipt.verb, 'project.agents.set'); assert.equal(define.receipt.form, 'define');
assert.equal(define.receipt.revisionBefore, p0.revision); assert.notEqual(define.receipt.revisionAfter, p0.revision);
assert.doesNotMatch(defineText, new RegExp(f.root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
assert.equal((await set('coder', { vars: { 'limits.network': 'none' } }))[1].receipt.form, 'narrow');
assert.equal((await set('scout', { exclude: true }))[1].receipt.form, 'exclude');
const [, listed] = await call(f, PROJECT);
assert.deepEqual(listed.agents, [
{ name: 'checker', form: 'define', definition: 'fact-checker', varKeys: ['model'] },
{ name: 'coder', form: 'narrow', varKeys: ['limits.network'] },
{ name: 'scout', form: 'exclude' },
]);
const before = readFileSync(f.file, 'utf8');
const [w, widen, widenText] = await set('coder', { vars: { model: SECRET } });
assert.equal(w, 400); assert.equal(widen.error, 'project-agents-invalid'); assert.match(widen.message, /can only narrow/);
assert.ok(!widenText.includes(SECRET));
for (const name of ['chief', 'assistant']) {
const [c, chief] = await set(name, { exclude: true });
assert.equal(c, 400, name); assert.equal(chief.error, 'project-agents-invalid');
}
const [stale, staleBody] = await call(f, PROJECT, 'POST', { revision: p0.revision, name: 'checker', entry: null });
assert.equal(stale, 409); assert.equal(staleBody.error, 'revision-conflict');
const [missing, missingBody] = await call(f, '/api/orgs/demo/agent-levels/projects/nope', 'POST', { revision: listed.revision, name: 'scout', entry: { exclude: true } });
assert.equal(missing, 404); assert.equal(missingBody.error, 'project-not-found');
assert.equal((await call(f, '/api/orgs/other/agent-levels/projects/stack'))[0], 404);
// A name the writers can't take is a 400 that names the field, never the value, on both routes.
for (const name of ['BadName', 'a'.repeat(65)]) {
for (const [route, entry] of [[PROJECT, { definition: 'research' }], [ROOT, { definition: 'research' }]]) {
const [s, body, text] = await call(f, route, 'POST', { revision: listed.revision, name, entry });
assert.equal(s, 400, `${route} ${name}: ${text}`); assert.equal(body.error, 'invalid-request'); assert.match(body.message, /^name must be an id/);
assert.ok(!text.includes(name), text);
}
}
assert.equal(readFileSync(f.file, 'utf8'), before);
for (const name of ['checker', 'coder', 'scout']) assert.equal((await set(name, null))[1].receipt.form, 'remove');
assert.deepEqual((await call(f, PROJECT))[1].agents, []);
const forms = f.trail.filter(e => e.action === 'project.agents.set').map(e => `${e.agent}:${e.form}:${e.outcome}`);
assert.deepEqual(forms.slice(0, 3), ['checker:define:done', 'coder:narrow:done', 'scout:exclude:done']);
assert.ok(forms.includes('coder:narrow:project-agents-invalid'));
assert.ok(f.trail.every(e => !JSON.stringify(e).includes(SECRET) && !JSON.stringify(e).includes('claude-sonnet')));
});
+2 -1
View File
@@ -193,9 +193,10 @@ try {
readNotifyConfig(system.dataRoot, id);
say("NOTIFY", "ok");
} catch (e) { say("NOTIFY", "bad", e.message.split("\n")[0]); }
const { parseJsonFileText } = await import(`${repo}/packages/organization/src/util.mjs`);
const file = businessFilePath(id);
let raw;
try { raw = JSON.parse(readFileSync(file, "utf8")); } catch (e) { say("BUSINESS", "bad", `cannot read ${file}: ${e.code ?? e.message}`); process.exit(0); }
try { raw = parseJsonFileText(readFileSync(file, "utf8"), file); } catch (e) { say("BUSINESS", "bad", `cannot read ${file}: ${e.code ?? "not valid JSON"}`); process.exit(0); }
const library = Object.entries(raw?.projects ?? {}).filter(([, p]) => p && p.kind === "library").map(([k]) => k);
if (library.length) say("LIBRARY", library.join(","));
let b;
+3 -2
View File
@@ -27,6 +27,7 @@
* - No secrets in mission/task data or run records.
*/
import { parseJsonFileText } from "../packages/organization/src/util.mjs";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -74,9 +75,9 @@ function readJsonFile(file, what) {
}
let document;
try {
document = JSON.parse(fs.readFileSync(file, "utf8"));
document = parseJsonFileText(fs.readFileSync(file, "utf8"), file, what);
} catch (error) {
fail(2, `${what} is not valid JSON (${file}): ${error.message}`);
fail(2, `${what} is not valid JSON (${file})${error.code ? ` (${error.code})` : ""}`);
}
return document;
}
+22 -13
View File
@@ -32,6 +32,9 @@ for (const agent of ['darkwing', 'dewey', 'filbert', 'researcher', 'sage']) test
// fixture is its own data root so no registration reaches a real one.
cpSync(join(source, 'scripts/mosaic'), join(root, 'scripts/mosaic'));
cpSync(join(source, 'packages/seat/src'), join(root, 'packages/seat/src'), { recursive: true });
mkdirSync(join(root, 'packages/organization/src'), { recursive: true });
for (const file of ['util.mjs', 'errors.mjs', 'vocabulary.mjs'])
cpSync(join(source, 'packages/organization/src', file), join(root, 'packages/organization/src', file));
mkdirSync(join(root, '.git'));
writeFileSync(join(root, 'config.json'), JSON.stringify({ configVersion: 1, dataRoot: root }));
const registration = join(root, `seats/repo/${agent}/registration.json`);
@@ -149,40 +152,46 @@ test('default agent entry retains container setup and refuses missing seat/auth'
try {
for (const dir of ['scripts', 'bin', 'runtime/user']) mkdirSync(join(root, dir), { recursive: true });
cpSync(join(source, 'scripts/agent.sh'), join(root, 'scripts/agent.sh'));
// All runtime effects remain in this fixture; Docker and release helpers are doubles.
// Observe the current processing-control handoff; its Docker behavior has
// its own package tests. Every runtime effect stays inside this fixture.
writeFileSync(join(root, 'scripts/common.sh'), `
load_config() { MOSAIC_DEV_DIR="$PWD/runtime"; MOSAIC_ADAPTER=pi; MOSAIC_MODEL=test; echo config >> trace; }
load_release() { echo release >> trace; }
bootstrap_runtime_dir() { echo bootstrap >> trace; }
processing_guard() { echo processing >> trace; }
ensure_release_aligned() { echo alignment >> trace; }
`);
writeFileSync(join(root, 'runtime/user/USER.md'), 'fixture user\n');
writeFileSync(join(root, 'bin/docker'), `#!/usr/bin/env node
require('node:fs').writeFileSync('docker.json', JSON.stringify({
args: process.argv.slice(2), session: process.env.MOSAIC_SESSION_DIR,
workspace: process.env.MOSAIC_WORKSPACE, tools: process.env.MOSAIC_TOOLS,
name: process.env.MOSAIC_AGENT_NAME, interactive: process.env.MOSAIC_INTERACTIVE
mkdirSync(join(root, 'packages/processing-control/src'), { recursive: true });
writeFileSync(join(root, 'packages/processing-control/src/container-cli.mjs'), `
import fs from 'node:fs';
fs.writeFileSync('container.json', JSON.stringify({
args: process.argv.slice(2), session: process.env.MOSAIC_SESSION_DIR ?? null,
workspace: process.env.MOSAIC_WORKSPACE ?? null, tools: process.env.MOSAIC_TOOLS,
name: process.env.MOSAIC_AGENT_NAME, interactive: process.env.MOSAIC_INTERACTIVE,
sessionSource: process.env.MOSAIC_SESSION_SOURCE, workspaceSource: process.env.MOSAIC_WORKSPACE_SOURCE
}));
`, { mode: 0o755 });
`);
const env = { ...process.env, PATH: join(root, 'bin') + ':' + process.env.PATH };
for (const name of ['MOSAIC_AGENTS_DIR', 'MOSAIC_ROLES_DIR', 'MOSAIC_SKILLS', 'MOSAIC_AGENT_ROLE']) delete env[name];
const run = (args, overrides = {}) => spawnSync('bash', [join(root, 'scripts/agent.sh'), ...args],
{ cwd: tmpdir(), env: { ...env, ...overrides }, encoding: 'utf8', timeout: 10000 });
let result = run(['fixture', '--tools', 'read,bash']);
assert.equal(result.status, 0, result.stdout + result.stderr);
assert.deepEqual(readFileSync(join(root, 'trace'), 'utf8').trim().split('\n'), ['config', 'release', 'bootstrap', 'alignment']);
assert.deepEqual(JSON.parse(readFileSync(join(root, 'docker.json'))), {
args: ['compose', 'run', '--rm', 'mosaic-agent'], session: '/var/lib/mosaic/sessions/agent-fixture',
workspace: '/var/lib/mosaic/workspaces/fixture', tools: 'read,bash', name: 'fixture', interactive: '1'
assert.deepEqual(readFileSync(join(root, 'trace'), 'utf8').trim().split('\n'), ['config', 'release', 'bootstrap', 'processing', 'alignment']);
assert.deepEqual(JSON.parse(readFileSync(join(root, 'container.json'))), {
args: ['--tty'], session: null,
workspace: null, tools: 'read,bash', name: 'fixture', interactive: '1',
sessionSource: join(root, 'runtime/sessions/agent-fixture'), workspaceSource: join(root, 'runtime/workspaces/fixture')
});
const captured = readFileSync(join(root, 'docker.json'), 'utf8');
const captured = readFileSync(join(root, 'container.json'), 'utf8');
result = run(['fixture'], { MOSAIC_AGENTS_DIR: join(root, 'missing-seats') });
assert.equal(result.status, 4);
assert.match(result.stderr, /no seat definition/);
result = run(['fixture', '--auth', 'missing']);
assert.equal(result.status, 4);
assert.match(result.stderr, /no mosaic-managed credential/);
assert.equal(readFileSync(join(root, 'docker.json'), 'utf8'), captured);
assert.equal(readFileSync(join(root, 'container.json'), 'utf8'), captured);
assert.equal(existsSync(join(root, '.pi')), false);
} finally {
rmSync(root, { recursive: true, force: true });
+3
View File
@@ -23,6 +23,9 @@ test('Rocko uses Sonnet, isolated resume IDs, current context and launch refusal
// Seat registration (packages/seat): the fixture is its own data root.
cpSync(join(source, 'scripts/mosaic'), join(root, 'scripts/mosaic'));
cpSync(join(source, 'packages/seat/src'), join(root, 'packages/seat/src'), { recursive: true });
mkdirSync(join(root, 'packages/organization/src'), { recursive: true });
for (const file of ['util.mjs', 'errors.mjs', 'vocabulary.mjs'])
cpSync(join(source, 'packages/organization/src', file), join(root, 'packages/organization/src', file));
mkdirSync(join(root, '.git'));
writeFileSync(join(root, 'config.json'), JSON.stringify({ configVersion: 1, dataRoot: root }));
const registration = join(root, 'seats/repo/rocko/registration.json');