From 9d00f9ad54670e63edd62d62c7c49272565fc3fd Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 10 Nov 2026 16:29:43 -0600 Subject: [PATCH 1/4] 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. --- packages/bus/src/broker.mjs | 27 +- packages/bus/src/runtime.mjs | 15 +- packages/bus/tests/agent-copies.test.mjs | 89 ++++++ packages/bus/tests/channel-proof.test.mjs | 4 +- packages/business/src/agent-copies.mjs | 2 + packages/cli/src/cli.mjs | 2 +- packages/cli/src/config.mjs | 27 +- packages/cli/src/launcher.mjs | 32 +- packages/cli/tests/agent-copies.test.mjs | 285 ++++++++++++++++++ packages/organization/src/agent-copies.mjs | 188 ++++++++++++ .../organization/tests/agent-copies.test.mjs | 178 +++++++++++ packages/webui/src/bus.mjs | 2 + packages/webui/src/chat-http.mjs | 4 + packages/webui/src/public/workspace.js | 47 ++- packages/webui/src/serve.mjs | 6 + .../tests/unified-workspace-browser.test.mjs | 77 ++++- 16 files changed, 955 insertions(+), 30 deletions(-) create mode 100644 packages/bus/tests/agent-copies.test.mjs create mode 100644 packages/business/src/agent-copies.mjs create mode 100644 packages/cli/tests/agent-copies.test.mjs create mode 100644 packages/organization/src/agent-copies.mjs create mode 100644 packages/organization/tests/agent-copies.test.mjs diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 3423236b1..2a7d4190c 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -1534,11 +1534,21 @@ 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}`); + } // 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( @@ -6110,6 +6120,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(() => { @@ -8121,7 +8137,10 @@ 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 ?? {}; + const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r) && (a.instance !== undefined || !Object.hasOwn(copies, r))); const instance = a.instance === undefined ? (instances.length === 1 ? instances[0] : null) : identifier(a.instance); @@ -8135,9 +8154,15 @@ 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. + if (a.instance !== undefined && instance) this.#copyRefused(b, instance); // 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: the chat project must name it. + if (Object.hasOwn(copies, instance) && copies[instance].project !== null && + this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', identifier(a.project), s.business)?.repo !== copies[instance].project) + fail('project-agent-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( diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 38fb80ca4..10bc5b44d 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -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); @@ -753,7 +760,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)); diff --git a/packages/bus/tests/agent-copies.test.mjs b/packages/bus/tests/agent-copies.test.mjs new file mode 100644 index 000000000..803e87f2a --- /dev/null +++ b/packages/bus/tests/agent-copies.test.mjs @@ -0,0 +1,89 @@ +// #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' } }, + 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 }, + }, + copies: { + gate: copy('gate', 'merge-gate', 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 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' }), + }, +}); + +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']); +}); diff --git a/packages/bus/tests/channel-proof.test.mjs b/packages/bus/tests/channel-proof.test.mjs index ee8e11e25..eb59400e4 100644 --- a/packages/bus/tests/channel-proof.test.mjs +++ b/packages/bus/tests/channel-proof.test.mjs @@ -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); diff --git a/packages/business/src/agent-copies.mjs b/packages/business/src/agent-copies.mjs new file mode 100644 index 000000000..35ec80915 --- /dev/null +++ b/packages/business/src/agent-copies.mjs @@ -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"; diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index ad074c1b7..b177cebb9 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -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'); diff --git a/packages/cli/src/config.mjs b/packages/cli/src/config.mjs index 816fa6447..bfabae6fe 100644 --- a/packages/cli/src/config.mjs +++ b/packages/cli/src/config.mjs @@ -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) { diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs index 178f786f8..134280257 100644 --- a/packages/cli/src/launcher.mjs +++ b/packages/cli/src/launcher.mjs @@ -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, 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`); @@ -616,13 +628,18 @@ 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: a project agent gets its own project's mapping and no other, and refuses when that + // project isn't admitted. + const own = copyOf(b, instance)?.project ?? null; for (const [project, entry] of Object.entries(b.projects)) { if (entry.kind === 'library') continue; + if (own !== null && project !== own) 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 { @@ -889,6 +906,9 @@ 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 project agent's manifest never gains another project. + const own = copyOf(b, i.target)?.project ?? null; + if (own !== null && i.project !== own) continue; let projects = []; try { projects = JSON.parse(readFileSync(join(session.runDir, 'bundle/manifest.json'), 'utf8')).projects ?? []; } catch {} if (!projects.includes(i.project)) { @@ -1348,7 +1368,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. diff --git a/packages/cli/tests/agent-copies.test.mjs b/packages/cli/tests/agent-copies.test.mjs new file mode 100644 index 000000000..6eb47b37d --- /dev/null +++ b/packages/cli/tests/agent-copies.test.mjs @@ -0,0 +1,285 @@ +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, mkdirSync, readFileSync, readdirSync, 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 { 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", "user.email=test@example.invalid", "-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); +}); + +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); +}); diff --git a/packages/organization/src/agent-copies.mjs b/packages/organization/src/agent-copies.mjs new file mode 100644 index 000000000..13cb5d177 --- /dev/null +++ b/packages/organization/src/agent-copies.mjs @@ -0,0 +1,188 @@ +// 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 { HOST_SERVICE } from "./vocabulary.mjs"; +import { loadRole } from "./role.mjs"; +import { resolveAgentLevels } from "./agent-levels.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}$/; +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 +// `.`, 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, copies: { instance: copy } } where each copy is +// { name, level, project, definition, vars, state, missing?, reason? }. +export function materializeCopies({ orgId, doc, root, rolesDir }) { + const rootState = root === null ? "refused" : root.state; + const levels = resolveAgentLevels({ orgId, doc, root: root ?? { agents: {} }, rolesDir }); + 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 }; + 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; } + if (Object.hasOwn(doc?.roles ?? {}, instance)) { + copies[key] = { ...base, state: "refused", reason: `the organization file already declares an instance named ${instance}` }; + 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, 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 } : {}), + }])); +} + +// 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, ""); + return message.slice(0, 400); +} + +const entryOf = (copy) => ({ definition: copy.definition, ...(Object.keys(copy.vars ?? {}).length ? { vars: structuredClone(copy.vars) } : {}) }); + +// Whether a copy can join this document: still the organization's, still not shadowed by an +// instance the file declares, and, for a project agent, its project still declared. +function joinable(copy, instance, document) { + if (copy.state !== "instance") return false; + if (Object.hasOwn(document.roles ?? {}, instance)) return false; + if (copy.project !== null) { + const p = document.projects?.[copy.project]; + if (!p || p.kind === "library") return false; + } + return true; +} + +// The organization document with the snapshot's bound copies added to `roles`, validated whole by +// validateBusinessDocument, the loader every org instance goes through. A copy the document refuses +// (a model var the organization doesn't list, say) is left out and reported refused; the rest still +// join. 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 gets no such key. +export function withCopies(document, file, { rolesDir, snapshot }) { + const copies = snapshot?.copies ?? {}; + if (Object.keys(copies).length === 0) return validateBusinessDocument(document, file, { rolesDir }); + const view = copiesView(copies); + const join = {}; + for (const [instance, copy] of Object.entries(copies)) { + if (!joinable(copy, instance, document)) { + if (copy.state === "instance") { + view[instance] = { ...view[instance], state: "refused", + reason: Object.hasOwn(document.roles ?? {}, instance) ? `the organization file now declares an instance named ${instance}` : `project ${copy.project} is no longer declared` }; + } + continue; + } + try { + validateBusinessDocument({ ...document, roles: { ...document.roles, [instance]: entryOf(copy) } }, file, { rolesDir }); + join[instance] = entryOf(copy); + } catch (error) { + if (error?.name !== "BusinessError") throw error; + view[instance] = { ...view[instance], state: "refused", reason: reasonOf(error, file, snapshot.org, document, copy) }; + } + } + const business = validateBusinessDocument({ ...document, roles: { ...document.roles, ...join } }, file, { rolesDir }); + return deepFreeze({ ...business, copies: view }); +} + +// The organization with its copies, as a bus or launcher loads it. The plain load keeps +// loadBusiness's file checks; when there are copies, 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 }) { + if (!snapshot || Object.keys(snapshot.copies ?? {}).length === 0) return loadBusiness(id, { dir, rolesDir }); + loadBusiness(id, { dir, rolesDir }); + const { document } = organizationWriter({ dir, rolesDir }).read(id); + return withCopies(document, businessFilePath(id, dir), { rolesDir, snapshot }); +} + +// 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}` }; +} diff --git a/packages/organization/tests/agent-copies.test.mjs b/packages/organization/tests/agent-copies.test.mjs new file mode 100644 index 000000000..927ffa123 --- /dev/null +++ b/packages/organization/tests/agent-copies.test.mjs @@ -0,0 +1,178 @@ +// #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, 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 copy, and a later one turns the copy off", () => { + const dir = tmp(); + const doc = org(dir, "acme"); + const { snapshot, business } = boot(dir, "acme", doc, root({ gate: { definition: "merge-gate" } })); + assert.equal(business.copies.gate.state, "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(later.copies.gate.state, "refused"); + assert.equal(copyRefusal(later, "gate").code, "copy-refused"); +}); diff --git a/packages/webui/src/bus.mjs b/packages/webui/src/bus.mjs index cb87d3bac..cbf0ea0bc 100644 --- a/packages/webui/src/bus.mjs +++ b/packages/webui/src/bus.mjs @@ -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, diff --git a/packages/webui/src/chat-http.mjs b/packages/webui/src/chat-http.mjs index 03c43ef63..dcd520638 100644 --- a/packages/webui/src/chat-http.mjs +++ b/packages/webui/src/chat-http.mjs @@ -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}).`; } diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 7446324bf..d6610b66c 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -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,14 @@ // ---------- 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); + const copyKey = a => (a.level === 'project' ? `${a.name}.${a.project}` : a.name); 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 +315,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 'status unavailable'; + const c = mine?.copies?.[copyKey(a)]; + if (!c) return 'starts with the next bus start'; + return `${dot(...copyDot(copyKey(a), c, mine.agents), true)}${c.state === 'instance' ? '' : `${esc(copyWhy(c))}`}`; + } if (inst === null) return 'status unavailable'; return dot(...(inst[a.name] ? activity(a.name, inst[a.name], mine.agents) : NOT_RUNNING), true); }; @@ -334,15 +347,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 = `

Agents

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}` : ''}

`; + const head = `

Agents

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}` : ''}

`; if (!Array.isArray(list)) return `
${head}

Organizations unavailable.

`; 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 = `
assistantroot${dot('s-unknown', '?', 'status not reported', true)}Works across${valid.map(org => av(org)).join('')}The assistant talks with you across organizations. Its status is not reported on this page; set it up under Settings, Assistant.
`; @@ -350,22 +363,32 @@ const cell = (org, inner, cls = '') => `
${av(org, 'xs')}${esc(label(org))}${inner}
`; 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 `${dot(acls, aglyph, aword)}${esc(name)}${cos && name !== 'chief' ? ' chief' : ''}`; }; - const idle = (name, small) => `${dot(...NOT_RUNNING)}${esc(name)} ${esc(small)}`; + // 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, key, name, small) => { + const c = copies?.[key]; + 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' ? `${esc(name)}: ${esc(copyWhy(c))}` : ''; + return `${dot(...mark)}${esc(name)} ${esc(small)}${why}`; + }; // Root: one copy per organization, each running inside that organization; an opt-out is said. const root = levels?.root; const rootRow = !root ? '
Root agents unavailable. The levels read did not answer.
' : root.state === 'invalid' ? `
Root agents unavailable. ${esc(root.reason)}
` : !root.agents.some(a => a.definition !== 'assistant') ? '
No root agents. They are defined in agents.json in your configuration directory.
' - : 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('') || 'none'}${off.length ? `${esc(off.map(a => a.name).join(', '))} turned off for ${esc(label(org))}` : ''}`, 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) && !Object.hasOwn(copies || {}, a.name)); + 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, a.name, 'copy')).join('') || 'none'}${replaced.length ? `${esc(replaced.map(a => a.name).join(', '))} replaced by ${esc(label(org))}'s own` : ''}${off.length ? `${esc(off.map(a => a.name).join(', '))} turned off for ${esc(label(org))}` : ''}`, endc(i)); }).join(''); const orgRow = results.map(({ org, inst, agents }, i) => cell(org, inst === null ? 'Agents unavailable' : Object.keys(inst).length ? Object.entries(inst).map(([k, v]) => pill(k, v, agents)).join('') : 'No agents configured', 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, `${lv?.state === 'invalid' ? 'Project agents unavailable: the organization file was refused' : 'Project agents unavailable'}`, ` 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}`, e.name, projectName(rows, pid)))); return cell(org, own.join('') || 'none', ` last${endc(i)}`); }).join(''); const levelsGrid = valid.length ? `
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 0340a3c99..1e111ca83 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -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 ?? '')) diff --git a/packages/webui/tests/unified-workspace-browser.test.mjs b/packages/webui/tests/unified-workspace-browser.test.mjs index 87c6d2d0d..260515d59 100644 --- a/packages/webui/tests/unified-workspace-browser.test.mjs +++ b/packages/webui/tests/unified-workspace-browser.test.mjs @@ -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 = {} } = {}) { 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 { @@ -426,13 +428,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 +449,69 @@ 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 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`)); + } + } +}); -- 2.54.0 From 17a48217f6584eae527d8141c0ddac9a22f8af4a Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 10 Nov 2026 17:16:25 -0600 Subject: [PATCH 2/4] 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. --- packages/bus/src/broker.mjs | 59 ++++++- packages/bus/tests/agent-copies.test.mjs | 35 +++- packages/cli/src/launcher.mjs | 16 +- packages/cli/tests/agent-copies.test.mjs | 105 ++++++++++++ packages/organization/src/agent-copies.mjs | 160 +++++++++++++----- .../organization/tests/agent-copies.test.mjs | 108 +++++++++++- packages/webui/src/public/workspace.js | 22 ++- .../tests/unified-workspace-browser.test.mjs | 67 +++++++- 8 files changed, 503 insertions(+), 69 deletions(-) diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 2a7d4190c..7880827e0 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -1549,6 +1549,25 @@ export class Broker { 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( @@ -1862,7 +1881,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 @@ -8049,7 +8076,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. @@ -8140,7 +8177,10 @@ export class Broker { // #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 ?? {}; - const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r) && (a.instance !== undefined || !Object.hasOwn(copies, r))); + // #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); @@ -8154,15 +8194,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. + // #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: the chat project must name it. - if (Object.hasOwn(copies, instance) && copies[instance].project !== null && - this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', identifier(a.project), s.business)?.repo !== copies[instance].project) - fail('project-agent-elsewhere'); + // #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( diff --git a/packages/bus/tests/agent-copies.test.mjs b/packages/bus/tests/agent-copies.test.mjs index 803e87f2a..0557b3d8c 100644 --- a/packages/bus/tests/agent-copies.test.mjs +++ b/packages/bus/tests/agent-copies.test.mjs @@ -18,21 +18,29 @@ const copy = (name, definition, project, state, extra = {}) => ({ name, level: p 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' } }, + 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 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) { @@ -87,3 +95,28 @@ test('A3 bus: a project agent takes a thread only in its own project, and only w 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'); +}); diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs index 134280257..cbcd22b65 100644 --- a/packages/cli/src/launcher.mjs +++ b/packages/cli/src/launcher.mjs @@ -146,7 +146,7 @@ import { dirname, join, relative } from "node:path"; import { configDir, resolveInstance, systemVars } from "../../organization/src/index.mjs"; import { organizationWriter } from "../../organization/src/organization.mjs"; import { businessFilePath } from "../../organization/src/organization-config.mjs"; -import { copyOf, copyRefusal, loadWithCopies, withCopies } from "../../organization/src/agent-copies.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"; @@ -566,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. @@ -628,12 +630,15 @@ 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: a project agent gets its own project's mapping and no other, and refuses when that - // project isn't admitted. + // #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. const own = copyOf(b, instance)?.project ?? null; for (const [project, entry] of Object.entries(b.projects)) { if (entry.kind === 'library') continue; 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; } @@ -906,9 +911,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 project agent's manifest never gains another project. - const own = copyOf(b, i.target)?.project ?? null; - if (own !== null && i.project !== own) 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)) { diff --git a/packages/cli/tests/agent-copies.test.mjs b/packages/cli/tests/agent-copies.test.mjs index 6eb47b37d..3b920f4b4 100644 --- a/packages/cli/tests/agent-copies.test.mjs +++ b/packages/cli/tests/agent-copies.test.mjs @@ -17,6 +17,7 @@ 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"; @@ -238,6 +239,110 @@ test("A3: a project agent launches with only its own project's mapping; a projec } // 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" }]); +}); + +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) => { diff --git a/packages/organization/src/agent-copies.mjs b/packages/organization/src/agent-copies.mjs index 13cb5d177..9bc48bdd2 100644 --- a/packages/organization/src/agent-copies.mjs +++ b/packages/organization/src/agent-copies.mjs @@ -19,11 +19,14 @@ 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}$/; +// 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 @@ -49,24 +52,42 @@ function needs(definition, rolesDir, cache) { // 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, copies: { instance: copy } } where each copy is -// { name, level, project, definition, vars, state, missing?, reason? }. +// 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 claims = {}; 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 }; - 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; } - if (Object.hasOwn(doc?.roles ?? {}, instance)) { - copies[key] = { ...base, state: "refused", reason: `the organization file already declares an instance named ${instance}` }; + const claim = project === null ? `root agent ${agent.name}` : `project ${project}'s ${agent.name}`; + // 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)) { + claims[key].push(claim); + const claimants = [...(copies[key].claims ?? [{ name: copies[key].name, project: copies[key].project }]), { name: agent.name, project }]; + copies[key] = { ...copies[key], state: "refused", reason: `the instance name ${key} is claimed by ${claims[key].join(" and ")}`, claims: claimants }; + delete copies[key].missing; return; } + claims[key] = [claim]; + // 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)) { delete claims[key]; 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" }; }; @@ -83,7 +104,7 @@ export function materializeCopies({ orgId, doc, root, rolesDir }) { add(agent, project, union.ok ? null : `project ${project} refuses its agents: ${union.reason}`); } } - return deepFreeze({ org: orgId, root: rootState, copies }); + return deepFreeze({ org: orgId, root: rootState, rootAgents, copies }); } // The view of a snapshot that leaves the boot: names, levels, definitions, states, the missing @@ -92,6 +113,7 @@ 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 })) } : {}), }])); } @@ -117,60 +139,120 @@ function reasonOf(error, file, orgId, document, copy) { return message.slice(0, 400); } -const entryOf = (copy) => ({ definition: copy.definition, ...(Object.keys(copy.vars ?? {}).length ? { vars: structuredClone(copy.vars) } : {}) }); +const entryOf = (copy, vars = copy.vars) => ({ definition: copy.definition, ...(Object.keys(vars ?? {}).length ? { vars: structuredClone(vars) } : {}) }); -// Whether a copy can join this document: still the organization's, still not shadowed by an -// instance the file declares, and, for a project agent, its project still declared. -function joinable(copy, instance, document) { - if (copy.state !== "instance") return false; - if (Object.hasOwn(document.roles ?? {}, instance)) return false; - if (copy.project !== null) { - const p = document.projects?.[copy.project]; - if (!p || p.kind === "library") return false; - } - return true; +// 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, and the project's union, recomputed from this +// document, still accepts 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}`; + 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. A copy the document refuses -// (a model var the organization doesn't list, say) is left out and reported refused; the rest still -// join. 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 gets no such key. +// 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) return validateBusinessDocument(document, file, { rolesDir }); + 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); const join = {}; for (const [instance, copy] of Object.entries(copies)) { - if (!joinable(copy, instance, document)) { - if (copy.state === "instance") { - view[instance] = { ...view[instance], state: "refused", - reason: Object.hasOwn(document.roles ?? {}, instance) ? `the organization file now declares an instance named ${instance}` : `project ${copy.project} is no longer declared` }; - } - continue; - } + // 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 (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({ ...document, roles: { ...document.roles, [instance]: entryOf(copy) } }, file, { rolesDir }); - join[instance] = entryOf(copy); + 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, snapshot.org, document, copy) }; + view[instance] = { ...view[instance], state: "refused", reason: reasonOf(error, file, orgId, document, copy) }; } } - const business = validateBusinessDocument({ ...document, roles: { ...document.roles, ...join } }, file, { rolesDir }); - return deepFreeze({ ...business, copies: view }); + 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, the document is read again through the -// organization's own writer and validated with them, and only that second read is used. +// 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 }) { - if (!snapshot || Object.keys(snapshot.copies ?? {}).length === 0) return loadBusiness(id, { dir, rolesDir }); - loadBusiness(id, { dir, rolesDir }); + 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; diff --git a/packages/organization/tests/agent-copies.test.mjs b/packages/organization/tests/agent-copies.test.mjs index 927ffa123..d476ef914 100644 --- a/packages/organization/tests/agent-copies.test.mjs +++ b/packages/organization/tests/agent-copies.test.mjs @@ -7,7 +7,7 @@ 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, withCopies, + 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"; @@ -166,13 +166,111 @@ test("A3: project agents copy into their own project, and a refused union refuse assert.equal(copyRefusal(later, "checker.stack").code, "project-refused"); }); -test("A3: an organization instance of the same name wins over a root copy, and a later one turns the copy off", () => { +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"); + 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(later.copies.gate.state, "refused"); - assert.equal(copyRefusal(later, "gate").code, "copy-refused"); + 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: 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); }); diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index d6610b66c..60f3f2e88 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -281,7 +281,13 @@ 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); - const copyKey = a => (a.level === 'project' ? `${a.name}.${a.project}` : a.name); + // 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 @@ -317,9 +323,9 @@ if (a.assistant) return dot('s-unknown', '?', 'status not reported', true); if (a.level !== 'org') { if (inst === null) return 'status unavailable'; - const c = mine?.copies?.[copyKey(a)]; + const { key, copy: c } = copyFor(mine?.copies, a.name, a.level === 'project' ? a.project : null); if (!c) return 'starts with the next bus start'; - return `${dot(...copyDot(copyKey(a), c, mine.agents), true)}${c.state === 'instance' ? '' : `${esc(copyWhy(c))}`}`; + return `${dot(...copyDot(key, c, mine.agents), true)}${c.state === 'instance' ? '' : `${esc(copyWhy(c))}`}`; } if (inst === null) return 'status unavailable'; return dot(...(inst[a.name] ? activity(a.name, inst[a.name], mine.agents) : NOT_RUNNING), true); @@ -365,8 +371,8 @@ const pill = (name, item, agents) => { const cos = item?.definition === 'chief-of-staff' || name === 'chief'; const [acls, aglyph, aword] = activity(name, item, agents); return `${dot(acls, aglyph, aword)}${esc(name)}${cos && name !== 'chief' ? ' chief' : ''}`; }; // 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, key, name, small) => { - const c = copies?.[key]; + 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' ? `${esc(name)}: ${esc(copyWhy(c))}` : ''; return `${dot(...mark)}${esc(name)} ${esc(small)}${why}`; @@ -379,16 +385,16 @@ : results.map(({ org, inst, copies, agents }, i) => { const mineRoot = root.agents.filter(a => a.definition !== 'assistant'); // 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) && !Object.hasOwn(copies || {}, a.name)); + 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, a.name, 'copy')).join('') || 'none'}${replaced.length ? `${esc(replaced.map(a => a.name).join(', '))} replaced by ${esc(label(org))}'s own` : ''}${off.length ? `${esc(off.map(a => a.name).join(', '))} turned off for ${esc(label(org))}` : ''}`, endc(i)); + return cell(org, `${on.map(a => copyPill(copies, agents, a.name, null, 'copy')).join('') || 'none'}${replaced.length ? `${esc(replaced.map(a => a.name).join(', '))} replaced by ${esc(label(org))}'s own` : ''}${off.length ? `${esc(off.map(a => a.name).join(', '))} turned off for ${esc(label(org))}` : ''}`, endc(i)); }).join(''); const orgRow = results.map(({ org, inst, agents }, i) => cell(org, inst === null ? 'Agents unavailable' : Object.keys(inst).length ? Object.entries(inst).map(([k, v]) => pill(k, v, agents)).join('') : 'No agents configured', endc(i))).join(''); // Project: the agents a project defines itself. Narrowing and exclusion show in the table. const projRow = results.map(({ org, lv, rows, copies, agents }, i) => { if (lv?.state !== 'ok') return cell(org, `${lv?.state === 'invalid' ? 'Project agents unavailable: the organization file was refused' : 'Project agents unavailable'}`, ` last${endc(i)}`); - const own = Object.entries(lv.defined).flatMap(([pid, entries]) => entries.filter(e => e.form === 'define').map(e => copyPill(copies, agents, `${e.name}.${pid}`, 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('') || 'none', ` last${endc(i)}`); }).join(''); const levelsGrid = valid.length ? `
diff --git a/packages/webui/tests/unified-workspace-browser.test.mjs b/packages/webui/tests/unified-workspace-browser.test.mjs index 260515d59..5fce19184 100644 --- a/packages/webui/tests/unified-workspace-browser.test.mjs +++ b/packages/webui/tests/unified-workspace-browser.test.mjs @@ -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 = {}, copies = {} } = {}) { +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 }, @@ -47,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 })) }), }; @@ -450,6 +451,68 @@ test('agents grid shows each agent\'s level and where it is defined, per project 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. -- 2.54.0 From 29f7c3af8564763d5dfbc7bfd0a1368cc9ba6d88 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 10 Nov 2026 17:38:38 -0600 Subject: [PATCH 3/4] 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. --- packages/bus/src/broker.mjs | 5 + packages/bus/src/process.mjs | 2 +- packages/bus/src/runtime.mjs | 10 +- packages/cli/src/launcher.mjs | 17 ++- packages/cli/tests/agent-copies.test.mjs | 114 ++++++++++++++++++ packages/organization/src/agent-copies.mjs | 34 ++++-- .../organization/tests/agent-copies.test.mjs | 53 ++++++++ packages/seat/src/sandbox.mjs | 13 +- packages/seat/tests/sandbox.test.mjs | 20 +++ 9 files changed, 252 insertions(+), 16 deletions(-) diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 7880827e0..f93dfb7bd 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -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; diff --git a/packages/bus/src/process.mjs b/packages/bus/src/process.mjs index 3bea31c2e..e17e6d26b 100644 --- a/packages/bus/src/process.mjs +++ b/packages/bus/src/process.mjs @@ -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, 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); diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 10bc5b44d..012da4523 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -629,7 +629,9 @@ 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. + function admission({ business: id, project, projects, instance, 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'); @@ -638,9 +640,15 @@ export async function startBroker({ if (!row) return no('session-not-found'); if (row.archived) return no('project-archived'); } + if (instance !== undefined && typeof instance !== 'string') return no('project-unknown'); 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 }; } diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs index cbcd22b65..e9ca2780e 100644 --- a/packages/cli/src/launcher.mjs +++ b/packages/cli/src/launcher.mjs @@ -632,13 +632,15 @@ export function createLauncher({ 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. + // 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 barred = []; for (const [project, entry] of Object.entries(b.projects)) { if (entry.kind === 'library') continue; - if (own !== null && project !== own) continue; + if (own !== null && project !== own) { barred.push(project); continue; } const unavailable = projectRefusal(b, instance, project); - if (unavailable) { record({ event: "checkout-skipped", instance, project, reason: unavailable.code }); continue; } + if (unavailable) { barred.push(project); 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; } @@ -671,6 +673,12 @@ export function createLauncher({ 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})); 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); + } const { run, runDir } = newRun(dataRoot, b.id); let modelKeyId; if (admittedProxy) { @@ -766,6 +774,7 @@ export function createLauncher({ gitSocket: git, threadStore: store, readOnly, + hidden: barred.map((project) => join(workspace, project)).filter((p) => { try { return lstatSync(p).isDirectory(); } catch { return false; } }), }; admission(admissionGeneration); await threadAdmission(thread, session); @@ -776,7 +785,7 @@ 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 }); + const finalAdmission = await host.op({ op: "request.admission", business: b.id, projects: Object.keys(codeProjects), instance, 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; diff --git a/packages/cli/tests/agent-copies.test.mjs b/packages/cli/tests/agent-copies.test.mjs index 3b920f4b4..e08ea74fa 100644 --- a/packages/cli/tests/agent-copies.test.mjs +++ b/packages/cli/tests/agent-copies.test.mjs @@ -270,6 +270,120 @@ test("A3: a project's exclude takes its mount from an inherited agent, and its n 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); + assert.deepEqual(s.runtime.admission({ business: "acme", projects: ["stack", "web"], instance: "gate" }).code, "project-refused"); + assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate" }).ready, true); + assert.equal(s.runtime.admission({ business: "acme", projects: ["stack", "web"] }).ready, true); +}); + +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); diff --git a/packages/organization/src/agent-copies.mjs b/packages/organization/src/agent-copies.mjs index 9bc48bdd2..30173e671 100644 --- a/packages/organization/src/agent-copies.mjs +++ b/packages/organization/src/agent-copies.mjs @@ -16,6 +16,7 @@ // // 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"; @@ -25,6 +26,9 @@ 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"]); @@ -66,26 +70,22 @@ export function materializeCopies({ orgId, doc, root, rolesDir }) { } const cache = new Map(); const copies = {}; - const claims = {}; 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 }; - const claim = project === null ? `root agent ${agent.name}` : `project ${project}'s ${agent.name}`; // 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)) { - claims[key].push(claim); const claimants = [...(copies[key].claims ?? [{ name: copies[key].name, project: copies[key].project }]), { name: agent.name, project }]; - copies[key] = { ...copies[key], state: "refused", reason: `the instance name ${key} is claimed by ${claims[key].join(" and ")}`, claims: claimants }; + copies[key] = { ...copies[key], state: "refused", reason: collision(key, claimants), claims: claimants }; delete copies[key].missing; return; } - claims[key] = [claim]; // 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)) { delete claims[key]; return; } + 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); @@ -159,8 +159,10 @@ function narrowVars(level, vars, narrows) { 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, and the project's union, recomputed from this -// document, still accepts it. +// 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`; @@ -168,6 +170,7 @@ function projectCopyRefusal(copy, document, levels) { 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; } @@ -207,10 +210,25 @@ export function withCopies(document, file, { rolesDir, snapshot }) { } 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; } diff --git a/packages/organization/tests/agent-copies.test.mjs b/packages/organization/tests/agent-copies.test.mjs index d476ef914..761c5c6ff 100644 --- a/packages/organization/tests/agent-copies.test.mjs +++ b/packages/organization/tests/agent-copies.test.mjs @@ -212,6 +212,59 @@ test("A3: two agents whose instance names come out the same both refuse, naming 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 }); diff --git a/packages/seat/src/sandbox.mjs b/packages/seat/src/sandbox.mjs index c2e864dc2..59b2fce2d 100644 --- a/packages/seat/src/sandbox.mjs +++ b/packages/seat/src/sandbox.mjs @@ -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,11 @@ 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. // `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 +126,11 @@ 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`); + 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 +154,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; } diff --git a/packages/seat/tests/sandbox.test.mjs b/packages/seat/tests/sandbox.test.mjs index 7637d85ca..e11a15720 100644 --- a/packages/seat/tests/sandbox.test.mjs +++ b/packages/seat/tests/sandbox.test.mjs @@ -91,6 +91,26 @@ 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 refuses", (t) => { + const root = scratch(t); + const workspace = join(root, "ws"); + 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); + } +}); + // 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.54.0 From 82dcba2d45460c642b8ec08b686662d19059d0f8 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 10 Nov 2026 20:19:16 -0600 Subject: [PATCH 4/4] fix(agents): A3 hidden checkouts cover every left-out project and refuse links, final admission binds the organization revision (#1873) --- packages/bus/src/process.mjs | 2 +- packages/bus/src/runtime.mjs | 12 ++- packages/cli/src/launcher.mjs | 36 ++++++--- packages/cli/tests/agent-copies.test.mjs | 97 +++++++++++++++++++++++- packages/seat/src/sandbox.mjs | 4 + packages/seat/tests/sandbox.test.mjs | 12 ++- 6 files changed, 146 insertions(+), 17 deletions(-) diff --git a/packages/bus/src/process.mjs b/packages/bus/src/process.mjs index e17e6d26b..1f181d337 100644 --- a/packages/bus/src/process.mjs +++ b/packages/bus/src/process.mjs @@ -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, instance: m.instance, 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); diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 012da4523..dc4d398cf 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -631,7 +631,9 @@ export async function startBroker({ // broker's reload now follows both readiness records (#802). // 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. - function admission({ business: id, project, projects, instance, thread, session }) { + // `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'); @@ -640,7 +642,13 @@ export async function startBroker({ if (!row) return no('session-not-found'); if (row.archived) return no('project-archived'); } - if (instance !== undefined && typeof instance !== 'string') return no('project-unknown'); + 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; diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs index e9ca2780e..29d9910f8 100644 --- a/packages/cli/src/launcher.mjs +++ b/packages/cli/src/launcher.mjs @@ -551,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. @@ -635,12 +635,13 @@ export function createLauncher({ // 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 barred = []; + const declared = new Set(); for (const [project, entry] of Object.entries(b.projects)) { if (entry.kind === 'library') continue; - if (own !== null && project !== own) { barred.push(project); continue; } + declared.add(project); + if (own !== null && project !== own) continue; const unavailable = projectRefusal(b, instance, project); - if (unavailable) { barred.push(project); record({ event: "checkout-skipped", instance, project, reason: unavailable.code }); continue; } + 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; } @@ -670,8 +671,8 @@ 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. @@ -679,6 +680,21 @@ export function createLauncher({ 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) { @@ -774,7 +790,7 @@ export function createLauncher({ gitSocket: git, threadStore: store, readOnly, - hidden: barred.map((project) => join(workspace, project)).filter((p) => { try { return lstatSync(p).isDirectory(); } catch { return false; } }), + hidden, }; admission(admissionGeneration); await threadAdmission(thread, session); @@ -785,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), instance, 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; diff --git a/packages/cli/tests/agent-copies.test.mjs b/packages/cli/tests/agent-copies.test.mjs index e08ea74fa..968eb3aa9 100644 --- a/packages/cli/tests/agent-copies.test.mjs +++ b/packages/cli/tests/agent-copies.test.mjs @@ -9,7 +9,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { DatabaseSync } from "node:sqlite"; -import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +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"; @@ -340,9 +340,100 @@ test("A3: a project that excludes the agent while its launch is prepared refuses assert.equal(s.running("gate").length, 0); // Once the bus has the change, its launch-boundary admission refuses the instance there too. reload(s); - assert.deepEqual(s.runtime.admission({ business: "acme", projects: ["stack", "web"], instance: "gate" }).code, "project-refused"); - assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate" }).ready, true); + 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) => { diff --git a/packages/seat/src/sandbox.mjs b/packages/seat/src/sandbox.mjs index 59b2fce2d..2232afa76 100644 --- a/packages/seat/src/sandbox.mjs +++ b/packages/seat/src/sandbox.mjs @@ -114,6 +114,7 @@ function systemBinds(node = process.execPath) { // `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, hidden = [], adapter, harnessMounts = [], brokerSocket, launchSocket = null, gitSocket = null, threadStore = null, node = process.execPath, readOnly = [] }) { const out = systemBinds(node); @@ -129,6 +130,9 @@ export function binds({ runDir, workspace, workspaceDest = null, hidden = [], ad ...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/" }, diff --git a/packages/seat/tests/sandbox.test.mjs b/packages/seat/tests/sandbox.test.mjs index e11a15720..08da8d152 100644 --- a/packages/seat/tests/sandbox.test.mjs +++ b/packages/seat/tests/sandbox.test.mjs @@ -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"; @@ -93,9 +93,11 @@ test("every bind says why; the bundle and session file stay read-only over the r // #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 refuses", (t) => { +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); @@ -109,6 +111,12 @@ test("A3 a hidden checkout is a tmpfs over the workspace bind; a path outside th 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 -- 2.54.0