business: agent limits as settings with one organization write path (row 122, #1616)

launch.max becomes launch.models plus launch.limits (total, providers,
exact provider/model). The launcher re-reads limits on every attempt.
Settings and `mosaic limits` write through one locked, journaled
organization writer. `mosaic limits migrate` is print-only.

Candidate 00b84e311475f97ced0d9a9599fba23a28442971ed15d8d489d9422d84a8812e,
Rocko packet 4b6e50636, Filbert round 2 approve (comment 28542).
Landing gate at 0c56cc2d4: 23 suites green. Decision 123 go from Mos.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
jason.woltjeandClaude Opus 5.5 committed 2026-10-14 20:50:52 -05:00
1 parent c0c1e8646e
commit 7b5b682ae8
44 files changed
+1066 -117

No files matched your search

+19
View File
@@ -566,3 +566,22 @@ input; error on stderr, never a clean verdict).
`3` configuration missing for a read operation · `4` usage/file/environment
problem. Scripts print diagnostics on stderr; model responses (and only
model responses) on stdout.
## Agent limits (`scripts/mosaic limits`)
View: `scripts/mosaic limits --business ID --json`. Set: `scripts/mosaic limits
set --business ID --file PATCH`, with the current revision and replacement
`limits` and/or `models`, or `instances` mapping role names to model-table keys.
Both use the human bus proof. Settings offers the same edits in signed-in send
mode. Unset means unlimited, zero blocks new launches, and running sessions
remain alive when a limit is lowered. Total, provider and exact model limits
are checked for each launch; counts have no hard-coded policy ceiling.
Convert an old organization file with `scripts/mosaic limits migrate --file
ORGANIZATION --models MAPPING --roles-dir roles > CONVERTED`. It needs no bus,
never edits the input or system config and has no write flag. Inherited models
require a valid system config and an exact matching provider/model mapping.
Mapping JSON contains `models`,
`families` and optional per-role `instances` selections. Jason checks and installs
the output in Mos's window with the PM run idle; re-pin in that same window.
Never migrate the live file from an agent session.
+22 -1
View File
@@ -107,14 +107,16 @@ export class Broker {
#secretCheck;
#credentialStatus;
#requirements;
#organization;
#clock = 0;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [] }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
this.#businesses = structuredClone(businesses);
this.#store = store;
this.#secretCheck = secretCheck;
this.#credentialStatus = credentialStatus;
this.#organization = organization;
// Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here.
this.#requirements = loadRequirements();
for (const table of [
@@ -666,6 +668,25 @@ export class Broker {
const args = request.args ?? {};
if (!object(args)) fail('invalid-request');
this.#secretCheck(args);
if (["limits", "limits.set"].includes(request.verb)) {
if (!s.human) fail("human-required");
if (!this.#organization) fail("not-configured");
if (request.verb === "limits") keys(args, []);
else keys(args, ["revision", "limits", "models", "instances"], ["revision"]);
let result;
try {
result = this.#organization(s.business, request.verb, args, s.human);
} catch (e) {
if (e.code === "revision-conflict") fail("revision-conflict");
if (e.code?.startsWith("organization-")) fail(e.code);
if (e.name === "BusinessError" || e.code === "invalid-request" || e.code === "unknown-model") {
fail("invalid-request");
}
throw new BusError("outcome-unknown");
}
this.#secretCheck(result);
return result;
}
return this.#store.transaction(() => {
if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state'].includes(request.verb))
fail('read-only');
+6 -1
View File
@@ -4,10 +4,12 @@ import { Store } from './store.mjs';
import { Broker, BusError } from './broker.mjs';
import { Credentials } from './credentials.mjs';
import { serve } from './server.mjs';
import { launchSettings } from '../../business/src/launch-settings.mjs';
import { readSessions } from '../../seat/src/session.mjs';
import { verifyHuman } from './human.mjs';
import { verifyTester } from './tester.mjs';
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
// Neither a business-file writer nor a socket-accessible configuration endpoint.
// Human limits writes use the shared organization writer behind the verified transport.
// `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}.
export async function startBroker({
dataRoot,
@@ -16,6 +18,7 @@ export async function startBroker({
readers = [],
repoRoots = [],
tasks = null,
organization = null,
}) {
let store, credentials, server, adapter;
try {
@@ -32,9 +35,11 @@ export async function startBroker({
];
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot });
store = new Store(dataRoot);
const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot)}):null;
const broker = new Broker({
store,
businesses,
organization:settings?(id,verb,args,actor)=>verb==='limits'?settings.read(id):settings.set(id,args,actor):null,
secretCheck: (value) => credentials.assertClean(value),
credentialStatus: () => credentials.status(),
});
+33
View File
@@ -0,0 +1,33 @@
// REQ-LIMIT-1: only the existing verified human channel can read/write settings.
import test from 'node:test';
import assert from 'node:assert/strict';
import {mkdtempSync,rmSync,mkdirSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {Broker} from '../src/broker.mjs';
import {Store} from '../src/store.mjs';
test('limits binds actor from human proof and rejects role, reader, tester and forged actor',t=>{
const root=mkdtempSync(join(tmpdir(),'mosaic-limits-bus-')),store=new Store(root);t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});const calls=[];
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},vars:{'bus.tester':true},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:(...args)=>{calls.push(args);return {revision:'b'.repeat(64)};}});
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),role=b.bindLaunch({business:'demo',role:'coder',run:'coder-run',harness:'pi',address:'coder-run'}),reader=b.bindReader({business:'demo'}),tester=b.bindTester({business:'demo'}),patch={revision:'a'.repeat(64),limits:{total:0}};
for(const cap of [role,reader,tester])for(const verb of ['limits','limits.set'])assert.throws(()=>b.request(cap,{verb,args:verb==='limits'?{}:patch}),/human-required/);
assert.equal(calls.length,0);assert.throws(()=>b.request(human,{verb:'limits.set',args:{...patch,actor:'forged'}}),/invalid-request/);
b.request(human,{verb:'limits'});b.request(human,{verb:'limits.set',args:patch});assert.deepEqual(calls,[['demo','limits',{},'jason'],['demo','limits.set',patch,'jason']]);
});
test('runtime wires verified human settings to the actual shared file writer and actor receipt',async t=>{
const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../business/tests/helpers.mjs');const {readFileSync}=await import('node:fs');
const root=mkdtempSync(join(tmpdir(),'mosaic-limits-runtime-')),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo');writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});
t.after(async()=>{await bus.close();rmSync(root,{recursive:true,force:true});});const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
const view=bus.broker.request(human,{verb:'limits'});const done=bus.broker.request(human,{verb:'limits.set',args:{revision:view.revision,limits:{total:0}}});assert.equal(done.limits.total,0);assert.equal(JSON.parse(readFileSync(join(root,'businesses/demo.json'),'utf8')).launch.limits.total,0);const receipt=JSON.parse(readFileSync(join(root,'businesses/.history/demo/changes.jsonl'),'utf8').trim().split('\n').at(-1));assert.equal(receipt.actor,'jason');assert.equal(receipt.state,'committed');
});
test('uncertain organization receipts leave bus startup and reads alone but refuse writes until reconciled',async t=>{
const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../business/tests/helpers.mjs');const {organizationWriter}=await import('../../business/src/organization.mjs');const {readFileSync}=await import('node:fs');
const root=mkdtempSync(join(tmpdir(),'mosaic-limits-recovery-')),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo'),file=writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
const writer=organizationWriter({dir:root,rolesDir}),original=readFileSync(file,'utf8'),revision=writer.read('demo').revision,next=structuredClone(doc);next.launch.limits.total=7;
const interrupted=organizationWriter({dir:root,rolesDir,barrier:()=>{throw Error('fixture interruption');}});assert.throws(()=>interrupted.write('demo',next,{revision,actor:'jason'}));const external=structuredClone(doc);external.launch.limits.total=12;writeJson(file,external);const bytes=readFileSync(file,'utf8');
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(async()=>{await bus.close();rmSync(root,{recursive:true,force:true});});
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});const view=bus.broker.request(human,{verb:'limits'});assert.equal(view.limits.total,12);assert.throws(()=>bus.broker.request(human,{verb:'limits.set',args:{revision:view.revision,limits:{total:1}}}),/organization-uncertain/);assert.equal(readFileSync(file,'utf8'),bytes);
// The operator reconciles the fixture to the recorded old whole document.
writeJson(file,original);const reconciled=bus.broker.request(human,{verb:'limits'});const result=bus.broker.request(human,{verb:'limits.set',args:{revision:reconciled.revision,limits:{total:1}}});assert.equal(result.limits.total,1);
});
+39 -7
View File
@@ -88,8 +88,14 @@ repository, `dataRoot` and every project root. A Vikunja token past
`expires` refuses. One within seven days of it, or a Gitea token past
`rotateBy`, prints a warning.
`launch` lets one instance (`by`) start the listed instances, up to `max`
sessions per model family (Opus 4, Sonnet 4 at most). `by` must name an
`launch` lets one instance (`by`) start the listed instances. `models` maps
selection keys to exact `{provider, model}` pairs; each role
`vars.model` selects a key. `limits` accepts optional `total`,
`providers: {provider: count}` and `models: {provider: {model: count}}`.
Counts are whole numbers of zero or more with no policy ceiling; absent
means unlimited and zero stops new launches. The example starts with four
Opus and four Sonnet sessions. The old `max` shape requires print-only
`mosaic limits migrate` before the new copy can load it. `by` must name an
instance whose definition holds `role.launch` within-role; otherwise the
file refuses. Only that instance keeps `role.launch`. Every other instance
loses it, so for them it's gated.
@@ -116,15 +122,19 @@ it is the only role `launch.by` can name. The PM holds no `role.launch`
(REQ-LAUNCH-1); it asks the chief of staff for a session.
The owner adds both instances under `roles` in the business file, next to
the others, and points `launch.by` at the chief of staff. The stack never
writes that file. The instance names are the owner's choice; these are the
the others, and points `launch.by` at the chief of staff. Initial role entry
and schema migration installation are human steps; later launch settings edits
use the shared, broker-backed organization writer. The instance names are the owner's choice; these are the
example file's:
```json
"chief": { "definition": "chief-of-staff" },
"assistant": { "definition": "assistant", "holder": "mos" }
"chief": { "definition": "chief-of-staff", "vars": { "model": "opus" } },
"assistant": { "definition": "assistant", "holder": "mos", "vars": { "model": "opus" } }
...
"launch": { "by": "chief", "instances": ["coder", "reviewer"], "max": { "opus": 4, "sonnet": 4 } }
"launch": { "by": "chief", "instances": ["coder", "reviewer"], "models": {
"opus": { "provider": "anthropic", "model": "claude-opus-5-5" },
"sonnet": { "provider": "anthropic", "model": "claude-sonnet-5-5" }
}, "limits": { "models": { "anthropic": { "claude-opus-5-5": 4, "claude-sonnet-5-5": 4 } } } }
```
Neither entry has a `tracker` or `credentials`, because neither role holds
@@ -212,3 +222,25 @@ file missing). The CLI adds 3 for a system config problem and 4 for usage.
The code that opens it at run time repeats these checks (rows S2 and S3).
- Slice 1 knows two services and one tracker kind. A new one is a
reviewed change to `src/vocabulary.mjs` and `src/vars.mjs`.
## Organization writes
`src/organization.mjs` is the trusted host write module. The caller supplies
a verified actor and the SHA-256 revision read earlier. It validates the whole
document, refuses stale revisions and concurrent writers, keeps exact previous
bytes under `businesses/.history/<id>/<transaction>/previous.json`, then publishes
a fsynced temporary file by atomic rename. `changes.jsonl` records prepared and
committed transactions with actor, hashes and old/new values per changed field.
Write-time recovery compares prepared hashes with current bytes; uncertain or
torn evidence refuses organization writes. Bus startup, Settings reads and
launches keep using the whole validated file on disk, without consulting receipts. A stale writer lock also refuses: an operator must
confirm that its old writer is gone before clearing it; the host does not
race another recovering process by deleting locks automatically. Settings reads never repair evidence themselves.
`launch-settings.mjs` restricts human edits to limits, model table and instance
model selections and validates effective selections before publication. Migration
uses none of this: it only prints a converted document (decision 145).
For inherited selections, print-only migration reads the existing system config
through its normal validator and requires an exact provider/model match. Missing
or invalid config refuses; it never changes the system config. Explicit role
models retain their external model and use the human-supplied provider table.
@@ -45,6 +45,9 @@
"file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/pm-vikunja.token",
"expires": "YYYY-MM-DD"
}
},
"vars": {
"model": "opus"
}
},
"cto": {
@@ -63,6 +66,9 @@
"file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/cto-vikunja.token",
"expires": "YYYY-MM-DD"
}
},
"vars": {
"model": "opus"
}
},
"coder": {
@@ -80,6 +86,9 @@
"file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/coder-vikunja.token",
"expires": "YYYY-MM-DD"
}
},
"vars": {
"model": "sonnet"
}
},
"reviewer": {
@@ -97,14 +106,23 @@
"file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/reviewer-vikunja.token",
"expires": "YYYY-MM-DD"
}
},
"vars": {
"model": "opus"
}
},
"chief": {
"definition": "chief-of-staff"
"definition": "chief-of-staff",
"vars": {
"model": "opus"
}
},
"assistant": {
"definition": "assistant",
"holder": "mos"
"holder": "mos",
"vars": {
"model": "opus"
}
}
},
"launch": {
@@ -113,9 +131,23 @@
"coder",
"reviewer"
],
"max": {
"opus": 4,
"sonnet": 4
"models": {
"opus": {
"provider": "your-provider",
"model": "claude-opus-5-5"
},
"sonnet": {
"provider": "your-provider",
"model": "claude-sonnet-5-5"
}
},
"limits": {
"models": {
"your-provider": {
"claude-opus-5-5": 4,
"claude-sonnet-5-5": 4
}
}
}
}
}
+9 -15
View File
@@ -1,12 +1,11 @@
// The business file, <configDir>/businesses/<id>.json (REQ-ROLE-3, note
// section 2, addendum A sections 7 and 8). Jason writes it; the stack reads
// it and never writes it. A missing or invalid file refuses (lead decision
// 46, 6.1).
// section 2, addendum A sections 7 and 8). Human edits go through the trusted
// organization writer. A missing or invalid file refuses (lead decision 46, 6.1).
import { dirname, isAbsolute, join, normalize } from "node:path";
import { homedir } from "node:os";
import { refuse } from "./errors.mjs";
import { LAUNCH_CEILING } from "./vocabulary.mjs";
import { checkModels, checkLimits } from "./launch.mjs";
import { checkVars } from "./vars.mjs";
import { parseCredentialRef } from "./credentials.mjs";
import { loadRole } from "./role.mjs";
@@ -114,7 +113,8 @@ function checkRoles(roles, file, rolesDir) {
function checkLaunch(launch, roles, definitions, file) {
const where = `${file} launch`;
requireObject(launch, where);
rejectUnknownKeys(launch, ["by", "instances", "max"], where);
if (Object.hasOwn(launch,"max")) refuse(`${where}.max needs migration: mosaic limits migrate --file FILE --models FILE`);
rejectUnknownKeys(launch, ["by", "instances", "models", "limits"], where);
requireId(launch.by, `${where}.by`);
if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`);
const launcher = definitions[roles[launch.by].definition];
@@ -126,16 +126,10 @@ function checkLaunch(launch, roles, definitions, file) {
if (!Object.hasOwn(roles, name)) refuse(`${where}.instances names ${name}, which the business file doesn't declare`);
if (name === launch.by) refuse(`${where}.instances can't include the launcher itself (${name})`);
}, { nonEmpty: true });
requireObject(launch.max, `${where}.max`);
if (Object.keys(launch.max).length === 0) refuse(`${where}.max must name at least one model family`);
const max = {};
for (const [family, count] of Object.entries(launch.max)) {
const ceiling = Object.hasOwn(LAUNCH_CEILING, family) ? LAUNCH_CEILING[family] : undefined;
if (ceiling === undefined) refuse(`${where}.max names unknown model family ${JSON.stringify(family)} (known: ${Object.keys(LAUNCH_CEILING).join(", ")})`);
if (!Number.isSafeInteger(count) || count < 0 || count > ceiling) refuse(`${where}.max.${family} must be an integer from 0 to ${ceiling}`);
max[family] = count;
}
return { by: launch.by, instances, max };
const models=checkModels(launch.models,`${where}.models`);
const limits=checkLimits(launch.limits===undefined?{}:launch.limits,`${where}.limits`);
for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} selects unknown model ${role.vars.model}`);
return { by: launch.by, instances, models, limits };
}
// Validate a parsed business document read from `file`. `rolesDir` is where
+1 -1
View File
@@ -4,7 +4,7 @@
export { BusinessError } from "./errors.mjs";
export {
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE, LAUNCH_CEILING,
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
} from "./vocabulary.mjs";
export { validateRoleDocument, loadRoleFile, loadRole } from "./role.mjs";
export { LAYERS, REGISTRY, checkVars, mergeVars } from "./vars.mjs";
+68
View File
@@ -0,0 +1,68 @@
// Shared human settings projection/update (REQ-LIMIT-1), used by the trusted broker.
import { organizationWriter, OrganizationError } from "./organization.mjs";
import { validateBusinessDocument, businessFilePath } from "./business.mjs";
import { resolveInstance } from "./resolve.mjs";
import { usageCounts } from "./launch.mjs";
import { requireObject, rejectUnknownKeys } from "./util.mjs";
export function launchSettings({ dir, rolesDir, system, sessions = () => [] }) {
const writer = organizationWriter({ dir, rolesDir });
function view(id) {
const { document, revision } = writer.read(id);
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
if (!business.launch) throw new OrganizationError("launch-not-configured");
const instances = Object.fromEntries(Object.keys(business.roles).map(instance => {
const vars = resolveInstance({ business, system, instance }).vars;
const key = vars.model ?? vars["execution.model"];
const entry = Object.hasOwn(business.launch.models, key) ? business.launch.models[key] : null;
if (!entry) throw new OrganizationError("unknown-model");
return [instance, {
harness: vars.harness ?? vars["execution.adapter"],
...entry, modelKey: key,
}];
}));
// model is the external id; modelKey is the table selection.
return {
business: id, revision, models: business.launch.models,
limits: business.launch.limits, instances, usage: usageCounts(sessions(), id),
};
}
function set(id, args, actor) {
requireObject(args, "limits update");
rejectUnknownKeys(args, ["revision", "limits", "models", "instances"], "limits update");
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) {
throw new OrganizationError("invalid-request");
}
if (!["limits", "models", "instances"].some(key => Object.hasOwn(args, key))) {
throw new OrganizationError("invalid-request");
}
const old = writer.read(id);
const next = structuredClone(old.document);
if (!next.launch) throw new OrganizationError("launch-not-configured");
if (Object.hasOwn(args, "limits")) next.launch.limits = args.limits;
if (Object.hasOwn(args, "models")) next.launch.models = args.models;
if (Object.hasOwn(args, "instances")) {
requireObject(args.instances, "instance model selections");
for (const [instance, key] of Object.entries(args.instances)) {
if (!Object.hasOwn(next.roles, instance) || typeof key !== "string") {
throw new OrganizationError("invalid-request");
}
next.roles[instance].vars = { ...next.roles[instance].vars, model: key };
}
}
// Check effective inherited selections before publishing the complete document.
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir });
for (const instance of Object.keys(proposed.roles)) {
const vars = resolveInstance({ business: proposed, system, instance }).vars;
if (!Object.hasOwn(proposed.launch.models, vars.model ?? vars["execution.model"])) {
throw new OrganizationError("unknown-model");
}
}
const receipt = writer.write(id, next, { revision: args.revision, actor });
return { ...view(id), transaction: receipt.transaction };
}
return { writer, read: view, set };
}
+89
View File
@@ -0,0 +1,89 @@
// REQ-LIMIT-1: editable concurrency policy, never a compiled-in capacity ceiling.
import { requireObject, requireId, requireString, rejectUnknownKeys } from "./util.mjs";
import { refuse } from "./errors.mjs";
const name = (value, at) => requireString(value, at, { max: 200 });
const count = (value, at) => {
if (!Number.isSafeInteger(value) || value < 0) refuse(`${at} must be a whole number of 0 or more`);
return value;
};
const own = (object, key) => Object.hasOwn(object ?? {}, key) ? object[key] : undefined;
export function checkModels(value, where = "launch.models") {
requireObject(value, where);
if (!Object.keys(value).length) refuse(`${where} must name at least one model`);
return Object.fromEntries(Object.entries(value).map(([key, entry]) => {
requireId(key, where);
requireObject(entry, `${where}.${key}`);
rejectUnknownKeys(entry, ["provider", "model"], `${where}.${key}`);
return [key, {
provider: name(entry.provider, `${where}.${key}.provider`),
model: name(entry.model, `${where}.${key}.model`),
}];
}));
}
export function checkLimits(value = {}, where = "launch.limits") {
requireObject(value, where);
rejectUnknownKeys(value, ["total", "providers", "models"], where);
const out = {};
if (Object.hasOwn(value, "total")) out.total = count(value.total, `${where}.total`);
if (Object.hasOwn(value, "providers")) {
requireObject(value.providers, `${where}.providers`);
out.providers = Object.fromEntries(Object.entries(value.providers).map(([provider, limit]) => {
return [name(provider, where), count(limit, `${where}.providers.${provider}`)];
}));
}
if (Object.hasOwn(value, "models")) {
requireObject(value.models, `${where}.models`);
out.models = Object.fromEntries(Object.entries(value.models).map(([provider, models]) => {
name(provider, where);
requireObject(models, where);
return [provider, Object.fromEntries(Object.entries(models).map(([model, limit]) => {
return [name(model, where), count(limit, `${where}.models.${provider}.${model}`)];
}))];
}));
}
return out;
}
export function usageCounts(sessions, business) {
const running = sessions.filter(session => session.business === business);
const providerCounts = new Map();
const modelCounts = new Map();
for (const session of running) {
const { provider, model } = session;
if (typeof provider !== "string" || typeof model !== "string") {
refuse("running session has no exact provider/model; reconcile before launching");
}
providerCounts.set(provider, (providerCounts.get(provider) ?? 0) + 1);
if (!modelCounts.has(provider)) modelCounts.set(provider, new Map());
const models = modelCounts.get(provider);
models.set(model, (models.get(model) ?? 0) + 1);
}
const providers = Object.fromEntries(providerCounts);
const models = Object.fromEntries([...modelCounts].map(([provider, counts]) => {
return [provider, Object.fromEntries(counts)];
}));
return { total: running.length, providers, models };
}
export function limitHit(sessions, business, limits, selected) {
const usage = usageCounts(sessions, business);
const { provider, model } = selected;
const checks = [
["total", limits.total, usage.total],
["provider", own(limits.providers, provider), own(usage.providers, provider) ?? 0],
["model", own(own(limits.models, provider), model), own(own(usage.models, provider), model) ?? 0],
];
for (const [scope, limit, running] of checks) {
if (limit !== undefined && running >= limit) {
return {
scope, limit, running,
...(scope !== "total" ? { provider } : {}),
...(scope === "model" ? { model } : {}),
};
}
}
return null;
}
+94
View File
@@ -0,0 +1,94 @@
// Decision 145: pure print-only conversion. System values are supplied by the
// existing read-only config loader; this module never reads credentials or writes.
import { checkModels, checkLimits } from "./launch.mjs";
import { requireObject, rejectUnknownKeys } from "./util.mjs";
import { refuse } from "./errors.mjs";
import { validateBusinessDocument } from "./business.mjs";
export function migrateLaunch(document, mapping, file, { rolesDir, system = null }) {
const out = structuredClone(document);
if (!out.launch?.max) {
validateBusinessDocument(out, file, { rolesDir });
return out;
}
requireObject(mapping, "migration mapping");
rejectUnknownKeys(mapping, ["models", "families", "selections", "instances"], "migration mapping");
const models = checkModels(mapping.models);
requireObject(mapping.families, "migration families");
requireObject(out.launch.max, "old launch.max");
for (const field of ["instances", "selections"]) {
if (Object.hasOwn(mapping, field)) requireObject(mapping[field], `migration ${field}`);
}
for (const key of Object.keys(mapping.instances ?? {})) {
if (!Object.hasOwn(out.roles, key)) refuse(`unknown migration instance ${key}`);
}
const limits = { models: Object.create(null) };
const used = new Set();
for (const [family, count] of Object.entries(out.launch.max)) {
const key = mapping.families[family];
if (typeof key !== "string" || !Object.hasOwn(models, key)) {
refuse(`unknown migration mapping for family ${family}`);
}
const entry = models[key];
const tuple = JSON.stringify([entry.provider, entry.model]);
if (used.has(tuple)) refuse("ambiguous migration: two family limits map to the same provider/model");
used.add(tuple);
limits.models[entry.provider] ??= Object.create(null);
limits.models[entry.provider][entry.model] = count;
}
const checkedLimits = checkLimits(limits);
const previous = new Map();
for (const [instance, role] of Object.entries(out.roles)) {
const inherited = role.vars?.model === undefined;
let old = role.vars?.model;
let provider = null;
if (inherited) {
old = system?.["execution.model"];
provider = system?.["execution.provider"];
if (typeof old !== "string" || !old || typeof provider !== "string" || !provider) {
refuse(`inherited model for ${instance} requires valid system config`);
}
}
previous.set(instance, old);
let selected = mapping.instances?.[instance] ?? mapping.selections?.[old];
if (selected === undefined) {
const matches = Object.entries(models).filter(([, entry]) => {
return entry.model === old && (!inherited || entry.provider === provider);
});
if (matches.length !== 1) refuse(`unknown or ambiguous model mapping for ${instance}`);
selected = matches[0][0];
}
if (typeof selected !== "string" || !Object.hasOwn(models, selected)) {
refuse(`unknown or ambiguous model mapping for ${instance}`);
}
const entry = models[selected];
if (entry.model !== old || (inherited && entry.provider !== provider)) {
refuse(`migration mapping changes ${instance}'s ${inherited ? "inherited provider/model" : "external model"}`);
}
role.vars = { ...role.vars, model: selected };
}
for (const instance of [out.launch.by, ...out.launch.instances]) {
const selected = models[out.roles[instance].vars.model];
const old = previous.get(instance);
const families = Object.keys(out.launch.max).filter(f => old.toLowerCase().includes(f.toLowerCase()));
if (families.length !== 1) refuse(`unknown or ambiguous family mapping for launchable ${instance}`);
const constrained = models[mapping.families[families[0]]];
if (selected.provider !== constrained.provider || selected.model !== constrained.model) {
refuse(`ambiguous migration mapping for launchable ${instance}`);
}
}
for (const family of Object.keys(out.launch.max)) {
const entry = models[mapping.families[family]];
for (const [instance, old] of previous) {
if (!old.toLowerCase().includes(family.toLowerCase())) continue;
const actual = models[out.roles[instance].vars.model];
if (old !== entry.model || actual.provider !== entry.provider) {
refuse(`ambiguous migration: family ${family} spans multiple provider/model pairs`);
}
}
}
const { max, ...launch } = out.launch;
out.launch = { ...launch, models, limits: checkedLimits };
validateBusinessDocument(out, file, { rolesDir });
return out;
}
+219
View File
@@ -0,0 +1,219 @@
// Trusted host write path (REQ-ROLE-3). Callers supply a verified actor;
// neither this module nor migration authenticates a new channel.
import {
constants, openSync, closeSync, fstatSync, readFileSync, writeFileSync,
fsyncSync, lstatSync, mkdirSync, renameSync, rmSync,
} from "node:fs";
import { join, dirname } from "node:path";
import { randomUUID } from "node:crypto";
import { businessFilePath, validateBusinessDocument } from "./business.mjs";
import { requireId, sha256, canonicalJson } from "./util.mjs";
export class OrganizationError extends Error {
constructor(code, message = code) {
super(message);
this.code = code;
}
}
const fail = (code, message) => {
throw new OrganizationError(code, message);
};
function regular(file) {
let fd;
try {
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o022)) {
fail("organization-refused", "organization file must be regular, owned and not group/world writable");
}
return readFileSync(fd, "utf8");
} finally {
if (fd !== undefined) closeSync(fd);
}
}
function directory(path) {
try {
mkdirSync(path, { mode: 0o700 });
syncDir(dirname(path));
} catch (e) {
if (e.code !== "EEXIST") throw e;
}
const st = lstatSync(path);
if (!st.isDirectory() || st.uid !== process.getuid() || (st.mode & 0o077)) {
fail("organization-refused", "organization evidence directory is unsafe");
}
}
function syncDir(dir) {
const fd = openSync(dir, constants.O_RDONLY | constants.O_DIRECTORY);
try {
fsyncSync(fd);
} finally {
closeSync(fd);
}
}
function durable(file, bytes) {
const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW;
const fd = openSync(file, flags, 0o600);
try {
writeFileSync(fd, bytes);
fsyncSync(fd);
} finally {
closeSync(fd);
}
}
function changes(old, next, path = "") {
if (canonicalJson(old) === canonicalJson(next)) return [];
const objects = old && next && typeof old === "object" && typeof next === "object";
if (objects && !Array.isArray(old) && !Array.isArray(next)) {
const keys = [...new Set([...Object.keys(old), ...Object.keys(next)])];
return keys.flatMap(k => changes(old[k], next[k], path ? path + "." + k : k));
}
return [{
path, old: old ?? null, new: next ?? null,
oldPresent: old !== undefined, newPresent: next !== undefined,
}];
}
export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
const evidence = join(dir, "businesses", ".history");
const logFile = id => join(evidence, id, "changes.jsonl");
// Reads and launches depend on the whole document, not transaction evidence.
function read(id) {
const file = businessFilePath(id, dir);
const bytes = regular(file);
const document = JSON.parse(bytes);
validateBusinessDocument(document, file, { rolesDir });
return { document, revision: sha256(bytes) };
}
function withLock(id, fn) {
const parent = join(dir, "businesses");
directory(evidence);
const home = join(evidence, id);
directory(home);
const lock = join(home, "write.lock");
try {
mkdirSync(lock, { mode: 0o700 });
durable(join(lock, "owner.json"), JSON.stringify({ pid: process.pid }));
syncDir(home);
} catch (e) {
if (e.code !== "EEXIST") throw e;
const st = lstatSync(lock);
if (!st.isDirectory() || st.uid !== process.getuid() || (st.mode & 0o077)) {
fail("organization-refused", "unsafe writer lock");
}
let owner;
try {
owner = JSON.parse(regular(join(lock, "owner.json")));
} catch {
fail("organization-uncertain", "writer lock has no verifiable owner");
}
if (!Number.isSafeInteger(owner.pid) || owner.pid < 1) fail("organization-uncertain");
try {
process.kill(owner.pid, 0);
fail("organization-busy");
} catch (x) {
if (x.code !== "ESRCH") throw x;
}
fail("organization-uncertain", "stale writer lock: confirm the old writer is gone before removing it");
}
try {
return fn({ home, parent });
} finally {
rmSync(lock, { recursive: true });
syncDir(home);
}
}
function log(id, event) {
let fd;
try {
const flags = constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW;
fd = openSync(logFile(id), flags, 0o600);
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail("organization-refused");
writeFileSync(fd, JSON.stringify(event) + "\n");
fsyncSync(fd);
} finally {
if (fd !== undefined) closeSync(fd);
}
}
// Only writes reconcile receipts. Uncertain evidence cannot stop a bus start or launch.
function recover(id) {
let text;
try {
text = regular(logFile(id));
} catch (e) {
if (e.code === "ENOENT") return;
throw e;
}
if (text && !text.endsWith("\n")) fail("organization-uncertain", "torn organization receipt");
const entries = text.trim() ? text.trim().split("\n").map(JSON.parse) : [];
const pending = new Map();
for (const e of entries) {
if (e.state === "prepared") pending.set(e.transaction, e);
else pending.delete(e.transaction);
}
for (const e of pending.values()) {
const hash = sha256(regular(businessFilePath(id, dir)));
if (hash !== e.before && hash !== e.after) {
fail("organization-uncertain", "organization bytes do not match prepared receipt");
}
log(id, {
...e, state: hash === e.after ? "committed" : "aborted",
recovered: true, at: new Date().toISOString(),
});
}
}
function write(id, document, { revision, actor }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
return withLock(id, ({ home, parent }) => {
recover(id);
const file = businessFilePath(id, dir);
const oldBytes = regular(file);
const before = sha256(oldBytes);
if (revision !== before) fail("revision-conflict", "organization revision changed; read again");
validateBusinessDocument(document, file, { rolesDir });
const old = JSON.parse(oldBytes);
const bytes = JSON.stringify(document, null, 2) + "\n";
const after = sha256(bytes);
const transaction = randomUUID();
const tx = join(home, transaction);
directory(tx);
const previous = join(tx, "previous.json");
durable(previous, oldBytes);
const candidate = join(parent, "." + id + "." + transaction + ".tmp");
durable(candidate, bytes);
syncDir(tx);
const event = {
transaction, actor, business: id, before, after,
changes: changes(old, document), at: new Date().toISOString(),
};
log(id, { ...event, state: "prepared" });
syncDir(home);
barrier("prepared");
if (sha256(regular(file)) !== before) {
log(id, { ...event, state: "aborted" });
rmSync(candidate);
fail("revision-conflict", "organization revision changed before publication");
}
renameSync(candidate, file);
syncDir(parent);
barrier("renamed");
log(id, { ...event, state: "committed" });
return { revision: after, transaction, previous };
});
}
return { logFile, read, write, recover: id => withLock(id, () => recover(id)) };
}
-4
View File
@@ -81,8 +81,4 @@ export const VIKUNJA_GRANTABLE = Object.freeze({
labels: Object.freeze(["read_all"]),
});
// Model families the launch block may cap, and the PRD's ceiling for each
// (REQ-LAUNCH-1: at most 4 Opus and 4 Sonnet sessions at once).
export const LAUNCH_CEILING = Object.freeze({ opus: 4, sonnet: 4 });
export const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
+1 -1
View File
@@ -86,7 +86,7 @@ test("a business grant through limits does not widen the role", () => {
test("a launch block whose by is the assistant is refused", () => {
refuses(
() => loadFixture((doc) => {
doc.launch = { by: INSTANCE, instances: ["coder"], max: { sonnet: 1 } };
doc.launch = { ...doc.launch, by: INSTANCE, instances: ["coder"] };
}),
/launch\.by names assistant, whose role assistant doesn't hold role\.launch within-role/,
);
+9 -9
View File
@@ -5,7 +5,7 @@ import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, symlinkSync
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { BusinessError, businessFilePath, configDir, loadBusiness, validateBusinessDocument } from "../src/index.mjs";
import { businessDoc, REPO, REPO_ROLES, rolesCopy, tmp, writeJson } from "./helpers.mjs";
import { launchDoc, businessDoc, REPO, REPO_ROLES, rolesCopy, tmp, writeJson } from "./helpers.mjs";
function refuses(fn, pattern, exitCode = 2) {
assert.throws(fn, (error) => {
@@ -39,7 +39,7 @@ test("the fixture business validates and comes back frozen", () => {
assert.equal(business.roles.reviewer.holder, null);
assert.equal(business.roles.coder.vars["limits.network"], "none");
assert.equal(business.definitions.pm.roleVersion, 2);
assert.deepEqual(business.launch, { by: "chief", instances: ["coder", "reviewer"], max: { opus: 2, sonnet: 4 } });
assert.deepEqual(business.launch, launchDoc());
assert.ok(Object.isFrozen(business.roles.pm.credentials.gitea));
assert.throws(() => { business.roles.pm.holder = "x"; }, TypeError);
});
@@ -125,13 +125,13 @@ test("launch", () => {
refuses(() => check((d) => { d.launch.instances = ["coder", "coder"]; }), /duplicate/);
refuses(() => check((d) => { d.launch.instances = ["ghost"]; }), /instances names ghost/);
refuses(() => check((d) => { d.launch.instances = ["chief"]; }), /can't include the launcher itself/);
refuses(() => check((d) => { d.launch.max = {}; }), /at least one model family/);
refuses(() => check((d) => { d.launch.max = { glm: 1 }; }), /unknown model family "glm"/);
refuses(() => check((d) => { d.launch.max = { opus: 5 }; }), /from 0 to 4/);
refuses(() => check((d) => { d.launch.max = { sonnet: -1 }; }), /from 0 to 4/);
refuses(() => check((d) => { d.launch.max = { opus: 1.5 }; }), /from 0 to 4/);
refuses(() => check((d) => { d.launch.models = {}; }), /at least one model/);
refuses(() => check((d) => { d.launch.models = { glm: {} }; }), /provider/);
assert.equal(check((d)=>{d.launch.limits.total=50;}).launch.limits.total,50);
refuses(() => check((d) => { d.launch.limits.total = -1; }), /whole number/);
refuses(() => check((d) => { d.launch.limits.total = 1.5; }), /whole number/);
refuses(() => check((d) => { d.launch.cap = 1; }), /unsupported .* launch key/);
assert.deepEqual(check((d) => { d.launch.max = { opus: 0 }; }).launch.max, { opus: 0 });
assert.deepEqual(check((d) => { d.launch.limits = {total:0}; }).launch.limits, {total:0});
});
test("loadBusiness: file checks", () => {
@@ -196,7 +196,7 @@ test("names that are Object.prototype properties don't count as declared", () =>
refuses(() => check((d) => { d.arbiters.delivery = "constructor"; }), /arbiters\.delivery names constructor/);
refuses(() => check((d) => { d.launch.by = "constructor"; }), /launch\.by names constructor/);
refuses(() => check((d) => { d.launch.instances = ["constructor"]; }), /instances names constructor/);
refuses(() => check((d) => { d.launch.max = { constructor: 1 }; }), /unknown model family "constructor"/);
refuses(() => check((d) => { d.roles.pm.vars.model = "constructor"; }), /unknown model constructor/);
refuses(() => check((d) => { d.roles.pm.definition = "constructor"; }), /not found/, 4);
const labels = check((d) => { d.tracker.labels = JSON.parse('{"__proto__": 5}'); }).tracker.labels;
assert.equal(Object.getPrototypeOf(labels), Object.prototype);
@@ -9,7 +9,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { ACTIONS, BusinessError, classify, loadBusiness, loadRole, resolveInstance } from "../src/index.mjs";
import { businessDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs";
import { businessDoc, launchDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs";
// Decision 104's list plus role.launch (decision 139). A role file that
// differs from it fails.
@@ -58,7 +58,7 @@ test("the role file loads and resolves through loadBusiness", () => {
assert.equal(record.definition, "chief-of-staff");
assert.equal(record.contract, join(REPO_ROLES, "chief-of-staff.md"));
assert.equal(record.tracker, null);
assert.deepEqual(record.launch, { by: INSTANCE, instances: ["coder", "reviewer"], max: { opus: 2, sonnet: 4 } });
assert.deepEqual(record.launch, launchDoc(INSTANCE));
});
test("the shipped file is exactly the approved list", () => {
@@ -123,7 +123,7 @@ test("a limit that drops role.launch leaves the chief of staff with no launch bl
test("a launch block whose by is the pm instance is refused", () => {
refuses(
() => loadFixture((doc) => {
doc.launch = { by: "pm", instances: ["coder"], max: { sonnet: 1 } };
doc.launch = {...doc.launch, by: "pm", instances: ["coder"]};
}),
/launch\.by names pm, whose role pm doesn't hold role\.launch within-role/,
);
+8 -1
View File
@@ -72,7 +72,7 @@ export function businessDoc(root, id = "acme") {
// credential and no tracker bot.
chief: { definition: "chief-of-staff" },
},
launch: { by: "chief", instances: ["coder", "reviewer"], max: { opus: 2, sonnet: 4 } },
launch: launchDoc(),
};
}
@@ -99,3 +99,10 @@ export function systemFor(root) {
"execution.adapter": "mock",
};
}
// REQ-LIMIT-1 fixture policy: exact entries; these values are data, not ceilings.
export function launchDoc(by='chief',instances=['coder','reviewer'],counts={opus:2,sonnet:4}){
const models=Object.fromEntries(['claude-opus-5-5','claude-sonnet-5-5','glm-5.3-flash'].map(model=>[model,{provider:'zai',model}]));
const values={};if(counts.opus!==undefined)values['claude-opus-5-5']=counts.opus;if(counts.sonnet!==undefined)values['claude-sonnet-5-5']=counts.sonnet;
return {by,instances,models,limits:{models:{zai:values}}};
}
@@ -0,0 +1,57 @@
// REQ-LIMIT-1 / REQ-ROLE-3: tunable settings, exact routing, audited writes.
import test,{after} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync,statSync,writeFileSync,readdirSync,mkdirSync,symlinkSync,rmSync} from 'node:fs';
import {join} from 'node:path';
import {businessDoc,rolesCopy,tmp as makeRoot,writeJson} from './helpers.mjs';
import {validateBusinessDocument} from '../src/business.mjs';
const roots=[];after(()=>roots.forEach(root=>rmSync(root,{recursive:true,force:true})));
function fixture(){const root=makeRoot();roots.push(root);const rolesDir=rolesCopy(root),doc=businessDoc(root,'acme');for(const r of Object.values(doc.roles))r.vars={...r.vars,model:'sonnet'};doc.roles.pm.vars.model='opus';doc.launch={by:'chief',instances:['coder','reviewer'],models:{opus:{provider:'anthropic',model:'claude-opus-5-5'},sonnet:{provider:'anthropic',model:'claude-sonnet-5-5'}},limits:{models:{anthropic:{'claude-opus-5-5':4,'claude-sonnet-5-5':4}}}};const file=join(root,'businesses/acme.json');writeJson(file,doc);return {root,rolesDir,doc,file};}
test('REQ-LIMIT-1 schema: every scope unset or zero, adjustable above four, bad values and unknown models refuse',()=>{
const f=fixture(),check=d=>validateBusinessDocument(d,f.file,{rolesDir:f.rolesDir});
for(const limits of [{},{total:0},{providers:{anthropic:0}},{models:{anthropic:{'claude-opus-5-5':0}}},{total:100,providers:{anthropic:25},models:{anthropic:{'claude-opus-5-5':12}}}]){const d=structuredClone(f.doc);d.launch.limits=limits;assert.deepEqual(check(d).launch.limits,limits);}
for(const bad of [-1,1.2,null,'4']){const d=structuredClone(f.doc);d.launch.limits={total:bad};assert.throws(()=>check(d));}
const old=structuredClone(f.doc);old.launch={by:'chief',instances:['coder'],max:{opus:4,sonnet:4}};assert.throws(()=>check(old),/migrat/);
const missing=structuredClone(f.doc);missing.roles.coder.vars.model='not-listed';assert.throws(()=>check(missing),/model/);
});
test('REQ-LIMIT-1 counts each exact scope at/over limit, unlimited and old tuple after model reassignment',async()=>{
const {limitHit}=await import('../src/launch.mjs');const sessions=[{business:'acme',provider:'anthropic',model:'opus-exact'},{business:'other',provider:'anthropic',model:'opus-exact'}];
for(const limits of [{total:1},{providers:{anthropic:1}},{models:{anthropic:{'opus-exact':1}}}]){assert.ok(limitHit(sessions,'acme',limits,{provider:'anthropic',model:'opus-exact'}));assert.equal(limitHit(sessions,'acme',JSON.parse(JSON.stringify(limits).replaceAll(':1',':2')),{provider:'anthropic',model:'opus-exact'}),null);assert.ok(limitHit(sessions,'acme',JSON.parse(JSON.stringify(limits).replaceAll(':1',':0')),{provider:'anthropic',model:'opus-exact'}));}
for(const limits of [{total:0},{providers:{anthropic:0}},{models:{anthropic:{'opus-exact':0}}}])assert.ok(limitHit([],'acme',limits,{provider:'anthropic',model:'opus-exact'}));
assert.equal(limitHit(sessions,'acme',{}, {provider:'anthropic',model:'opus-exact'}),null);
assert.equal(limitHit(sessions,'acme',{models:{anthropic:{'sonnet-exact':1}}},{provider:'anthropic',model:'sonnet-exact'}),null);
});
test('REQ-ROLE-3 writer: atomic validated revision, immutable previous bytes and authenticated actor/old/new receipt',async()=>{
const {organizationWriter}=await import('../src/organization.mjs');const f=fixture(),w=organizationWriter({dir:f.root,rolesDir:f.rolesDir});const before=readFileSync(f.file,'utf8'),view=w.read('acme');
const next=structuredClone(f.doc);next.launch.limits.total=99;const done=w.write('acme',next,{revision:view.revision,actor:'jason'});
assert.equal(JSON.parse(readFileSync(f.file,'utf8')).launch.limits.total,99);assert.notEqual(done.revision,view.revision);assert.equal(readFileSync(done.previous,'utf8'),before);
const events=readFileSync(w.logFile('acme'),'utf8').trim().split('\n').map(JSON.parse);const e=events.at(-1);assert.equal(e.actor,'jason');assert.equal(e.state,'committed');assert.ok(e.changes.some(c=>c.path==='launch.limits.total'&&c.new===99));
const bytes=readFileSync(f.file,'utf8');assert.throws(()=>w.write('acme',next,{revision:view.revision,actor:'jason'}),/revision/);next.launch.limits.total=-1;assert.throws(()=>w.write('acme',next,{revision:done.revision,actor:'jason'}));assert.equal(readFileSync(f.file,'utf8'),bytes);next.launch.limits.total=100;w.write('acme',next,{revision:done.revision,actor:'jason'});const last=JSON.parse(readFileSync(w.logFile('acme'),'utf8').trim().split('\n').at(-1));assert.ok(last.changes.some(c=>c.path==='launch.limits.total'&&c.old===99&&c.new===100));
});
test('REQ-ROLE-3 writer: pre-rename fault leaves old file, post-rename recovery records commit, conflicting recovery refuses',async()=>{
const {organizationWriter}=await import('../src/organization.mjs');const f=fixture(),w=organizationWriter({dir:f.root,rolesDir:f.rolesDir});const before=readFileSync(f.file,'utf8'),revision=w.read('acme').revision,next=structuredClone(f.doc);next.launch.limits.total=7;
const crash=organizationWriter({dir:f.root,rolesDir:f.rolesDir,barrier:stage=>{if(stage==='prepared')throw Error('fixture crash');}});assert.throws(()=>crash.write('acme',next,{revision,actor:'jason'}));assert.equal(readFileSync(f.file,'utf8'),before);
assert.equal(w.read('acme').revision,revision);w.recover('acme');assert.equal(w.read('acme').revision,revision);
const committed=organizationWriter({dir:f.root,rolesDir:f.rolesDir,barrier:stage=>{if(stage==='renamed')throw Error('fixture after rename');}});assert.throws(()=>committed.write('acme',next,{revision,actor:'jason'}));w.recover('acme');assert.equal(w.read('acme').document.launch.limits.total,7);assert.equal(JSON.parse(readFileSync(w.logFile('acme'),'utf8').trim().split('\n').at(-1)).state,'committed');
});
test('REQ-LIMIT-1 / decision145 migration prints only, preserves values, rejects ambiguity, and is idempotent',async()=>{
const {migrateLaunch}=await import('../src/migrate-launch.mjs');const f=fixture(),old=structuredClone(f.doc);old.roles.pm.vars.model='claude-opus-5-5';for(const [name,r] of Object.entries(old.roles))if(name!=='pm')r.vars.model='claude-sonnet-5-5';old.launch={by:'chief',instances:['coder','reviewer'],max:{opus:4,sonnet:4}};writeJson(f.file,old);const bytes=readFileSync(f.file,'utf8'),mtime=statSync(f.file).mtimeMs;
const mapping={models:f.doc.launch.models,families:{opus:'opus',sonnet:'sonnet'}};const next=migrateLaunch(old,mapping,f.file,{rolesDir:f.rolesDir});assert.deepEqual(next.launch.limits, f.doc.launch.limits);assert.equal(next.roles.pm.vars.model,'opus');assert.equal(next.roles.coder.vars.model,'sonnet');assert.deepEqual(migrateLaunch(next,mapping,f.file,{rolesDir:f.rolesDir}),next);
assert.throws(()=>migrateLaunch(old,{...mapping,families:{opus:'missing',sonnet:'sonnet'}},f.file,{rolesDir:f.rolesDir}));assert.throws(()=>migrateLaunch(old,{...mapping,families:{opus:'opus',sonnet:'opus'}},f.file,{rolesDir:f.rolesDir}),/ambiguous/);assert.throws(()=>migrateLaunch(old,{...mapping,models:{...mapping.models,proxy:{provider:'proxy',model:'claude-opus-5-5'}},instances:{pm:'proxy'}},f.file,{rolesDir:f.rolesDir}),/ambiguous/);const onlyOpus=structuredClone(old);onlyOpus.launch.max={opus:4};assert.throws(()=>migrateLaunch(onlyOpus,mapping,f.file,{rolesDir:f.rolesDir}),/unknown or ambiguous/);const allOpus=structuredClone(old);for(const r of Object.values(allOpus.roles))r.vars.model='claude-opus-5-5';assert.throws(()=>migrateLaunch(allOpus,{...mapping,families:{opus:'opus',sonnet:'opus'}},f.file,{rolesDir:f.rolesDir}),/ambiguous/);assert.equal(readFileSync(f.file,'utf8'),bytes);assert.equal(statSync(f.file).mtimeMs,mtime);
});
test('REQ-LIMIT-1 shared settings validates the complete proposed selection and logs exact actor',async()=>{
const {launchSettings}=await import('../src/launch-settings.mjs');const f=fixture(),system={environment:'development',dataRoot:join(f.root,'data'),'execution.backend':'docker','execution.provider':'anthropic','execution.model':'sonnet','execution.adapter':'pi'};const settings=launchSettings({dir:f.root,rolesDir:f.rolesDir,system,sessions:()=>[{business:'acme',provider:'anthropic',model:'claude-opus-5-5'}]});const view=settings.read('acme');assert.equal(view.usage.total,1);assert.equal(view.instances.pm.modelKey,'opus');
const result=settings.set('acme',{revision:view.revision,limits:{total:0},instances:{coder:'opus'}},'jason');assert.equal(result.limits.total,0);assert.equal(result.instances.coder.modelKey,'opus');const models=structuredClone(result.models);models.opus.provider='proxy';const updated=settings.set('acme',{revision:result.revision,models},'jason');assert.equal(updated.instances.pm.provider,'proxy');assert.throws(()=>settings.set('acme',{revision:result.revision,instances:{coder:'ghost'}},'jason'));assert.throws(()=>settings.set('acme',{revision:result.revision,roles:{}},'jason'));
});
test('REQ-LIMIT-1 provider is table data: another provider routes separately and aliases share exact counts',async()=>{
const {limitHit}=await import('../src/launch.mjs');const {sessionModel}=await import('../../harness/src/bundle.mjs');
const models={alias:{provider:'proxy-next',model:'claude-opus-5-5'}},selected=sessionModel({vars:{harness:'claude-code',model:'alias'}},models);assert.equal(selected.provider,'proxy-next');assert.equal(selected.model,'claude-opus-5-5');
const sessions=[{business:'acme',provider:'anthropic',model:selected.model}];assert.equal(limitHit(sessions,'acme',{providers:{'proxy-next':1},models:{'proxy-next':{[selected.model]:1}}},selected),null);sessions.push({business:'acme',provider:'proxy-next',model:selected.model});assert.equal(limitHit(sessions,'acme',{providers:{'proxy-next':1}},selected).scope,'provider');assert.equal(limitHit(sessions,'acme',{models:{'proxy-next':{[selected.model]:1}}},selected).scope,'model');
assert.equal(limitHit([],'acme',{providers:{constructor:0}},{provider:'constructor',model:'toString'}).running,0);
});
test('REQ-ROLE-3 refuses live writer locks, symlink targets and uncertain recovery without changing bytes',async()=>{
const {organizationWriter}=await import('../src/organization.mjs');const f=fixture(),w=organizationWriter({dir:f.root,rolesDir:f.rolesDir}),revision=w.read('acme').revision,next=structuredClone(f.doc);next.launch.limits.total=8;
const crash=organizationWriter({dir:f.root,rolesDir:f.rolesDir,barrier:()=>{throw Error('fixture interruption');}});assert.throws(()=>crash.write('acme',next,{revision,actor:'jason'}));const external=structuredClone(f.doc);external.launch.limits.total=12;writeJson(f.file,external);const bytes=readFileSync(f.file,'utf8');assert.throws(()=>w.recover('acme'),/match prepared/);assert.equal(readFileSync(f.file,'utf8'),bytes);
const g=fixture(),gw=organizationWriter({dir:g.root,rolesDir:g.rolesDir}),gr=gw.read('acme').revision,lock=join(g.root,'businesses/.history/acme/write.lock');mkdirSync(lock,{recursive:true,mode:0o700});writeJson(join(lock,'owner.json'),{pid:process.pid});assert.throws(()=>gw.write('acme',g.doc,{revision:gr,actor:'jason'}),/busy/);rmSync(lock,{recursive:true});const target=join(g.root,'target.json');writeJson(target,g.doc);rmSync(g.file);symlinkSync(target,g.file);assert.throws(()=>gw.write('acme',g.doc,{revision:gr,actor:'jason'}));assert.equal(JSON.parse(readFileSync(target,'utf8')).id,'acme');
});
+1 -1
View File
@@ -39,7 +39,7 @@ test("systemVars flattens the validated config", () => {
test("precedence: system, business, project, project role, agent", () => {
const s = setup((d) => {
d.roles.coder.vars.model = "agent-model";
d.roles.coder.vars.model = "agent-model";d.launch.models["agent-model"]={provider:"zai",model:"agent-model"};
}, {
vars: { "tracker.pollSeconds": 20, "tracker.project": 3 },
roles: { coder: { vars: { "tracker.pollSeconds": 15 } } },
+30 -2
View File
@@ -263,8 +263,8 @@ is `{"ok": true, "result": {run, instance, harness, model}}` or
| its role has a contract file | `no-role-contract` |
| a Claude Code session has `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, or `ANTHROPIC_AUTH_TOKEN` with `ANTHROPIC_BASE_URL` to log in with | `no-session-credential` |
| bwrap runs here | `sandbox-unavailable` |
| its model names exactly one `launch.max` family | `unknown-model-family` |
| that family has room; every running session counts, the PM's too | `capacity-full` |
| its model selects an exact entry in `launch.models` | `unsupported-harness` |
| total, provider and exact provider/model limits have room; the PM counts too | `capacity-full` |
| the broker authorizes `role.launch` for the caller | the broker's code (`launch-revoked`, `decision-required`, …) |
| the workspace has a clone of each project, or the host can make one | `checkout-failed`, with the project id |
| the bundle builds and the runner starts | `launch-failed` |
@@ -628,3 +628,31 @@ exit 3, so a host never starts until someone decides whether it DMs.
## Not in this piece
The WebUI (S5).
## Agent limits and models
`mosaic limits [--business ID] [--json]` reads the model table, each role's
selection, limits and running counts. `mosaic limits set --business ID
--file PATCH` submits JSON with the read's `revision` and any of `limits`,
`models`, `instances`. `limits` and `models` replace those entire objects;
`instances` maps role instance names to model-table keys. Both commands use
the existing human proof and broker; neither is available to a managed role
or tester. The Console Settings form uses the same write path and requires
signed-in send mode, named Host, exact Origin and same-origin Fetch Metadata.
Unset limits mean unlimited; zero blocks new launches. Limits are checked
afresh at each launch in total, per provider and per exact provider/model.
Lowering one leaves running sessions alive and counted against their recorded
provider/model, including the PM. Capacity refusals log the scope, configured
limit and running count; launches log harness, provider and external model.
`mosaic limits migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]`
needs no bus. If a role inherits its model, it reads and validates the system
config and requires the mapping to preserve both provider and exact model.
Missing or invalid config refuses. It prints the whole converted document and never writes, including
on refusal. There is no `--write`. Mapping JSON supplies `models` (keys to
provider/model pairs), `families` (old family names to keys), and optional
`instances` or `selections` (old model strings to keys). Unknown or ambiguous
mappings refuse. Every held role must map too. A migrated file prints unchanged.
Jason installs the output himself in Mos's window with the PM run idle, and
the re-pin follows in the same window (decision 145).
+3
View File
@@ -13,6 +13,7 @@ import { join } from "node:path";
import { createInterface } from "node:readline/promises";
import { fileURLToPath } from "node:url";
import { BINDING_NAME } from "../../discord/src/binding.mjs";
import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
import { CliError } from "./errors.mjs";
import { bootConfig, loadSystem, socketPath } from "./config.mjs";
import { humanTransport, refuseInsideAgent, testerTransport } from "./transport.mjs";
@@ -24,6 +25,7 @@ import { readSessions, stopSession } from "../../seat/src/session.mjs";
import { SeatError } from "../../seat/src/seat.mjs";
export const USAGE = `usage:
${LIMITS_USAGE}
mosaic inbox [--business <id>] [--json] [--tester]
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
mosaic tasks [--business <id>] [--json] [--tester]
@@ -379,6 +381,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (argv.length === 0) return app(io, deps);
const [verb, ...rest] = argv;
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
if (verb === "talk") return talk(rest, io, deps);
if (verb === "follow") return talk(rest, io, deps, true);
if (verb === "stop") return stop(rest, io, deps);
+1
View File
@@ -94,6 +94,7 @@ export function bootConfig({ system, businessId, env = process.env, warn = () =>
const config = {
dataRoot: system.dataRoot,
businesses: { [b.id]: busBusiness(b, resolved) },
organization: {dir:configDir(env),rolesDir:rolesDir(env),system:vars},
launches: [],
readers: [b.id],
};
+17 -15
View File
@@ -16,8 +16,8 @@
// 5. a Claude Code session has a credential to log in with
// (no-session-credential)
// 6. bwrap runs here (sandbox-unavailable)
// 7. its model is in exactly one launch.max family (unknown-model-family)
// 8. that family has room; every running session counts, the PM's too
// 7. its model names an exact provider/model table entry
// 8. total, provider and provider/model have room, including the launcher
// (capacity-full)
// 9. the broker authorizes role.launch for the caller (its own codes:
// launch-revoked, gated, ...)
@@ -45,12 +45,12 @@ import { createServer } from "node:net";
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
import { limitHit } from "../../business/src/launch.mjs";
import { buildBundle, sessionModel } from "../../harness/src/bundle.mjs";
import {
ADAPTERS,
appendLaunchLog,
endReason,
family,
hasSessionCredential,
gitSocketPath,
launchSocketPath,
@@ -120,7 +120,7 @@ export function createLauncher({
log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`),
}) {
const dataRoot = system.dataRoot;
const b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) });
let b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) });
const vars = systemVars(system);
// Sessions an earlier host left in sessions.json: it died without ending
@@ -157,13 +157,14 @@ export function createLauncher({
// Steps 2 to 8. Returns what the launch needs. Only `--pm` may name
// launch.by itself.
function check(instance, { pm = false } = {}) {
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } 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))) throw new Refusal("instance-not-listed");
if (registry.running(b.id, instance)) throw new Refusal("instance-running");
let resolved, model;
try {
resolved = resolveInstance({ system: vars, business: b, instance });
model = sessionModel(resolved);
model = sessionModel(resolved, b.launch.models);
} catch (e) {
throw new Refusal("unsupported-harness", e.message);
}
@@ -174,14 +175,13 @@ export function createLauncher({
}
const unavailable = sandboxProblem({ bwrap });
if (unavailable) throw new Refusal("sandbox-unavailable", unavailable);
const fam = family(model.model, b.launch.max);
if (!fam) throw new Refusal("unknown-model-family", `model ${model.model} is in none, or more than one, of ${Object.keys(b.launch.max).join(", ")}`);
if (registry.count(b.id, fam) >= b.launch.max[fam]) throw new Refusal("capacity-full", `${fam} sessions: ${registry.count(b.id, fam)} of ${b.launch.max[fam]}`);
return { resolved, model, fam };
const hit=limitHit(registry.sessions,b.id,b.launch.limits,model);
if(hit){const error=new Refusal("capacity-full",JSON.stringify(hit));error.limit=hit;throw error;}
return { resolved, model };
}
// Bundle, spawn, bind, register, hand over the capability.
async function start({ instance, launchedBy, resolved, model, fam }) {
async function start({ instance, launchedBy, resolved, model }) {
const credentials = await host.op({ op: "credentialStatus", business: b.id, role: instance });
// The clones come before the run, so one that can't be made leaves no
// run behind. The host's git treats each clone as hostile, and its
@@ -201,6 +201,7 @@ export function createLauncher({
if (c.outcome === "kept") record({ event: "checkout-kept", instance, project: c.project, reason: c.reason });
else if (c.outcome === "fetched") record({ event: "checkout-fetched", instance, project: c.project, reason: c.reason });
}
({resolved,model}=check(instance,{pm:instance===b.launch.by}));
const { run, runDir } = newRun(dataRoot, b.id);
const sessionDir = join(runDir, "session");
mkdirSync(sessionDir, { mode: 0o700 });
@@ -210,6 +211,7 @@ export function createLauncher({
const bundle = buildBundle({
dir: join(runDir, "bundle"),
resolved,
models: b.launch.models,
contract: readFileSync(resolved.contract, "utf8"),
arbiter: Object.values(b.arbiters).includes(instance),
tracker,
@@ -266,7 +268,7 @@ export function createLauncher({
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, pid: child.pid, startTime: outerStart });
bound = true;
entry = {
business: b.id, instance, run, launchedBy, harness: model.harness, model: model.model, family: fam,
business: b.id, instance, run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey,
pid: child.pid, startTime: outerStart, runnerPid: runner.pid, runnerStartTime: runner.startTime, runDir, startedAt: new Date().toISOString(),
};
registry.add(entry);
@@ -291,7 +293,7 @@ export function createLauncher({
children.delete(run);
}
});
record({ event: "launch", instance, run, launchedBy, harness: model.harness, model: model.model, family: fam, pid: child.pid, runnerPid: entry.runnerPid, manifest });
record({ event: "launch", instance, run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey, pid: child.pid, runnerPid: entry.runnerPid, manifest });
return { run, instance, harness: model.harness, model: model.model };
}
@@ -324,7 +326,7 @@ export function createLauncher({
} catch (e) {
if (!(e instanceof Refusal)) throw e;
await host.op({ op: "refuse", cap, code: e.code }).catch(() => {});
record({ event: "refused", by: who.role, instance, code: e.code, why: e.message });
record({ event: "refused", by: who.role, instance, code: e.code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
return { ok: false, error: e.code };
}
try {
@@ -339,7 +341,7 @@ export function createLauncher({
const code = e instanceof Refusal ? e.code : "launch-failed";
log(`launch of ${instance} for ${who.role} failed: ${e.message}`);
await host.op({ op: "refuse", cap, code }).catch(() => {});
record({ event: "refused", by: who.role, instance, code, why: e.message });
record({ event: "refused", by: who.role, instance, code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
return { ok: false, error: code };
}
});
@@ -353,7 +355,7 @@ export function createLauncher({
return await start({ instance: b.launch.by, launchedBy: b.human, ...check(b.launch.by, { pm: true }) });
} catch (e) {
if (!(e instanceof Refusal)) throw e;
record({ event: "refused", by: b.human, instance: b.launch.by, code: e.code, why: e.message });
record({ event: "refused", by: b.human, instance: b.launch.by, code: e.code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
throw new CliError(`PM launch refused: ${e.code}${e.message !== e.code ? ` (${e.message})` : ""}`, 3);
}
});
+59
View File
@@ -0,0 +1,59 @@
// REQ-LIMIT-1: human settings; decision 145 migration is a separate print-only read.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { loadSystem } from "./config.mjs";
import { systemVars } from "../../business/src/index.mjs";
import { CliError } from "./errors.mjs";
import { migrateLaunch } from "../../business/src/migrate-launch.mjs";
export const LIMITS_USAGE = "mosaic limits [--business ID] [--json] | set --file PATCH [--business ID] | " +
"migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]";
export function parseLimits(args) {
const out = { positional: [], flags: new Set(), values: {} };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json") out.flags.add(arg);
else if (["--file", "--models", "--roles-dir", "--business"].includes(arg)) {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(LIMITS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(LIMITS_USAGE, 4);
else out.positional.push(arg);
}
return out;
}
export async function limitsCommand(args, io, callFor) {
const parsed = parseLimits(args);
const verb = parsed.positional[0] ?? "view";
const values = parsed.values;
if (parsed.positional.length > 1) throw new CliError(LIMITS_USAGE, 4);
try {
if (verb === "migrate") {
if (!values["--file"] || !values["--models"] || values["--business"]) {
throw new CliError(LIMITS_USAGE, 4);
}
const file = values["--file"];
const raw = JSON.parse(readFileSync(file, "utf8"));
const mapping = JSON.parse(readFileSync(values["--models"], "utf8"));
const rolesDir = values["--roles-dir"] ?? fileURLToPath(new URL("../../../roles", import.meta.url));
const inherits = raw.launch?.max && Object.values(raw.roles ?? {}).some(role => role.vars?.model === undefined);
const system = inherits ? systemVars(loadSystem({ env: io.env })) : null;
const converted = migrateLaunch(raw, mapping, file, { rolesDir, system });
io.stdout.write(JSON.stringify(converted, null, 2) + "\n");
return;
}
if (!["view", "set"].includes(verb) || values["--models"] || values["--roles-dir"] ||
(verb === "view" && values["--file"]) || (verb === "set" && !values["--file"])) {
throw new CliError(LIMITS_USAGE, 4);
}
const call = callFor(parsed);
const result = verb === "set"
? await call("limits.set", JSON.parse(readFileSync(values["--file"], "utf8")))
: await call("limits", {});
io.stdout.write(JSON.stringify(result, null, 2) + "\n");
} catch (error) {
if (error instanceof CliError) throw error;
throw new CliError(error.message, 2);
}
}
+20
View File
@@ -0,0 +1,20 @@
// REQ-LIMIT-1: pure launch-policy checks; no sandbox, Docker or session process.
import test from 'node:test';
import assert from 'node:assert/strict';
import {join} from 'node:path';
import {readFileSync,writeFileSync} from 'node:fs';
import {createLauncher} from '../src/launcher.mjs';
import {fixture,tmp} from './helpers.mjs';
import {systemFor,writeJson} from '../../business/tests/helpers.mjs';
import {launchLogFile} from '../../seat/src/session.mjs';
test('each launch rereads limits and model selections; lowered limits keep sessions and log exact hit',async t=>{
const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars.model='claude-sonnet-5-5';return doc;}),file=join(root,'config/businesses/acme.json'),ops=[];
const host={business:'acme',beforeClose:()=>{},op:async x=>{ops.push(x);if(x.op==='identity')return {business:'acme',role:'pm'};if(x.op==='authorizeLaunch')throw Object.assign(Error('fixture authorization stop'),{code:'fixture-policy'});return {};}};
const launcher=createLauncher({host,system:JSON.parse(readFileSync(f.config,'utf8')),env:f.env,bwrap:'/usr/bin/true',log:()=>{}});
const session={business:'acme',instance:'reviewer',run:'fixture-existing',harness:'pi',provider:'zai',model:'claude-sonnet-5-5'};launcher.registry.add(session);
const update=limits=>{f.doc.launch.limits=limits;writeJson(file,f.doc);};
for(const [limits,scope] of [[{total:0},'total'],[{providers:{zai:1}},'provider'],[{models:{zai:{'claude-sonnet-5-5':1}}},'model']]){update(limits);assert.deepEqual(await launcher.fromSession('fixture-cap','coder'),{ok:false,error:'capacity-full'});const event=JSON.parse(readFileSync(launchLogFile(f.dataRoot,'acme'),'utf8').trim().split('\n').at(-1));assert.equal(event.limit.scope,scope);assert.equal(event.limit.running,1);assert.deepEqual(launcher.registry.sessions,[session]);}
update({total:2});assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy');
f.doc.launch.models['claude-sonnet-5-5'].provider='another-provider';update({models:{zai:{'claude-sonnet-5-5':1}}});assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy');assert.equal(launcher.registry.sessions[0].provider,'zai');
writeFileSync(file,'invalid JSON');assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'organization-refused');assert.deepEqual(launcher.registry.sessions,[session]);
});
+13 -4
View File
@@ -1,3 +1,4 @@
import { launchDoc } from "../../business/tests/helpers.mjs";
// The S6 launcher inside a real bus host: real broker child, real runners
// in the bwrap sandbox, the fake adapter from packages/harness standing in for the
// harness. The chief of staff launches a coder through its `launch` tool (row 119: the
@@ -33,7 +34,8 @@ function edit(doc) {
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5" };
doc.roles.coder.vars.model = "claude-sonnet-5-5";
doc.roles.reviewer.vars = { harness: "pi", model: "claude-sonnet-5-5" };
doc.launch = { by: "chief", instances: ["coder", "reviewer", "cto"], max: { opus: 1, sonnet: 1 } };
doc.launch = launchDoc("chief",["coder","reviewer","cto"],{opus:1,sonnet:1});
doc.launch.limits.models.zai["glm-5.3-flash"]=0;
return doc;
}
@@ -130,7 +132,14 @@ function ask(path, line) {
const log = (f) => readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
test("bus start --pm: the launcher instance (the chief of staff) runs under its own PID namespace, registered, with a manifest", { skip, timeout: 30000 }, async (t) => {
const { f, launcher, host, cto, close } = await setup(t);
// Row 122: uncertain write receipts cannot prevent the window's first chief launch.
const prepared = prepare(t);
const history = join(prepared.root, "config", "businesses", ".history", "acme");
mkdirSync(history, { recursive: true, mode: 0o700 });
writeFileSync(join(history, "changes.jsonl"), JSON.stringify({
state: "prepared", transaction: "fixture-uncertain", before: "a".repeat(64), after: "b".repeat(64),
}) + "\n", { mode: 0o600 });
const { f, launcher, host, cto, close } = await setup(t, doc => doc, prepared);
const sock = launchSocketPath(f.dataRoot);
assert.equal(statSync(sock).mode & 0o777, 0o600);
@@ -141,7 +150,7 @@ test("bus start --pm: the launcher instance (the chief of staff) runs under its
const [s] = readSessions(f.dataRoot);
assert.equal(s.run, chief.run);
assert.equal(s.live, true);
assert.equal(s.family, "opus");
assert.equal(s.provider, "zai");
assert.equal(s.launchedBy, "jason");
assert.notEqual(s.pid, s.runnerPid, "the runner is the bwrap process's child");
assert.equal(statSync(sessionsFile(f.dataRoot)).mode & 0o777, 0o600);
@@ -252,7 +261,7 @@ test("the chief of staff launches a coder through its launch tool; the coder ans
["nobody", "instance-not-listed"],
["coder", "instance-running"],
["reviewer", "capacity-full"],
["cto", "unknown-model-family"],
["cto", "capacity-full"],
];
for (const [instance, code] of refusals) {
assert.deepEqual(await ask(sock, `${JSON.stringify({ cap: cto.cap, instance })}\n`), { ok: false, error: code }, instance);
+47
View File
@@ -0,0 +1,47 @@
// REQ-LIMIT-1 and decision145: human transport and print-only migration.
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync,statSync} from 'node:fs';
import {join} from 'node:path';
import {main} from '../src/cli.mjs';
import {limitsCommand} from '../src/limits.mjs';
import {fixture,io,tmp} from './helpers.mjs';
import {writeJson} from '../../business/tests/helpers.mjs';
test('migration needs no bus/config, prints whole converted document, is idempotent and never writes',async t=>{
const root=tmp(t),f=fixture(root,'acme'),file=join(root,'config/businesses/acme.json');
const old=f.doc;old.roles.pm.vars.model='claude-opus-5-5';old.roles.cto.vars.model='claude-opus-5-5';old.roles.coder.vars.model='claude-sonnet-5-5';old.roles.reviewer.vars={model:'claude-opus-5-5'};old.roles.chief.vars={model:'claude-opus-5-5'};old.launch={by:'chief',instances:['coder','reviewer'],max:{opus:4,sonnet:4}};writeJson(file,old);
const mapping={models:{opus:{provider:'anthropic',model:'claude-opus-5-5'},sonnet:{provider:'anthropic',model:'claude-sonnet-5-5'}},families:{opus:'opus',sonnet:'sonnet'},instances:{pm:'opus',cto:'opus',coder:'sonnet',reviewer:'opus',chief:'opus'}};
const map=writeJson(join(root,'mapping.json'),mapping),args=['limits','migrate','--file',file,'--models',map,'--roles-dir',f.roles],bytes=readFileSync(file,'utf8'),mtime=statSync(file).mtimeNs??statSync(file).mtimeMs;
const x=io({MOSAIC_CONFIG:'/nonexistent'});await main(args,x,{transport:()=>assert.fail('migration contacted bus')});const migrated=JSON.parse(x.out.text);
assert.equal(migrated.roles.cto.vars.model,'opus');assert.deepEqual(migrated.launch.limits,{models:{anthropic:{'claude-opus-5-5':4,'claude-sonnet-5-5':4}}});assert.equal(migrated.roles.pm.vars.harness,'pi');assert.equal(readFileSync(file,'utf8'),bytes);assert.equal(statSync(file).mtimeNs??statSync(file).mtimeMs,mtime);
for(const bad of [{...mapping,families:{opus:'missing',sonnet:'sonnet'}},{...mapping,families:{opus:'opus',sonnet:'opus'}}]){writeJson(map,bad);await assert.rejects(main(args,io(),{}));assert.equal(readFileSync(file,'utf8'),bytes);assert.equal(statSync(file).mtimeMs,mtime);}
writeJson(map,mapping);const migratedFile=writeJson(join(root,'acme.json'),migrated),before=readFileSync(migratedFile,'utf8'),mt=statSync(migratedFile).mtimeMs,y=io();await main(['limits','migrate','--file',migratedFile,'--models',map,'--roles-dir',f.roles],y,{});assert.deepEqual(JSON.parse(y.out.text),migrated);assert.equal(readFileSync(migratedFile,'utf8'),before);assert.equal(statSync(migratedFile).mtimeMs,mt);
await assert.rejects(main([...args,'--write'],io(),{}),/mosaic limits/);
});
test('limits view/set use the human bus transport and pass only the explicit patch',async t=>{
const root=tmp(t),patch={revision:'a'.repeat(64),limits:{total:9},instances:{coder:'opus'}},file=writeJson(join(root,'patch.json'),patch),calls=[];
const deps={system:{dataRoot:root},transport:business=>(verb,args)=>{calls.push({business,verb,args});return {revision:'b'.repeat(64),limits:args.limits??{}};}};
const x=io();await main(['limits','--business','acme','--json'],x,deps);await main(['limits','set','--business','acme','--file',file],io(),deps);
assert.deepEqual(calls,[{business:'acme',verb:'limits',args:{}},{business:'acme',verb:'limits.set',args:patch}]);assert.equal(JSON.parse(x.out.text).revision,'b'.repeat(64));
await assert.rejects(main(['limits','--business','acme'],io({CODEX_THREAD_ID:'fixture-agent'}),deps),/agent/i);assert.equal(calls.length,2);
});
test('post-119 example and Jason fixture migrate with checked inherited models; wrong or absent config refuses',async t=>{
const {execFileSync}=await import('node:child_process');const {validateBusinessDocument}=await import('../../business/src/business.mjs');
const root=tmp(t),f=fixture(root,'acme'),doc=JSON.parse(execFileSync('git',['show','94e41ae29:packages/business/examples/mosaic-stack.example.json'],{encoding:'utf8'}));
doc.id='acme';let bot=10;for(const r of Object.values(doc.roles))if(r.tracker)r.tracker.botId=bot++;
doc.tracker.sync.botId=bot;for(const r of [...Object.values(doc.roles),{credentials:doc.tracker.sync.credentials}])for(const ref of Object.values(r.credentials??{})){if(ref.rotateBy)ref.rotateBy='2099-01-01';if(ref.expires)ref.expires='2099-01-01';}
const models={opus:{provider:'anthropic',model:'claude-opus-5-5'},sonnet:{provider:'anthropic',model:'claude-sonnet-5-5'}},instances=Object.fromEntries(Object.keys(doc.roles).map(k=>[k,'opus']));
const mapping={models,families:{opus:'opus',sonnet:'sonnet'},instances},map=writeJson(join(root,'mapping.json'),mapping),file=writeJson(join(root,'config/businesses/acme.json'),doc);
const config=JSON.parse(readFileSync(f.config,'utf8'));config.execution.provider='anthropic';config.execution.model='claude-opus-5-5';writeJson(f.config,config);
const args=['limits','migrate','--file',file,'--models',map,'--roles-dir',f.roles],run=async()=>{const x=io(f.env);await main(args,x,{});return JSON.parse(x.out.text);};
const before=readFileSync(file,'utf8'),mt=statSync(file).mtimeMs,configBytes=readFileSync(f.config,'utf8'),configMt=statSync(f.config).mtimeMs;
let converted=await run();assert.equal(converted.launch.by,'chief');assert.equal(converted.roles.assistant.holder,'mos');assert.equal(converted.roles.chief.vars.model,'opus');assert.equal(converted.roles.assistant.vars.model,'opus');validateBusinessDocument(converted,file,{rolesDir:f.roles});assert.equal(readFileSync(file,'utf8'),before);assert.equal(statSync(file).mtimeMs,mt);assert.equal(readFileSync(f.config,'utf8'),configBytes);assert.equal(statSync(f.config).mtimeMs,configMt);
// Jason's known four explicit selections plus 119's inherited chief/assistant.
for(const [name,key]of Object.entries({pm:'opus',cto:'opus',coder:'sonnet',reviewer:'opus'})){doc.roles[name].vars={...doc.roles[name].vars,model:models[key].model};mapping.instances[name]=key;}
writeJson(file,doc);writeJson(map,mapping);const jasonBytes=readFileSync(file,'utf8'),jasonMt=statSync(file).mtimeMs;converted=await run();validateBusinessDocument(converted,file,{rolesDir:f.roles});assert.equal(converted.roles.coder.vars.model,'sonnet');assert.equal(converted.roles.chief.vars.model,'opus');assert.equal(readFileSync(f.config,'utf8'),configBytes);assert.equal(statSync(f.config).mtimeMs,configMt);
for(const bad of [{...mapping,instances:{...mapping.instances,assistant:'sonnet'}},{...mapping,models:{...models,opus:{...models.opus,provider:'wrong-provider'}}}]){writeJson(map,bad);await assert.rejects(run(),/inherited provider\/model/);assert.equal(readFileSync(file,'utf8'),jasonBytes);assert.equal(statSync(file).mtimeMs,jasonMt);}
writeJson(map,mapping);await assert.rejects(main(args,io({...f.env,MOSAIC_CONFIG:join(root,'missing.json')}),{}),/system config/);assert.equal(readFileSync(file,'utf8'),jasonBytes);assert.equal(statSync(file).mtimeMs,jasonMt);writeJson(f.config,'invalid JSON');await assert.rejects(run(),/system config/);assert.equal(readFileSync(file,'utf8'),jasonBytes);assert.equal(statSync(file).mtimeMs,jasonMt);
writeJson(f.config,configBytes);assert.equal(readFileSync(f.config,'utf8'),configBytes);assert.equal(readFileSync(file,'utf8'),jasonBytes);
});
+4 -3
View File
@@ -46,12 +46,13 @@ const quote = (s) => {
// The harness, provider and model one instance runs with. The agent-layer
// `harness` and `model` variables win; otherwise the system's execution
// adapter and model. Anything outside the two harnesses refuses.
export function sessionModel(resolved) {
export function sessionModel(resolved, models = null) {
const v = resolved.vars;
const harness = v.harness ?? v["execution.adapter"];
if (!HARNESSES.includes(harness)) throw new BundleError(`instance ${resolved.instance}: harness ${JSON.stringify(harness)} isn't one of ${HARNESSES.join(", ")}`);
const model = v.model ?? v["execution.model"];
if (typeof model !== "string" || !model) throw new BundleError(`instance ${resolved.instance}: no model`);
if(models!==null){const entry=Object.hasOwn(models,model)?models[model]:null;if(!entry)throw new BundleError(`instance ${resolved.instance}: unknown model table selection ${model}`);return {harness,provider:entry.provider,model:entry.model,modelKey:model,thinking:v.thinking??null};}
return { harness, provider: v["execution.provider"] ?? null, model, thinking: v.thinking ?? null };
}
@@ -113,8 +114,8 @@ function prompt({ contract, resolved, session, tools, untyped }) {
// piVersion the pinned pi version, for the manifest
// claudeVersion the host's `claude --version`, for the manifest
// Returns { manifest, files } with absolute paths.
export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, credentials = [], session, piVersion = null, claudeVersion = null }) {
const model = sessionModel(resolved);
export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, credentials = [], session, piVersion = null, claudeVersion = null, models = null }) {
const model = sessionModel(resolved, models);
const tools = typedTools({ resolved, arbiter, tracker, projects: (session.checkouts ?? []).map((c) => c.project) });
const untyped = untypedActions(resolved, tools);
mkdirSync(dir, { recursive: true, mode: 0o700 });
+4 -1
View File
@@ -24,7 +24,10 @@ async function session(t, script, place = (dir) => join(dir, "ws"), instance = "
writeFileSync(join(dir, "secret.txt"), "outside\n");
const api = await mockAnthropic(t, { script, done: (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 80)]))}` });
writeFileSync(join(agentDir, "models.json"), JSON.stringify({ providers: { probe: { baseUrl: api.url, api: "anthropic-messages", apiKey: "probe-dummy", models: [{ id: "probe-model" }] } } }));
const resolved = resolvedFor(t, instance, (d) => (d.roles[instance].vars = { ...d.roles[instance].vars, harness: "pi", model: "probe-model" }));
const resolved = resolvedFor(t, instance, (d) => {
d.roles[instance].vars = { ...d.roles[instance].vars, harness: "pi", model: "probe-model" };
d.launch.models["probe-model"] = { provider: "probe", model: "probe-model" };
});
const sock = await fakeToolSocket(t, dir, (tool, args) => {
if (tool === "launch") throw new Error("capacity-full");
// trail answers a string `size` characters long once JSON-encoded, for the reply cap.
+1 -1
View File
@@ -23,7 +23,7 @@ const businesses = {
id: "demo",
human: "jason",
arbiters: { technical: "pm", delivery: "pm" },
launch: { by: "pm", instances: ["coder"], max: { opus: 2, sonnet: 2 } },
launch: { by: "pm", instances: ["coder"], models:{}, limits:{} },
roles: {
pm: { authority: { withinRole: ["message.send", "role.launch"], crossRole: [] } },
coder: { authority: { withinRole: ["message.send"], crossRole: [] } },
+2 -10
View File
@@ -48,14 +48,6 @@ export const runsDir = (dataRoot, business) => join(dataRoot, "launches", busine
export const launchLogFile = (dataRoot, business) => join(dataRoot, "launches", `${business}.jsonl`);
export const workspaceDir = (dataRoot, business, instance) => join(dataRoot, "workspaces", business, instance);
// The capacity family of a model: the one key of the business file's
// launch.max that the model name contains (`claude-opus-5-5` is `opus`).
// None, or more than one, is null, and the launch refuses.
export function family(model, max) {
const hits = Object.keys(max).filter((f) => model.toLowerCase().includes(f.toLowerCase()));
return hits.length === 1 ? hits[0] : null;
}
// The session's environment is an allowlist: nothing else from the host's
// environment passes, so a founder credential variable (GITEA_TOKEN,
// GH_TOKEN, SSH_AUTH_SOCK, ...) never reaches a session (REQ-CRED-2).
@@ -186,8 +178,8 @@ export class Registry {
this.#sessions = this.#sessions.filter((s) => !(s.business === business && s.run === run));
this.#write();
}
count(business, fam) {
return this.#sessions.filter((s) => s.business === business && s.family === fam).length;
count(business, provider, model = undefined) {
return this.#sessions.filter((s) => s.business === business && s.provider === provider && (model === undefined || s.model === model)).length;
}
running(business, instance) {
return this.#sessions.find((s) => s.business === business && s.instance === instance) ?? null;
+5 -12
View File
@@ -17,7 +17,6 @@ import {
appendLaunchLog,
endReason,
ENV_ALLOW,
family,
hasSessionCredential,
SESSION_CREDENTIALS,
isRunner,
@@ -46,13 +45,7 @@ function scratch(t) {
const mode = (p) => statSync(p).mode & 0o777;
test("family: exactly one launch.max key in the model name, else null", () => {
const max = { opus: 2, sonnet: 4 };
assert.equal(family("claude-opus-5-5", max), "opus");
assert.equal(family("Claude-Sonnet-5-5", max), "sonnet");
assert.equal(family("glm-5.3-flash", max), null);
assert.equal(family("opus-sonnet-mix", max), null);
});
test("REQ-LIMIT-1: family substring matching is no longer exported",async()=>{const m=await import('../src/session.mjs');assert.equal(Object.hasOwn(m,'family'),false);});
test("sessionEnv passes only the allowlist, the repo's bin on PATH, and the run id", () => {
const env = sessionEnv({ HOME: "/h", ANTHROPIC_API_KEY: "k", GITEA_TOKEN: "t", GH_TOKEN: "t", SSH_AUTH_SOCK: "/s", MOSAIC_CONFIG: "/c", PATH: "/usr/bin" }, "r0123456789ab");
@@ -122,11 +115,11 @@ test("Registry mirrors to sessions.json; readSessions marks live entries; bad fi
const r = new Registry(root);
assert.equal(mode(sessionsFile(root)), 0o600);
assert.deepEqual(readSessions(root), []);
const me = { business: "acme", instance: "coder", run: "r1", family: "sonnet", runnerPid: process.pid, runnerStartTime: startTimeOf(process.pid) };
const me = { business: "acme", instance: "coder", run: "r1", provider:"zai",model:"claude-sonnet-5-5", runnerPid: process.pid, runnerStartTime: startTimeOf(process.pid) };
r.add(me);
r.add({ ...me, instance: "pm", run: "r2", family: "opus", runnerStartTime: "1" });
assert.equal(r.count("acme", "sonnet"), 1);
assert.equal(r.count("other", "sonnet"), 0);
r.add({ ...me, instance: "pm", run: "r2", provider:"zai",model:"claude-opus-5-5", runnerStartTime: "1" });
assert.equal(r.count("acme", "zai","claude-sonnet-5-5"), 1);
assert.equal(r.count("other", "zai","claude-sonnet-5-5"), 0);
assert.equal(r.running("acme", "coder").run, "r1");
assert.deepEqual(readSessions(root).map((s) => [s.run, s.live]), [["r1", true], ["r2", false]]);
r.remove("acme", "r1");
+1
View File
@@ -85,6 +85,7 @@ export function sendOutcome(err) {
const STATUS = {
'human-required': 403, unauthenticated: 403, 'unknown-business': 403, 'read-only': 403,
'outcome-unknown': 503, 'no-bus-host': 503, 'not-configured': 503,
'revision-conflict':409,'organization-busy':409,'organization-uncertain':503,
'invalid-request': 400, 'unknown-role': 400, 'message-not-found': 400, 'request-too-large': 400,
};
export const busStatus = code => STATUS[code] ?? 502;
+3 -3
View File
@@ -51,13 +51,13 @@ try {
// the system config, Business can't resolve authority and Settings has
// no notifier. A refusal printed here names no path (redactor).
// --send without a bus refuses to start rather than serve a composer that can't send.
let bus = null, send = null, busLine, system = null;
let bus = null, send = null, settingsWrite = null, busLine, system = null;
try {
system = loadSystem();
const { dataRoot } = system;
const call = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
bus = views({ call });
if (sendOn) send = args => call('message.send', args);
if (sendOn) {send = args => call('message.send', args);settingsWrite=args=>call('limits.set',args);}
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
} catch (err) {
const why = redactor({ configDir: configDir() })(err.message);
@@ -65,7 +65,7 @@ try {
busLine = `Bus: not configured (${why})`;
}
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send });
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send, settingsWrite });
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : '';
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}\nCtrl-C stops this server.`);
+33 -3
View File
@@ -539,7 +539,7 @@
const refused = instances.filter(i => i.refused).map(i => `<li>${txt(i.instance)}: ${txt(i.refused)}</li>`).join('');
const credRows = creds.map(c => `<tr><td>${txt(c.service)}</td><td>${txt(c.account) || '<span class="muted">not named in the business file</span>'}</td><td>${txt(c.role)}</td><td>${txt(c.dateKind === 'expires' ? 'expires' : 'rotate by')} <time datetime="${esc(c.date)}">${txt(c.date)}</time></td><td>${badge(c.state, CRED[c.state] || 'muted')}</td></tr>`).join('');
return `${head(`Business ${txt(b.id)}`, `${readLine()} The business file is edited where it lives; Console only reads it.`)}
<section class="panel" aria-labelledby="bz-about"><h2 id="bz-about">Business</h2><dl class="kv"><dt>Human</dt><dd>${txt(b.human) || none}</dd><dt>Arbiters</dt><dd>${arbiterRefs(b.arbiters)}</dd><dt>Projects</dt><dd>${names(b.projects)}</dd><dt>Launcher</dt><dd>${txt(launch.by) || none}</dd><dt>May launch</dt><dd>${names(launch.instances)}</dd><dt>At most</dt><dd>${Object.entries(launch.max || {}).map(([f, c]) => `${txt(c)} ${txt(f)}`).join(', ') || none}</dd></dl>
<section class="panel" aria-labelledby="bz-about"><h2 id="bz-about">Business</h2><dl class="kv"><dt>Human</dt><dd>${txt(b.human) || none}</dd><dt>Arbiters</dt><dd>${arbiterRefs(b.arbiters)}</dd><dt>Projects</dt><dd>${names(b.projects)}</dd><dt>Launcher</dt><dd>${txt(launch.by) || none}</dd><dt>May launch</dt><dd>${names(launch.instances)}</dd><dt>At most</dt><dd>${Object.entries(launch.limits?.models || {}).flatMap(([p, ms]) => Object.entries(ms).map(([m, c]) => `${txt(c)} ${txt(p)} / ${txt(m)}`)).join(', ') || none}</dd></dl>
<h3>Vars</h3>${kvRows(b.vars, 'No vars Console shows are set.')}</section>
<section aria-labelledby="bz-roles"><h2 id="bz-roles">Role instances <span class="count">${instances.length}</span></h2>${instances.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Role instances, scroll for all columns"><table class="s1-table s1-roles"><thead><tr>${['Instance', 'Definition', 'Holder', 'Harness'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${roleRows}</tbody></table></div>` : empty('This business has no role instances.', 'An instance shows here when the business file names one under roles.')}</section>
${instances.length ? `<section aria-labelledby="bz-auth"><h2 id="bz-auth">Authority</h2><p class="small muted">Within the role, cross-role (needs the other role), or gated (needs the human), as the business package classifies each action.</p>${matrix}${refused ? `<ul class="s1-plain">${refused}</ul>` : ''}</section>` : ''}
@@ -552,12 +552,42 @@
const pick = (id, label, src) => `<label>${label}<select data-mirror="${id}">${[...src.options].map(o => `<option value="${esc(o.value)}"${o.value === src.value ? ' selected' : ''}>${esc(o.textContent)}</option>`).join('')}</select></label>`;
return `<section aria-labelledby="st-look" class="panel"><h2 id="st-look">Appearance</h2><p class="small muted">Stored in this browser only. The same control is in the command bar.</p><div class="filters">${pick('palette', 'Palette', p)}${pick('mode', 'Appearance', m)}</div></section>`;
}
// REQ-WEB-1: drafts survive polling/refusals; only an explicit submit writes.
let settingsSnapshot=null, settingsInputs=null, settingsSaving=false, settingsReceipt='';
const inputKey=parts=>JSON.stringify(parts);
function launchForm(value){
if(!value||value.refused)return `<section class="panel"><h2>Agent limits</h2><p>${txt(value?.message||'Launch settings are not configured.')}</p></section>`;
if(!settingsInputs)settingsSnapshot=value;
const v=settingsSnapshot, modelTable=v.models||{}, limits=v.limits||{}, usage=v.usage||{};
const val=(parts,fallback)=>settingsInputs&&Object.hasOwn(settingsInputs,inputKey(parts))?settingsInputs[inputKey(parts)]:fallback;
const disabled=!canSend||out()||settingsSaving?' disabled':'';
const field=(parts,label,count,current)=>`<label>${txt(label)} <input data-setting="${esc(inputKey(parts))}" type="number" min="0" step="1" value="${esc(val(parts,count??''))}"${disabled}> <span class="small muted">${txt(current??0)} running</span></label>`;
const providers=new Set([...Object.keys(limits.providers||{}),...Object.values(modelTable).map(x=>x.provider)]);
const pairs=new Map();for(const [provider,models]of Object.entries(limits.models||{}))for(const model of Object.keys(models))pairs.set(inputKey([provider,model]),[provider,model]);for(const m of Object.values(modelTable))pairs.set(inputKey([m.provider,m.model]),[m.provider,m.model]);
const providerRows=[...providers].map(p=>field(['providers',p],p,limits.providers?.[p],usage.providers?.[p])).join('');
const modelRows=[...pairs.values()].map(([p,m])=>field(['models',p,m],`${p} / ${m}`,limits.models?.[p]?.[m],usage.models?.[p]?.[m])).join('');
const table=Object.entries(modelTable).map(([key,m])=>`<div class="filters"><strong>${txt(key)}</strong><label>Provider <input data-setting="${esc(inputKey(['table',key,'provider']))}" value="${esc(val(['table',key,'provider'],m.provider))}" maxlength="200"${disabled}></label><label>Model <input data-setting="${esc(inputKey(['table',key,'model']))}" value="${esc(val(['table',key,'model'],m.model))}" maxlength="200"${disabled}></label></div>`).join('');
const selections=Object.entries(v.instances||{}).map(([instance,entry])=>`<label>${txt(instance)} <select data-setting="${esc(inputKey(['instances',instance]))}"${disabled}>${Object.keys(modelTable).map(key=>`<option value="${esc(key)}"${val(['instances',instance],entry.modelKey)===key?' selected':''}>${txt(key)}</option>`).join('')}</select></label>`).join('');
return `<section class="panel" aria-labelledby="launch-settings-title"><h2 id="launch-settings-title">Agent limits</h2><p>Leave a limit blank for no limit. Zero stops new launches. Running sessions keep running when limits or models change.</p><form id="launch-settings-form"><fieldset><legend>All sessions</legend>${field(['total'],'Total',limits.total,usage.total)}</fieldset><fieldset><legend>By provider</legend><div class="filters">${providerRows}</div></fieldset><fieldset><legend>By provider and model</legend><div class="filters">${modelRows}</div></fieldset><fieldset><legend>Model table</legend>${table}</fieldset><fieldset><legend>Role models</legend><div class="filters">${selections}</div></fieldset><p class="small muted">${canSend?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><button type="submit"${disabled}>${settingsSaving?'Saving…':'Save launch settings'}</button> <button type="button" id="launch-settings-reload">Read current settings and discard draft</button><p role="status" id="launch-settings-receipt">${txt(settingsReceipt)}</p></form></section>`;
}
function settingDraft(form){const values={};for(const el of form.querySelectorAll('[data-setting]'))values[el.dataset.setting]=el.value;return values;}
function settingPayload(){const v=settingsSnapshot, limits={providers:Object.create(null),models:Object.create(null)},models=structuredClone(v.models),instances={};for(const [encoded,text]of Object.entries(settingsInputs)){const [kind,p,m]=JSON.parse(encoded);if(kind==='table'){models[p][m]=text;continue;}if(kind==='instances'){instances[p]=text;continue;}if(text==='')continue;if(!/^\d+$/.test(text)||!Number.isSafeInteger(Number(text)))throw Error('Limits need whole numbers of zero or more.');const n=Number(text);if(kind==='total')limits.total=n;else if(kind==='providers')limits.providers[p]=n;else {limits.models[p]??=Object.create(null);limits.models[p][m]=n;}}
return {revision:v.revision,limits,models,instances};
}
document.addEventListener('input',e=>{const form=e.target.closest?.('#launch-settings-form');if(form)settingsInputs=settingDraft(form);});
document.addEventListener('change',e=>{const form=e.target.closest?.('#launch-settings-form');if(form)settingsInputs=settingDraft(form);});
document.addEventListener('click',e=>{if(e.target.id!=='launch-settings-reload')return;settingsInputs=null;settingsSnapshot=null;settingsReceipt='';last.delete('api:settings');render(true);});
document.addEventListener('submit',async e=>{
if(e.target.id!=='launch-settings-form')return;e.preventDefault();if(!canSend||out()||settingsSaving)return;settingsInputs=settingDraft(e.target);let payload;try{payload=settingPayload();}catch(err){settingsReceipt=err.message;render(false);return;}settingsSaving=true;settingsReceipt='';render(false);
try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){settingsReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsInputs=null;settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});settingsReceipt='Launch settings saved.';}else settingsReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again. Draft kept.':`${result?.message||'Settings refused.'} Draft kept.`;}}
catch{settingsReceipt='Outcome unknown. Read current settings before trying again. Draft kept.';}finally{settingsSaving=false;if(!out())render(false);}
});
async function settingsView(get) {
const s = await get.api('settings'), n = s.notifier || {};
const notify = typeof n.binding === 'string'
? `<dl class="kv"><dt>Binding</dt><dd><code>${txt(n.binding)}</code></dd></dl><p class="small muted">The binding name only. Its channels, server and user ids stay in the binding file.</p>`
: empty('No notifier binding to show.', `${n.refused ? `<code>${txt(n.refused)}</code>: ` : ''}${txt(n.message || 'the notifier settings could not be read')}`);
return `${head('Settings', `${readLine()} Only appearance changes here.`)}${appearance()}
return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}
<section aria-labelledby="st-notify"><h2 id="st-notify">Notifications</h2>${notify}</section>
<section aria-labelledby="st-about" class="panel"><h2 id="st-about">About</h2><dl class="kv"><dt>Address</dt><dd><code>${txt(s.address) || 'unknown'}</code></dd><dt>Release</dt><dd>${s.release ? `<code>${txt(s.release)}</code>` : '<span class="muted">unknown: no RELEASE file was read</span>'}</dd><dt>Business</dt><dd>${txt(s.business) || none}</dd><dt>Board</dt><dd><code>${txt(s.board) || 'unknown'}</code></dd></dl></section>`;
}
@@ -888,7 +918,7 @@
if (c?.send !== true) return;
canSend = true;
const path = routeOf().split('?')[0];
if (path.startsWith('/inbox')) render(false); else if (path === '/' || path === '') paintHeld(heldErr);
if (path.startsWith('/inbox')||path==='/settings') render(false); else if (path === '/' || path === '') paintHeld(heldErr);
}).catch(() => {});
window.addEventListener('hashchange', () => render(true));
$('refresh').addEventListener('click', () => render(false));
+5 -1
View File
@@ -20,6 +20,8 @@ import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { BusinessError, loadBusiness, resolveInstance, classify, systemVars } from '../../business/src/index.mjs';
import { launchSettings } from '../../business/src/launch-settings.mjs';
import { readSessions } from '../../seat/src/session.mjs';
import { ACTIONS } from '../../business/src/vocabulary.mjs';
import { readHostState } from '../../cli/src/host.mjs';
import { readNotifyConfig } from '../../cli/src/cli.mjs';
@@ -216,7 +218,9 @@ export function consoleReads({ root, queueRoot = root, system = null, configDir
} catch (err) {
notifier = { refused: err instanceof ReadError ? err.code : 'not-configured', message: err.message };
}
return scrub({ release, business: id, notifier, at: new Date(now()).toISOString() }, redact);
let launch;
try{if(!configDir||!system||!id)throw new Error('limits are not configured');launch=launchSettings({dir:configDir,rolesDir,system:systemVars(system),sessions:()=>readSessions(dataRoot)}).read(id);}catch{launch={refused:'not-configured',message:'Launch settings could not be read.'};}
return scrub({ release, business: id, notifier, launch, at: new Date(now()).toISOString() }, redact);
},
};
}
+45 -2
View File
@@ -152,10 +152,49 @@ export function previewOptions({ publicHost, authHeader, allowUser } = {}) {
if (typeof allowUser !== 'string' || !USER.test(allowUser)) throw new Error('allowed user must be 1..256 visible ASCII characters without a comma');
return { host, header, user: allowUser };
}
// REQ-WEB-1: same signed-in send boundary, a distinct strict settings payload.
async function settingsSend(req, res, { settingsWrite, preview, log }) {
const refuse = (status, error, message) => json(res, status, { error, message, sent: false });
if (one(req, preview.header) !== preview.user) {
return refuse(401, 'sign-in required', 'sign in again');
}
if (one(req, 'origin') !== `https://${preview.host}`) {
return refuse(403, 'cross-origin request refused', 'settings need the Console origin');
}
if (one(req, 'sec-fetch-site') !== 'same-origin') {
return refuse(403, 'cross-site request refused', 'settings need same-origin Fetch Metadata');
}
let value;
try {
value = JSON.parse((await body(req, 16384)).toString('utf8'));
} catch {
return refuse(400, 'invalid-request', 'settings need a JSON object within 16 KiB');
}
const writable = ['limits', 'models', 'instances'];
if (Object.keys(value).some(key => !['revision', ...writable].includes(key)) ||
typeof value.revision !== 'string' || !/^[a-f0-9]{64}$/.test(value.revision) ||
!writable.some(key => Object.hasOwn(value, key))) {
return refuse(400, 'invalid-request', 'settings need a revision and limits, models or instance selections');
}
try {
const result = await settingsWrite(value);
log(`${new Date().toISOString()} settings user=${JSON.stringify(preview.user)} changed`);
return json(res, 200, result);
} catch (err) {
const { code, sent } = sendOutcome(err);
log(`${new Date().toISOString()} settings user=${JSON.stringify(preview.user)} error=${code}`);
if (sent === 'unknown') {
return json(res, 503, {
error: 'outcome-unknown', message: 'Outcome unknown. Read settings before trying again.', sent: 'unknown',
});
}
return refuse(busStatus(code), code, 'Settings refused; read the current values before trying again.');
}
}
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log`
// takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json.
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, settingsWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1';
const upstream = boardURL(board);
@@ -191,7 +230,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
const values = req.headersDistinct[preview.header];
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
}
// With --send, POST /api/bus/message is the one exception; it checks the header again itself.
if(path==='/api/settings'&&req.method==='POST'){
if(!send||!settingsWrite||!named)return json(res,403,{error:'read-only',sent:false});
return settingsSend(req,res,{settingsWrite,preview,log});
}
// Message sending has its own bounded payload and repeats the identity check.
if (send && path === '/api/bus/message') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
return busSend(req, res, { send, preview, log });
+1 -1
View File
@@ -42,7 +42,7 @@ export function seeded(t, { business = true, notify = true } = {}) {
doc.roles.coder.credentials.gitea = { env: 'CODER_GITEA_SECRET_ENV', rotateBy: day(-1) };
doc.roles.coder.credentials.vikunja.expires = day(3);
doc.roles.reviewer.credentials.vikunja.expires = day(-2);
doc.roles.reviewer.vars = { model: '/srv/secret/models/local.gguf' };
doc.roles.reviewer.vars = { model: 'local-model' };doc.launch.models['local-model']={provider:'fixture',model:'/srv/secret/models/local.gguf'};
if (business) writeJson(join(configDir, 'businesses', 'acme.json'), doc);
if (notify) writeJson(join(dataRoot, 'notify', 'acme', 'notify.json'), { notifyVersion: 1, binding: 'jason-dm' });
writeJson(join(dataRoot, 'discord', 'jason-dm.json'), {
+10 -4
View File
@@ -1,3 +1,4 @@
import { launchDoc } from "../../business/tests/helpers.mjs";
// Row 54 (#1543): the Queue, Business and Settings reads over the seeded
// fixture in reads-fixture.mjs. No response may carry a secret, an id or
// the temporary directory.
@@ -69,7 +70,7 @@ test('queue: rows, one row, ids and methods, notes; no path or id from a row', a
assert.equal((await get(base, '/api/queue/7')).status, 404);
for (const bad of ['abc', '0', '01', '1234567', '9/x', '-1']) assert.equal((await get(base, `/api/queue/${bad}`)).status, 400, bad);
for (const path of ['/api/queue', '/api/queue/9', '/api/business', '/api/settings']) {
assert.equal((await get(base, path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).status, 405, path);
assert.equal((await get(base, path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).status, path === '/api/settings' ? 403 : 405, path);
}
assert.equal((await get(base, '/api/queue', { headers: { origin: 'https://evil.example' } })).status, 403);
});
@@ -119,7 +120,7 @@ test('business: names, vars on the allowlist, authority, credential metadata onl
assert.deepEqual(b.instances.map(i => [i.instance, i.definition, i.holder]), [['pm', 'pm', 'sage'], ['cto', 'cto', 'darkwing'], ['coder', 'coder', null], ['reviewer', 'reviewer', null], ['chief', 'chief-of-staff', null]]);
assert.deepEqual(b.instances.find(i => i.instance === 'coder').vars, { harness: 'pi', 'limits.network': 'none' });
// An allowlisted var is scrubbed too (Filbert T1).
assert.deepEqual(b.instances.find(i => i.instance === 'reviewer').vars, { model: '<path>' });
assert.deepEqual(b.instances.find(i => i.instance === 'reviewer').vars, { model: 'local-model' });
for (const i of b.instances) {
assert.deepEqual(Object.keys(i.authority), b.actions);
assert.ok(Object.values(i.authority).every(v => ['within', 'cross', 'gated'].includes(v)));
@@ -127,7 +128,9 @@ test('business: names, vars on the allowlist, authority, credential metadata onl
// Row 119 (#1613): the chief of staff launches; the PM does not.
assert.equal(b.instances.find(i => i.instance === 'chief').authority['role.launch'], 'within');
assert.equal(b.instances.find(i => i.instance === 'pm').authority['role.launch'], 'gated');
assert.deepEqual(b.launch, { by: 'chief', instances: ['coder', 'reviewer'], max: { opus: 2, sonnet: 4 } });
assert.equal(b.launch.by, 'chief');
assert.deepEqual(b.launch.limits, launchDoc().limits);
assert.equal(b.launch.models['local-model'].model, '<path>');
const cred = (role, service) => b.credentials.find(c => c.role === role && c.service === service);
assert.deepEqual(Object.keys(cred('pm', 'gitea')).sort(), ['account', 'date', 'dateKind', 'role', 'service', 'state']);
assert.deepEqual(cred('coder', 'gitea'), { service: 'gitea', account: null, role: 'coder', dateKind: 'rotateBy', date: day(-1), state: 'rotation-due' });
@@ -179,7 +182,10 @@ test('settings: release, board, loopback address, notifier binding name only', a
let r = await get(base, '/api/settings');
assert.equal(r.status, 200);
const body = JSON.parse(r.text);
assert.deepEqual({ ...body, at: undefined }, { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' }, board: 'http://127.0.0.1:7331', address: `${base}/`, at: undefined });
assert.equal(body.launch.instances.pm.provider, 'zai');
assert.equal(body.launch.limits.models.zai['claude-opus-5-5'], 2);
assert.match(body.launch.revision, /^[a-f0-9]{64}$/);
assert.deepEqual({ ...body, launch: undefined, at: undefined }, { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' }, board: 'http://127.0.0.1:7331', address: `${base}/`, launch: undefined, at: undefined });
clean(r.text, s.base, repo.base);
// No notifier config: still 200, so appearance works; the reason is redacted.
const bare = seeded(t, { notify: false });
@@ -0,0 +1,23 @@
// REQ-WEB-1: actual Settings form over fixture reads and intercepted sends.
import test from 'node:test';
import assert from 'node:assert/strict';
import {browser} from './browser.mjs';
import {startServer} from '../src/serve.mjs';
import {close} from './fixture.mjs';
const launch={business:'acme',revision:'a'.repeat(64),models:{opus:{provider:'anthropic',model:'claude-opus-5-5'},sonnet:{provider:'anthropic',model:'claude-sonnet-5-5'}},limits:{models:{anthropic:{'claude-opus-5-5':4,'claude-sonnet-5-5':4}}},instances:{pm:{modelKey:'opus'},cto:{modelKey:'opus'},coder:{modelKey:'sonnet'},reviewer:{modelKey:'opus'}},usage:{total:1,providers:{anthropic:1},models:{anthropic:{'claude-opus-5-5':1}}}};
test('Settings form shows every scope and held role; gated save, exact payload, retained draft and no automatic retry', {timeout:90000},async()=>{
const bus={inbox:async()=>[],tasks:async()=>[],agents:async()=>[],messages:async()=>({messages:[],unread:0})};const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch})}}),b=await browser();
try{
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.enabled=new URL(location.href).searchParams.has('send');window.mode='success';window.fixture=${JSON.stringify(launch)};const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(url==='/api/settings'&&options.method==='POST'){posts.push(JSON.parse(options.body));if(mode==='unknown')throw Error('fixture lost answer');if(mode==='refused')return new Response(JSON.stringify({message:'fixture conflict',sent:false}),{status:409});return new Response(JSON.stringify({...fixture,...posts.at(-1),revision:'b'.repeat(64)}));}return original(url,options);};`});
await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings`);
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
await wait('!!document.querySelector("#launch-settings-form")');assert.equal(await b.evaluate('document.querySelector("#launch-settings-form button[type=submit]").disabled'),true);
assert.equal(await b.evaluate('document.querySelectorAll("#launch-settings-form select").length'),4);assert.equal(await b.evaluate('document.querySelectorAll("#launch-settings-form input[type=number]").length'),4);
await b.navigate(`http://127.0.0.1:${web.address().port}/?send=1#/settings`);await wait('document.querySelector("#launch-settings-form button[type=submit]")?.disabled===false');
const change=async(parts,value)=>b.evaluate(`(()=>{const e=[...document.querySelectorAll('[data-setting]')].find(e=>e.dataset.setting===${JSON.stringify(JSON.stringify(parts))});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`);
const submit=()=>b.evaluate('document.querySelector("#launch-settings-form").requestSubmit();true');
await change(['total'],'0');await change(['providers','anthropic'],'12');await change(['instances','coder'],'opus');await submit();await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("saved")');const [payload]=await b.evaluate('posts');assert.equal(payload.limits.total,0);assert.equal(payload.limits.providers.anthropic,12);assert.equal(payload.instances.coder,'opus');assert.equal(payload.revision,launch.revision);assert.deepEqual(payload.models,launch.models);
await change(['total'],'9');await b.evaluate('mode="unknown"');await submit();await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("Outcome unknown")');assert.equal(await b.evaluate('posts.length'),2);assert.equal(await b.evaluate('document.querySelector("#launch-settings-form input[type=number]").value'),'9');await b.evaluate('new Promise(r=>setTimeout(r,300))');assert.equal(await b.evaluate('posts.length'),2);
await b.evaluate('mode="refused"');await submit();await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("fixture conflict")');assert.equal(await b.evaluate('posts.length'),3);assert.equal(await b.evaluate('document.querySelector("#launch-settings-form input[type=number]").value'),'9');
}finally{await b.close();await close(web);}
});
@@ -0,0 +1,18 @@
// REQ-WEB-1 / REQ-LIMIT-1: Settings uses the Console's human send boundary.
import test from 'node:test';
import assert from 'node:assert/strict';
import {request} from 'node:http';
import {startServer} from '../src/serve.mjs';
const preview={host:'console.example',header:'x-user',user:'jason'};
const headers={host:'console.example','x-user':'jason',origin:'https://console.example','sec-fetch-site':'same-origin','content-type':'application/json'};
const payload={revision:'a'.repeat(64),limits:{total:0}};
async function setup(t,enabled=true){const calls=[];const server=await startServer({port:0,preview,send:enabled?async()=>({}):null,settingsWrite:async args=>{calls.push(args);return {...args,business:'acme'};}});t.after(()=>new Promise(r=>server.close(r)));return {calls,port:server.address().port};}
function post(s,hs=headers,data=payload){return new Promise((resolve,reject)=>{const req=request({hostname:'127.0.0.1',port:s.port,path:'/api/settings',method:'POST',headers:hs},r=>{let text='';r.on('data',c=>text+=c);r.on('end',()=>resolve({status:r.statusCode,text}));});req.on('error',reject);req.end(typeof data==='string'?data:JSON.stringify(data));});}
test('REQ-WEB-1 signed-in same-origin Settings write reaches exactly one human operation',async t=>{const s=await setup(t);const r=await post(s);assert.equal(r.status,200,r.text);assert.deepEqual(s.calls,[payload]);});
test('REQ-WEB-1 Settings writes require enabled send, named Host, identity, exact Origin and same-origin Fetch Metadata',async t=>{
const off=await setup(t,false);assert.equal((await post(off)).status,403);assert.equal(off.calls.length,0);
const s=await setup(t);for(const [key,value,status] of [['host','wrong.example',403],['x-user','other',401],['origin','https://else.example',403],['sec-fetch-site','cross-site',403],['origin',undefined,403],['sec-fetch-site',undefined,403],['x-user',['jason','jason'],401]]){const hs={...headers};if(value===undefined)delete hs[key];else hs[key]=value;assert.equal((await post(s,hs)).status,status,key);}assert.equal(s.calls.length,0);
});
test('REQ-WEB-1 Settings validates JSON, size, revision and strict writable keys before broker call',async t=>{
const s=await setup(t);for(const value of [{...payload,actor:'jason'},{limits:{}},[],null,'bad','x'.repeat(17000)])assert.equal((await post(s,headers,value)).status,400,String(value));assert.equal((await post(s,{...headers,'content-type':'text/plain'})).status,400);assert.equal(s.calls.length,0);
});
+4 -4
View File
@@ -50,12 +50,12 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
['bus.js', 'No open tasks.'], ['bus.js', 'No role is held right now.'], ['bus.js', 'Nothing of this kind in the trail.'],
['bus.js', 'Console shows nothing rather than a guess. Nothing was written.'],
]) assert.equal(plain[file].split(s).length - 1, n, `${file}: ${s}`);
// No Resolve control. The board's Seen and reply are app.js's POSTs; bus.js has one write,
// row 76's message to a role, and it sends only to and body.
// No Resolve control. The board's Seen and reply are app.js's POSTs; bus.js has two explicit writes:
// row 76's message to a role and row 122's human Settings update.
assert.doesNotMatch(src, /<button[^>]*>[^<]*(Resolve|Decide|Approve)/i);
const writes = read('bus.js').toString().match(/fetch\([^;]*method:\s*['"](POST|PUT|PATCH|DELETE)[^;]*;/g) ?? [];
assert.deepEqual(writes, ["fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 1);
assert.deepEqual(writes, ["fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 2);
assert.equal(read('icons.svg').toString().includes('<script'), false);
});
+1 -1
View File
@@ -29,7 +29,7 @@ function stubReads() {
business: {
id: 'acme', human: 'jason', arbiters: { delivery: 'pm' }, projects: ['stack'], vars: { 'tracker.pollSeconds': 60 }, actions: ['role.launch'],
instances: [{ instance: 'pm', definition: 'pm', holder: 'sage', vars: { harness: 'pi' }, authority: { 'role.launch': 'within' } }],
launch: { by: 'pm', instances: ['pm'], max: { opus: 1 } },
launch: { by: 'pm', instances: ['pm'], models: {opus:{provider:'fixture',model:'opus'}}, limits:{models:{fixture:{opus:1}}} },
credentials: [{ service: 'gitea', account: 'bot-acme', role: 'pm', dateKind: 'expires', date: '2099-01-01', state: 'valid' }],
},
settings: { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' } },
+1 -1
View File
@@ -24,6 +24,6 @@ if [ "${1:-}" = business ]; then
exec node "$REPO/packages/business/src/cli.mjs" "$@"
fi
case "${1:-}" in
inbox|decide|tasks|agents|trail|talk|stop|launches|bus) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
esac
exec node "$REPO/packages/seat/src/cli.mjs" "$@"