Compare commits

...
4 Commits
Author SHA1 Message Date
jason.woltje 82dcba2d45 fix(agents): A3 hidden checkouts cover every left-out project and refuse links, final admission binds the organization revision (#1873) 2026-11-10 20:27:33 -06:00
jason.woltje 29f7c3af85 fix(agents): A3 excluded checkouts hidden, launch boundary rechecks unions, reload collisions and changed defines refuse (#1873)
- A checkout an earlier launch left for a project the instance may no longer
  work in is covered by an empty tmpfs inside the sandbox; the host copy stays.
- The launcher rechecks every prepared project against the reloaded document
  after preparation, and the bus's final admission takes the instance and
  checks the broker's current unions.
- A project define whose vars changed since boot refuses its copy until the
  next start.
- Instance-name collisions are recomputed at each reload from the frozen root
  claims and the current project defines; every claimant refuses.
2026-11-10 20:27:33 -06:00
jason.woltje 17a48217f6 fix(agents): A3 project unions reach every agent, copies never collide or shadow (#1873)
Review fix round for slice A3.

- Every project's union is recomputed from the current organization file
  at each load. A refused union refuses the project's own copies and every
  inherited root or org agent there: no mount at launch (checkout-skipped,
  project-refused), no thread and no room, each naming the project and why.
- A project exclude removes that project's mount from the excluded agent;
  a narrow reaches the instance's resolved limits, so its real bundle is
  narrowed. A narrow in any project narrows the instance for every launch,
  since one launch mounts every project it works in.
- Two copies whose instance names collide both refuse, naming each
  claimant; the view lists the claimants. An organization instance of the
  same name replaces a root or project copy, at boot and after it, and the
  copy is gone rather than refused.
- Rooms hold only the thread instances that work in the room's project.
- The grid finds an agent's copy by name and project from the bus view,
  never by rebuilding the instance name.
- Real-boot regressions: inherited agents in a refused union, exclude and
  narrow in the bundle, an org override after boot, two organizations each
  running a copy, a root file change while running, and a grid fed by a
  booted bus.
2026-11-10 20:27:33 -06:00
jason.woltje 9d00f9ad54 feat(agents): A3 root and project agent copies at bus boot (#1873)
Each root agent an organization does not exclude, and each agent a project
defines, becomes a copy when the organization's bus boots. The root file is
read once, through loadRootAgents, at the cli boot step; the snapshot is fixed
until the next boot. A bound copy joins the organization document's roles and
goes through validateBusinessDocument and checkRoles with the organization's
own credential map. A copy whose role needs a credential service is unbound
and refuses at launch by name and service (credential-unbound); a copy the
organization refuses is copy-refused; every agent in a project whose union
refuses is project-refused, naming the project. A project agent launches
only with its own project's mounts and takes a thread only in its project
(project-agent-elsewhere). Refusal events carry the code alone. The bus's
instances read carries the copies with their running runs, and the Agents
grid shows each copy's real status and reason.

channel-proof re-pins webui bus.mjs for four STATUS entries; no proof or
transport change. packages/business gets the agent-copies.mjs re-export.
2026-11-10 20:27:33 -06:00
19 changed files with 1767 additions and 43 deletions

No files matched your search

+76 -3
View File
@@ -1220,6 +1220,11 @@ export class Broker {
this.#businesses[name] = next;
this.#scheduleRequests();
}
// #1873 A3: host-only, for the launch boundary's admission: the code that refuses `instance` in code
// project `repo` under the business's current document (the one the last reload swapped in), or null.
projectBarred(business, instance, repo) {
return this.#projectRefused(this.#business(business), instance, repo)?.code ?? null;
}
// Host-only current role resolver shared by the broker and built-in task tracker.
taskRoles(business) {
const roles = this.#business(business).roles;
@@ -1534,11 +1539,40 @@ export class Broker {
this.#processingWork();
const b = this.#business(s.business);
if ((b.retired ?? []).includes(instance)) fail('instance-retired');
this.#copyRefused(b, instance);
if (!Object.hasOwn(b.roles, instance)) fail('unknown-role');
if (this.#threaded(s.business, instance)) fail(thread);
if (this.#store.get('SELECT state FROM launch_state WHERE business=?', s.business)?.state === 'revoked') fail('launch-revoked');
if (this.#firing(s.business, instance)) fail('instance-firing');
}
// #1873 A3: a root or project copy the boot left unbound or refused isn't in `roles`. Its refusal
// names the agent and the missing services or the reason, never a value (organization
// agent-copies.mjs copyRefusal; inlined because this module also loads in the sandbox client).
#copyRefused(b, instance) {
const copy = b.copies && Object.hasOwn(b.copies, instance) ? b.copies[instance] : null;
if (!copy || copy.state === 'instance') return;
if (copy.state === 'unbound') throw new BusError('credential-unbound', `${instance} has no binding in ${b.id} for ${copy.missing.join(', ')}`);
throw new BusError(copy.project === null ? 'copy-refused' : 'project-refused', `${instance} can't launch in ${b.id}: ${copy.reason}`);
}
// #1873 A3: why `instance` can't work in code project `repo`, as a BusError, or null when it can
// (organization agent-copies.mjs projectRefusal, inlined for the same reason). A project copy works
// only in its own project; in a project whose union refuses no agent works; an instance the
// project excludes doesn't work there. The reason names the instance, the project and why.
#projectRefused(b, instance, repo) {
const copy = b.copies && Object.hasOwn(b.copies, instance) ? b.copies[instance] : null;
if (copy && copy.project !== null && copy.project !== repo)
return new BusError('project-agent-elsewhere', `${instance} works only in project ${copy.project}`);
const union = repo === null || !b.unions || !Object.hasOwn(b.unions, repo) ? null : b.unions[repo];
if (!union) return null;
if (!union.ok) return new BusError('project-refused', `${instance} can't work in project ${repo}: project ${repo} refuses its agents: ${union.reason}`);
if (!union.agents.includes(instance)) return new BusError('project-refused', `${instance} can't work in project ${repo}: the project excludes it`);
return null;
}
// #1873 A3: the union refusal of code project `repo`, as a BusError naming the project and why, or null.
#unionRefused(b, repo) {
const union = repo === null || !b.unions || !Object.hasOwn(b.unions, repo) ? null : b.unions[repo];
return union && !union.ok ? new BusError('project-refused', `project ${repo} refuses its agents: ${union.reason}`) : null;
}
// W-N1 (webby-0014): a fire of I stopped its runs, and no fire of I has finished since.
#firing(business, instance) {
return !!this.#store.get(
@@ -1852,7 +1886,15 @@ export class Broker {
this.#revokeLost('access-expiry', true, doc);
rows = this.#threadRows('t.business=?', business);
}
return rows.map(({ owner, project, ...r }) => ({ ...r, ...(this.#archived(business, project) ? { archived: true } : {}), ...(lost(r, owner, project) ? { revoked: true } : {}) }));
// #1873 A3: a thread whose instance no longer works in its project's code project (the project's
// union refuses, or it excludes the instance) says so, so the launcher refuses its launch.
const b = this.#business(business);
const refusedIn = (instance, project) => {
if (!b.unions && !b.copies) return null;
const e = this.#projectRefused(b, instance, this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', project, business)?.repo ?? null);
return e ? { projectRefused: { code: e.code, reason: e.reason } } : null;
};
return rows.map(({ owner, project, ...r }) => ({ ...r, ...(this.#archived(business, project) ? { archived: true } : {}), ...(lost(r, owner, project) ? { revoked: true } : {}), ...refusedIn(r.instance, project) }));
}
// Row 121 round 2 (#1615, Dewey's F3 in 28591): the launcher's idle stop. The tick decides
// from a threadsState snapshot, and a message the run took after it would be delivered to a
@@ -6110,6 +6152,12 @@ export class Broker {
entry.running = this.#running(s.business, name);
entry.alive = this.#aliveAfterFire(s.business, name);
}
// #1873 A3: the copies this bus booted with, and each one's running runs. The file's own
// instances above carry no copy; a copy carries its state, missing service names or reason.
if (request.verb === "instances" && result?.instances && typeof result.instances === "object" && this.#business(s.business).copies)
result.copies = Object.fromEntries(Object.entries(this.#business(s.business).copies).map(([name, c]) => [name, {
...structuredClone(c), running: c.state === 'instance' ? this.#running(s.business, name) : [],
}]));
return result;
}
return this.#store.transaction(() => {
@@ -8033,7 +8081,17 @@ export class Broker {
// #797 MVP3: an archived project takes no new room threads and no new requests; reading it
// (room.read) and every existing record stay open.
if ((opening || sending) && this.#archived(s.business, project)) fail('project-archived');
const members = Object.keys(b.roles).filter((instance) => this.#threaded(s.business, instance)).sort();
// #1873 A3: the room's members are the thread instances that work in its code project. A project
// whose union refuses takes no new room thread or request, naming the project and why; its
// existing threads stay readable.
const repo = this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', project, s.business)?.repo ?? null;
const unionRefused = this.#unionRefused(b, repo);
if ((opening || sending) && unionRefused) throw unionRefused;
const members = Object.keys(b.roles).filter((instance) => {
if (!this.#threaded(s.business, instance)) return false;
const refused = this.#projectRefused(b, instance, repo);
return !refused || (unionRefused !== null && refused.code === 'project-refused');
}).sort();
if (!members.length) fail('thread-instance-missing');
const owner = this.#owner(s);
// Preflight all collisions/missing threads before open creates or send writes anything.
@@ -8121,7 +8179,13 @@ export class Broker {
if (!s.tester && !s.user) this.#human(s);
keys(a, ['project', 'name', 'instance'], ['project', 'name']);
if (!name(a.name)) fail('invalid-request');
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
// #1873 A3: a root or project copy takes a thread only when the caller names it, so leaving the
// instance out still chooses among the organization file's own thread instances.
const copies = b.copies ?? {};
// #1873 A3: the code project the chat project maps to decides which agents work in it.
const repo = this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', identifier(a.project), s.business)?.repo ?? null;
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r) &&
(a.instance !== undefined || (!Object.hasOwn(copies, r) && !this.#projectRefused(b, r, repo))));
const instance = a.instance === undefined
? (instances.length === 1 ? instances[0] : null)
: identifier(a.instance);
@@ -8135,9 +8199,18 @@ export class Broker {
fail('project-not-found');
// #797 MVP3: an archived project refuses new threads (and the room's requests, above).
if (this.#archived(s.business, identifier(a.project))) fail('project-archived');
// #1873 A3: a named copy the boot left unbound or refused says why, and a project whose union
// refuses takes no thread, naming the project and why.
if (a.instance !== undefined && instance) this.#copyRefused(b, instance);
const unionRefused = this.#unionRefused(b, repo);
if (unionRefused) throw unionRefused;
// The human selects an instance; omission is compatible only with one candidate.
if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');
if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');
// #1873 A3: a project agent works only in its own project, and an agent the project excludes
// doesn't work in it.
const elsewhere = this.#projectRefused(b, instance, repo);
if (elsewhere) throw elsewhere;
if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists');
const tid = randomUUID();
this.#store.run(
+1 -1
View File
@@ -88,7 +88,7 @@ function launchOp(m) {
if (m.op === 'request.release') return runtime.requestRelease(m.cap, m.record);
if (m.op === 'request.manifest') return runtime.requestManifest(m.record);
if (m.op === 'request.chief') return runtime.requestChief(m.business);
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, thread: m.thread, session: m.session });
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, instance: m.instance, revision: m.revision, thread: m.thread, session: m.session });
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
if (m.op === 'modelAdmission') return runtime.modelAdmission(m);
if (m.op === 'modelPreflight') return runtime.modelPreflight(m);
+28 -5
View File
@@ -35,7 +35,7 @@ import { instanceSettings } from '../../organization/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { loadWithCopies } from '../../organization/src/agent-copies.mjs';
import { loadRole } from '../../organization/src/role.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
@@ -284,7 +284,14 @@ export async function startBroker({
builtinBinding = null,
modelCatalogue = null, // in-process fixture seam; boot JSON cannot supply a service
makeProviderLoader = createLoader, // in-process fixture seam; boot JSON cannot supply a function
// #1873 A3: the boot's root and project agent copies (cli config.mjs copiesAtBoot), plain data. Every
// reload of the organization file below re-applies this snapshot, never the root file.
copies = null,
}) {
if (copies !== null && (typeof copies !== 'object' || Array.isArray(copies) || typeof copies.org !== 'string'
|| !Object.hasOwn(businesses ?? {}, copies.org) || copies.copies === null || typeof copies.copies !== 'object' || Array.isArray(copies.copies)))
throw new BusError('invalid-host-request');
const loadOrg = id => loadWithCopies(id, { dir: organization.dir, rolesDir: organization.rolesDir, snapshot: copies?.org === id ? copies : null });
if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) ||
Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512)))
throw new BusError('invalid-host-request');
@@ -564,7 +571,7 @@ export async function startBroker({
const reloadLog = entry => process.stderr.write(`organization: ${JSON.stringify(entry)}\n`);
function reload(id) {
try {
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const b = loadOrg(id);
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
const next = withoutPending(id, busOrganization(b, resolved));
@@ -595,7 +602,7 @@ export async function startBroker({
if (!found || found.after !== organization_revision) refuse('bootstrap-receipt-mismatch');
// Only this bootstrap's own post-add revision may be admitted: any later organization write means the confirmed world moved (#800).
if (revision !== found.after) refuse('bootstrap-revision-stale');
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const b = loadOrg(id);
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
const full = busOrganization(b, resolved);
@@ -622,7 +629,11 @@ export async function startBroker({
// product the broker holds is ready only while it has a complete marker and a committed activate receipt: the
// answer is read from both on every call, so a marker removed after the activation closes it again, and the
// broker's reload now follows both readiness records (#802).
function admission({ business: id, project, projects, thread, session }) {
// With `instance` (#1873 A3), the launch boundary also asks whether that instance may work in each
// project as the business stands now: a project that excludes it, or whose union refuses, is not ready.
// `instance` comes with `revision`, the organization file revision the launch resolved its bundle
// from; a file that has changed since (a narrow, a project define's vars, an exclude) is not ready.
function admission({ business: id, project, projects, instance, revision: launched, thread, session }) {
if (projects !== undefined) {
const no = code => ({ ready: false, revision: null, code });
if (typeof id !== 'string' || !Object.hasOwn(businesses, id) || !Array.isArray(projects) || projects.some(p => typeof p !== 'string')) return no('project-unknown');
@@ -631,9 +642,21 @@ export async function startBroker({
if (!row) return no('session-not-found');
if (row.archived) return no('project-archived');
}
if (instance !== undefined && (typeof instance !== 'string' || typeof launched !== 'string')) return no('project-unknown');
if (instance !== undefined) {
let now;
try { now = writer?.read(id).revision; } catch { return no('organization-unreadable'); }
if (now === undefined) return no('organization-unreadable');
if (now !== launched) return no('organization-changed');
}
for (const p of projects) {
const result = admission({ business: id, project: p });
if (!result.ready) return result;
if (instance !== undefined) {
// The broker holds the document the last writer reload swapped in; `businesses` is the boot one.
const barred = broker.projectBarred(id, instance, p);
if (barred) return no(barred);
}
}
return { ready: true, revision: null, code: null };
}
@@ -753,7 +776,7 @@ export async function startBroker({
// plugins.set reloads the business's limit checks in the background.
organization:settings?(proxyOrganization?.wrap ?? (operation => operation))((id,verb,args,actor,context)=>{
if (verb === 'project.remote.get.console' || verb === 'project.remote.set.console') {
const current = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const current = loadOrg(id);
const resolved = {};
for (const instance of Object.keys(current.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: current, instance });
const live = withoutPending(id, busOrganization(current, resolved));
+122
View File
@@ -0,0 +1,122 @@
// #1873 Phase A slice A3: the broker half of root and project agent copies. The business carries the
// boot's `copies` view (organization agent-copies.mjs withCopies): a bound copy is also in `roles`,
// an unbound or refused one is not. The cli half, with real runners, is cli agent-copies.test.mjs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { BusError, Broker } from '../src/broker.mjs';
const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
const SECRET = 'sk-test-NOT-A-SECRET-1234';
const copy = (name, definition, project, state, extra = {}) => ({ name, level: project === null ? 'root' : 'project', project, definition, state, ...extra });
const demo = () => ({
id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'chief' },
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging }, companion: { messaging: shipped('companion').messaging } },
projects: { stack: { root: '/nonexistent/stack' }, web: { root: '/nonexistent/web' }, docs: { root: '/nonexistent/docs' } },
roles: {
chief: { definition: 'chief-of-staff', ...none },
pm: { definition: 'pm', ...none },
assistant: { definition: 'assistant', ...none },
gate: { definition: 'merge-gate', ...none },
'companion.stack': { definition: 'companion', ...none },
buddy: { definition: 'companion', ...none },
},
copies: {
gate: copy('gate', 'merge-gate', null, 'instance'),
buddy: copy('buddy', 'companion', null, 'instance'),
'companion.stack': copy('companion', 'companion', 'stack', 'instance'),
researcher: copy('researcher', 'research', null, 'unbound', { missing: ['gitea'] }),
wide: copy('wide', 'merge-gate', null, 'refused', { reason: 'model <value> is not in launch.models' }),
'lint.web': copy('lint', 'security-reviewer', 'web', 'refused', { reason: 'project web refuses its agents: ghost is excluded, but nothing defines it' }),
},
// Each code project's union as withCopies computes it: web excludes buddy, docs refuses.
unions: {
stack: { ok: true, agents: ['assistant', 'buddy', 'chief', 'companion.stack', 'gate', 'pm'] },
web: { ok: true, agents: ['assistant', 'chief', 'gate', 'pm'] },
docs: { ok: false, reason: 'projects.docs.agents.ghost: names no root or organization agent to exclude' },
},
});
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-copies-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const b = new Broker({ store, businesses: { demo: demo() } });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const call = (verb, args = {}) => b.request(human, { verb, args });
const refusal = (fn) => {
try { fn(); } catch (e) { assert.ok(e instanceof BusError, String(e)); return e; }
assert.fail('no refusal');
};
const trail = () => store.all('SELECT kind, body FROM events').map((e) => `${e.kind} ${e.body}`).join('\n');
return { b, store, call, refusal, trail };
}
test('A3 bus: a bound copy takes a launch request; an unbound or refused one refuses at the verb by name, with the code alone in the trail', (t) => {
const f = setup(t);
assert.match(f.call('instance.launch', { instance: 'gate' }).request, /\S/);
const unbound = f.refusal(() => f.call('instance.launch', { instance: 'researcher' }));
assert.deepEqual([unbound.code, unbound.reason], ['credential-unbound', 'researcher has no binding in demo for gitea']);
const refused = f.refusal(() => f.call('instance.launch', { instance: 'wide' }));
assert.equal(refused.code, 'copy-refused');
assert.match(refused.reason, /^wide can't launch in demo: /);
const project = f.refusal(() => f.call('instance.launch', { instance: 'lint.web' }));
assert.equal(project.code, 'project-refused');
assert.match(project.reason, /^lint\.web can't launch in demo: project web refuses its agents: /);
// Regenerate takes the same check.
assert.equal(f.refusal(() => f.call('instance.regenerate', { instance: 'researcher' })).code, 'credential-unbound');
// A name that is no copy and no instance is still unknown.
assert.equal(f.refusal(() => f.call('instance.launch', { instance: 'nobody' })).code, 'unknown-role');
// The trail holds each refusal's code, never its reason.
const trail = f.trail();
assert.match(trail, /credential-unbound/);
for (const text of ['has no binding', "can't launch", 'refuses its agents', SECRET]) assert.equal(trail.includes(text), false, text);
});
test('A3 bus: a project agent takes a thread only in its own project, and only when named', (t) => {
const f = setup(t);
const stack = f.call('project.create', { name: 'stack work', repo: 'stack' }).id;
const web = f.call('project.create', { name: 'web work', repo: 'web' }).id;
const loose = f.call('project.create', { name: 'loose' }).id;
// Unnamed, the organization file's own thread instance is still the one choice.
const plain = f.call('thread.create', { project: stack, name: 'plain' }).id;
assert.equal(f.store.get('SELECT instance FROM threads WHERE id=?', plain).instance, 'assistant');
const own = f.call('thread.create', { project: stack, name: 'own', instance: 'companion.stack' });
assert.match(own.id, /\S/);
for (const project of [web, loose])
assert.equal(f.refusal(() => f.call('thread.create', { project, name: 'elsewhere', instance: 'companion.stack' })).code, 'project-agent-elsewhere');
// A named copy the boot refused says why, before the thread instance check.
const unbound = f.refusal(() => f.call('thread.create', { project: stack, name: 'r', instance: 'researcher' }));
assert.deepEqual([unbound.code, unbound.reason], ['credential-unbound', 'researcher has no binding in demo for gitea']);
});
test('A3 bus: a room holds only the thread instances that work in its project; an excluded agent takes no thread there; a refused union takes none at all, naming the project', (t) => {
const f = setup(t);
const project = (repo) => f.call('project.create', { name: `${repo ?? 'loose'} work`, ...(repo ? { repo } : {}) }).id;
const [stack, web, docs, loose] = [project('stack'), project('web'), project('docs'), project(null)];
const members = (id) => f.call('room.open', { project: id }).threads.map((x) => x.instance).sort();
// Another project's agent never joins, and the project's own exclusion holds.
assert.deepEqual(members(stack), ['assistant', 'buddy', 'companion.stack']);
assert.deepEqual(members(web), ['assistant']);
assert.deepEqual(members(loose), ['assistant', 'buddy']);
assert.deepEqual(f.call('room.send', { project: web, body: 'hello' }).to, ['assistant']);
const excluded = f.refusal(() => f.call('thread.create', { project: web, name: 'b', instance: 'buddy' }));
assert.deepEqual([excluded.code, excluded.reason], ['project-refused', "buddy can't work in project web: the project excludes it"]);
// docs's union refuses: no thread, named or not, and no room, each naming docs and why.
for (const fn of [
() => f.call('thread.create', { project: docs, name: 'plain' }),
() => f.call('thread.create', { project: docs, name: 'a', instance: 'assistant' }),
() => f.call('room.open', { project: docs }),
]) {
const e = f.refusal(fn);
assert.equal(e.code, 'project-refused');
assert.match(e.reason, /^project docs refuses its agents: .*ghost/);
}
assert.equal(f.trail().includes('refuses its agents'), false, 'the trail holds the code only');
});
+3 -1
View File
@@ -37,7 +37,9 @@ const baselines = {
// #1839: artifact Console replies alone may use 2 MiB; the proof and other limits are unchanged.
// #1873 U1: humanCall, busCaller, userCall and userCaller take an optional signal and absolute
// deadline; userCall shares transport(). Nonces, argv and limits are unchanged.
"packages/webui/src/bus.mjs": "3ef8064dd7911f9eee4475b52c74a1d551583a065474b1bcee968cd45f722747",
// #1873 A3: STATUS maps credential-unbound, copy-refused, project-refused and project-agent-elsewhere
// to 409; no proof or transport change.
"packages/webui/src/bus.mjs": "d8f3e0eb5299fdb192e98a1238ca693aae7c36af9a21de72d5f5d39610589377",
};
const repoFile = path => new URL("../../../" + path, import.meta.url);
+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/agent-copies.mjs (rename design r2, stage B).
export * from "../../organization/src/agent-copies.mjs";
+1 -1
View File
@@ -658,7 +658,7 @@ async function bus(rest, io, deps) {
// tracker gets (the bus runtime makes the same choice, runtime.mjs trackerOf).
const taskModule = boot.businesses?.[businessId]?.plugins?.["task-tracker"];
const tracker = Boolean(boot.trackers?.[businessId]) || taskModule === undefined || taskModule === "store";
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker, launches, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker, launches, copies: boot.copies ?? null, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
await launcher.listen();
if (instance) io.stdout.write(`DEV LOCAL OWNER MODE: console owner local-dev accepted (named instance ${instance})\n`);
if (instance && parsed.flags.has("--pm")) io.stdout.write('DEV LOCAL OWNER MODE: launched agents run as this OS user and can reach the loopback Console as owner; use this instance for UI testing only\n');
+26 -1
View File
@@ -10,6 +10,10 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { managedState } from "../../organization/src/project-bootstrap.mjs";
import { BusinessError, accessFilePath, configDir, loadAccess, loadBusiness, loadProject, projectFilePath, resolveInstance, systemVars } from "../../organization/src/index.mjs";
import { loadRootAgents } from "../../organization/src/agent-levels.mjs";
import { materializeCopies, withCopies } from "../../organization/src/agent-copies.mjs";
import { organizationWriter } from "../../organization/src/organization.mjs";
import { businessFilePath } from "../../organization/src/organization-config.mjs";
import { busOrganization } from "../../bus/src/organization-context.mjs";
import { BusError } from "../../bus/src/broker.mjs";
import { CliError } from "./errors.mjs";
@@ -144,10 +148,29 @@ export const consoleOwners = (sso) =>
// The `{op: 'boot'}` config for packages/bus/src/process.mjs, for one
// business. The host holds a reader capability for the notifier and binds
// launches later through bindLaunch, so `launches` starts empty.
// #1873 A3: the organization's root and project agent copies, read once here. The root file is read
// through loadRootAgents and the organization file through the organization's own writer; the
// snapshot is plain data that the broker and the launcher re-apply to every later read of the
// organization file, so a root file change takes effect at the next boot only. A root file that
// refuses hides the root level (design section 6): no root copy, and the organization still boots.
export function copiesAtBoot({ businessId, env = process.env, warn = () => {} }) {
const dir = configDir(env), roles = rolesDir(env);
loadBusiness(businessId, { dir, rolesDir: roles });
const { document } = organizationWriter({ dir, rolesDir: roles }).read(businessId);
let root = null;
try { root = loadRootAgents(dir, { rolesDir: roles }); }
catch (error) {
if (!(error instanceof BusinessError)) throw error;
warn(`root agent file refused, so ${businessId} runs no root copies until it is fixed and the bus restarts: ${error.message}`);
}
const snapshot = materializeCopies({ orgId: businessId, doc: document, root, rolesDir: roles });
return { snapshot, business: withCopies(document, businessFilePath(businessId, dir), { rolesDir: roles, snapshot }) };
}
export function bootConfig({ system, businessId, orgRoot = system.dataRoot, env = process.env, warn = () => {} }) {
return business(() => {
const vars = systemVars(system);
const b = loadBusiness(businessId, { dir: configDir(env), rolesDir: rolesDir(env) });
const { snapshot, business: b } = copiesAtBoot({ businessId, env, warn });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: vars, business: b, instance });
const config = {
@@ -158,6 +181,8 @@ export function bootConfig({ system, businessId, orgRoot = system.dataRoot, env
organization: {dir:configDir(env),rolesDir:rolesDir(env),system:vars},
launches: [],
readers: [b.id],
// Present only when the organization has a copy, so a boot with none is the boot it was.
...(Object.keys(snapshot.copies).length ? { copies: snapshot } : {}),
};
const tracker = trackerFor(vars, b, warn, orgRoot);
if (tracker) {
+62 -11
View File
@@ -143,9 +143,10 @@ import { createHash } from 'node:crypto';
import { watchModelOutput } from './model-stall.mjs';
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../organization/src/index.mjs";
import { configDir, resolveInstance, systemVars } from "../../organization/src/index.mjs";
import { organizationWriter } from "../../organization/src/organization.mjs";
import { businessFilePath, validateBusinessDocument } from "../../organization/src/organization-config.mjs";
import { businessFilePath } from "../../organization/src/organization-config.mjs";
import { copyOf, copyRefusal, loadWithCopies, projectRefusal, withCopies } from "../../organization/src/agent-copies.mjs";
import { selectModelProvider } from "../../plugins/src/model-provider-selection.mjs";
import { THREAD_DEFINITIONS } from "../../organization/src/vocabulary.mjs";
import { limitHit } from "../../organization/src/launch.mjs";
@@ -244,6 +245,9 @@ export function createLauncher({
git = {},
stopOptions = {},
log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`),
// #1873 A3: the root and project agent copies the bus booted with (config.mjs copiesAtBoot), or
// null. Every read of the organization file here re-applies this snapshot, never the root file.
copies = null,
}) {
if (testProxy !== null && system.proxy)
throw Object.assign(new Error("testProxy skips P2 admission, so it is refused with system.proxy set"), { code: "test-proxy-refused" });
@@ -257,7 +261,8 @@ export function createLauncher({
}
// Row 64 (#1557): where a role's skills are read from; scripts/skill.sh activate fills it.
const skillsDir = join(system.instanceRoot ?? dataRoot, "skills-enabled");
let b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) });
const loadOrg = () => loadWithCopies(host.business, { dir: configDir(env), rolesDir: rolesDir(env), snapshot: copies?.org === host.business ? copies : null });
let b = loadOrg();
const vars = systemVars(system);
// Sessions an earlier host left in sessions.json: it died without ending
@@ -355,10 +360,17 @@ export function createLauncher({
try {
const snapshot = organizationWriter({dir:configDir(env),rolesDir:rolesDir(env)}).read(host.business);
businessRevision = snapshot.revision;
b = validateBusinessDocument(snapshot.document, businessFilePath(host.business, configDir(env)), {rolesDir:rolesDir(env)});
b = withCopies(snapshot.document, businessFilePath(host.business, configDir(env)), {rolesDir:rolesDir(env), snapshot: copies?.org === host.business ? copies : null});
} catch(e){throw new Refusal("organization-refused",e.message);}
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed");
// #1873 A3: an unbound or refused copy never launches. The message names the copy, its project
// and the missing service names or the refusal, never a value.
const copyRefused = copyRefusal(b, instance);
if (copyRefused) throw new Refusal(copyRefused.code, copyRefused.message);
// #1873 A3: a bound copy isn't on the chief's list. It launches when the human asks, or for a
// thread, which only a human or a granted user makes, naming the copy.
const boundCopy = copyOf(b, instance)?.state === "instance" && Object.hasOwn(b.roles, instance);
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)) || (thread && boundCopy))) throw new Refusal("instance-not-listed");
const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
@@ -539,12 +551,12 @@ export function createLauncher({
await pickRoute(() => standIn);
await gate(model, harness, null);
admission(admissionGeneration);
return { resolved, model, account: null, harness, route, routeReason, admissionGeneration, resolver };
return { resolved, model, account: null, harness, route, routeReason, admissionGeneration, resolver, revision: businessRevision };
}
const sessionProxy = await pickRoute(proxyFor);
const account = await gate(model, harness, sessionProxy);
admission(admissionGeneration);
return { resolved, model, account, harness, route, routeReason, admissionGeneration, sessionProxy, resolver };
return { resolved, model, account, harness, route, routeReason, admissionGeneration, sessionProxy, resolver, revision: businessRevision };
}
// Reread host state on every new-run path, including opens/clears queued before archive.
@@ -554,6 +566,8 @@ export function createLauncher({
const row = rows.find(r => r.thread === thread && r.session === session);
if (!row) throw new Refusal("session-not-found");
if (row.archived) throw new Refusal("project-archived");
// #1873 A3: the thread's project no longer takes its instance: the union refuses, or excludes it.
if (row.projectRefused) throw new Refusal(row.projectRefused.code, row.projectRefused.reason);
}
// Bundle, spawn, bind, register, hand over the capability.
@@ -616,13 +630,24 @@ export function createLauncher({
// (a mapping published before the seed and records finished) is left out and the launch goes on without
// it. Ordinary mappings also recheck archive state through the same host admission.
const codeProjects = {};
// #1873 A3: an instance gets only the code projects it works in: a project agent its own one, and
// no instance a project that excludes it or whose union refuses. A project agent refuses when its
// own project isn't admitted. A checkout an earlier launch left in the workspace for a project
// the instance may not work in now is hidden from the session, never deleted (sandbox.mjs).
const own = copyOf(b, instance)?.project ?? null;
const declared = new Set();
for (const [project, entry] of Object.entries(b.projects)) {
if (entry.kind === 'library') continue;
declared.add(project);
if (own !== null && project !== own) continue;
const unavailable = projectRefusal(b, instance, project);
if (unavailable) { record({ event: "checkout-skipped", instance, project, reason: unavailable.code }); continue; }
let answer = null;
try { answer = await host.op({ op: 'request.admission', business: b.id, project }); } catch (e) { answer = { ready: false, code: e.code ?? 'admission-unavailable' }; }
if (answer?.ready !== true) { record({ event: "checkout-skipped", instance, project, reason: answer?.code ?? 'project-pending' }); continue; }
codeProjects[project] = entry;
}
if (own !== null && !Object.hasOwn(codeProjects, own)) throw new Refusal("project-refused", `${instance} works only in project ${own}, which isn't ready`);
await threadAdmission(thread, session);
let checkouts;
try {
@@ -646,9 +671,30 @@ export function createLauncher({
catch (error) { throw new Refusal(error.code ?? 'proxy-down', error.code ?? 'proxy-down'); }
if (!admittedProxy || typeof admittedProxy.key !== 'string' || !admittedProxy.key || admittedProxy.baseUrl !== `http://127.0.0.1:${system.proxy.port}`) throw new Refusal('proxy-down', 'proxy-down');
}
let harness, route, routeReason, sessionProxy, resolver;
({resolved,model,account,harness,route,routeReason,sessionProxy,resolver}=await check(instance,{pm:instance===b.launch.by,thread,session,human,proxyContext:admittedProxy,proxyReady:true}));
let harness, route, routeReason, sessionProxy, resolver, revision;
({resolved,model,account,harness,route,routeReason,sessionProxy,resolver,revision}=await check(instance,{pm:instance===b.launch.by,thread,session,human,proxyContext:admittedProxy,proxyReady:true}));
admission(admissionGeneration);
// #1873 A3: check() read the organization again. A project that excluded or refused this
// instance while its checkouts were prepared refuses the launch; the next one leaves it out.
for (const project of Object.keys(codeProjects)) {
const unavailable = projectRefusal(b, instance, project);
if (unavailable) throw new Refusal(unavailable.code, unavailable.message);
}
// #1873 A3: every declared code project this launch leaves out, in the document it started from
// or the one check() read, is hidden if an earlier launch left its checkout in the workspace,
// whatever kept it out (not admitted, excluded, refused, another project's agent). Only a real
// directory can be covered: a tmpfs mounted at a symlink would land on its target, so anything
// else at that path refuses the launch, and nothing on the host is changed or removed.
for (const [project, entry] of Object.entries(b.projects)) if (entry.kind !== 'library') declared.add(project);
const hidden = [];
for (const project of declared) {
if (Object.hasOwn(codeProjects, project)) continue;
const path = join(workspace, project);
let stat;
try { stat = lstatSync(path); } catch (e) { if (e.code === "ENOENT") continue; throw new Refusal("checkout-unsafe", `${instance}'s workspace path for project ${project} can't be read`); }
if (!stat.isDirectory()) throw new Refusal("checkout-unsafe", `${instance}'s workspace path for project ${project} isn't a directory, so it can't be hidden; a person removes it before ${instance} launches again`);
hidden.push(path);
}
const { run, runDir } = newRun(dataRoot, b.id);
let modelKeyId;
if (admittedProxy) {
@@ -744,6 +790,7 @@ export function createLauncher({
gitSocket: git,
threadStore: store,
readOnly,
hidden,
};
admission(admissionGeneration);
await threadAdmission(thread, session);
@@ -754,7 +801,9 @@ export function createLauncher({
}
// One synchronous broker snapshot covers the thread and the entire manifest after every
// asynchronous preparation read. Admission is the launch boundary; bindLaunch remains a fence.
const finalAdmission = await host.op({ op: "request.admission", business: b.id, projects: Object.keys(codeProjects), thread, session });
// #1873 A3: `revision` is the organization file check() resolved the bundle from; a file that
// changed since (a narrow, a define's vars, an exclude) refuses, and the next launch resolves anew.
const finalAdmission = await host.op({ op: "request.admission", business: b.id, projects: Object.keys(codeProjects), instance, revision, thread, session });
if (finalAdmission?.ready !== true) throw new Refusal(finalAdmission?.code ?? "project-pending");
const managed = registerRuntime(dataRoot, { kind: 'session', business: b.id, run });
let child;
@@ -889,6 +938,8 @@ export function createLauncher({
for (const i of pending) {
const session = registry.running(b.id, i.target, null);
if (!session || !i.project || stopping.has(session.run)) continue;
// #1873 A3: a manifest never gains a project its instance doesn't work in.
if (projectRefusal(b, i.target, i.project)) continue;
let projects = [];
try { projects = JSON.parse(readFileSync(join(session.runDir, 'bundle/manifest.json'), 'utf8')).projects ?? []; } catch {}
if (!projects.includes(i.project)) {
@@ -1348,7 +1399,7 @@ export function createLauncher({
if (typeof instance === 'string') await host.op({ op: 'request.launchResult', record: { business: b.id, instance, code: out.ok ? null : out.error, run: out.ok ? out.result.run : null } });
return out;
};
try { b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) }); }
try { b = loadOrg(); }
catch { return reply({ ok: false, error: 'organization-refused' }); }
// Product enums and clones are frozen in a session's manifest. A legitimate chief
// launch may request a drain, never interrupt the turn or start a privileged worker.
+595
View File
@@ -0,0 +1,595 @@
import { requestHostOps } from './request-host-ops.mjs';
// #1873 Phase A slice A3: root and project agent copies through the real bus and launcher. The
// cli's boot step reads the root file once and hands the snapshot to the broker and the launcher;
// a bound copy launches as an ordinary instance of the organization, an unbound or refused one
// refuses at the verb, and a project agent gets only its own project. Real runners in the bwrap
// sandbox on the fake adapter, with the in-process host lifecycle.test.mjs uses.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { DatabaseSync } from "node:sqlite";
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { businessDoc, launchDoc, tokenFile, writeJson } from "../../organization/tests/helpers.mjs";
import { BusError } from "../../bus/src/broker.mjs";
import { Client } from "../../bus/src/client.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { launchLogFile } from "../../seat/src/session.mjs";
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { startTimeOf } from "../src/host.mjs";
import { createLauncher } from "../src/launcher.mjs";
import { fakeHarnesses, fixture, tmp, withoutHostCredentials } from "./helpers.mjs";
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
const skip = sandboxProblem() ?? false;
const SECRET = "sk-test-NOT-A-SECRET-1234";
const VARS = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
async function until(fn, ms = 15000) {
const end = Date.now() + ms;
while (Date.now() < end) {
const v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, 50));
}
throw new Error("timed out waiting");
}
function gitRepo(dir) {
mkdirSync(dir, { recursive: true });
const git = (...args) => {
const r = spawnSync("git", ["-c", "maintenance.auto=false", "-c", "gc.auto=0", "-c", "user.name=test", "-c", "[email protected]", "-C", dir, ...args], { env: gitEnv(), encoding: "utf8" });
assert.equal(r.status, 0, r.stderr);
};
git("init", "-q", "-b", "main");
writeFileSync(join(dir, "file.txt"), "one\n");
git("add", "file.txt");
git("commit", "-q", "-m", "one");
}
// acme: two code projects, stack and web. stack defines the project agent checker. The root file
// holds gate (merge-gate, no credential: bound), researcher (research, needs gitea: unbound in every
// organization) and sec (security-reviewer, excluded from acme). The coder's gitea token file is
// named for, and holds, the test secret, so it is bound elsewhere in the organization.
function prepare(t, edit = (doc) => doc) {
const root = tmp(t, "mosaic-copy-");
const f = fixture(root, "acme", (doc) => {
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
doc.roles.coder.vars.model = "claude-sonnet-5-5";
const secret = tokenFile(join(root, "secrets"), SECRET);
writeFileSync(secret, SECRET);
doc.roles.coder.credentials.gitea = { ...doc.roles.coder.credentials.gitea, file: secret };
doc.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
doc.projects.stack.agents = { checker: { definition: "merge-gate", vars: VARS } };
doc.projects.web = { root: join(root, "web") };
return edit(doc);
});
withoutHostCredentials(f.env, { piKey: true });
gitRepo(f.doc.projects.stack.root);
gitRepo(f.doc.projects.web.root);
writeJson(join(root, "config", "agents.json"), { agentsVersion: 1, agents: {
gate: { definition: "merge-gate", vars: VARS },
researcher: { definition: "research", vars: VARS },
sec: { definition: "security-reviewer", vars: VARS, excludeOrgs: ["acme"] },
} }, 0o600);
const adapter = join(root, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${FAKE}'\n`, { mode: 0o644 });
return { root, f, adapter };
}
async function start(t, { f, adapter }, { businessId = "acme", orgRoot } = {}) {
const system = loadSystem({ env: f.env });
const { trackers, ...boot } = bootConfig({ system, businessId, env: f.env, ...(orgRoot ? { orgRoot } : {}) });
const runtime = await startBroker(boot);
const dataRoot = boot.dataRoot;
mkdirSync(join(dataRoot, "bus-host"), { recursive: true, mode: 0o700 });
const ops = {
...requestHostOps(runtime),
identity: (m) => runtime.identity(m.cap),
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
endLaunch: (m) => runtime.endLaunch(m.record),
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
threadsState: (m) => runtime.threadsState(m.business),
threadIdle: (m) => runtime.threadIdle(m.record),
mayLaunch: (m) => runtime.mayLaunch(m),
landHeld: (m) => runtime.landHeld(m.record),
landHolders: (m) => runtime.landHolders(m.business),
landRelease: (m) => runtime.landRelease(m.record),
lifecycleRequests: (m) => runtime.lifecycleRequests(m.business),
lifecycleTake: (m) => runtime.lifecycleTake(m.record),
lifecycleDelete: (m) => runtime.lifecycleDelete(m.record),
lifecycleDone: (m) => runtime.lifecycleDone(m.record),
};
const refusing = (what, fn) => async (m) => {
try {
return fn(m);
} catch (e) {
const code = e instanceof BusError ? e.code : `${what}-refused`;
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
}
};
const hooks = [];
let closing = null;
const host = {
business: businessId,
path: runtime.path,
dataRoot,
op: refusing("launch-op", (m) => {
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
return ops[m.op](m);
}),
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
beforeClose: (fn) => void hooks.push(fn),
};
const logs = [];
const launcher = createLauncher({
host,
system: orgRoot ? { ...system, dataRoot } : system,
env: f.env,
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
sessionDefaults: { pollInterval: 100 },
harnessVersion: "0.85.1",
readOnly: [FAKE],
threadTickMs: 3600000,
stopTimeoutMs: 1000,
log: (l) => logs.push(l),
copies: boot.copies ?? null,
});
const close = () =>
(closing ??= (async () => {
for (const fn of hooks) await fn(0);
await runtime.close();
})());
t.after(close);
await launcher.listen();
const human = runtime.broker.bindHuman({ business: businessId, human: f.doc.human, via: "cli", outsideAgent: true });
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
const carry = async (request) => {
await launcher.tick();
return call("instance.request", { request });
};
const running = (instance) => launcher.registry.sessions.filter((s) => s.instance === instance);
return { boot, dataRoot, runtime, launcher, host, call, carry, running, logs, close };
}
// What the bus's trail holds: every event body, as text.
function trail(dataRoot) {
const db = new DatabaseSync(join(dataRoot, "bus", "bus.sqlite"), { readOnly: true });
try {
return db.prepare("SELECT kind, body FROM events").all().map((e) => `${e.kind} ${e.body}`).join("\n");
} finally {
db.close();
}
}
const refusedAt = (s, instance) => {
try {
s.call("instance.launch", { instance });
} catch (e) {
assert.ok(e instanceof BusError, String(e));
return e;
}
assert.fail(`${instance} launched`);
};
test("A3: a bound root copy launches as an instance of the organization; the excluded one and the root assistant aren't there", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
assert.deepEqual(Object.keys(s.boot.copies.copies).sort(), ["checker.stack", "gate", "researcher"]);
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(done.done.refusal, null, JSON.stringify(done));
const [run] = s.running("gate");
assert.equal(run.run, done.done.run);
// The grid read names it running from the bus's own claims.
await until(() => readFileSync(join(s.dataRoot, "launches", "acme", run.run, "runner.log"), "utf8").includes("claimed by run"));
const grid = s.call("instances");
assert.equal(grid.copies.gate.state, "instance");
assert.deepEqual(grid.copies.gate.running, [run.run]);
// sec is excluded from acme: no copy, no instance, and a launch names it unknown.
assert.equal(Object.hasOwn(grid.copies, "sec"), false);
assert.equal(refusedAt(s, "sec").code, "unknown-role");
});
test("A3: an unbound copy refuses at the verb by name and binding; no value reaches the refusal, the trail or the grid", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const e = refusedAt(s, "researcher");
assert.equal(e.code, "credential-unbound");
assert.match(e.reason, /^researcher has no binding in acme for gitea$/);
const grid = JSON.stringify(s.call("instances"));
assert.match(grid, /"researcher":\{[^}]*"state":"unbound"/);
for (const text of [e.message, e.reason, grid, trail(s.dataRoot), s.logs.join("\n")]) {
assert.equal(text.includes(SECRET), false);
assert.equal(text.includes(p.root), false, "no host path");
}
// Nothing was asked of the launcher.
await s.launcher.tick();
assert.equal(s.running("researcher").length, 0);
});
test("A3: a project agent launches with only its own project's mapping; a project whose union refuses refuses every agent in it", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const done = await s.carry(s.call("instance.launch", { instance: "checker.stack" }).request);
assert.equal(done.done.refusal, null, JSON.stringify(done));
const [run] = s.running("checker.stack");
const manifest = JSON.parse(readFileSync(join(run.runDir, "bundle", "manifest.json"), "utf8"));
assert.deepEqual(manifest.projects, ["stack"]);
// An org instance of the same role takes both code projects.
const coder = await s.carry(s.call("instance.launch", { instance: "coder" }).request);
assert.equal(coder.done.refusal, null, JSON.stringify(coder));
const [c] = s.running("coder");
assert.deepEqual(JSON.parse(readFileSync(join(c.runDir, "bundle", "manifest.json"), "utf8")).projects.sort(), ["stack", "web"]);
// web excludes an agent nothing defines: its union refuses, so its agents refuse, naming web.
const q = prepare(t, (doc) => {
doc.projects.web.agents = { checker: { definition: "merge-gate", vars: VARS }, lint: { definition: "security-reviewer", vars: VARS }, ghost: { exclude: true } };
return doc;
});
const r = await start(t, q);
for (const name of ["checker.web", "lint.web"]) {
const e = refusedAt(r, name);
assert.equal(e.code, "project-refused");
assert.match(e.reason, new RegExp(`^${name.replace(".", "\\.")} can't launch in acme: project web refuses its agents: .*ghost`));
}
// The other project's agent is untouched.
assert.equal((await r.carry(r.call("instance.launch", { instance: "checker.stack" }).request)).done.refusal, null);
// An inherited root copy and an organization instance still launch, without web's mount.
for (const instance of ["gate", "coder"]) {
const out = await r.carry(r.call("instance.launch", { instance }).request);
assert.equal(out.done.refusal, null, JSON.stringify(out));
const [inherited] = r.running(instance);
assert.deepEqual(projectsOf(inherited), ["stack"], instance);
}
});
const projectsOf = (run) => JSON.parse(readFileSync(join(run.runDir, "bundle", "manifest.json"), "utf8")).projects.sort();
const bundleOf = (run, file) => JSON.parse(readFileSync(join(run.runDir, "bundle", file), "utf8"));
test("A3: a project's exclude takes its mount from an inherited agent, and its narrow reaches the agent's real bundle", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t, (doc) => {
doc.projects.stack.agents.gate = { vars: { "limits.tools": ["read"] } };
doc.projects.web.agents = { gate: { exclude: true } };
return doc;
});
const s = await start(t, p);
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(done.done.refusal, null, JSON.stringify(done));
const [run] = s.running("gate");
assert.deepEqual(projectsOf(run), ["stack"]);
const tools = bundleOf(run, "policy.json").tools;
assert.ok(tools.includes("read"), JSON.stringify(tools));
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
// The launch record names the skipped project and the code, never a reason text.
const skipped = readFileSync(launchLogFile(s.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l)).filter((e) => e.event === "checkout-skipped");
assert.deepEqual(skipped.map(({ instance, project, reason }) => ({ instance, project, reason })), [{ instance: "gate", project: "web", reason: "project-refused" }]);
});
// Writes the organization file the way a human edit does, outside any writer verb.
const editOrg = (p, change) => {
const file = join(p.root, "config", "businesses", "acme.json");
const doc = JSON.parse(readFileSync(file, "utf8"));
change(doc);
writeFileSync(file, JSON.stringify(doc, null, 2) + "\n", { mode: 0o600 });
};
// A writer verb reloads the broker from the file. Keeping coder twice changes nothing and refuses,
// so a repeat reload drops the keep instead.
const reload = (s) => {
try { return s.call("veto.set", { revision: s.call("instances").revision, op: "keep", name: "coder" }); }
catch (e) { if (e.code !== "organization-no-change") throw e; }
return s.call("veto.set", { revision: s.call("instances").revision, op: "unkeep", name: "coder" });
};
// What the run's first turn said, from inside the real sandbox, about the web checkout.
async function webSeen(s, run) {
await until(() => existsSync(join(run.runDir, "runner.log")) && readFileSync(join(run.runDir, "runner.log"), "utf8").includes("claimed by run"));
s.call("message.send", { to: run.instance, class: "REQUEST", body: "fixture" });
const said = join(run.runDir, "turns", "0001.stderr");
await until(() => existsSync(said) && /WEB-(READABLE|ABSENT)/.test(readFileSync(said, "utf8")));
return readFileSync(said, "utf8").match(/WEB-(READABLE|ABSENT)/)[1];
}
test("A3: a checkout an earlier launch made is out of sight in the sandbox once its project excludes the agent, and stays on the host", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
// The adapter runs inside the sandbox and says only whether the web checkout's file is readable.
writeFileSync(p.adapter, `if test -r "$MOSAIC_WORKSPACE/web/file.txt"; then echo WEB-READABLE >&2; else echo WEB-ABSENT >&2; fi\nexec '${process.execPath}' '${FAKE}'\n`);
const s = await start(t, p);
let out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(out.done.refusal, null, JSON.stringify(out));
let [run] = s.running("gate");
assert.deepEqual(projectsOf(run), ["stack", "web"]);
assert.equal(await webSeen(s, run), "READABLE");
await s.carry(s.call("instance.end", { instance: "gate" }).request);
await until(() => s.running("gate").length === 0);
for (const exclude of [{ gate: { exclude: true } }, { ghost: { exclude: true } }]) {
// The project excludes gate, then refuses its whole union.
editOrg(p, (d) => { d.projects.web.agents = exclude; });
reload(s);
out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(out.done.refusal, null, JSON.stringify(out));
[run] = s.running("gate");
assert.deepEqual(projectsOf(run), ["stack"]);
assert.equal(await webSeen(s, run), "ABSENT");
await s.carry(s.call("instance.end", { instance: "gate" }).request);
await until(() => s.running("gate").length === 0);
}
// The earlier work is still on the host, untouched.
assert.equal(readFileSync(join(s.dataRoot, "workspaces", "acme", "gate", "web", "file.txt"), "utf8"), "one\n");
});
test("A3: a project that excludes the agent while its launch is prepared refuses the launch; the bus's last admission asks too", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const op = s.host.op;
let changed = false;
s.host.op = async (m) => {
const out = await op(m);
if (!changed && m.op === "request.admission" && m.project === "web") {
changed = true;
editOrg(p, (d) => { d.projects.web.agents = { gate: { exclude: true } }; });
}
return out;
};
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(changed, true);
assert.equal(out.done.refusal, "project-refused", JSON.stringify(out));
assert.equal(s.running("gate").length, 0);
// Once the bus has the change, its launch-boundary admission refuses the instance there too.
reload(s);
const revision = s.call("instances").revision;
assert.deepEqual(s.runtime.admission({ business: "acme", projects: ["stack", "web"], instance: "gate", revision }).code, "project-refused");
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate", revision }).ready, true);
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack", "web"] }).ready, true);
// The launch boundary takes an instance only with the revision its bundle came from.
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate" }).code, "project-unknown");
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate", revision: "0".repeat(64) }).code, "organization-changed");
});
// Launches gate with both projects, reads web from inside the sandbox, and ends the run, so the
// workspace keeps a web checkout an earlier launch made.
async function launchBoth(s) {
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(out.done.refusal, null, JSON.stringify(out));
const [run] = s.running("gate");
assert.deepEqual(projectsOf(run), ["stack", "web"]);
assert.equal(await webSeen(s, run), "READABLE");
await s.carry(s.call("instance.end", { instance: "gate" }).request);
await until(() => s.running("gate").length === 0);
}
const readsWeb = (p) => writeFileSync(p.adapter, `if test -r "$MOSAIC_WORKSPACE/web/file.txt"; then echo WEB-READABLE >&2; else echo WEB-ABSENT >&2; fi\nexec '${process.execPath}' '${FAKE}'\n`);
test("A3: a checkout of a project the bus doesn't admit now is out of sight too, not only an excluded one", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
readsWeb(p);
const s = await start(t, p);
await launchBoth(s);
// web is declared and gate is in its union, but the bus doesn't admit the project for this launch.
const op = s.host.op;
s.host.op = async (m) => (m.op === "request.admission" && m.project === "web" ? { ready: false, revision: null, code: "project-pending" } : op(m));
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(out.done.refusal, null, JSON.stringify(out));
const [run] = s.running("gate");
assert.deepEqual(projectsOf(run), ["stack"]);
assert.equal(await webSeen(s, run), "ABSENT");
assert.equal(readFileSync(join(s.dataRoot, "workspaces", "acme", "gate", "web", "file.txt"), "utf8"), "one\n");
});
test("A3: a symlink or a file where a hidden checkout would be refuses the launch, and nothing on the host is touched", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
readsWeb(p);
const s = await start(t, p);
await launchBoth(s);
const ws = join(s.dataRoot, "workspaces", "acme", "gate");
// The run's own workspace moves the checkout aside and leaves a link at web, then web excludes gate.
renameSync(join(ws, "web"), join(ws, "kept"));
symlinkSync(join(ws, "kept"), join(ws, "web"));
editOrg(p, (d) => { d.projects.web.agents = { gate: { exclude: true } }; });
reload(s);
let out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(out.done.refusal, "checkout-unsafe", JSON.stringify(out));
assert.equal(s.running("gate").length, 0);
assert.equal(lstatSync(join(ws, "web")).isSymbolicLink(), true);
assert.equal(readFileSync(join(ws, "kept", "file.txt"), "utf8"), "one\n");
// A plain file there refuses the same way.
renameSync(join(ws, "web"), join(ws, "link"));
writeFileSync(join(ws, "web"), "not a checkout\n");
out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(out.done.refusal, "checkout-unsafe", JSON.stringify(out));
assert.equal(readFileSync(join(ws, "web"), "utf8"), "not a checkout\n");
});
test("A3: a narrow that lands after the launcher's recheck and before the bus's last admission refuses the launch; the next one takes it", { skip, timeout: 90000 }, async (t) => {
for (const [instance, narrow, after] of [
// An inherited agent's narrow in its project.
["gate", (d) => { d.projects.stack.agents.gate = { vars: { "limits.tools": ["read"] } }; }, "launches"],
// A project agent's define: its vars change after boot.
["checker.stack", (d) => { d.projects.stack.agents.checker.vars["limits.tools"] = ["read"]; }, "project-refused"],
]) {
const p = prepare(t);
const s = await start(t, p);
const op = s.host.op;
let landed = false;
// The narrow lands after every launcher read, just before the bus's last admission answers.
s.host.op = async (m) => {
if (!landed && m.op === "request.admission" && m.projects !== undefined) { landed = true; editOrg(p, narrow); reload(s); }
return op(m);
};
let out = await s.carry(s.call("instance.launch", { instance }).request);
assert.equal(landed, true, instance);
assert.equal(out.done.refusal, "organization-changed", `${instance} ${JSON.stringify(out)}`);
assert.equal(s.running(instance).length, 0, instance);
// The next launch resolves from the narrowed file.
if (after === "launches") {
out = await s.carry(s.call("instance.launch", { instance }).request);
assert.equal(out.done.refusal, null, JSON.stringify(out));
const tools = bundleOf(s.running(instance)[0], "policy.json").tools;
assert.ok(tools.includes("read"), JSON.stringify(tools));
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
} else {
assert.equal(refusedAt(s, instance).code, after, instance);
}
await s.close();
}
});
test("A3: a project define whose vars change after boot refuses until the next start rather than launch with the old ones", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
let s = await start(t, p);
editOrg(p, (d) => { d.projects.stack.agents.checker.vars["limits.tools"] = ["read"]; });
reload(s);
const copy = s.call("instances").copies["checker.stack"];
assert.equal(copy.state, "refused");
assert.match(copy.reason, /^project stack changed checker's vars since the bus started/);
assert.equal(refusedAt(s, "checker.stack").code, "project-refused");
await s.close();
s = await start(t, p);
const out = await s.carry(s.call("instance.launch", { instance: "checker.stack" }).request);
assert.equal(out.done.refusal, null, JSON.stringify(out));
const tools = bundleOf(s.running("checker.stack")[0], "policy.json").tools;
assert.ok(tools.includes("read"), JSON.stringify(tools));
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
});
test("A3: a collision a project brings in after boot refuses the copy it collides with, naming both claimants", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const rootFile = join(p.root, "config", "agents.json");
const root = JSON.parse(readFileSync(rootFile, "utf8"));
root.agents["new.stack"] = { definition: "security-reviewer", vars: VARS };
writeFileSync(rootFile, JSON.stringify(root));
const s = await start(t, p);
assert.equal(s.call("instances").copies["new.stack"].state, "instance");
editOrg(p, (d) => { d.projects.stack.agents.new = { definition: "merge-gate", vars: VARS }; });
reload(s);
const copy = s.call("instances").copies["new.stack"];
assert.equal(copy.state, "refused");
assert.deepEqual(copy.claims, [{ name: "new.stack", project: null }, { name: "new", project: "stack" }]);
assert.equal(copy.reason, "the instance name new.stack is claimed by root agent new.stack and project stack's new");
assert.equal(refusedAt(s, "new.stack").code, "copy-refused");
// Taking the project's define back out frees the root copy again.
editOrg(p, (d) => { delete d.projects.stack.agents.new; });
reload(s);
assert.equal(s.call("instances").copies["new.stack"].state, "instance");
});
test("A3: an organization instance written over a root copy after boot launches as itself, with its own role", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
assert.equal(s.call("instances").copies.gate.state, "instance");
const file = join(p.root, "config", "businesses", "acme.json");
const doc = JSON.parse(readFileSync(file, "utf8"));
doc.roles.gate = { definition: "security-reviewer", vars: VARS };
writeFileSync(file, JSON.stringify(doc, null, 2) + "\n", { mode: 0o600 });
// A writer verb reloads the broker from the file, as any human edit would.
const { revision } = s.call("instances");
s.call("veto.set", { revision, op: "keep", name: "coder" });
assert.equal(Object.hasOwn(s.call("instances").copies ?? {}, "gate"), false);
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(done.done.refusal, null, JSON.stringify(done));
const [run] = s.running("gate");
assert.equal(bundleOf(run, "manifest.json").definition, "security-reviewer");
});
test("A3: two organizations each launch their own gate copy; runs, sessions and trails stay under each one's data root", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
const beta = businessDoc(p.root, "beta");
for (const r of Object.values(beta.roles)) r.vars = { ...VARS, ...r.vars, model: r.vars?.model ?? "claude-sonnet-5-5" };
beta.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
beta.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
beta.plugins = { ...beta.plugins, harness: ["pi", "claude-code"] };
writeJson(join(p.root, "config", "businesses", "beta.json"), beta);
const a = await start(t, p);
mkdirSync(join(p.root, "ob"), { mode: 0o700 });
const b = await start(t, p, { businessId: "beta", orgRoot: join(p.root, "ob") });
const runs = {};
for (const [id, s] of [["acme", a], ["beta", b]]) {
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(done.done.refusal, null, JSON.stringify(done));
const [run] = s.running("gate");
runs[id] = run;
assert.ok(run.runDir.startsWith(join(s.dataRoot, "launches", id) + "/"), run.runDir);
await until(() => existsSync(join(run.runDir, "session")));
await until(() => readFileSync(join(run.runDir, "runner.log"), "utf8").includes("claimed by run"));
assert.deepEqual(s.call("instances").copies.gate.running, [run.run]);
}
assert.notEqual(runs.acme.run, runs.beta.run);
// Neither bus, trail or data root holds the other's run.
for (const [mine, other, s] of [["acme", "beta", a], ["beta", "acme", b]]) {
assert.equal(trail(s.dataRoot).includes(runs[other].run), false, mine);
assert.equal(existsSync(join(s.dataRoot, "launches", other)), false, mine);
assert.equal(readdirSync(s.dataRoot, { recursive: true }).join("\n").includes(runs[other].run), false, mine);
}
assert.equal(b.running("gate").some((r) => r.run === runs.acme.run), false);
});
test("A3: a root file change while the bus runs reaches no relaunch or reload; the next boot takes it", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const rootFile = join(p.root, "config", "agents.json");
writeJson(rootFile, { agentsVersion: 1, agents: { fresh: { definition: "merge-gate", vars: VARS } } }, 0o600);
// A launch, a relaunch and a reload all keep the boot's copy set.
for (let i = 0; i < 2; i++) {
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(done.done.refusal, null, JSON.stringify(done));
await until(() => s.running("gate").length === 1 && readFileSync(join(s.running("gate")[0].runDir, "runner.log"), "utf8").includes("claimed by run"));
const { revision } = s.call("instances");
s.call("veto.set", { revision, op: i ? "unkeep" : "keep", name: "coder" });
assert.deepEqual(Object.keys(s.call("instances").copies).sort(), ["checker.stack", "gate", "researcher"]);
assert.equal(refusedAt(s, "fresh").code, "unknown-role");
await s.carry(s.call("instance.end", { instance: "gate" }).request);
await until(() => s.running("gate").length === 0);
}
await s.close();
// A restart reads the file again.
const next = await start(t, p);
assert.deepEqual(Object.keys(next.boot.copies.copies).sort(), ["checker.stack", "fresh"]);
assert.equal(refusedAt(next, "gate").code, "unknown-role");
assert.equal((await next.carry(next.call("instance.launch", { instance: "fresh" }).request)).done.refusal, null);
});
test("A3: the chief can't launch a copy; only the human or a thread names one", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const chief = await s.host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
await new Client({ path: s.runtime.path, cap: chief.cap }).call("role.claim");
for (const instance of ["gate", "checker.stack"]) {
const out = await s.launcher.fromSession(chief.cap, instance);
assert.deepEqual(out, { ok: false, error: "instance-not-listed" }, instance);
}
const unbound = await s.launcher.fromSession(chief.cap, "researcher");
assert.deepEqual(unbound, { ok: false, error: "credential-unbound" });
assert.equal(s.launcher.registry.sessions.length, 0);
});
test("A3: a second organization boots its own copies from the same root file and shares no record, request or session with the first", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const beta = businessDoc(p.root, "beta");
for (const r of Object.values(beta.roles)) r.vars = { ...VARS, ...r.vars, model: r.vars?.model ?? "claude-sonnet-5-5" };
beta.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
beta.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
beta.plugins = { ...beta.plugins, harness: ["pi", "claude-code"] };
writeJson(join(p.root, "config", "businesses", "beta.json"), beta);
const a = await start(t, p);
mkdirSync(join(p.root, "ob"), { mode: 0o700 });
const b = await start(t, p, { businessId: "beta", orgRoot: join(p.root, "ob") });
assert.notEqual(a.dataRoot, b.dataRoot);
assert.equal(a.boot.copies.org, "acme");
assert.equal(b.boot.copies.org, "beta");
// beta isn't excluded from sec, and has no project agent of acme's.
assert.deepEqual(Object.keys(b.boot.copies.copies).sort(), ["gate", "researcher", "sec"]);
const { request } = a.call("instance.launch", { instance: "gate" });
const done = await a.carry(request);
assert.equal(done.done.refusal, null, JSON.stringify(done));
const [run] = a.running("gate");
// beta's bus holds none of it: no request, no claim, no run, no session.
assert.throws(() => b.call("instance.request", { request }), BusError);
assert.deepEqual(b.call("instances").copies.gate.running, []);
assert.equal(trail(b.dataRoot).includes(run.run), false);
assert.equal(existsSync(join(b.dataRoot, "launches", "acme")), false);
const sessions = (root) => existsSync(join(root, "sessions")) ? readdirSync(join(root, "sessions"), { recursive: true }).join("\n") : "";
assert.equal(sessions(b.dataRoot).includes(run.run), false);
assert.equal(b.running("gate").length, 0);
});
+288
View File
@@ -0,0 +1,288 @@
// Root and project agent copies (#1873 Phase A, slice A3; design
// docs/plans/2026-11-10_agent-levels-design.md sections 4 and 9 item 2).
//
// When an organization's bus boots it reads the root file once, through loadRootAgents, and its
// own organization file through its own writer. Each root agent the organization does not exclude,
// and each agent a project defines, becomes a copy. The snapshot this module returns is plain data
// and is fixed until the next boot: nothing here, or anything that holds the snapshot, reads the
// root file again.
//
// A copy runs as an ordinary role instance of the organization: withCopies adds it to the
// organization document's `roles` and validates the whole document, so every copy goes through the
// same checkRoles an org instance takes, with the organization's credential map. A root or project
// definition carries no credential (agent-levels.mjs refuses one), and A3 has no way to bind one,
// so a copy whose role needs a credential service or a tracker bot is unbound: it is not added, and
// a launch refuses it by name, naming the missing services and never a value.
//
// Nothing in a copy's view carries a var value, a credential reference, a path or file bytes.
import { isDeepStrictEqual } from "node:util";
import { HOST_SERVICE } from "./vocabulary.mjs";
import { loadRole } from "./role.mjs";
import { resolveAgentLevels } from "./agent-levels.mjs";
import { mergeVars } from "./vars.mjs";
import { businessFilePath, loadBusiness, validateBusinessDocument } from "./organization-config.mjs";
import { organizationWriter } from "./organization.mjs";
import { deepFreeze } from "./util.mjs";
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
// How a refusal names one claimant of an instance name.
const claimLabel = ({ name, project }) => (project === null ? `root agent ${name}` : `project ${project}'s ${name}`);
const collision = (key, claimants) => `the instance name ${key} is claimed by ${claimants.map(claimLabel).join(" and ")}`;
// The root assistant is shown, not copied (A6); agent-levels.mjs names it the same way.
const ASSISTANT = "assistant";
export const COPY_STATES = Object.freeze(["instance", "unbound", "refused"]);
// The bus instance name of a copy. A root copy keeps its name. A project agent is
// `<name>.<project>`, so two projects may define the same name and a project may define a name an
// organization or root agent already has, each as its own instance. Null when it isn't a valid id.
export function copyInstanceName(name, project = null) {
const instance = project === null ? name : `${name}.${project}`;
return ID.test(instance) ? instance : null;
}
// The credential services a copy of `definition` would need bound in the organization, and whether
// it needs a tracker bot. The host key comes from a grant record at bus start, never from the file,
// so it is not one (organization-config.mjs, checkRoles).
function needs(definition, rolesDir, cache) {
if (!cache.has(definition)) {
const role = loadRole(rolesDir, definition);
const services = role.credentials.map((c) => c.service).filter((s) => s !== HOST_SERVICE);
cache.set(definition, [...new Set(services)].sort());
}
return cache.get(definition);
}
// The copies of one organization, from its raw document (as its own writer read it) and the root
// agents loadRootAgents returned. `root` is null when the root file refused: the root level is then
// hidden everywhere (design section 6), so no root copy is made, and project agents still are.
// Returns a frozen plain-data snapshot { org, root, rootAgents, copies: { instance: copy } } where
// each copy is { name, level, project, definition, vars, state, missing?, reason? }. `rootAgents`
// holds the root agents this organization takes (not excluded, not the root assistant), so a later
// read of the organization file recomputes each project's union without reading the root file.
export function materializeCopies({ orgId, doc, root, rolesDir }) {
const rootState = root === null ? "refused" : root.state;
const levels = resolveAgentLevels({ orgId, doc, root: root ?? { agents: {} }, rolesDir });
const rootAgents = {};
for (const [name, entry] of Object.entries(root?.agents ?? {})) {
if (entry.definition === ASSISTANT || entry.excludeOrgs.includes(orgId)) continue;
rootAgents[name] = { definition: entry.definition, vars: entry.vars ?? {}, excludeOrgs: [] };
}
const cache = new Map();
const copies = {};
const add = (agent, project, refusal = null) => {
const instance = copyInstanceName(agent.name, project);
// An over-long project agent name has no instance; it refuses under its own name.
const key = instance ?? `${agent.name}.${project}`;
const base = { name: agent.name, level: project === null ? "root" : "project", project, definition: agent.definition, vars: agent.vars };
// Two agents whose instance names come out the same (root checker.stack and project stack's
// checker, or two dotted names) refuse both, naming each; neither overwrites the other. The
// entry lists every claimant, so a reader finds each agent's copy by its name and project.
if (Object.hasOwn(copies, key)) {
const claimants = [...(copies[key].claims ?? [{ name: copies[key].name, project: copies[key].project }]), { name: agent.name, project }];
copies[key] = { ...copies[key], state: "refused", reason: collision(key, claimants), claims: claimants };
delete copies[key].missing;
return;
}
// The organization file's own instance of that name wins: no copy is made (design section 3).
if (instance !== null && Object.hasOwn(doc?.roles ?? {}, instance)) return;
if (refusal) { copies[key] = { ...base, state: "refused", reason: refusal }; return; }
if (instance === null) { copies[key] = { ...base, state: "refused", reason: `project ${project}: ${agent.name}.${project} is longer than an instance name may be` }; return; }
const missing = needs(agent.definition, rolesDir, cache);
copies[key] = missing.length ? { ...base, state: "unbound", missing } : { ...base, state: "instance" };
};
// Root copies: the base union's root-level agents. It already leaves out the root assistant (A6),
// the ones this organization is excluded from and the ones an org instance shadows by name.
for (const agent of levels.base.agents) if (agent.level === "root") add(agent, null);
// Project agents: each project's own define entries. When the project's union refuses, every
// agent it defines refuses too, naming the project and the reason.
for (const [project, entries] of Object.entries(levels.projects)) {
const union = levels.unions[project];
for (const [name, entry] of Object.entries(entries)) {
if (entry.form !== "define") continue;
const agent = { name, definition: entry.definition, vars: entry.vars };
add(agent, project, union.ok ? null : `project ${project} refuses its agents: ${union.reason}`);
}
}
return deepFreeze({ org: orgId, root: rootState, rootAgents, copies });
}
// The view of a snapshot that leaves the boot: names, levels, definitions, states, the missing
// service names and refusal reasons. No vars.
export function copiesView(copies) {
return Object.fromEntries(Object.entries(copies ?? {}).map(([instance, c]) => [instance, {
name: c.name, level: c.level, project: c.project, definition: c.definition, state: c.state,
...(c.missing ? { missing: [...c.missing] } : {}), ...(c.reason ? { reason: c.reason } : {}),
...(c.claims ? { claims: c.claims.map(({ name, project }) => ({ name, project })) } : {}),
}]));
}
// Every string the document or the copy holds as a var or a credential reference, longest first.
function valuesOf(document, copy) {
const out = new Set();
const walk = (x) => {
if (typeof x === "string") { if (x.length >= 3) out.add(x); }
else if (x && typeof x === "object") for (const v of Object.values(x)) walk(v);
};
walk(copy.vars);
walk(document.vars);
for (const role of Object.values(document.roles ?? {})) { walk(role?.vars); walk(role?.credentials); }
for (const project of Object.values(document.projects ?? {})) walk(project?.vars);
return [...out].sort((a, b) => b.length - a.length);
}
// A validation refusal for a copy, said without the host path, a quoted "got" value, or any string
// the document or the copy holds as a var or a credential reference.
function reasonOf(error, file, orgId, document, copy) {
let message = error.message.replaceAll(file, `${orgId}.json`).replace(/; got [^)]*\)/g, ")").replace(/ \(got [\s\S]*$/, "");
for (const value of valuesOf(document, copy)) message = message.replaceAll(value, "<value>");
return message.slice(0, 400);
}
const entryOf = (copy, vars = copy.vars) => ({ definition: copy.definition, ...(Object.keys(vars ?? {}).length ? { vars: structuredClone(vars) } : {}) });
// Whether any code project of the raw document has its own agent entries.
const hasProjectAgents = (document) => Object.values(document?.projects ?? {}).some((p) => p?.kind !== "library" && p?.agents !== undefined);
// `vars` narrowed by each project entry in `narrows`, the way projectUnion narrows one: mergeVars
// applies the intersect rule, and only the keys either side set are kept.
function narrowVars(level, vars, narrows) {
return narrows.reduce((current, entry) => {
const merged = mergeVars([{ source: level, vars: current }, { source: "project", vars: entry }]).vars;
const keys = new Set([...Object.keys(current), ...Object.keys(entry)]);
return Object.fromEntries(Object.entries(merged).filter(([key]) => keys.has(key)));
}, vars ?? {});
}
// The instance name each union agent runs as: a project's own agent is its copy, every other agent
// is the instance of its own name.
const unionInstance = (agent, project) => (agent.level === "project" ? copyInstanceName(agent.name, project) ?? `${agent.name}.${project}` : agent.name);
// Why a project copy can't join this document, or null when it can: its project is still a declared
// code project, the project still defines it with the definition and vars the bus started with, and
// the project's union, recomputed from this document, still accepts it. A copy is made at boot, so
// a changed definition or vars waits for the next start; until then the copy refuses rather than
// launch with settings the project no longer gives it.
function projectCopyRefusal(copy, document, levels) {
const p = document.projects?.[copy.project];
if (!p || p.kind === "library") return `project ${copy.project} is no longer declared`;
const union = levels.unions[copy.project];
if (!union.ok) return `project ${copy.project} refuses its agents: ${union.reason}`;
const entry = levels.projects[copy.project]?.[copy.name];
if (entry?.form !== "define" || entry.definition !== copy.definition) return `project ${copy.project} no longer defines ${copy.name} as ${copy.definition}`;
if (!isDeepStrictEqual(entry.vars ?? {}, copy.vars ?? {})) return `project ${copy.project} changed ${copy.name}'s vars since the bus started; the change takes effect at the next start`;
return null;
}
// The organization document with the snapshot's bound copies added to `roles`, validated whole by
// validateBusinessDocument, the loader every org instance goes through. Each project's union is
// recomputed from this document and the snapshot's root agents (never the root file), so:
// - the business carries `unions`: for each code project with agents, { ok: true, agents: [the
// instance names available there] } or { ok: false, reason };
// - an instance a project narrows is narrowed in `roles` itself, so its tools, network and actions
// are the intersection over every project that narrows it (a narrow in one project narrows the
// instance in every launch: fail closed, since one launch mounts every project it may work in);
// - a project copy whose project stops defining it, or whose union now refuses, is refused.
// A copy the document refuses (a model var the organization doesn't list, say) is left out and
// reported refused; the rest still join. A copy whose name the organization file now declares is
// dropped: the file's own instance wins. Returns a frozen business whose `copies` key is the
// snapshot's view, with each copy's state as it is in this document; a business with no copies and
// no project agents is plain validateBusinessDocument.
export function withCopies(document, file, { rolesDir, snapshot }) {
const copies = snapshot?.copies ?? {};
if (Object.keys(copies).length === 0 && !hasProjectAgents(document)) return validateBusinessDocument(document, file, { rolesDir });
// The document's own refusals come first, whole.
validateBusinessDocument(document, file, { rolesDir });
const orgId = snapshot?.org ?? document.id;
const levels = resolveAgentLevels({ orgId, doc: document, root: { agents: snapshot?.rootAgents ?? {} }, rolesDir });
const unions = {};
const narrows = {};
for (const [project, union] of Object.entries(levels.unions)) {
if (!union.ok) { unions[project] = { ok: false, reason: union.reason }; continue; }
unions[project] = { ok: true, agents: union.agents.map((a) => unionInstance(a, project)).sort() };
for (const agent of union.agents) {
if (agent.level !== "project" && agent.narrowed.length) (narrows[agent.name] ??= []).push(levels.projects[project][agent.name].vars);
}
}
const roles = { ...document.roles };
for (const [name, list] of Object.entries(narrows)) {
if (Object.hasOwn(roles, name)) roles[name] = { ...roles[name], vars: narrowVars("org", roles[name].vars, list) };
}
const narrowed = { ...document, roles };
const view = copiesView(copies);
// Who claims each copy's instance name now: the boot's root copies and the projects' define
// entries as this document has them. A project that comes to define an agent whose name collides
// with a copy refuses that copy, naming every claimant, as the boot does.
const claimants = {};
const claim = (key, who) => { if (!(claimants[key] ??= []).some((c) => c.name === who.name && c.project === who.project)) claimants[key].push(who); };
for (const [instance, copy] of Object.entries(copies)) for (const c of copy.claims ?? [{ name: copy.name, project: copy.project }]) if (c.project === null) claim(instance, c);
for (const [project, entries] of Object.entries(levels.projects)) {
for (const [name, entry] of Object.entries(entries)) if (entry.form === "define") claim(copyInstanceName(name, project) ?? `${name}.${project}`, { name, project });
}
const join = {};
for (const [instance, copy] of Object.entries(copies)) {
// The organization file's own instance of this name wins; the copy is gone, not refused.
if (Object.hasOwn(roles, instance)) { delete view[instance]; continue; }
if ((claimants[instance]?.length ?? 0) > 1) {
const all = claimants[instance].map(({ name, project }) => ({ name, project }));
const { missing: _, ...rest } = view[instance];
view[instance] = { ...rest, state: "refused", reason: collision(instance, all), claims: all };
continue;
}
if (copy.state !== "instance") continue;
const refused = copy.project === null ? null : projectCopyRefusal(copy, document, levels);
if (refused) { view[instance] = { ...view[instance], state: "refused", reason: refused }; continue; }
const entry = entryOf(copy, copy.project === null && narrows[copy.name] ? narrowVars("root", copy.vars, narrows[copy.name]) : copy.vars);
try {
validateBusinessDocument({ ...narrowed, roles: { ...roles, [instance]: entry } }, file, { rolesDir });
join[instance] = entry;
} catch (error) {
if (error?.name !== "BusinessError") throw error;
view[instance] = { ...view[instance], state: "refused", reason: reasonOf(error, file, orgId, document, copy) };
}
}
const business = validateBusinessDocument({ ...narrowed, roles: { ...roles, ...join } }, file, { rolesDir });
return deepFreeze({ ...business, ...(Object.keys(copies).length ? { copies: view } : {}), unions });
}
// The organization with its copies, as a bus or launcher loads it. The plain load keeps
// loadBusiness's file checks; when there are copies or project agents, the document is read again
// through the organization's own writer and validated with them, and only that second read is used.
export function loadWithCopies(id, { dir, rolesDir, snapshot }) {
const plain = loadBusiness(id, { dir, rolesDir });
const { document } = organizationWriter({ dir, rolesDir }).read(id);
if ((!snapshot || Object.keys(snapshot.copies ?? {}).length === 0) && !hasProjectAgents(document)) return plain;
return withCopies(document, businessFilePath(id, dir), { rolesDir, snapshot });
}
// Why `instance` can't work in code project `project` of this business, as { code, message }, or
// null when it can. A project copy works only in its own project; an instance a project excludes,
// or any instance in a project whose union refuses, doesn't work there. The message names the
// instance, the project and the reason, never a value.
export function projectRefusal(business, instance, project) {
const copy = copyOf(business, instance);
if (copy?.project != null && copy.project !== project) {
return { code: "project-agent-elsewhere", message: `${instance} works only in project ${copy.project}` };
}
const union = project == null ? undefined : business?.unions?.[project];
if (!union) return null;
if (!union.ok) return { code: "project-refused", message: `${instance} can't work in project ${project}: project ${project} refuses its agents: ${union.reason}` };
if (!union.agents.includes(instance)) return { code: "project-refused", message: `${instance} can't work in project ${project}: the project excludes it` };
return null;
}
// The copy a launch names, or null for an org instance. `business` is one withCopies returned.
export function copyOf(business, instance) {
return business?.copies && Object.hasOwn(business.copies, instance) ? business.copies[instance] : null;
}
// Why a copy can't launch, as { code, message }, or null when it can. The message names the agent,
// its project and the missing services or the refusal, and never a value.
export function copyRefusal(business, instance) {
const copy = copyOf(business, instance);
if (!copy || copy.state === "instance") return null;
if (copy.state === "unbound") {
return { code: "credential-unbound", message: `${instance} has no binding in ${business.id} for ${copy.missing.join(", ")}` };
}
return { code: copy.project === null ? "copy-refused" : "project-refused", message: `${instance} can't launch in ${business.id}: ${copy.reason}` };
}
@@ -0,0 +1,329 @@
// #1873 Phase A slice A3: root and project agent copies (design
// docs/plans/2026-11-10_agent-levels-design.md sections 4 and 9 item 2).
import test from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { checkRootAgents, loadRootAgents } from "../src/agent-levels.mjs";
import {
copiesView, copyInstanceName, copyRefusal, loadWithCopies, materializeCopies, projectRefusal, withCopies,
} from "../src/agent-copies.mjs";
import { businessFilePath, validateBusinessDocument } from "../src/organization-config.mjs";
import { resolveInstance } from "../src/resolve.mjs";
import { REPO, REPO_ROLES, businessDoc, systemFor, tmp, tokenFile, writeJson } from "./helpers.mjs";
const rolesDir = REPO_ROLES;
const SECRET = "sk-test-NOT-A-SECRET-1234";
const root = (agents) => ({ state: "ok", ...checkRootAgents({ agentsVersion: 1, agents }, { rolesDir }) });
// An organization `id` under `dir`, written where loadBusiness and the writer read it.
function org(dir, id, edit = (d) => d) {
const doc = edit(businessDoc(dir, id));
writeJson(businessFilePath(id, dir), doc);
return doc;
}
const boot = (dir, id, doc, r) => {
const snapshot = materializeCopies({ orgId: id, doc, root: r, rolesDir });
return { snapshot, business: withCopies(doc, businessFilePath(id, dir), { rolesDir, snapshot }) };
};
test("A3: two root agents, one excluded: the other becomes an instance in each organization, each its own", () => {
const dir = tmp();
const r = root({ gate: { definition: "merge-gate" }, sec: { definition: "security-reviewer", excludeOrgs: ["acme"] }, assistant: { definition: "assistant" } });
const acme = boot(dir, "acme", org(dir, "acme"), r);
assert.deepEqual(Object.keys(acme.snapshot.copies), ["gate"]);
assert.equal(acme.business.roles.gate.definition, "merge-gate");
assert.equal(Object.hasOwn(acme.business.roles, "sec"), false, "the excluded root agent is no instance");
// The root assistant stays shown, not launched (A6): never a copy.
assert.equal(Object.hasOwn(acme.business.roles, "assistant"), false);
assert.equal(acme.business.copies.gate.state, "instance");
// A second organization makes its own copies from the same root agents.
const beta = boot(dir, "beta", org(dir, "beta"), r);
assert.deepEqual(Object.keys(beta.snapshot.copies).sort(), ["gate", "sec"]);
assert.equal(beta.snapshot.org, "beta");
assert.notEqual(beta.snapshot.copies.gate, acme.snapshot.copies.gate);
assert.ok(Object.isFrozen(acme.snapshot.copies.gate), "the boot snapshot can't be changed in place");
// Each copy carries only its own organization's entry: the credential map is the organization's
// own, and a copy holds no credential.
assert.deepEqual(acme.business.roles.gate.credentials, {});
assert.deepEqual(beta.business.roles.gate.credentials, {});
});
test("A3: an unbound copy refuses by name and binding, and no value reaches the refusal or the view", () => {
const dir = tmp();
const doc = org(dir, "acme", (d) => {
// The secret is bound elsewhere in this organization: the coder's gitea token file is named it
// and holds it.
const file = tokenFile(join(dir, "secrets"), SECRET);
writeFileSync(file, SECRET);
d.roles.coder.credentials.gitea = { file, rotateBy: "2099-01-01" };
return d;
});
const r = root({ researcher: { definition: "research" }, reviewer2: { definition: "reviewer" } });
const { snapshot, business } = boot(dir, "acme", doc, r);
assert.equal(snapshot.copies.researcher.state, "unbound");
assert.deepEqual(snapshot.copies.researcher.missing, ["gitea"]);
assert.deepEqual(snapshot.copies.reviewer2.missing, ["gitea", "vikunja"]);
assert.equal(Object.hasOwn(business.roles, "researcher"), false, "an unbound copy is no instance");
const refusal = copyRefusal(business, "researcher");
assert.equal(refusal.code, "credential-unbound");
assert.match(refusal.message, /researcher/);
assert.match(refusal.message, /gitea/);
assert.equal(copyRefusal(business, "coder"), null, "an org instance is no copy");
for (const text of [refusal.message, copyRefusal(business, "reviewer2").message, JSON.stringify(business.copies), JSON.stringify(copiesView(snapshot.copies))]) {
assert.equal(text.includes(SECRET), false);
assert.equal(text.includes(dir), false, "no host path");
}
});
test("A3: a copy whose var the organization refuses is refused alone, and its value stays out of the reason", () => {
const dir = tmp();
const doc = org(dir, "acme");
const r = root({ gate: { definition: "merge-gate" }, wide: { definition: "merge-gate", vars: { model: SECRET } } });
const { business } = boot(dir, "acme", doc, r);
assert.equal(business.roles.gate.definition, "merge-gate", "the other copy still joins");
assert.equal(Object.hasOwn(business.roles, "wide"), false);
assert.equal(business.copies.wide.state, "refused");
const refusal = copyRefusal(business, "wide");
assert.equal(refusal.code, "copy-refused");
assert.match(refusal.message, /^wide can't launch in acme: /);
assert.equal(refusal.message.includes(SECRET), false);
assert.equal(JSON.stringify(business.copies).includes(SECRET), false);
});
test("A3: a copy's tools and actions equal an org instance's of the same role; a copy that skips checkRoles fails this", () => {
const dir = tmp();
const doc = org(dir, "acme", (d) => ({ ...d, roles: { ...d.roles, "gate-org": { definition: "merge-gate", vars: { model: "glm-5.3-flash" } } } }));
const r = root({ gate: { definition: "merge-gate", vars: { model: "glm-5.3-flash" } }, wide: { definition: "merge-gate", vars: { model: "not-a-listed-model" } } });
const snapshot = materializeCopies({ orgId: "acme", doc, root: r, rolesDir });
const file = businessFilePath("acme", dir);
const system = systemFor(dir);
const same = (business) => {
assert.deepEqual(business.roles.gate, business.roles["gate-org"]);
const copy = resolveInstance({ system, business, instance: "gate" });
const own = resolveInstance({ system, business, instance: "gate-org" });
assert.deepEqual(copy.limits.tools, own.limits.tools);
assert.deepEqual(copy.limits.authority, own.limits.authority);
assert.deepEqual(copy.credentials, own.credentials);
// A copy the organization's own checks refuse never joins.
assert.equal(Object.hasOwn(business.roles, "wide"), false);
};
same(withCopies(doc, file, { rolesDir, snapshot }));
// The counterfactual: the copies joined as their raw entries, with no checkRoles.
const plain = validateBusinessDocument(doc, file, { rolesDir });
const skipped = { ...plain, roles: { ...plain.roles, gate: { definition: "merge-gate", vars: { model: "glm-5.3-flash" } }, wide: { definition: "merge-gate", vars: { model: "not-a-listed-model" } } } };
assert.throws(() => same(skipped), assert.AssertionError);
});
test("A3: a root file change after boot changes nothing until the next boot", () => {
const dir = tmp();
const doc = org(dir, "acme");
writeJson(join(dir, "agents.json"), { agentsVersion: 1, agents: { gate: { definition: "merge-gate" } } });
const snapshot = materializeCopies({ orgId: "acme", doc, root: loadRootAgents(dir, { rolesDir }), rolesDir });
const first = loadWithCopies("acme", { dir, rolesDir, snapshot });
assert.equal(first.roles.gate.definition, "merge-gate");
// The root file now drops gate, adds one and excludes acme; the bus holds its snapshot.
writeJson(join(dir, "agents.json"), { agentsVersion: 1, agents: { sec: { definition: "security-reviewer" }, gate: { definition: "merge-gate", excludeOrgs: ["acme"] } } });
const later = loadWithCopies("acme", { dir, rolesDir, snapshot });
assert.deepEqual(Object.keys(later.copies), ["gate"]);
assert.equal(later.roles.gate.definition, "merge-gate");
assert.equal(Object.hasOwn(later.roles, "sec"), false);
// A broken root file is no different: nothing after boot reads it.
writeFileSync(join(dir, "agents.json"), "{ not json");
assert.equal(loadWithCopies("acme", { dir, rolesDir, snapshot }).roles.gate.definition, "merge-gate");
// The next boot reads it again.
writeJson(join(dir, "agents.json"), { agentsVersion: 1, agents: { sec: { definition: "security-reviewer" } } });
const next = materializeCopies({ orgId: "acme", doc, root: loadRootAgents(dir, { rolesDir }), rolesDir });
assert.deepEqual(Object.keys(next.copies), ["sec"]);
// Nothing that runs after boot reads the root file: only the cli's boot step names loadRootAgents.
for (const src of ["packages/bus/src/runtime.mjs", "packages/bus/src/broker.mjs", "packages/cli/src/launcher.mjs", "packages/organization/src/agent-copies.mjs"])
assert.equal(/loadRootAgents\s*\(/.test(readFileSync(join(REPO, src), "utf8")), false, src);
});
test("A3: project agents copy into their own project, and a refused union refuses every agent it defines", () => {
const dir = tmp();
mkdirSync(join(dir, "web"), { recursive: true });
const doc = org(dir, "acme", (d) => {
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
// web excludes an agent nothing defines, so its union refuses.
d.projects.web = { root: join(dir, "web"), agents: { checker: { definition: "merge-gate" }, ghost: { exclude: true } } };
return d;
});
const { snapshot, business } = boot(dir, "acme", doc, root({}));
assert.equal(copyInstanceName("checker", "stack"), "checker.stack");
assert.deepEqual({ ...snapshot.copies["checker.stack"], vars: undefined }, { name: "checker", level: "project", project: "stack", definition: "merge-gate", vars: undefined, state: "instance" });
assert.equal(business.roles["checker.stack"].definition, "merge-gate");
assert.equal(Object.hasOwn(business.roles, "checker.web"), false);
const refusal = copyRefusal(business, "checker.web");
assert.equal(refusal.code, "project-refused");
assert.match(refusal.message, /^checker\.web can't launch in acme: project web refuses its agents: /);
assert.match(refusal.message, /ghost/);
// A project that stops being declared after boot takes its copy with it.
const { stack, ...rest } = doc.projects;
const later = withCopies({ ...doc, projects: rest }, businessFilePath("acme", dir), { rolesDir, snapshot });
assert.equal(Object.hasOwn(later.roles, "checker.stack"), false);
assert.equal(copyRefusal(later, "checker.stack").code, "project-refused");
});
test("A3: an organization instance of the same name wins over a root or project copy, at boot and after it, and the copy is gone, not refused", () => {
const dir = tmp();
const doc = org(dir, "acme", (d) => {
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
// A raw org instance already named like the project copy wins at boot.
d.roles["checker.stack"] = { definition: "security-reviewer" };
return d;
});
const { snapshot, business } = boot(dir, "acme", doc, root({ gate: { definition: "merge-gate" } }));
assert.equal(business.copies.gate.state, "instance");
assert.equal(Object.hasOwn(snapshot.copies, "checker.stack"), false);
assert.equal(business.roles["checker.stack"].definition, "security-reviewer");
assert.equal(copyRefusal(business, "checker.stack"), null);
// An org instance added after boot replaces the root copy: the copy leaves the lookup, so nothing
// refuses the organization's own instance.
const later = withCopies({ ...doc, roles: { ...doc.roles, gate: { definition: "security-reviewer" } } }, businessFilePath("acme", dir), { rolesDir, snapshot });
assert.equal(later.roles.gate.definition, "security-reviewer", "the organization file's own instance");
assert.equal(Object.hasOwn(later.copies, "gate"), false);
assert.equal(copyRefusal(later, "gate"), null);
assert.equal(projectRefusal(later, "gate", "stack"), null);
});
test("A3: two agents whose instance names come out the same both refuse, naming each; neither overwrites the other", () => {
const dir = tmp();
mkdirSync(join(dir, "a.b"), { recursive: true });
mkdirSync(join(dir, "b"), { recursive: true });
const doc = org(dir, "acme", (d) => {
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
// Project a.b's c and project b's c.a both come out c.a.b.
d.projects["a.b"] = { root: join(dir, "a.b"), agents: { c: { definition: "merge-gate" } } };
d.projects.b = { root: join(dir, "b"), agents: { "c.a": { definition: "merge-gate" } } };
return d;
});
// Root checker.stack is a different agent from project stack's checker.
const { snapshot, business } = boot(dir, "acme", doc, root({ "checker.stack": { definition: "security-reviewer" } }));
for (const [instance, claims] of [["checker.stack", /root agent checker\.stack and project stack's checker/], ["c.a.b", /project a\.b's c and project b's c\.a/]]) {
assert.equal(snapshot.copies[instance].state, "refused", instance);
assert.match(snapshot.copies[instance].reason, claims);
assert.equal(Object.hasOwn(business.roles, instance), false, instance);
assert.ok(copyRefusal(business, instance), instance);
}
// The view lists every claimant, so a reader finds each agent's copy by its name and project.
assert.deepEqual(business.copies["checker.stack"].claims, [{ name: "checker.stack", project: null }, { name: "checker", project: "stack" }]);
assert.deepEqual(business.copies["c.a.b"].claims, [{ name: "c", project: "a.b" }, { name: "c.a", project: "b" }]);
});
test("A3: a collision the current file brings in after boot refuses every claimant it reaches, and lifts when the file drops it", () => {
const dir = tmp();
mkdirSync(join(dir, "a.b"), { recursive: true });
mkdirSync(join(dir, "b"), { recursive: true });
const file = businessFilePath("acme", dir);
const doc = org(dir, "acme", (d) => {
d.projects["a.b"] = { root: join(dir, "a.b"), agents: { c: { definition: "merge-gate" } } };
d.projects.b = { root: join(dir, "b") };
return d;
});
const { snapshot, business } = boot(dir, "acme", doc, root({ "new.stack": { definition: "security-reviewer" } }));
assert.equal(business.copies["c.a.b"].state, "instance");
assert.equal(business.copies["new.stack"].state, "instance");
// Project b's c.a and project stack's new now come out as the names of copies made at boot.
const edited = structuredClone(doc);
edited.projects.b.agents = { "c.a": { definition: "merge-gate" } };
edited.projects.stack.agents = { new: { definition: "merge-gate" } };
const later = withCopies(edited, file, { rolesDir, snapshot });
for (const [instance, claims, reason] of [
["c.a.b", [{ name: "c", project: "a.b" }, { name: "c.a", project: "b" }], "the instance name c.a.b is claimed by project a.b's c and project b's c.a"],
["new.stack", [{ name: "new.stack", project: null }, { name: "new", project: "stack" }], "the instance name new.stack is claimed by root agent new.stack and project stack's new"],
]) {
assert.equal(later.copies[instance].state, "refused", instance);
assert.deepEqual(later.copies[instance].claims, claims, instance);
assert.equal(later.copies[instance].reason, reason, instance);
assert.equal(Object.hasOwn(later.roles, instance), false, instance);
assert.ok(copyRefusal(later, instance), instance);
}
// Without the new defines, both copies are instances again.
const back = withCopies(doc, file, { rolesDir, snapshot });
assert.equal(back.copies["c.a.b"].state, "instance");
assert.equal(back.copies["new.stack"].state, "instance");
});
test("A3: a project define whose vars change after boot refuses its copy until the next boot", () => {
const dir = tmp();
const file = businessFilePath("acme", dir);
const doc = org(dir, "acme", (d) => { d.projects.stack.agents = { checker: { definition: "merge-gate" } }; return d; });
const { snapshot, business } = boot(dir, "acme", doc, root({}));
assert.equal(business.copies["checker.stack"].state, "instance");
const edited = structuredClone(doc);
edited.projects.stack.agents.checker.vars = { "limits.tools": ["read"] };
const later = withCopies(edited, file, { rolesDir, snapshot });
const r = copyRefusal(later, "checker.stack");
assert.equal(r.code, "project-refused");
assert.match(r.message, /project stack changed checker's vars since the bus started; the change takes effect at the next start$/);
assert.equal(Object.hasOwn(later.roles, "checker.stack"), false);
// The next boot makes the copy from the new vars.
const next = boot(dir, "acme", edited, root({})).business;
assert.equal(next.copies["checker.stack"].state, "instance");
assert.deepEqual(next.roles["checker.stack"].vars["limits.tools"], ["read"]);
});
test("A3: each project's union is recomputed from the current file: a refused union refuses its own copy and every agent in it; exclude and narrow reach the instance", () => {
const dir = tmp();
mkdirSync(join(dir, "web"), { recursive: true });
const file = businessFilePath("acme", dir);
const doc = org(dir, "acme", (d) => {
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
d.projects.web = { root: join(dir, "web") };
d.roles["gate-org"] = { definition: "merge-gate" };
return d;
});
const { snapshot, business } = boot(dir, "acme", doc, root({ gate: { definition: "merge-gate" } }));
assert.deepEqual(business.unions.stack, { ok: true, agents: [...Object.keys(doc.roles), "checker.stack", "gate"].sort() });
assert.equal(projectRefusal(business, "checker.stack", "web").code, "project-agent-elsewhere");
assert.equal(projectRefusal(business, "gate", "web"), null);
const system = systemFor(dir);
const tools = (b, instance) => resolveInstance({ system, business: b, instance }).limits.tools;
assert.ok(tools(business, "gate").includes("grep"));
// After boot: stack excludes something nothing defines, so its union refuses; web excludes gate and
// narrows gate-org's tools.
const edited = structuredClone(doc);
edited.projects.stack.agents.ghost = { exclude: true };
edited.projects.web.agents = { gate: { exclude: true }, "gate-org": { vars: { "limits.tools": ["read"] } } };
const later = withCopies(edited, file, { rolesDir, snapshot });
assert.equal(later.unions.stack.ok, false);
assert.match(later.unions.stack.reason, /ghost/);
// stack's own copy refuses, naming the project and why.
assert.equal(Object.hasOwn(later.roles, "checker.stack"), false);
const own = copyRefusal(later, "checker.stack");
assert.equal(own.code, "project-refused");
assert.match(own.message, /^checker\.stack can't launch in acme: project stack refuses its agents: .*ghost/);
// Every inherited agent refuses there too.
for (const instance of ["gate", "coder", "gate-org"]) {
const r = projectRefusal(later, instance, "stack");
assert.equal(r.code, "project-refused", instance);
assert.match(r.message, new RegExp(`^${instance} can't work in project stack: project stack refuses its agents: .*ghost`));
}
assert.match(projectRefusal(later, "gate", "web").message, /the project excludes it$/);
assert.equal(projectRefusal(later, "coder", "web"), null);
// The narrow reaches the instance itself, so its resolved tools and actions are narrowed.
assert.deepEqual(later.roles["gate-org"].vars["limits.tools"], ["read"]);
assert.deepEqual(tools(later, "gate-org"), ["read"]);
// A project that stops defining its agent takes the copy with it.
const dropped = structuredClone(doc);
dropped.projects.stack.agents = { other: { definition: "merge-gate" } };
assert.match(copyRefusal(withCopies(dropped, file, { rolesDir, snapshot }), "checker.stack").message, /project stack no longer defines checker as merge-gate$/);
});
test("A3: a project narrowing a root copy narrows that copy; an organization with only project entries still gets its unions", () => {
const dir = tmp();
const doc = org(dir, "acme", (d) => {
d.projects.stack.agents = { gate: { vars: { "limits.tools": ["read", "ls"] } } };
return d;
});
const { business } = boot(dir, "acme", doc, root({ gate: { definition: "merge-gate" } }));
assert.deepEqual(resolveInstance({ system: systemFor(dir), business, instance: "gate" }).limits.tools, ["read", "ls"]);
// No root agents and no copy: the project's entries still shape the business.
const only = org(dir, "beta", (d) => { d.projects.stack.agents = { coder: { vars: { "limits.tools": ["read"] } } }; return d; });
const plain = loadWithCopies("beta", { dir, rolesDir, snapshot: null });
assert.deepEqual(plain.unions.stack.agents, Object.keys(only.roles).sort());
assert.deepEqual(plain.roles.coder.vars["limits.tools"], ["read"]);
assert.equal(Object.hasOwn(plain, "copies"), false);
});
+15 -2
View File
@@ -35,7 +35,7 @@
import { spawnSync } from "node:child_process";
import { existsSync, lstatSync, mkdirSync, readlinkSync, realpathSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { ID_PATTERN } from "../../organization/src/vocabulary.mjs";
@@ -111,8 +111,12 @@ function systemBinds(node = process.execPath) {
// sessionStore(), or null) and node. workspaceDest (row 269) is where the
// workspace shows inside the sandbox when that isn't its host path: a legacy
// thread session's, so its harness finds the cwd its history recorded.
// `hidden` (#1873 A3) lists directories inside the workspace the session may not see: a project
// checkout a launch before this one made, in a project that now excludes or refuses the instance.
// Each gets an empty tmpfs over it, so its files stay on the host, untouched, and out of sight.
// Each must be a real directory: a tmpfs mounted at a symlink would land on the link's target.
// `readOnly` adds files, for tests only.
export function binds({ runDir, workspace, workspaceDest = null, adapter, harnessMounts = [], brokerSocket, launchSocket = null, gitSocket = null, threadStore = null, node = process.execPath, readOnly = [] }) {
export function binds({ runDir, workspace, workspaceDest = null, hidden = [], adapter, harnessMounts = [], brokerSocket, launchSocket = null, gitSocket = null, threadStore = null, node = process.execPath, readOnly = [] }) {
const out = systemBinds(node);
for (const f of RUNNER_FILES) out.push({ kind: "ro", src: f, why: "a repository file the runner loads" });
out.push({ kind: "ro", src: adapter, why: "the harness adapter the runner starts each turn" });
@@ -123,6 +127,14 @@ export function binds({ runDir, workspace, workspaceDest = null, adapter, harnes
for (const f of readOnly) out.push({ kind: "ro", src: f, why: "a test fixture" });
out.push(
{ kind: "rw", src: workspace, ...(workspaceDest && workspaceDest !== workspace ? { dest: workspaceDest } : {}), why: "the instance's workspace, where the harness works" },
...hidden.map((h) => {
const inside = relative(workspace, h);
if (!inside || inside.startsWith("..") || isAbsolute(inside)) throw new Error(`hidden path ${h} is not inside the workspace`);
let real = false;
try { real = lstatSync(h).isDirectory(); } catch { real = false; }
if (!real) throw new Error(`hidden path ${h} is not a directory`);
return { kind: "tmpfs", dest: join(workspaceDest ?? workspace, inside), why: "a project checkout this launch may not see, covered by an empty tmpfs" };
}),
{ kind: "rw", src: runDir, why: "the run directory: turns/, session/, tools.sock, turn.done, home/, tmp/" },
{ kind: "ro", src: join(runDir, "bundle"), why: "the run's bundle stays as the host wrote it" },
{ kind: "ro", src: join(runDir, "session.json"), why: "the session file stays as the host wrote it" },
@@ -146,6 +158,7 @@ function argsOf(list) {
else if (b.kind === "symlink") args.push("--symlink", b.src, b.dest);
else if (b.kind === "dev") args.push("--dev", b.dest);
else if (b.kind === "proc") args.push("--proc", b.dest);
else if (b.kind === "tmpfs") args.push("--tmpfs", b.dest);
}
return args;
}
+29 -1
View File
@@ -9,7 +9,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { Broker } from "../../bus/src/broker.mjs";
@@ -91,6 +91,34 @@ test("every bind says why; the bundle and session file stay read-only over the r
assert.ok(!list.some((b) => b.src === realpathSync(join(REPO, "node_modules"))), "no harness mounts unless the module names them");
});
// #1873 A3: a checkout the launch may not see is covered by an empty tmpfs after the workspace bind,
// at the path the session sees; a path outside the workspace is refused, never mounted.
test("A3 a hidden checkout is a tmpfs over the workspace bind; a path outside the workspace, a symlink or a file refuses", (t) => {
const root = scratch(t);
const workspace = join(root, "ws");
mkdirSync(join(workspace, "web"), { recursive: true });
mkdirSync(join(workspace, "stack"));
const args = { runDir: join(root, "run"), workspace, adapter: join(root, "a.sh"), brokerSocket: join(root, "bus.sock") };
const list = binds({ ...args, hidden: [join(workspace, "web")] });
const ws = list.findIndex((b) => b.src === workspace);
const hide = list.findIndex((b) => b.kind === "tmpfs");
assert.ok(ws >= 0 && hide > ws, "the tmpfs comes after the workspace bind");
assert.equal(list[hide].dest, join(workspace, "web"));
assert.ok(list[hide].why);
const moved = binds({ ...args, workspaceDest: "/work", hidden: [join(workspace, "web")] });
assert.equal(moved.find((b) => b.kind === "tmpfs").dest, "/work/web");
assert.equal(binds(args).some((b) => b.kind === "tmpfs"), false);
for (const bad of [workspace, join(root, "other"), join(workspace, "..", "ws2")]) {
assert.throws(() => binds({ ...args, hidden: [bad] }), /is not inside the workspace/, bad);
}
// A tmpfs at a symlink would mount over its target, so only a real directory is covered.
symlinkSync(join(workspace, "stack"), join(workspace, "linked"));
writeFileSync(join(workspace, "file"), "x\n");
for (const bad of [join(workspace, "linked"), join(workspace, "file"), join(workspace, "missing")]) {
assert.throws(() => binds({ ...args, hidden: [bad] }), /is not a directory/, bad);
}
});
// Row 65: the harness module names its own mounts; binds() mounts each read-only, after the
// adapter and before the broker socket, and names no harness itself.
test("row 65 a harness module's mounts bind read-only after the adapter, each with the module's reason", async (t) => {
+2
View File
@@ -154,6 +154,8 @@ const STATUS = {
// Row 239 (#1745): a fire's or a delete's write refused after its revoke ran.
'revision-conflict-revoked': 409, 'outcome-unknown-revoked': 503,
'invalid-request': 400, 'unknown-role': 400, 'message-not-found': 400, 'request-too-large': 400,
// #1873 A3: a root or project copy that can't launch.
'credential-unbound': 409, 'copy-refused': 409, 'project-refused': 409, 'project-agent-elsewhere': 409,
// Row 148: the spec and docs editor. A stale base, a busy or dirty repository and an unchanged
// file are the user's to resolve by reloading; a path or content the project refuses is a 400.
'stale-base': 409, 'project-busy': 409, 'project-dirty': 409,
+4
View File
@@ -23,6 +23,8 @@ const STATUS = {
'project-exists': 409, 'project-archived': 409, 'thread-exists': 409, 'human-required': 403, unauthenticated: 403,
'unknown-business': 403, 'read-only': 403, 'not-configured': 503, 'no-bus-host': 503,
'thread-instance-missing': 503, 'thread-instance-ambiguous': 503, 'outcome-unknown': 503,
// #1873 A3: a project agent named in a chat of another project, and a copy that can't launch.
'project-agent-elsewhere': 409, 'credential-unbound': 409, 'copy-refused': 409, 'project-refused': 409,
'session-not-found': 404, 'operation-not-found': 404, 'session-exists': 409,
'session-op-conflict': 409, 'session-clear-complete': 409, 'session-name-too-long': 409, 'session-clearing': 409, 'session-revision-stale': 409,
'session-retired': 409, 'processing-halted': 409, 'confirm-required': 400,
@@ -37,6 +39,8 @@ function safeMessage(code) {
if (code === 'unknown-verb' || code === 'channel-operation') return 'This session operation is unavailable until its dependency is installed.';
if (code === 'channel-disabled') return 'Run mosaic plugins to read the revision, then mosaic plugins set --file PATCH with plugins.channel set to ["cli","webui"].';
if (code === 'thread-not-owned') return 'This thread is not available to this user.';
if (code === 'project-agent-elsewhere') return 'That project agent works only in its own project. Choose an agent of this project.';
if (code === 'credential-unbound' || code === 'copy-refused' || code === 'project-refused') return 'That agent can\'t launch in this organization. The Agents grid says why.';
if (code === 'outcome-unknown') return 'Outcome unknown. Read the current list or conversation before trying again. Nothing was retried.';
return `Chat operation refused (${code}).`;
}
+41 -12
View File
@@ -60,6 +60,9 @@
const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
const ensureThemes = async () => { if (!themes) themes = await data.themes().then(b => (Array.isArray(b?.themes) ? b.themes : [])).catch(() => []); return themes; };
const instancesOf = value => { const v = value?.rows?.[0]?.instances ?? value?.instances; return v && typeof v === 'object' && !Array.isArray(v) ? v : null; };
// #1873 A3: the root and project agent copies the bus booted with, from the same instances read.
// An answer without the key (a bus with no copies, or one older than A3) gives an empty map.
const copiesOf = value => { const v = value?.rows?.[0]?.copies ?? value?.copies; return v && typeof v === 'object' && !Array.isArray(v) ? v : {}; };
const running = item => (Array.isArray(item?.running) ? item.running.length : 0);
const rowsOf = v => (Array.isArray(v) ? v : Array.isArray(v?.rows) ? v.rows : null);
// An agent's activity is the state the bus derives for the run holding its role. A live process
@@ -271,9 +274,20 @@
// ---------- agents levels (sample 08) ----------
// Root and organization agents come from their files through /api/agents/levels (#1873 A1, read
// only). Organization agents' status comes from each bus. Root and project agents are not launched
// in A1, so they read "not running"; nothing here invents a status, a model or a memory.
// only). Every status comes from the organization's bus: its instances, and since A3 the root and
// project agent copies it booted with. Nothing here invents a status, a model or a memory.
const NOT_RUNNING = ['s-idle', '–', 'not running'];
// A copy's status: running or not as the bus reports its runs, or why it can't launch.
const CANT = ['s-error', '▲', "can't launch"];
const copyWhy = (c) => (c.state === 'unbound' ? `no binding in this organization for ${(c.missing || []).join(', ')}` : c.reason || 'refused');
const copyDot = (name, c, agents) => (c.state === 'instance' ? activity(name, c, agents) : CANT);
// The bus names each copy's instance. Find an agent's copy by its name and project, never by
// rebuilding the instance name: a collision refuses one entry that lists every claimant.
const copyFor = (copies, name, project = null) => {
const mine = c => c && c.name === name && (c.project ?? null) === project;
const hit = Object.entries(copies || {}).find(([, c]) => mine(c) || (Array.isArray(c?.claims) && c.claims.some(mine)));
return hit ? { key: hit[0], copy: hit[1] } : { key: null, copy: null };
};
const LEVEL_CLS = { root: 'root', org: 'org', project: 'proj' };
const projectName = (rows, id) => { const p = Array.isArray(rows) ? rows.find(r => r?.id === id) : null; return p?.name || id; };
let agentsPick = null; // the project chosen in "Available in a project", kept across repaints
@@ -307,7 +321,12 @@
const sub = a => [a.definition && a.definition !== a.name ? a.definition : '', a.overrides?.length ? `replaces the ${a.overrides.map(o => (o.level === 'org' ? 'organization' : o.level)).join(' and ')} ${a.overrides.length > 1 ? 'definitions' : 'definition'}` : '', a.narrowed?.length ? `narrowed here: ${a.narrowed.join(', ')}` : ''].filter(Boolean).join(' · ');
const status = a => {
if (a.assistant) return dot('s-unknown', '?', 'status not reported', true);
if (a.level !== 'org') return dot(...NOT_RUNNING, true);
if (a.level !== 'org') {
if (inst === null) return '<span class="muted">status unavailable</span>';
const { key, copy: c } = copyFor(mine?.copies, a.name, a.level === 'project' ? a.project : null);
if (!c) return '<span class="muted">starts with the next bus start</span>';
return `${dot(...copyDot(key, c, mine.agents), true)}${c.state === 'instance' ? '' : `<span class="t2">${esc(copyWhy(c))}</span>`}`;
}
if (inst === null) return '<span class="muted">status unavailable</span>';
return dot(...(inst[a.name] ? activity(a.name, inst[a.name], mine.agents) : NOT_RUNNING), true);
};
@@ -334,15 +353,15 @@
async function agentGrid(note = '', selected = window.MosaicOrg?.id) {
const [list, levels] = await Promise.all([data.organizations().catch(() => null), data.agentLevels().catch(() => null), ensureThemes()]);
const head = `<div class="ws-vh"><h1 tabindex="-1">Agents</h1><div class="ws-right"><a class="ws-btn" href="#/settings/agents" data-focus-key="agents-edit">${icon('settings')}Edit agents</a></div><p class="sub">Agents are defined at three levels: root, organization and project. Root and project agents are read from their files and are not launched yet.${note ? ` ${note}` : ''}</p></div>`;
const head = `<div class="ws-vh"><h1 tabindex="-1">Agents</h1><div class="ws-right"><a class="ws-btn" href="#/settings/agents" data-focus-key="agents-edit">${icon('settings')}Edit agents</a></div><p class="sub">Agents are defined at three levels: root, organization and project. Each organization runs its own copy of a root agent, and a project's agents run only in that project. The copies are made when the organization's bus starts.${note ? ` ${note}` : ''}</p></div>`;
if (!Array.isArray(list)) return `<section class="ws ws-agents" data-org-agents>${head}<p class="ws-panel tight note">Organizations unavailable.</p></section>`;
const valid = list.filter(org => !org.invalid);
const admitted = valid.some(org => org.id === selected);
const [results, business] = await Promise.all([
Promise.all(valid.map(org => Promise.all([
data.instances(org.id).then(instancesOf).catch(() => null), data.agents(org.id).then(rowsOf).catch(() => null),
data.instances(org.id).then(v => ({ inst: instancesOf(v), copies: copiesOf(v) })).catch(() => ({ inst: null, copies: null })), data.agents(org.id).then(rowsOf).catch(() => null),
projects.get(org.id) !== undefined ? projects.get(org.id) : data.projects(org.id).then(b => (Array.isArray(b?.rows) ? b.rows : null)).catch(() => null),
]).then(([inst, agents, rows]) => ({ org, inst, agents, rows, lv: levels?.orgs?.find(o => o?.id === org.id) ?? null })))),
]).then(([{ inst, copies }, agents, rows]) => ({ org, inst, copies, agents, rows, lv: levels?.orgs?.find(o => o?.id === org.id) ?? null })))),
admitted ? data.business(selected).catch(() => null) : null,
]);
const band = `<div class="ws-span-band"><span class="ws-av all md" aria-hidden="true">${LOGO}</span><b>assistant</b><span class="ws-lvl root">root</span>${dot('s-unknown', '?', 'status not reported', true)}<span>Works across</span><span class="orgs">${valid.map(org => av(org)).join('')}</span><span class="muted ws-band-note">The assistant talks with you across organizations. Its status is not reported on this page; set it up under Settings, Assistant.</span></div>`;
@@ -350,22 +369,32 @@
const cell = (org, inner, cls = '') => `<div class="ws-o${cls}" style="${colours(org)}" data-org="${esc(org.id)}"><span class="mlabel">${av(org, 'xs')}${esc(label(org))}</span>${inner}</div>`;
const endc = i => (i === results.length - 1 ? ' endc' : '');
const pill = (name, item, agents) => { const cos = item?.definition === 'chief-of-staff' || name === 'chief'; const [acls, aglyph, aword] = activity(name, item, agents); return `<span class="ws-agent${cos ? ' cos' : ''}">${dot(acls, aglyph, aword)}${esc(name)}${cos && name !== 'chief' ? ' <small>chief</small>' : ''}</span>`; };
const idle = (name, small) => `<span class="ws-agent" data-level-agent="${esc(name)}">${dot(...NOT_RUNNING)}${esc(name)} <small>${esc(small)}</small></span>`;
// A root or project copy in one organization's cell: its status from that organization's bus, and
// why it can't launch when it can't. No answer from the bus reads as unavailable, never as idle.
const copyPill = (copies, agents, name, project, small) => {
const { key, copy: c } = copyFor(copies, name, project);
const mark = copies === null ? ['s-unknown', '?', 'status unavailable'] : !c ? ['s-unknown', '?', 'starts with the next bus start'] : copyDot(key, c, agents);
const why = c && c.state !== 'instance' ? `<span class="note ws-off" data-copy-why="${esc(key)}">${esc(name)}: ${esc(copyWhy(c))}</span>` : '';
return `<span class="ws-agent" data-level-agent="${esc(name)}" data-copy-state="${esc(c?.state || 'unknown')}">${dot(...mark)}${esc(name)} <small>${esc(small)}</small></span>${why}`;
};
// Root: one copy per organization, each running inside that organization; an opt-out is said.
const root = levels?.root;
const rootRow = !root ? '<div class="span endc"><span class="muted">Root agents unavailable. The levels read did not answer.</span></div>'
: root.state === 'invalid' ? `<div class="span endc"><span class="muted">Root agents unavailable. ${esc(root.reason)}</span></div>`
: !root.agents.some(a => a.definition !== 'assistant') ? '<div class="span endc"><span class="muted">No root agents. They are defined in agents.json in your configuration directory.</span></div>'
: results.map(({ org }, i) => {
: results.map(({ org, inst, copies, agents }, i) => {
const mineRoot = root.agents.filter(a => a.definition !== 'assistant');
const on = mineRoot.filter(a => !a.excludeOrgs.includes(org.id)), off = mineRoot.filter(a => a.excludeOrgs.includes(org.id));
return cell(org, `${on.map(a => idle(a.name, 'copy')).join('') || '<span class="muted">none</span>'}${off.length ? `<span class="note ws-off">${esc(off.map(a => a.name).join(', '))} turned off for ${esc(label(org))}</span>` : ''}`, endc(i));
// An organization agent of the same name replaces the root one there, so no copy is made.
const own = a => Boolean(inst && Object.hasOwn(inst, a.name) && !copyFor(copies, a.name).copy);
const kept = mineRoot.filter(a => !a.excludeOrgs.includes(org.id)), off = mineRoot.filter(a => a.excludeOrgs.includes(org.id));
const on = kept.filter(a => !own(a)), replaced = kept.filter(own);
return cell(org, `${on.map(a => copyPill(copies, agents, a.name, null, 'copy')).join('') || '<span class="muted">none</span>'}${replaced.length ? `<span class="note ws-off">${esc(replaced.map(a => a.name).join(', '))} replaced by ${esc(label(org))}'s own</span>` : ''}${off.length ? `<span class="note ws-off">${esc(off.map(a => a.name).join(', '))} turned off for ${esc(label(org))}</span>` : ''}`, endc(i));
}).join('');
const orgRow = results.map(({ org, inst, agents }, i) => cell(org, inst === null ? '<span class="muted">Agents unavailable</span>' : Object.keys(inst).length ? Object.entries(inst).map(([k, v]) => pill(k, v, agents)).join('') : '<span class="muted">No agents configured</span>', endc(i))).join('');
// Project: the agents a project defines itself. Narrowing and exclusion show in the table.
const projRow = results.map(({ org, lv, rows }, i) => {
const projRow = results.map(({ org, lv, rows, copies, agents }, i) => {
if (lv?.state !== 'ok') return cell(org, `<span class="muted">${lv?.state === 'invalid' ? 'Project agents unavailable: the organization file was refused' : 'Project agents unavailable'}</span>`, ` last${endc(i)}`);
const own = Object.entries(lv.defined).flatMap(([pid, entries]) => entries.filter(e => e.form === 'define').map(e => idle(e.name, projectName(rows, pid))));
const own = Object.entries(lv.defined).flatMap(([pid, entries]) => entries.filter(e => e.form === 'define').map(e => copyPill(copies, agents, e.name, pid, projectName(rows, pid))));
return cell(org, own.join('') || '<span class="muted">none</span>', ` last${endc(i)}`);
}).join('');
const levelsGrid = valid.length ? `<div class="ws-levels" style="grid-template-columns:200px repeat(${n},minmax(0,1fr))">
+6
View File
@@ -446,6 +446,12 @@ const AGENT_REASONS = {
'unknown-role': 'the business has no such instance',
'unknown-request': 'the bus has no such request',
'instance-not-listed': 'the instance isn\'t on the launch list',
// #1873 A3: a root or project copy that can't launch. The Agents grid names the agent and the
// missing service or the reason; these lines carry no value.
'credential-unbound': 'the agent needs a credential service this organization has no binding for; the Agents grid names it',
'copy-refused': 'this organization refused its copy of the root agent; the Agents grid says why',
'project-refused': 'the project agent was refused, or its project refuses its agents; the Agents grid says why',
'project-agent-elsewhere': 'a project agent works only in its own project',
};
const reasonFor = (code, done) => {
if (code === 'skill-not-enabled' && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(done?.skill ?? ''))
@@ -12,7 +12,7 @@ const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++)
// Three organizations as sample 01 and 03 show them: two running buses and one stopped. P2: each running
// organization's System types read comes from the installed modules; `send` adds the owner's writes.
function workspaceOrgs({ failThreads = false, seen = null, send = false, types = {} } = {}) {
function workspaceOrgs({ failThreads = false, seen = null, send = false, types = {}, copies = {}, buses = {} } = {}) {
const ORGS = [
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
@@ -30,7 +30,9 @@ function workspaceOrgs({ failThreads = false, seen = null, send = false, types =
const fakes = {};
const FILES = { 'spring-launch': ['docs/north-star.md', 'docs/launch-plan.md', 'src/index.js'], 'brand-refresh': ['docs/brand-voice-guide.md'], 'api-gateway': [] };
const instances = id => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {},
instances: Object.fromEntries(Object.entries(INSTANCES[id]).map(([n, [definition, running]]) => [n, { definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running, alive: [] }])) });
instances: Object.fromEntries(Object.entries(INSTANCES[id]).map(([n, [definition, running]]) => [n, { definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running, alive: [] }])),
// #1873 A3: the bus's copies key, present only when the test gives the organization copies.
...(copies[id] ? { copies: copies[id] } : {}) });
const business = id => ({ business: { id, human: 'jason', arbiters: ['chief'], projects: PROJECTS[id].map(p => p.id), vars: id === 'ddigital' ? { 'tracker.kind': 'vikunja' } : {},
instances: Object.keys(INSTANCES[id]).map(n => ({ instance: n, vars: { model: 'sonnet-5.5' } })), appearance: { theme: ORGS.find(o => o.id === id).theme } } });
return {
@@ -45,7 +47,8 @@ function workspaceOrgs({ failThreads = false, seen = null, send = false, types =
busHealthy: true, requestRead: st.requestRead, systemTypesWrite: send ? st.systemTypesWrite : null, ...(send ? { send: async () => ({}) } : {}),
reads: { business: async () => business(id), settings: async () => ({ release: '0.0.42', notifier: { binding: 'discord' }, launch: { limits: { total: 4 } } }),
queue: async () => ({ rows: id === 'ddigital' ? [{ state: 'done' }, { state: 'in-progress' }, { state: 'queued' }] : [] }), busStatus: async () => ({}) },
bus: { instances: async () => instances(id), messages: async () => ({ messages: [], unread: 0 }), agents: async () => AGENTS[id], 'thread.claims': async () => CLAIMS[id], inbox: async () => [], tasks: async () => [] },
// `buses` hands an organization's instance read to a real booted bus instead.
bus: { instances: async () => (buses[id] ? buses[id]() : instances(id)), messages: async () => ({ messages: [], unread: 0 }), agents: async () => AGENTS[id], 'thread.claims': async () => CLAIMS[id], inbox: async () => [], tasks: async () => [] },
chat: { projects: async () => PROJECTS[id], threads: async () => { if (failThreads) throw new Error('threads down'); return THREADS[id]; } },
projectFiles: async ({ project }) => ({ head: 'a'.repeat(40), files: (FILES[project] || []).map(path => ({ path })) }),
};
@@ -426,13 +429,14 @@ test('agents grid shows each agent\'s level and where it is defined, per project
['social-scheduler', 'PROJECT', 'Spring launch project', 'This organization only'],
]);
const byName = Object.fromEntries(rows.map(r => [r[0].split(' ')[0], r]));
assert.equal(byName.researcher[4], '– not running'); assert.equal(byName.researcher[5], 'opus-5.5');
// A3: this fixture bus booted with no copies, so a copy's status is the next boot.
assert.equal(byName.researcher[4], 'starts with the next bus start'); assert.equal(byName.researcher[5], 'opus-5.5');
assert.equal(byName.reviewer[5], 'not set'); assert.equal(byName.assistant[4], '? status not reported');
// The model the root file sets for the assistant, shown as configured.
assert.equal(byName.assistant[5], 'opus-5.5');
assert.equal(byName.chief[4], '◐ waiting on you'); assert.equal(byName.chief[5], 'sonnet-5.5 Change');
assert.match(byName.editor[0], /narrowed here: limits\.network/);
assert.equal(byName['social-scheduler'][4], '– not running'); assert.equal(byName['social-scheduler'][5], 'haiku-5.5');
assert.equal(byName['social-scheduler'][4], 'starts with the next bus start'); assert.equal(byName['social-scheduler'][5], 'haiku-5.5');
assert.match(byName['social-scheduler'][0], /coder/);
assert.match(await b.evaluate('document.querySelector("[data-agents-available] .ws-away").innerText'), /Not available here: every Forge or Home agent\./);
// Another project: its exclusion and the first project's own agent are said, not hidden.
@@ -446,3 +450,131 @@ test('agents grid shows each agent\'s level and where it is defined, per project
await waitFor(b)('document.querySelector("[data-org-agents] h2")?.innerText === "Agents in Home"');
assert.match(await b.evaluate('document.querySelector("[data-agents-available]").innerText'), /reviewer \(turned off for Home\)/);
});
// #1873 A3: the same grid, fed by a booted bus. Distinctly Digital's bus boots from a real organization
// file and root file: gate is a bound copy with a real claim, researcher is unbound (its role needs
// gitea), stack's checker is a bound project copy and web's union refuses (it excludes an agent
// nothing defines), so web's lint says why. The test secret sits in the organization's coder token
// and never reaches the page.
test('agents grid takes a running copy, an unbound copy and a refused project from a booted bus', { timeout: 90000 }, async t => {
const { join } = await import('node:path');
const { mkdirSync, writeFileSync } = await import('node:fs');
const { checkRootAgents } = await import('../../organization/src/index.mjs');
const { launchDoc, tokenFile, writeJson } = await import('../../organization/tests/helpers.mjs');
const { fixture, tmp, withoutHostCredentials } = await import('../../cli/tests/helpers.mjs');
const { bootConfig, loadSystem } = await import('../../cli/src/config.mjs');
const { startTimeOf } = await import('../../cli/src/host.mjs');
const { startBroker } = await import('../../bus/src/runtime.mjs');
const { Client } = await import('../../bus/src/client.mjs');
const SECRET = 'sk-test-NOT-A-SECRET-1234';
const VARS = { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high' };
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
const dir = tmp(t, 'ws-copy-');
const f = fixture(dir, 'ddigital', doc => {
doc.roles.chief.vars = { harness: 'pi', model: 'claude-opus-5-5', thinking: 'high' };
doc.roles.coder.vars.model = 'claude-sonnet-5-5';
const secret = tokenFile(join(dir, 'secrets'), SECRET);
writeFileSync(secret, SECRET);
doc.roles.coder.credentials.gitea = { ...doc.roles.coder.credentials.gitea, file: secret };
doc.launch = launchDoc('chief', ['coder'], { opus: 1, sonnet: 4 });
doc.projects.stack.agents = { checker: { definition: 'merge-gate', vars: VARS } };
doc.projects.web = { root: join(dir, 'web'), agents: { lint: { definition: 'merge-gate', vars: VARS }, ghost: { exclude: true } } };
return doc;
});
mkdirSync(join(dir, 'web'), { recursive: true });
withoutHostCredentials(f.env, { piKey: true });
const rootDoc = { agentsVersion: 1, agents: { gate: { definition: 'merge-gate', vars: VARS }, researcher: { definition: 'research', vars: VARS } } };
writeJson(join(dir, 'config', 'agents.json'), rootDoc, 0o600);
const boot = (({ trackers, ...rest }) => rest)(bootConfig({ system: loadSystem({ env: f.env }), businessId: 'ddigital', env: f.env }));
const runtime = await startBroker(boot); t.after(() => runtime.close());
// gate runs: a launch bound to the bus and its claim, as the launcher makes them.
const bound = await runtime.bindLaunch({ business: 'ddigital', role: 'gate', run: 'gate-run', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
await new Client({ path: runtime.path, cap: bound.cap }).call('role.claim');
const human = runtime.broker.bindHuman({ business: 'ddigital', human: f.doc.human, via: 'cli', outsideAgent: true });
const read = () => runtime.broker.request(human, { verb: 'instances' });
assert.deepEqual(Object.fromEntries(Object.entries(read().copies).map(([k, c]) => [k, c.state])), { gate: 'instance', researcher: 'unbound', 'checker.stack': 'instance', 'lint.web': 'refused' });
const orgs = workspaceOrgs({ buses: { ddigital: async () => read() } });
const DOCS = { ddigital: f.doc, forge: { roles: { chief: { definition: 'chief-of-staff' } }, projects: {} }, home: { roles: {}, projects: {} } };
const organizations = { ...orgs, members: async () => Object.entries(DOCS).map(([id, doc]) => ({ id, doc })) };
const root = { state: 'ok', ...checkRootAgents(rootDoc, { rolesDir }) };
const agentLevels = { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } };
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, agentLevels, log: () => {} }); 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/ddigital/agents`);
await waitFor(b)('document.querySelectorAll(".ws-levels [data-org=ddigital] [data-copy-state]").length === 4');
const pills = await b.evaluate('[...document.querySelectorAll(".ws-levels [data-org=ddigital] [data-copy-state]")].map(p => [p.dataset.levelAgent, p.dataset.copyState, p.querySelector(".ws-dot").title])');
assert.deepEqual(pills, [['gate', 'instance', 'running'], ['researcher', 'unbound', "can't launch"], ['checker', 'instance', 'not running'], ['lint', 'refused', "can't launch"]]);
const why = await b.evaluate('Object.fromEntries([...document.querySelectorAll(".ws-levels [data-org=ddigital] [data-copy-why]")].map(n => [n.dataset.copyWhy, n.innerText.trim()]))');
assert.deepEqual(Object.keys(why), ['researcher', 'lint.web']);
assert.equal(why.researcher, 'researcher: no binding in this organization for gitea');
assert.match(why['lint.web'], /^lint: project web refuses its agents: .*ghost/);
const text = await b.evaluate('document.body.innerText');
for (const value of [SECRET, dir]) assert.equal(text.includes(value), false, value);
});
// #1873 A3: the grid's copy status comes from each bus's copies. Distinctly Digital runs its researcher
// copy, its reviewer copy is unbound (the reason names the services, never a value), and Forge's
// api-gateway project refuses, so its load-tester says why, naming the project.
test('agents grid shows a running copy, an unbound copy and a refused project with their reasons', async t => {
const { checkRootAgents } = await import('../../organization/src/index.mjs');
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
const root = { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: {
assistant: { definition: 'assistant' },
researcher: { definition: 'research', vars: { model: 'opus-5.5' } },
reviewer: { definition: 'reviewer', excludeOrgs: ['home'] },
} }, { rolesDir }) };
const DOCS = {
ddigital: { roles: { chief: { definition: 'chief-of-staff' } }, projects: { 'spring-launch': { root: '/p/spring', agents: { 'social-scheduler': { definition: 'research' } } } } },
forge: { roles: { chief: { definition: 'chief-of-staff' } }, projects: { 'api-gateway': { root: '/p/api', agents: { 'load-tester': { definition: 'research' } } } } },
home: { roles: {}, projects: {} },
};
const copy = (name, project, state, extra = {}) => ({ name, level: project ? 'project' : 'root', project, definition: 'research', state, running: [], ...extra });
const copies = {
ddigital: { researcher: copy('researcher', null, 'instance', { running: ['r9'] }), reviewer: copy('reviewer', null, 'unbound', { definition: 'reviewer', missing: ['gitea', 'vikunja'] }),
'social-scheduler.spring-launch': copy('social-scheduler', 'spring-launch', 'instance') },
forge: { researcher: copy('researcher', null, 'instance'), reviewer: copy('reviewer', null, 'unbound', { definition: 'reviewer', missing: ['gitea', 'vikunja'] }),
'load-tester.api-gateway': copy('load-tester', 'api-gateway', 'refused', { reason: 'project api-gateway refuses its agents: instance load-tester: model is not one the organization lists' }) },
};
const orgs = workspaceOrgs({ copies });
const organizations = { ...orgs, members: async () => Object.entries(DOCS).map(([id, doc]) => ({ id, doc })) };
const agentLevels = { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } };
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, agentLevels, log: () => {} }); 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/ddigital/agents`);
await waitFor(b)('document.querySelectorAll(".ws-levels [data-copy-state]").length === 7');
const pills = await b.evaluate('[...document.querySelectorAll(".ws-levels [data-org]")].flatMap(c => [...c.querySelectorAll("[data-copy-state]")].map(p => [c.dataset.org, p.dataset.levelAgent, p.dataset.copyState, p.querySelector(".ws-dot").title]))');
assert.deepEqual(pills, [
['ddigital', 'researcher', 'instance', 'running'], ['ddigital', 'reviewer', 'unbound', "can't launch"],
['forge', 'researcher', 'instance', 'not running'], ['forge', 'reviewer', 'unbound', "can't launch"],
// Home's bus is down: no answer reads as unavailable, never as idle.
['home', 'researcher', 'unknown', 'status unavailable'],
['ddigital', 'social-scheduler', 'instance', 'not running'], ['forge', 'load-tester', 'refused', "can't launch"],
]);
const why = await b.evaluate('Object.fromEntries([...document.querySelectorAll(".ws-levels [data-org]")].flatMap(c => [...c.querySelectorAll("[data-copy-why]")].map(n => [`${c.dataset.org}:${n.dataset.copyWhy}`, n.innerText.trim()])))');
assert.deepEqual(why, {
'ddigital:reviewer': 'reviewer: no binding in this organization for gitea, vikunja',
'forge:reviewer': 'reviewer: no binding in this organization for gitea, vikunja',
'forge:load-tester.api-gateway': 'load-tester: project api-gateway refuses its agents: instance load-tester: model is not one the organization lists',
});
// The table for Distinctly Digital's first project: the copies' status from its bus, the reason under an unbound one.
const rows = await b.evaluate('Object.fromEntries([...document.querySelectorAll("[data-agents-available] tbody tr")].map(tr => [tr.cells[0].innerText.trim().split(/\\s/)[0], tr.cells[4].innerText.replace(/\\s+/g, " ").trim()]))');
assert.equal(rows.researcher, '● running');
assert.equal(rows.reviewer, "▲ can't launch no binding in this organization for gitea, vikunja");
assert.equal(rows['social-scheduler'], '– not running');
assert.doesNotMatch(await b.evaluate('document.body.innerText'), /not launched yet/);
const shots = process.env.MOSAIC_UX_SHOTS;
if (shots) {
const { mkdir } = await import('node:fs/promises');
const { join } = await import('node:path');
await mkdir(shots, { recursive: true });
for (const [width, height] of [[1440, 900], [390, 844]]) for (const mode of ['light', 'dark']) {
await b.viewport(width, height);
// The header's switch sets this browser's mode for the organization, so the theme's colours follow.
await b.evaluate(`(() => { const s = document.querySelector("#theme-switch"); if ((s.getAttribute("aria-checked") === "true") !== ${mode === 'dark'}) s.click(); scrollTo(0, 0); document.querySelector("main, .ws-main")?.scrollTo?.(0, 0); })()`);
await new Promise(r => setTimeout(r, 200));
await b.screenshot(join(shots, `a3-agents-${width}-${mode}.png`));
}
}
});