Files

1989 lines
133 KiB
Diff

diff --git a/docs/TOOLS.md b/docs/TOOLS.md
index 13366b63a..bba405c3f 100644
--- a/docs/TOOLS.md
+++ b/docs/TOOLS.md
@@ -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.
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index 4ab951135..64689c573 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -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');
diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs
index dbb09e9b6..c28716de4 100644
--- a/packages/bus/src/runtime.mjs
+++ b/packages/bus/src/runtime.mjs
@@ -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(),
});
diff --git a/packages/bus/tests/limits.test.mjs b/packages/bus/tests/limits.test.mjs
new file mode 100644
index 000000000..eaa8e7059
--- /dev/null
+++ b/packages/bus/tests/limits.test.mjs
@@ -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);
+});
diff --git a/packages/business/README.md b/packages/business/README.md
index 29683efa5..9a6baad3c 100644
--- a/packages/business/README.md
+++ b/packages/business/README.md
@@ -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.
diff --git a/packages/business/examples/mosaic-stack.example.json b/packages/business/examples/mosaic-stack.example.json
index fd955602a..3a81ca0fa 100644
--- a/packages/business/examples/mosaic-stack.example.json
+++ b/packages/business/examples/mosaic-stack.example.json
@@ -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
+ }
+ }
}
}
}
diff --git a/packages/business/src/business.mjs b/packages/business/src/business.mjs
index 9e12cea18..cbe04a59b 100644
--- a/packages/business/src/business.mjs
+++ b/packages/business/src/business.mjs
@@ -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
diff --git a/packages/business/src/index.mjs b/packages/business/src/index.mjs
index b935fff5b..95fd05c3d 100644
--- a/packages/business/src/index.mjs
+++ b/packages/business/src/index.mjs
@@ -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";
diff --git a/packages/business/src/launch-settings.mjs b/packages/business/src/launch-settings.mjs
new file mode 100644
index 000000000..fb5887291
--- /dev/null
+++ b/packages/business/src/launch-settings.mjs
@@ -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 };
+}
diff --git a/packages/business/src/launch.mjs b/packages/business/src/launch.mjs
new file mode 100644
index 000000000..313d8ecd3
--- /dev/null
+++ b/packages/business/src/launch.mjs
@@ -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;
+}
diff --git a/packages/business/src/migrate-launch.mjs b/packages/business/src/migrate-launch.mjs
new file mode 100644
index 000000000..654d95f62
--- /dev/null
+++ b/packages/business/src/migrate-launch.mjs
@@ -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;
+}
diff --git a/packages/business/src/organization.mjs b/packages/business/src/organization.mjs
new file mode 100644
index 000000000..03ddc6714
--- /dev/null
+++ b/packages/business/src/organization.mjs
@@ -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)) };
+}
diff --git a/packages/business/src/vocabulary.mjs b/packages/business/src/vocabulary.mjs
index 4b151af04..592a476aa 100644
--- a/packages/business/src/vocabulary.mjs
+++ b/packages/business/src/vocabulary.mjs
@@ -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}$/;
diff --git a/packages/business/tests/assistant.test.mjs b/packages/business/tests/assistant.test.mjs
index aaa809c44..ce2648fab 100644
--- a/packages/business/tests/assistant.test.mjs
+++ b/packages/business/tests/assistant.test.mjs
@@ -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/,
);
diff --git a/packages/business/tests/business.test.mjs b/packages/business/tests/business.test.mjs
index 96ba7fda8..48e181f26 100644
--- a/packages/business/tests/business.test.mjs
+++ b/packages/business/tests/business.test.mjs
@@ -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);
diff --git a/packages/business/tests/chief-of-staff.test.mjs b/packages/business/tests/chief-of-staff.test.mjs
index d32ec7bfe..b993cf5c6 100644
--- a/packages/business/tests/chief-of-staff.test.mjs
+++ b/packages/business/tests/chief-of-staff.test.mjs
@@ -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/,
);
diff --git a/packages/business/tests/helpers.mjs b/packages/business/tests/helpers.mjs
index e105b4263..cca29ef94 100644
--- a/packages/business/tests/helpers.mjs
+++ b/packages/business/tests/helpers.mjs
@@ -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}}};
+}
diff --git a/packages/business/tests/launch-settings.test.mjs b/packages/business/tests/launch-settings.test.mjs
new file mode 100644
index 000000000..a62d090f8
--- /dev/null
+++ b/packages/business/tests/launch-settings.test.mjs
@@ -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');
+});
diff --git a/packages/business/tests/resolve.test.mjs b/packages/business/tests/resolve.test.mjs
index d442c5883..21aeece63 100644
--- a/packages/business/tests/resolve.test.mjs
+++ b/packages/business/tests/resolve.test.mjs
@@ -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 } } },
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 1757ef1b6..90836150c 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -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).
diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs
index a95eabaab..0a96dcd50 100644
--- a/packages/cli/src/cli.mjs
+++ b/packages/cli/src/cli.mjs
@@ -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);
diff --git a/packages/cli/src/config.mjs b/packages/cli/src/config.mjs
index e6373532c..856320d34 100644
--- a/packages/cli/src/config.mjs
+++ b/packages/cli/src/config.mjs
@@ -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],
};
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
index 574614a64..40f85ad42 100644
--- a/packages/cli/src/launcher.mjs
+++ b/packages/cli/src/launcher.mjs
@@ -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);
}
});
diff --git a/packages/cli/src/limits.mjs b/packages/cli/src/limits.mjs
new file mode 100644
index 000000000..1858541c9
--- /dev/null
+++ b/packages/cli/src/limits.mjs
@@ -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);
+ }
+}
diff --git a/packages/cli/tests/launch-limits.test.mjs b/packages/cli/tests/launch-limits.test.mjs
new file mode 100644
index 000000000..4d46ba416
--- /dev/null
+++ b/packages/cli/tests/launch-limits.test.mjs
@@ -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]);
+});
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
index e9f087d1a..0cfe3cebd 100644
--- a/packages/cli/tests/launcher.test.mjs
+++ b/packages/cli/tests/launcher.test.mjs
@@ -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);
diff --git a/packages/cli/tests/limits.test.mjs b/packages/cli/tests/limits.test.mjs
new file mode 100644
index 000000000..0122e4ed3
--- /dev/null
+++ b/packages/cli/tests/limits.test.mjs
@@ -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);
+});
diff --git a/packages/harness/src/bundle.mjs b/packages/harness/src/bundle.mjs
index 0e4d63148..95dbb1ebe 100644
--- a/packages/harness/src/bundle.mjs
+++ b/packages/harness/src/bundle.mjs
@@ -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 });
diff --git a/packages/harness/tests/pi-session.test.mjs b/packages/harness/tests/pi-session.test.mjs
index 9482d6136..879dfc66d 100644
--- a/packages/harness/tests/pi-session.test.mjs
+++ b/packages/harness/tests/pi-session.test.mjs
@@ -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.
diff --git a/packages/harness/tests/runner.test.mjs b/packages/harness/tests/runner.test.mjs
index 29d17af4b..784bd674e 100644
--- a/packages/harness/tests/runner.test.mjs
+++ b/packages/harness/tests/runner.test.mjs
@@ -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: [] } },
diff --git a/packages/seat/src/session.mjs b/packages/seat/src/session.mjs
index f04386ac3..19c4fc53d 100644
--- a/packages/seat/src/session.mjs
+++ b/packages/seat/src/session.mjs
@@ -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;
diff --git a/packages/seat/tests/session.test.mjs b/packages/seat/tests/session.test.mjs
index e072709cb..c9babcd6a 100644
--- a/packages/seat/tests/session.test.mjs
+++ b/packages/seat/tests/session.test.mjs
@@ -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");
diff --git a/packages/webui/src/bus.mjs b/packages/webui/src/bus.mjs
index 78f7d647a..ed0581498 100644
--- a/packages/webui/src/bus.mjs
+++ b/packages/webui/src/bus.mjs
@@ -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;
diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs
index de03a5ee4..fcf353f80 100644
--- a/packages/webui/src/cli.mjs
+++ b/packages/webui/src/cli.mjs
@@ -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.`);
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
index 0cc841bbc..15fcdeb21 100644
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -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));
diff --git a/packages/webui/src/reads.mjs b/packages/webui/src/reads.mjs
index 588db4997..797afbd31 100644
--- a/packages/webui/src/reads.mjs
+++ b/packages/webui/src/reads.mjs
@@ -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);
},
};
}
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
index 4b3859325..b5d8dc5b4 100644
--- a/packages/webui/src/serve.mjs
+++ b/packages/webui/src/serve.mjs
@@ -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 });
diff --git a/packages/webui/tests/reads-fixture.mjs b/packages/webui/tests/reads-fixture.mjs
index 274717d84..539a1ba6c 100644
--- a/packages/webui/tests/reads-fixture.mjs
+++ b/packages/webui/tests/reads-fixture.mjs
@@ -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'), {
diff --git a/packages/webui/tests/reads.test.mjs b/packages/webui/tests/reads.test.mjs
index 7b1d6a0a4..a62e207a0 100644
--- a/packages/webui/tests/reads.test.mjs
+++ b/packages/webui/tests/reads.test.mjs
@@ -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 });
diff --git a/packages/webui/tests/settings-form.test.mjs b/packages/webui/tests/settings-form.test.mjs
new file mode 100644
index 000000000..efd95d237
--- /dev/null
+++ b/packages/webui/tests/settings-form.test.mjs
@@ -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);}
+});
diff --git a/packages/webui/tests/settings-write.test.mjs b/packages/webui/tests/settings-write.test.mjs
new file mode 100644
index 000000000..d7f58672b
--- /dev/null
+++ b/packages/webui/tests/settings-write.test.mjs
@@ -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);
+});
diff --git a/packages/webui/tests/shell.test.mjs b/packages/webui/tests/shell.test.mjs
index 3aa02734d..8b943638f 100644
--- a/packages/webui/tests/shell.test.mjs
+++ b/packages/webui/tests/shell.test.mjs
@@ -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);
});
diff --git a/packages/webui/tests/views.test.mjs b/packages/webui/tests/views.test.mjs
index 80ffceac9..a6e5cd7bc 100644
--- a/packages/webui/tests/views.test.mjs
+++ b/packages/webui/tests/views.test.mjs
@@ -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' } },
diff --git a/scripts/mosaic b/scripts/mosaic
index 1f43dcb12..010e1a7e6 100755
--- a/scripts/mosaic
+++ b/scripts/mosaic
@@ -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" "$@"