Merge refactor fbf2f6fc8 (rename stage B) into F2

Conflict in packages/webui/src/serve.mjs imports: kept F2's
THREAD_DEFINITION and onboarding imports on stage B's organization path.
The F2 assistant test edit followed the move to packages/organization;
onboarding-browser now imports the organization service directly.
This commit was merged in pull request #1879.
This commit is contained in:
jason.woltje committed 2026-11-09 13:17:33 -06:00
commit bad760ce08
284 files changed
+6234 -5481

No files matched your search

+15 -7
View File
@@ -251,16 +251,24 @@ Exit codes: `0` ok · `1` failed · `2` invalid or refused · `3` uncertain,
retry the same op (for a review request, the retry sends nothing; resolve or
abandon it) · `4` usage. Details: `packages/queue/README.md`.
## Business files and role instances (`scripts/mosaic business`)
## Organization files and role instances (`scripts/mosaic org config`, `scripts/mosaic org instances`)
```bash
scripts/mosaic business validate <business>
scripts/mosaic business resolve <business> <instance> [--project <id>]
scripts/mosaic business instances <business> [--json]
scripts/mosaic business instance add <business> <instance> --definition <def> [--vars <file>] [--launch] --revision <rev>
scripts/mosaic business instance set <business> <instance> [--definition <def>] [--vars <file>] [--launch | --no-launch] --revision <rev>
scripts/mosaic org config validate <org>
scripts/mosaic org config resolve <org> <instance> [--project <id>]
scripts/mosaic org instances <org> [--json]
scripts/mosaic org instance add <org> <instance> --definition <def> [--vars <file>] [--launch] --revision <rev>
scripts/mosaic org instance set <org> <instance> [--definition <def>] [--vars <file>] [--launch | --no-launch] --revision <rev>
```
`scripts/mosaic business validate|resolve|instances|instance|veto` is the old
spelling of these commands, with the same arguments, and it still works. Both
spellings run the same code and give the same status, stdout and stderr.
Only a validate/resolve usage error differs: it prints the usage of the
spelling you used. Every other validate/resolve stderr line, refusals and
warnings included, starts `mosaic business: ` in both spellings. `scripts/mosaic org validate FILE` is a different command: it
checks a draft file by path and prints `valid`.
Reads `businesses/<business>.json` next to the system config
(`$MOSAIC_CONFIG` or `~/.config/mosaic-dev/`), the version 2 role files in
`roles/` (or `$MOSAIC_ROLES_DIR`) and each project's `.mosaic/project.json`.
@@ -271,7 +279,7 @@ its narrowed tools, network and authority, and its credential references.
The business file must belong to you and not be group or other writable.
Exit codes: `0` ok · `2` invalid file or credential reference · `3` system
config problem · `4` usage or a required file missing. Details:
`packages/business/README.md`.
`packages/organization/README.md`.
`mosaic credentials status|issue|rotate|revoke|delete` (row 199) goes to
`packages/cli` and the bus's human-only credential verbs: an agent's own
+1 -1
View File
@@ -157,7 +157,7 @@ Kinds of work:
| Talk `#/talk` | the conversation History view exists; the live PM session is row 41 | blocked | Row 41 (S6). Reuse the controller/observer rules of `packages/conversation` |
| Queue `#/queue` | `packages/queue/src/store.mjs` `list`/`show`; no endpoint | new view | Read-only. Rows move only through `queue move` |
| Ledger `#/ledger` | `packages/ledger` `summarize`, `queueChecks` | new view | Read-only |
| Business `#/business` | `packages/business` `loadBusiness`, `loadRole`, `resolveInstance`, `vocabulary.mjs` | new view | Read-only. A missing or invalid file shows the refusal state. Credential refs as metadata only |
| Business `#/business` | `packages/organization` `loadBusiness`, `loadRole`, `resolveInstance`, `vocabulary.mjs` | new view | Read-only. A missing or invalid file shows the refusal state. Credential refs as metadata only |
| Runs and releases `#/runs` | readers live inside `scripts/mosaic-task.mjs` and `scripts/release.sh`, not exported | backend | Extract a read-only module first |
| Library `#/library` | no meta-harness in committed code | blocked | Row 41 (S6) creates `packages/harness` |
| Settings `#/settings` | appearance exists in the webui header; notifier config `readNotifyConfig` | extend | Appearance, notifications (binding names only), about (loopback, release). Read-only apart from appearance |
+3 -3
View File
@@ -327,7 +327,7 @@ After install, `stack-copy.sh check --units external` reads the copy the units r
Before running `case2-install.sh`, check the exact file you will pass as `--business-file`. Each
non-library `projects` key must equal the `id` in `<root>/.mosaic/project.json`, and that file must exist
in the stack's user's clone. Library entries are skipped by validate (`packages/business/src/cli.mjs:72`).
in the stack's user's clone. Library entries are skipped by validate (`packages/organization/src/config-command.mjs:95`).
Run this read-only check as `mosaic` with section 2's pinned node:
```sh
@@ -356,7 +356,7 @@ MOSAIC_PROJECT_KEYS
For .53 expect `ok project stack: /srv/mosaic/src/mosaic-stack/.mosaic/project.json has id stack`
(`.mosaic/project.json:3`, `docs/guides/case1-business.mosaic-stack.json:10-11`). Any STOP or nonzero exit
stops the install. Fix a mismatched business key, not the project id (mos-1563). The install otherwise
reaches that refusal at step 7, after making the copy (`packages/business/src/cli.mjs:80`,
reaches that refusal at step 7, after making the copy (`packages/organization/src/config-command.mjs:103`,
`scripts/case2-install.sh:178`, `scripts/case2-install.sh:217`).
The stack's user's `scripts/case2-install.sh` (C2-13b, design 10 step 2b) makes the first copy with
@@ -365,7 +365,7 @@ harness, so the host needs to reach the npm registry during the install. The ins
`release.sh package` or `activate`.
A missing `.mosaic/project.json` only warns: `project <id>: no project file at <path>; it has no project
variables`. Validate reports that project `absent` and continues (`packages/business/src/cli.mjs:74-78`).
variables`. Validate reports that project `absent` and continues (`packages/organization/src/config-command.mjs:97-101`).
An install can therefore pass step 7 without the project's variables. It captures validate's output
and prints `business <id> validates` (`scripts/case2-install.sh:217-221`). Treat the missing-file warning as a stop,
place the correct project file and repeat the check above before continuing (Duckworth 323 N2;
+3 -3
View File
@@ -718,7 +718,7 @@ keys with `invalid-request` before the writer runs (sage-0918). `instances` take
answers the business's instances, their definitions, vars and launch membership, plus the
definitions `instances.set` accepts. `instances.set` takes `{revision,instance,op,definition?,
vars?,launch?}`. `op` is `add` or `change`, and every other key is `invalid-request`. The
business package's `instance-settings.mjs` writes through the organization writer, which
organization package's `instance-settings.mjs` writes through the organization writer, which
logs the human as the actor, or `tester` for a tester's write. Its refusals pass through as `organization-*` codes, among them
`organization-credentials-out-of-scope` for a definition that declares credentials. The answer
carries `restart: true` after an add or a definition change: the broker keeps the roles it
@@ -731,8 +731,8 @@ tester may send `routes.set` (row 175), logged with actor `tester`; its `routes`
`ORGANIZATION_KEYS`, so the chief of staff's `change_organization` can't send a `routes` key.
The chief reaches a route only through the assistant's whole vars (Q169), and that write runs
the same route checks. `routes` takes no args and answers the routing table;
`routes.set` takes exactly `{revision,routes}`. The business package's `launch-settings.mjs`
writes them with the human as actor (`packages/business/README.md`, "Routes"). `harness-off`,
`routes.set` takes exactly `{revision,routes}`. The organization package's `launch-settings.mjs`
writes them with the human as actor (`packages/organization/README.md`, "Routes"). `harness-off`,
`harness-refused` and `thinking-unsupported` pass through as they are, from `routes.set`,
`instances.set` and `organization.set` alike. The runtime builds the write checks' `levels`
from `harnessLevels()`, which loads each harness module in the tree once at start, switched on
+1 -1
View File
@@ -7,7 +7,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../business/src/vocabulary.mjs';
import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
+2 -27
View File
@@ -1,27 +1,2 @@
import { BusError } from './broker.mjs';
import { isHostKind } from '../../business/src/vocabulary.mjs';
// Adapt S1's validated loadBusiness + resolveInstance outputs, without loading or writing files.
export function busBusiness(business, resolved) {
if (!business || typeof business.id !== 'string' || !business.roles || !resolved)
throw new BusError('invalid-business');
const out = structuredClone(business);
for (const role of Object.keys(out.roles)) {
const r = resolved[role];
if (
!r ||
r.business !== business.id ||
r.instance !== role ||
r.definition !== out.roles[role].definition ||
!r.limits?.authority
)
throw new BusError('invalid-resolution');
out.roles[role].authority = structuredClone(r.limits.authority);
// REQ-HARN-2: "web" in the resolved tool limit is the grant for the broker's web verbs.
out.roles[role].web = (r.limits.tools ?? []).includes('web');
// Row 210 (#1707, design D5): resolveInstance leaves host.<id> keys out of the session's
// credentials. The bus child uses them, so they come back here from the business entry.
const hostKeys = Object.entries(business.roles[role].credentials ?? {}).filter(([kind]) => isHostKind(kind));
out.roles[role].credentials = { ...structuredClone(r.credentials ?? {}), ...structuredClone(Object.fromEntries(hostKeys)) };
}
return out;
}
// Old path of organization-context.mjs (rename design r2, stage B): busBusiness is busOrganization.
export { busOrganization as busBusiness } from './organization-context.mjs';
+1 -1
View File
@@ -1,5 +1,5 @@
import { BusError } from './broker.mjs';
import { isHostKind } from '../../business/src/vocabulary.mjs';
import { isHostKind } from '../../organization/src/vocabulary.mjs';
// Row 199 (#1697, design section 6): issue, rotate, revoke and delete of one agent's credentials, for
// the broker's human-only verbs. The broker checks the session, the keys and a running claim first.
// Each verb leaves one receipt in the organization's changes.jsonl: the actor and a detail naming the
+1 -1
View File
@@ -6,7 +6,7 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { createHash } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import { BusError } from './broker.mjs';
import { credentialKind, isHostKind, ID_PATTERN } from '../../business/src/vocabulary.mjs';
import { credentialKind, isHostKind, ID_PATTERN } from '../../organization/src/vocabulary.mjs';
const deny = (code) => {
throw new BusError(code);
};
+2 -2
View File
@@ -17,8 +17,8 @@ import { join } from 'node:path';
import { BusError, GRANT_EVIDENCE } from './broker.mjs';
import { day, privateDir } from './credentials.mjs';
import { definitionSha256, grantTag } from './host-grant.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { forkUrl, giteaName, ID_PATTERN, parseGiteaUrl, CREATIVE_DEFINITIONS } from '../../business/src/vocabulary.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
import { forkUrl, giteaName, ID_PATTERN, parseGiteaUrl, CREATIVE_DEFINITIONS } from '../../organization/src/vocabulary.mjs';
const deny = (code) => {
throw new BusError(code);
+3 -3
View File
@@ -17,9 +17,9 @@ import { dirname, join } from 'node:path';
import { BusError } from './broker.mjs';
import { day, parseHostKey, privateDir } from './credentials.mjs';
import { runHost, HOST_LIMITS } from './host-run.mjs';
import { checkAddress, checkCommands, HOST_KEY, USER } from '../../business/src/hosts.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { HOST_ID, ID_PATTERN } from '../../business/src/vocabulary.mjs';
import { checkAddress, checkCommands, HOST_KEY, USER } from '../../organization/src/hosts.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
import { HOST_ID, ID_PATTERN } from '../../organization/src/vocabulary.mjs';
const deny = (code) => {
throw new BusError(code);
+1
View File
@@ -2,5 +2,6 @@ export { Broker, BusError, ACTIONS, TASK_VERBS } from './broker.mjs';
export { startBroker } from './runtime.mjs';
export { Client } from './client.mjs';
export { views } from './views.mjs';
export { busOrganization } from './organization-context.mjs';
export { busBusiness } from './business.mjs';
export { parseAddress } from './address.mjs';
+28
View File
@@ -0,0 +1,28 @@
import { BusError } from './broker.mjs';
import { isHostKind } from '../../organization/src/vocabulary.mjs';
// Adapt S1's validated loadOrganization (loadBusiness) + resolveInstance outputs, without loading
// or writing files. The result keeps the v1 business shape and error codes (rename design r2).
export function busOrganization(business, resolved) {
if (!business || typeof business.id !== 'string' || !business.roles || !resolved)
throw new BusError('invalid-business');
const out = structuredClone(business);
for (const role of Object.keys(out.roles)) {
const r = resolved[role];
if (
!r ||
r.business !== business.id ||
r.instance !== role ||
r.definition !== out.roles[role].definition ||
!r.limits?.authority
)
throw new BusError('invalid-resolution');
out.roles[role].authority = structuredClone(r.limits.authority);
// REQ-HARN-2: "web" in the resolved tool limit is the grant for the broker's web verbs.
out.roles[role].web = (r.limits.tools ?? []).includes('web');
// Row 210 (#1707, design D5): resolveInstance leaves host.<id> keys out of the session's
// credentials. The bus child uses them, so they come back here from the business entry.
const hostKeys = Object.entries(business.roles[role].credentials ?? {}).filter(([kind]) => isHostKind(kind));
out.roles[role].credentials = { ...structuredClone(r.credentials ?? {}), ...structuredClone(Object.fromEntries(hostKeys)) };
}
return out;
}
+3 -3
View File
@@ -31,9 +31,9 @@ import { gitEnv, runGit } from '../../seat/src/checkout.mjs';
import { initializeWorking, inspectWorkingRepository, workingRepo } from '../../seat/src/working.mjs';
import { initProject, writeHumanFile } from '../../project/src/index.mjs';
import { SAFE, identity } from '../../project/src/git.mjs';
import { loadProject } from '../../business/src/project.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { managedPaths, markerBytes, markerFile, readMarker } from '../../business/src/project-bootstrap.mjs';
import { loadProject } from '../../organization/src/project.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
import { managedPaths, markerBytes, markerFile, readMarker } from '../../organization/src/project-bootstrap.mjs';
const INTENT_PATH = 'spec/north-star.md';
const LAYOUT_SUBJECT = 'Initialize project layout v1';
+1 -1
View File
@@ -10,7 +10,7 @@
// Not verified here, because nothing the host holds can: that the tracker board the owner selected exists.
// That is checked when the first task is created, not before the owner confirms.
import { prepareBootstrap, prepareExistingPlan } from './request-bootstrap.mjs';
import { loadProject } from '../../business/src/project.mjs';
import { loadProject } from '../../organization/src/project.mjs';
import { digest, refuse } from './request-store.mjs';
const positive = n => Number.isSafeInteger(n) && n > 0;
+2 -2
View File
@@ -1,8 +1,8 @@
// Trusted host journal. No SQL, transition or effect interface is exposed to workers.
import { createHash } from 'node:crypto';
import { isAbsolute, normalize } from 'node:path';
import { ID_PATTERN, parseGiteaUrl } from '../../business/src/vocabulary.mjs';
import { checkVars } from '../../business/src/vars.mjs';
import { ID_PATTERN, parseGiteaUrl } from '../../organization/src/vocabulary.mjs';
import { checkVars } from '../../organization/src/vars.mjs';
export class RequestError extends Error {
constructor(code) { super(code); this.code = code; }
+15 -15
View File
@@ -11,18 +11,18 @@ import { requestWorkspace } from './request-workspace.mjs';
import { readReviewedArtifact } from '../../seat/src/artifact.mjs';
import { executeBootstrap } from './request-bootstrap.mjs';
import { hostPreparation } from './request-host.mjs';
import { managedPaths, managedState } from '../../business/src/project-bootstrap.mjs';
import { managedPaths, managedState } from '../../organization/src/project-bootstrap.mjs';
import * as projectModule from '../../project/src/index.mjs';
import * as libraryModule from '../../library/src/index.mjs';
import { Broker, projectArchived, BusError, LIBRARY_FETCH_FIX } from './broker.mjs';
import { Credentials } from './credentials.mjs';
import { serve } from './server.mjs';
import { launchSettings } from '../../business/src/launch-settings.mjs';
import { launchSettings } from '../../organization/src/launch-settings.mjs';
import { readSessions, sessionsFile, stopSession } from '../../seat/src/session.mjs';
import { compactionCount, storeDir } from '../../seat/src/compactions.mjs';
import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working.mjs';
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
import { pluginSettings } from '../../organization/src/plugin-settings.mjs';
import { loadManagementClient } from '../../model-proxy/src/client.mjs';
import { ProxyPage } from './proxy-page.mjs';
import { createModelCatalogue } from './model-catalogue.mjs';
@@ -30,22 +30,22 @@ import { sha256 } from '../../model-proxy/src/common.mjs';
import { initializeProxy } from '../../model-proxy/src/settings.mjs';
import { createProxyLock } from './proxy-lock.mjs';
import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } from './model-keys.mjs';
import { instanceSettings } from '../../business/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../business/src/organization.mjs';
import { getProjectRemote } from '../../business/src/project-remotes.mjs';
import { instanceSettings } from '../../organization/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { loadRole } from '../../business/src/role.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { loadRole } from '../../organization/src/role.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
import { selectModelProvider } from '../../plugins/src/model-provider-selection.mjs';
import { busBusiness } from './business.mjs';
import { busOrganization } from './organization-context.mjs';
import { webService } from './web.mjs';
import { hostExecutor, sweepRunDir } from './host-run.mjs';
import { grantKeys, hostGrant, hostReference, sweepHostKeys } from './host-grant.mjs';
import { giteaGrant, giteaKeys, giteaStart, giteaReference } from './gitea-grant.mjs';
import { verifyPrincipal } from './principal.mjs';
import { loadAccess } from '../../business/src/access.mjs';
import { loadAccess } from '../../organization/src/access.mjs';
import { inspectInterpreter, recordInterpreterStart } from './interpreter.mjs';
import { createPeerReader } from './peercred.mjs';
import { createUsage } from './usage.mjs';
@@ -559,7 +559,7 @@ export async function startBroker({
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
const next = withoutPending(id, busBusiness(b, resolved));
const next = withoutPending(id, busOrganization(b, resolved));
if (!sameFixed(booted.get(id), next)) {
reloadLog({ event: 'reload-needs-restart', business: id });
return false;
@@ -590,7 +590,7 @@ export async function startBroker({
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
const full = busBusiness(b, resolved);
const full = busOrganization(b, resolved);
const entry = full.projects?.[project];
const here = managedPaths({ dataRoot, business: id, project });
if (!entry || entry.kind === 'library' || entry.root !== here.root || entry.repo !== here.repo
@@ -734,7 +734,7 @@ export async function startBroker({
const current = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const resolved = {};
for (const instance of Object.keys(current.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: current, instance });
const live = withoutPending(id, busBusiness(current, resolved));
const live = withoutPending(id, busOrganization(current, resolved));
if (!sameFixed(booted.get(id), live) || canonicalJson(live.projects) !== known.get(id)) throw new BusError('project-remote-protected');
if (!Object.hasOwn(live.projects, args.project)) throw new BusError('project-remote-unknown');
if (verb === 'project.remote.get.console') return getProjectRemote(writer, id, args.project);
@@ -13,9 +13,9 @@ import { prepareBootstrap } from '../src/request-bootstrap.mjs';
import { hostPreparation, staffFor } from '../src/request-host.mjs';
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { organizationWriter } from '../../business/src/organization.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { managedState } from '../../business/src/project-bootstrap.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { managedState } from '../../organization/src/project-bootstrap.mjs';
import { workingRepo } from '../../seat/src/working.mjs';
const INTENT = 'Build a single-file timer.\nKeep these exact bytes.\n';
+4 -4
View File
@@ -4,9 +4,9 @@ import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { busBusiness } from '../src/business.mjs';
import { resolveInstance, validateBusinessDocument } from '../../business/src/index.mjs';
import { businessDoc, REPO_ROLES, systemFor } from '../../business/tests/helpers.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { resolveInstance, validateBusinessDocument } from '../../organization/src/index.mjs';
import { businessDoc, REPO_ROLES, systemFor } from '../../organization/tests/helpers.mjs';
const { Broker } = await import('../src/broker.mjs').catch((e) => {
if (e.code === 'ERR_MODULE_NOT_FOUND') return {};
throw e;
@@ -399,7 +399,7 @@ test('the shipped coder role raises task.reassign as gated to the human; task.sc
const resolved = {};
for (const instance of Object.keys(business.roles))
resolved[instance] = resolveInstance({ system: systemFor(root), business, instance });
const { b, agent, human } = setup(t, { demo: busBusiness(business, resolved) });
const { b, agent, human } = setup(t, { demo: busOrganization(business, resolved) });
const c = agent('coder');
call(b, c, 'role.claim');
const reassign = raise(b, c, 'task.reassign', { target: 'vikunja:3/7' });
+6 -6
View File
@@ -8,12 +8,12 @@ import { DatabaseSync } from 'node:sqlite';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker, ACTIONS } from '../src/broker.mjs';
import { HIRE_ALLOWED } from '../../business/src/vocabulary.mjs';
import { HIRE_ALLOWED } from '../../organization/src/vocabulary.mjs';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { busBusiness } from '../src/business.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
// Row 123 (#1617): the chief of staff ends runs without a decision and changes the organization
// file through the shared write path. REQ-ROLE-2 (one holder; revoke), REQ-ROLE-3 (the
@@ -1041,7 +1041,7 @@ test('row 194 T8, T9, T18: through the runtime the chief hires and fires with th
const b = loadBusiness('demo', { dir, rolesDir: roles });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(dir), business: b, instance });
return busBusiness(b, resolved);
return busOrganization(b, resolved);
};
const bus = await startBroker({
dataRoot: join(root, 'data'),
+2 -2
View File
@@ -7,8 +7,8 @@ import { Client } from '../src/client.mjs';
import { serve } from '../src/server.mjs';
import { FIXED_TEXTS, GuardError } from '../../library/src/guard.mjs';
import { git } from '../../library/tests/helpers.mjs';
import { organizationWriter, setReplyLimits } from '../../business/src/organization.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../business/tests/helpers.mjs';
import { organizationWriter, setReplyLimits } from '../../organization/src/organization.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
import { resolveReplyLimits } from '../../library/src/limits.mjs';
import { citationWorld, citedBody, fixtureCite, guardCases } from './citation-helpers.mjs';
+1 -1
View File
@@ -4,7 +4,7 @@ import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { checkReply, GuardError, libraryReader } from '../../library/src/guard.mjs';
import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
import { THREAD_DEFINITIONS } from '../../organization/src/vocabulary.mjs';
import { resolveReplyLimits } from '../../library/src/limits.mjs';
import { mapping, repo, MARKER } from '../../library/tests/helpers.mjs';
+2 -2
View File
@@ -5,8 +5,8 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { validateBusinessDocument } from '../../business/src/business.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { validateBusinessDocument } from '../../organization/src/organization-config.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { mappingEntry } from '../../library/tests/helpers.mjs';
test('C2-06 runtime reads changed and unreadable reply limits without restarting', async t => {
+1 -1
View File
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, FAR_DATE, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { businessDoc, FAR_DATE, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
// Row 213 (C2-08, #1714, Case 2 design 5.1): a creative instance talks with the user only, and it has
// no decision channel (M6). The messaging lists are read from the shipped role files.
+1 -1
View File
@@ -10,7 +10,7 @@ import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { Broker, CREDENTIAL_VERBS } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
const TOKEN = 'fake_issued_token_0123456789';
const ROTATED = 'fake_rotated_token_9876543210';
@@ -15,7 +15,7 @@ const ROLES = { coder: { definition: 'coder', authority: { withinRole: [], cross
async function setup(t, limit = 0) {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'mosaic-daily-quota-')), rolesDir = rolesCopy(root), doc = businessDoc(root, 'demo');
doc.plugins = { 'limit-check': ['daily-quota'], harness: ['pi', 'claude-code'] };
doc.launch.account = { name: 'jason-max', credential: 'CLAUDE_CODE_OAUTH_TOKEN' };
+1 -1
View File
@@ -10,7 +10,7 @@ import { fixture, notifyConfig } from '../../cli/tests/helpers.mjs';
import { initializeControl, transition } from '../../processing-control/src/state.mjs';
import { verifyPrincipal } from '../src/principal.mjs';
import { checkConsole } from '../../webui/src/sso-config.mjs';
import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs';
import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs';
const uid=process.getuid(), nonce='a'.repeat(64);
const paths={ console:'/tree/cli.mjs', consoleCli:'/tree/console-cli.mjs' };
+1 -1
View File
@@ -11,7 +11,7 @@ import { Broker, registrySha256 } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { hostGrant, definitionSha256 } from '../src/host-grant.mjs';
import { hostRecord, hostGrantRecord } from './host-fixtures.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
const refusesWith = async (p, code) => assert.rejects(Promise.resolve().then(() => p), (e) => (assert.equal(e.code, code), true));
@@ -6,7 +6,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
// Break caught: missing runtime setter routing or losing the organization receipt.
test('row 298 runtime: human scope approval writes the file and organization history at the supplied revision', async t => {
+2 -2
View File
@@ -4,7 +4,7 @@
// Gitea grants run against the fake Gitea in fake-gitea-api.mjs on 127.0.0.1; host grants stop at the
// spend, as no host key store is configured, and nothing connects to a host. Every token is a fake the
// fake made and none is printed. The organization writer is a fake here; the real one is
// packages/business/tests/grant-scope.test.mjs.
// packages/organization/tests/grant-scope.test.mjs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
@@ -16,7 +16,7 @@ import { Credentials } from '../src/credentials.mjs';
import { giteaGrant, giteaKeys } from '../src/gitea-grant.mjs';
import { hostGrant, grantKeys, fingerprint } from '../src/host-grant.mjs';
import { Store } from '../src/store.mjs';
import { checkGrantScope, grantScopeOf } from '../../business/src/vocabulary.mjs';
import { checkGrantScope, grantScopeOf } from '../../organization/src/vocabulary.mjs';
import { fakeGiteaApi } from './fake-gitea-api.mjs';
import { hostRecord, grantDecision } from './host-fixtures.mjs';
+1 -1
View File
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker, ACTIONS, HARD_WAITS } from '../src/broker.mjs';
import { ACTIONS as VOCABULARY, GATED_ONLY } from '../../business/src/vocabulary.mjs';
import { ACTIONS as VOCABULARY, GATED_ONLY } from '../../organization/src/vocabulary.mjs';
import { fixture } from './spec-fixtures.mjs';
import { hostGrantRecord, hostRecord } from './host-fixtures.mjs';
+2 -2
View File
@@ -17,7 +17,7 @@ import { Broker, ORGANIZATION_KEYS } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { hostGrant } from '../src/host-grant.mjs';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { ephemeralRange, installSleepStub, pickPort, startScratchSshd } from './scratch-sshd.mjs';
@@ -422,7 +422,7 @@ test('T24: no literal gitea and vikunja kind list outside vocabulary.mjs, and th
return existsSync(src) ? readdirSync(src).filter((n) => n.endsWith('.mjs')).map((n) => `packages/${pkg}/src/${n}`) : [];
});
const code = (file) => readFileSync(join(REPO, file), 'utf8').split('\n').filter((l) => !/^\s*(\/\/|\*)/.test(l)).join('\n');
const lists = files.filter((file) => !file.endsWith('business/src/vocabulary.mjs') &&
const lists = files.filter((file) => !file.endsWith('organization/src/vocabulary.mjs') &&
/\[\s*['"]gitea['"]\s*,\s*['"]vikunja['"]\s*\]/.test(code(file)));
assert.deepEqual(lists, []);
const uses = files.filter((file) => /credentials\.(use|useFor)\(/.test(code(file))).sort();
+1 -1
View File
@@ -60,7 +60,7 @@ test('row 165: instances and instances.set refuse every non-human session but th
test('row 165 runtime: an add and a change reach the organization file with the human as actor; each refusal keeps the bytes and logs nothing', async (t) => {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'instances-runtime-'));
const rolesDir = rolesCopy(root);
writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
+1 -1
View File
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { createContinuations } from '../src/request-continuation.mjs';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
import { ID_PATTERN } from '../../organization/src/vocabulary.mjs';
// Row 146 (#1640): a message.send to a role nobody holds tells the business's launch.by
// instance once, and the result says so. The assistant's lists are the shipped ones, as in
@@ -22,7 +22,7 @@ import { mappingEntry, repo } from '../../library/tests/helpers.mjs';
import * as library from '../../library/src/index.mjs';
import { fakeGiteaApi } from './fake-gitea-api.mjs';
import { grantDecision } from './host-fixtures.mjs';
import { systemFor } from '../../business/tests/helpers.mjs';
import { systemFor } from '../../organization/tests/helpers.mjs';
const AT = '2026-10-22T00:00:00.000Z';
const REV = 'b'.repeat(64);
+5 -5
View File
@@ -7,13 +7,13 @@ import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { busBusiness } from '../src/business.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { startBroker } from '../src/runtime.mjs';
import { Store } from '../src/store.mjs';
import { hostGrantRecord, hostRecord } from './host-fixtures.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, FAR_DATE, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { businessDoc, FAR_DATE, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
const TOKEN = 'fake_issued_token_0123456789';
const store = (agent, service) => ({ store: true, [service === 'gitea' ? 'rotateBy' : 'expires']: FAR_DATE });
@@ -42,7 +42,7 @@ async function setup(t, { drop = [], seed = null } = {}) {
const b = loadBusiness('demo', { dir: root, rolesDir });
const resolved = {};
for (const i of Object.keys(b.roles)) resolved[i] = resolveInstance({ system: systemFor(root), business: b, instance: i });
const boot = busBusiness(b, resolved);
const boot = busOrganization(b, resolved);
for (const name of drop) delete boot.roles[name].credentials;
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: boot }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => {
+5 -5
View File
@@ -366,10 +366,10 @@ test('row 194 V5 retry: a retired name deletes again only after workspace-rename
test('row 194 V5 runtime: a delete request, the take and lifecycleDelete commit the file with retired, log the human and reload so the name no longer binds', async (t) => {
const { startBroker } = await import('../src/runtime.mjs');
const { busBusiness } = await import('../src/business.mjs');
const { loadBusiness } = await import('../../business/src/business.mjs');
const { resolveInstance } = await import('../../business/src/resolve.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { busOrganization } = await import('../src/organization-context.mjs');
const { loadBusiness } = await import('../../organization/src/organization-config.mjs');
const { resolveInstance } = await import('../../organization/src/resolve.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'lifecycle-runtime-'));
const rolesDir = rolesCopy(root);
const file = writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
@@ -377,7 +377,7 @@ test('row 194 V5 runtime: a delete request, the take and lifecycleDelete commit
const b = loadBusiness('demo', { dir: root, rolesDir });
const resolved = {};
for (const i of Object.keys(b.roles)) resolved[i] = resolveInstance({ system: systemFor(root), business: b, instance: i });
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busBusiness(b, resolved) }, organization: { dir: root, rolesDir, system: systemFor(root) } });
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busOrganization(b, resolved) }, 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 ask = (verb, args) => bus.broker.request(human, { verb, args });
+1 -1
View File
@@ -13,7 +13,7 @@ const CODER = ['tokens', 'instances', 'coder'];
async function setup(t, plugins, limit = 0) {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'mosaic-limits-tokens-')), rolesDir = rolesCopy(root), doc = businessDoc(root, 'demo');
doc.plugins = { 'limit-check': plugins, harness: ['pi'] };
doc.launch.limits = { ...doc.launch.limits, tokens: { instances: { coder: { limit, since: SINCE } } } };
+2 -2
View File
@@ -17,14 +17,14 @@ test('limits binds actor from human proof and rejects role, reader, a tester set
b.request(human,{verb:'limits'});b.request(human,{verb:'limits.set',args:patch});b.request(tester,{verb:'limits'});assert.deepEqual(calls,[['demo','limits',{},'jason'],['demo','limits.set',patch,'jason'],['demo','limits',{},'tester']]);
});
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 {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../organization/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 {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../organization/tests/helpers.mjs');const {organizationWriter}=await import('../../organization/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');
+2 -2
View File
@@ -36,7 +36,7 @@ test('disabled, refused, and failed providers never retain authorization', async
test('launch admission refreshes selection and refuses a removed model and incompatible harness', async t => {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const { mkdtempSync, mkdirSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os'); const { join } = await import('node:path');
const root=mkdtempSync(join(tmpdir(),'model-admission-')); t.after(()=>rmSync(root,{recursive:true,force:true}));
@@ -68,7 +68,7 @@ test('an account mutation fences reads begun before or during the write', async
});
test('a different enabled fixture provider supplies legacy mapping and admission without a core case', async t => {
const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../business/tests/helpers.mjs');
const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../organization/tests/helpers.mjs');
const {mkdtempSync,mkdirSync,rmSync}=await import('node:fs');const {tmpdir}=await import('node:os');const {join}=await import('node:path');
const root=mkdtempSync(join(tmpdir(),'second-provider-'));t.after(()=>rmSync(root,{recursive:true,force:true}));const rolesDir=rolesCopy(root),doc=businessDoc(root,'demo');
doc.plugins={...doc.plugins,harness:['pi'],'model-provider':['second-fixture']};doc.launch.models['fixture-legacy']={provider:'fixture',model:'fixture-model'};doc.roles.coder.vars={...doc.roles.coder.vars,harness:'pi',model:'fixture-legacy',thinking:'low'};
@@ -11,8 +11,8 @@ import { loadSystem, bootConfig } from '../../cli/src/config.mjs';
import { createLauncher } from '../../cli/src/launcher.mjs';
import { startBroker } from '../../bus/src/runtime.mjs';
import { gitEnv } from '../../seat/src/checkout.mjs';
import { organizationWriter } from '../../business/src/organization.mjs';
import { writeJson } from '../../business/tests/helpers.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { writeJson } from '../../organization/tests/helpers.mjs';
import { readSessions, runsDir, sessionsFile, SESSIONS_VERSION, launchLogFile } from '../../seat/src/session.mjs';
export const waitFor = async (read, message, ms=15000) => { const end=Date.now()+ms;while(Date.now()<end){const value=await read();if(value)return value;await new Promise(r=>setTimeout(r,20));}throw Error(message); };
export const pause = ms => new Promise(r=>setTimeout(r,ms));
@@ -7,9 +7,9 @@ import { DatabaseSync } from 'node:sqlite';
import { createHash } from 'node:crypto';
import { startHost } from '../../cli/src/host.mjs';
import { createLauncher } from '../../cli/src/launcher.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { busBusiness } from '../src/business.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { sessionsFile, launchLogFile } from '../../seat/src/session.mjs';
import { reviewFixture } from './model-review-fixtures.mjs';
const id = key => createHash('sha256').update(key).digest('hex').slice(0, 12);
@@ -42,7 +42,7 @@ async function ipcFixture(t, { down = true, ended = false, live = false } = {})
for (const instance of Object.keys(business.roles)) resolved[instance] = resolveInstance({ system: f.system, business, instance });
const logs = [];
host = await startHost({ business: 'demo', log: line => logs.push(line), boot: {
dataRoot: f.dataRoot, readers: ['demo'], businesses: { demo: busBusiness(business, resolved) },
dataRoot: f.dataRoot, readers: ['demo'], businesses: { demo: busOrganization(business, resolved) },
organization: { dir: f.configDir, rolesDir: f.rolesDir, system: f.system },
proxy: { port: Number(new URL(proxy.baseUrl).port), binary: '/fixture-unused' },
} });
+6 -6
View File
@@ -10,11 +10,11 @@ import { Credentials } from '../src/credentials.mjs';
import { ManagementClient } from '../../model-proxy/src/client.mjs';
import { ModelKeys, REVOKE_TIMEOUT_MS, REVOKE_POLL_MS } from '../src/model-keys.mjs';
import { startBroker } from '../src/runtime.mjs';
import { busBusiness } from '../src/business.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { Registry } from '../../seat/src/session.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
const hash = s => createHash('sha256').update(s).digest('hex').slice(0, 12);
const failCode = code => e => e.code === code;
async function fixture(t, { delayed = 0 } = {}) {
@@ -192,7 +192,7 @@ async function bootBusiness(f, binding = {}) {
fs.mkdirSync(join(f.root, 'data'), { mode: 0o700 });
// Fresh mkdtemp fixture + exclusive mkdir above: no prior session registry can exist.
new Registry(join(f.root, 'data'));
const bus = await startBroker({ dataRoot: join(f.root, 'data'), businesses: { demo: busBusiness(business, resolved) },
const bus = await startBroker({ dataRoot: join(f.root, 'data'), businesses: { demo: busOrganization(business, resolved) },
organization: { dir: f.configDir, rolesDir, system }, proxy: { port: Number(new URL(f.baseUrl).port), binary: '/fixture-unused' }, ...binding });
f.closers.push(() => bus.close());
const chief = bus.broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
@@ -320,7 +320,7 @@ test('beta bus registers while alpha bus runs and alpha key stays accepted', asy
const business = loadBusiness('beta', { dir: f.configDir, rolesDir: alpha.rolesDir }), resolved = {};
for (const instance of Object.keys(business.roles)) resolved[instance] = resolveInstance({ system: alpha.system, business, instance });
const betaRoot = join(f.root, 'beta-bus'); fs.mkdirSync(betaRoot, { mode: 0o700 }); new Registry(betaRoot);
const beta = await startBroker({ dataRoot: betaRoot, businesses: { beta: busBusiness(business, resolved) },
const beta = await startBroker({ dataRoot: betaRoot, businesses: { beta: busOrganization(business, resolved) },
organization: { dir: f.configDir, rolesDir: alpha.rolesDir, system: alpha.system },
proxy: { port: Number(new URL(f.baseUrl).port), binary: '/fixture-unused' }, ...binding, org: { id: 'beta' } });
f.closers.push(() => beta.close());
@@ -5,10 +5,10 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { busBusiness } from '../src/business.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { lifecycleFixture } from './model-lifecycle-fixtures.mjs';
async function hireFixture(t,{revoking=false}={}){
const f=await lifecycleFixture(t),old=f.create(['demo/orphan']);await old.manager.reconcile();const issued=await old.manager.ensure('demo/orphan');
@@ -22,7 +22,7 @@ async function hireFixture(t,{revoking=false}={}){
const dataRoot=join(f.root,'data');fs.mkdirSync(dataRoot,{mode:0o700});
// Capture only value-free runtime proxy receipts; never print a leaked value.
const log=[],original=process.stderr.write;process.stderr.write=function(chunk,...args){if(String(chunk).startsWith('model-proxy: ')){log.push(String(chunk));const callback=args.find(a=>typeof a==='function');callback?.();return true;}return original.call(this,chunk,...args);};f.own(()=>{process.stderr.write=original;});
const bus=await startBroker({dataRoot,businesses:{demo:busBusiness(business,resolved)},organization:{dir:f.configDir,rolesDir,system},proxy:{port:Number(new URL(f.baseUrl).port),binary:'/fixture-unused'}});
const bus=await startBroker({dataRoot,businesses:{demo:busOrganization(business,resolved)},organization:{dir:f.configDir,rolesDir,system},proxy:{port:Number(new URL(f.baseUrl).port),binary:'/fixture-unused'}});
f.own(()=>bus.close());
assert.equal(f.state.keys.includes(issued.key),false,'runtime boot reconciled omitted orphan');assert.equal(bus.credentials.modelRecord('demo/orphan')===null,true,'orphan remains unregistered before hire');
const cap=bus.broker.bindLaunch({business:'demo',role:'chief',run:'orphan-hire-chief',harness:'pi'});bus.broker.request(cap,{verb:'role.claim'});
+5 -5
View File
@@ -6,10 +6,10 @@ import { createHash, randomBytes } from 'node:crypto';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { startBroker } from '../src/runtime.mjs';
import { busBusiness } from '../src/business.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { sessionsFile, SESSIONS_VERSION } from '../../seat/src/session.mjs';
const hash = value => createHash('sha256').update(value).digest('hex');
const deferred = () => {let resolve;const promise=new Promise(r=>{resolve=r;});return {promise,resolve};};
@@ -23,7 +23,7 @@ export async function pendingFixture(t,{twoBusinesses=false}={}){
const doc=businessDoc(root,'demo');doc.plugins={...doc.plugins,harness:['pi']};
// Existing row331 scratch aide pattern only; repository role authority unchanged.
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});
const ids=twoBusinesses?['demo','other']:['demo'],files={},businesses={};for(const name of ids){const document={...structuredClone(doc),id:name};files[name]=writeJson(join(configDir,'businesses',name+'.json'),document);const business=loadBusiness(name,{dir:configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});businesses[name]=busBusiness(business,resolved);}
const ids=twoBusinesses?['demo','other']:['demo'],files={},businesses={};for(const name of ids){const document={...structuredClone(doc),id:name};files[name]=writeJson(join(configDir,'businesses',name+'.json'),document);const business=loadBusiness(name,{dir:configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});businesses[name]=busOrganization(business,resolved);}
fs.mkdirSync(join(configDir,'secrets'),{recursive:true,mode:0o700});fs.writeFileSync(join(configDir,'secrets','proxy-management.key'),'fx654-manage',{mode:0o600});fs.mkdirSync(join(dataRoot,'bus-host'),{recursive:true,mode:0o700});writeJson(sessionsFile(dataRoot),{sessionsVersion:SESSIONS_VERSION,sessions:[]});
const sentinel=`msk-${hash(configDir).slice(0,12)}-sentinel-${randomBytes(32).toString('base64url')}`;state.keys.push(sentinel);history.add(sentinel);
server=http.createServer(async(req,res)=>{try{
+5 -5
View File
@@ -13,10 +13,10 @@ import { startBroker } from '../src/runtime.mjs';
import { Credentials } from '../src/credentials.mjs';
import { ModelKeys } from '../src/model-keys.mjs';
import { ManagementClient } from '../../model-proxy/src/client.mjs';
import { busBusiness } from '../src/business.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, systemConfig, writeJson } from '../../business/tests/helpers.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, systemConfig, writeJson } from '../../organization/tests/helpers.mjs';
import { startTimeOf } from '../../seat/src/proc.mjs';
import { Registry, SESSIONS_VERSION, sessionsFile } from '../../seat/src/session.mjs';
import { createLauncher } from '../../cli/src/launcher.mjs';
@@ -97,7 +97,7 @@ async function fixture(t, { overdue = false, exited = false } = {}) {
writeSessions([entry]);
if (exited) await exit();
const boot = async () => {
bus = await startBroker({ dataRoot, businesses: { demo: busBusiness(business, resolved) }, organization: { dir: configDir, rolesDir, system },
bus = await startBroker({ dataRoot, businesses: { demo: busOrganization(business, resolved) }, organization: { dir: configDir, rolesDir, system },
proxy: { port: server.address().port, binary: '/fixture-unused' } });
return bus;
};
+5 -5
View File
@@ -12,10 +12,10 @@ import { startBroker } from '../src/runtime.mjs';
import { Credentials } from '../src/credentials.mjs';
import { ModelKeys } from '../src/model-keys.mjs';
import { ManagementClient } from '../../model-proxy/src/client.mjs';
import { busBusiness } from '../src/business.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, systemConfig, writeJson } from '../../business/tests/helpers.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, systemConfig, writeJson } from '../../organization/tests/helpers.mjs';
import { startTimeOf } from '../../seat/src/proc.mjs';
import { Registry, SESSIONS_VERSION, sessionsFile } from '../../seat/src/session.mjs';
import { createLauncher } from '../../cli/src/launcher.mjs';
@@ -116,7 +116,7 @@ export async function reviewFixture(t, { overdue = false, exited = false } = {})
writeSessions([entry]);
if (exited) await exit();
const boot = async ({ proxyMode = true } = {}) => {
bus = await startBroker({ dataRoot, businesses: { demo: busBusiness(business, resolved) }, organization: { dir: configDir, rolesDir, system },
bus = await startBroker({ dataRoot, businesses: { demo: busOrganization(business, resolved) }, organization: { dir: configDir, rolesDir, system },
proxy: proxyMode ? { port: server.address().port, binary: '/fixture-unused' } : null });
return bus;
};
@@ -12,11 +12,11 @@ import { Credentials } from '../src/credentials.mjs';
import { ModelKeys } from '../src/model-keys.mjs';
import { ManagementClient } from '../../model-proxy/src/client.mjs';
import { startBroker } from '../src/runtime.mjs';
import { busBusiness } from '../src/business.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { organizationWriter } from '../../business/src/organization.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { startTimeOf } from '../../seat/src/proc.mjs';
import { sessionsFile, SESSIONS_VERSION, Registry } from '../../seat/src/session.mjs';
export const id = value => createHash('sha256').update(value).digest('hex').slice(0,12);
@@ -53,7 +53,7 @@ export async function revocationFixture(t,{runtime=false,onRoot=()=>{},missingBo
const rolesDir=rolesCopy(root),system=systemFor(root),doc=businessDoc(root,'demo');doc.plugins={...doc.plugins,harness:['pi']};doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};doc.launch.instances.push('aide');
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});writeJson(join(configDir,'businesses','demo.json'),doc);
const business=loadBusiness('demo',{dir:configDir,rolesDir}),resolved={};for(const name of Object.keys(business.roles))resolved[name]=resolveInstance({system,business,instance:name});
boot={dataRoot,businesses:{demo:busBusiness(business,resolved)},organization:{dir:configDir,rolesDir,system},proxy:{port,binary:'/fixture-unused'}};writer=organizationWriter({dir:configDir,rolesDir});
boot={dataRoot,businesses:{demo:busOrganization(business,resolved)},organization:{dir:configDir,rolesDir,system},proxy:{port,binary:'/fixture-unused'}};writer=organizationWriter({dir:configDir,rolesDir});
stderr=process.stderr.write;process.stderr.write=function(chunk,...args){if(String(chunk).startsWith('model-proxy: ')){receipts.push(String(chunk));args.find(a=>typeof a==='function')?.();return true;}return stderr.call(this,chunk,...args);};
const openBus=async()=>{bus=await startBroker(boot);const ownedBus=bus,run='tmp-chief-'+(++busBoots);let closed=false,bound=false;currentBusClose=async()=>{if(closed)return;closed=true;const errors=[];if(bound){try{ownedBus.endLaunch({business:'demo',run,reason:'host-lost',exitCode:null});}catch{errors.push('owned-chief-end');}}try{await ownedBus.close();}catch{errors.push('owned-bus-close');}if(errors.length)throw new AggregateError(errors.map(code=>Error(code)),'owned bus cleanup failed');};closers.push(currentBusClose);c=bus.credentials;chief=bus.broker.bindLaunch({business:'demo',role:'chief',run,harness:'pi'});bound=true;bus.broker.request(chief,{verb:'role.claim'});human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});};
await openBus();boot.openBus=openBus;
@@ -1,6 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { busOrganization } from '../src/organization-context.mjs';
import { busBusiness } from '../src/business.mjs';
import * as bus from '../src/index.mjs';
test('S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input', () => {
const business = {
id: 'demo',
@@ -19,10 +21,16 @@ test('S1 adapter takes resolved limits and refs, rejects mismatched instance, ne
},
};
const before = structuredClone(business),
b = busBusiness(business, resolved);
b = busOrganization(business, resolved);
assert.deepEqual(b.roles.coder.authority, resolved.coder.limits.authority);
assert.deepEqual(b.roles.coder.credentials, {});
assert.deepEqual(business, before);
resolved.coder.instance = 'pm';
assert.throws(() => busBusiness(business, resolved), /invalid-resolution/);
assert.throws(() => busOrganization(business, resolved), /invalid-resolution/);
});
test('rename design r2 stage B: the old module and the package root give the same adapter function', () => {
assert.equal(typeof busOrganization, 'function');
assert.equal(busBusiness, busOrganization);
assert.equal(bus.busBusiness, busOrganization);
assert.equal(bus.busOrganization, busOrganization);
});
@@ -7,8 +7,8 @@ import { Store } from '../src/store.mjs';
import { createLimits, modelUsageKey } from '../src/limits.mjs';
import * as token from '../../plugins/limit-check/token-limit/index.mjs';
import * as daily from '../../plugins/limit-check/daily-quota/index.mjs';
import { launchSettings } from '../../business/src/launch-settings.mjs';
import { businessDoc, rolesCopy, writeJson, systemFor } from '../../business/tests/helpers.mjs';
import { launchSettings } from '../../organization/src/launch-settings.mjs';
import { businessDoc, rolesCopy, writeJson, systemFor } from '../../organization/tests/helpers.mjs';
const key='abcdefabcdef',other='012345abcdef',since='2026-01-01T00:00:00.000Z';
const quota={limit:10,since,reset:'00:00',timeZone:'UTC'};
const native={at:'2026-10-26T12:00:00.000Z',run:'legacy-run',turn:'turn',account:key,modelKeyId:other,status:'known',input:10,output:0,cacheWrite:0,cacheRead:0};
@@ -56,7 +56,7 @@ test('P5 R1 legacy hex account chief resets stay allowed; daily model old/new/re
});
test('P5 R1 daily model matching/report is separate from legacy account and schema refuses partial or invalid daily shapes',async()=>{
const {checkTokenLimits}=await import('../../business/src/launch.mjs');
const {checkTokenLimits}=await import('../../organization/src/launch.mjs');
assert.deepEqual(checkTokenLimits({keys:{[key]:quota}}).keys[key],quota);
const args={limits:{tokens:{keys:{[key]:quota}}},records:[native],subject:{account:key,modelKeyId:other},now:native.at,lineages:[[key],[other]]};
assert.deepEqual(token.report(args),[]);
+2 -2
View File
@@ -15,8 +15,8 @@ import { ModelKeys, MAX_PREVIOUS_KEY_IDS } from '../src/model-keys.mjs';
import { Store } from '../src/store.mjs';
import { createLimits, modelUsageKey, modelKeyLineages, modelKeyLineage } from '../src/limits.mjs';
import { createUsage } from '../src/usage.mjs';
import { launchSettings } from '../../business/src/launch-settings.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { launchSettings } from '../../organization/src/launch-settings.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
const hash = s => createHash('sha256').update(s).digest('hex').slice(0, 12);
const START = Date.parse('2026-10-24T12:00:00.000Z');
+1 -1
View File
@@ -9,7 +9,7 @@ import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { Registry } from '../../seat/src/session.mjs';
import { startTimeOf } from '../../seat/src/proc.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
const hash=s=>createHash('sha256').update(s).digest('hex').slice(0,12);
test('P5 real runtime binds stack usage to issued key, daily stops at stack count and human-only telemetry settings pass broker contract',async t=>{
const root=mkdtempSync(join(tmpdir(),'p5-runtime-')),config=join(root,'config'),data=join(root,'data');
+1 -1
View File
@@ -6,7 +6,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { Registry } from '../../seat/src/session.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
// Row 333 R3 (#1831): every wait races a real-timer deadline, captured here before any test mocks
// setTimeout, so a missed event fails by name instead of hanging the file. R4: the real setImmediate
// too, for the yield after a poll, which now runs inside that poll's deadline.
+3 -3
View File
@@ -32,7 +32,7 @@ test('plugins and plugins.set refuse every non-human session and a forged actor,
test('runtime: the switch changes the organization file, the receipt carries the actor, and a bad shape, an unknown module and a stale revision are refused with nothing written', async (t) => {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'plugins-runtime-'));
const rolesDir = rolesCopy(root);
writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
@@ -67,7 +67,7 @@ test('runtime: the switch changes the organization file, the receipt carries the
test('vikunja:19/309 through the broker, a patch switches the kinds it names and keeps the rest; null removes one kind and is trailed', async (t) => {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'plugins-merge-'));
const rolesDir = rolesCopy(root);
writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { 'usage-source': ['meter'] } });
@@ -93,7 +93,7 @@ test('vikunja:19/309 through the broker, a patch switches the kinds it names and
test('plugins read advertises loadable harnesses even when switched off', async t => {
const {startBroker}=await import('../src/runtime.mjs');
const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../business/tests/helpers.mjs');
const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../organization/tests/helpers.mjs');
const root=mkdtempSync(join(tmpdir(),'plugins-available-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
const rolesDir=rolesCopy(root);const doc=businessDoc(root,'demo');doc.plugins={harness:['claude-code']};
writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
+2 -2
View File
@@ -39,12 +39,12 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
// FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it.
// Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission.
"import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../business/src/vocabulary.mjs';",
"import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';",
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/proxy-page-vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
assert.doesNotMatch(readFileSync(new URL('../../business/src/vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
assert.doesNotMatch(readFileSync(new URL('../../organization/src/vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
assert.doesNotMatch(readFileSync(new URL('../../plugins/src/kinds.mjs', import.meta.url), 'utf8'), /\bimport\b/);
});
@@ -5,7 +5,7 @@ import {join} from 'node:path';
import {tmpdir} from 'node:os';
import {DatabaseSync} from 'node:sqlite';
import {startBroker} from '../src/runtime.mjs';
import {businessDoc,rolesCopy,systemFor,writeJson} from '../../business/tests/helpers.mjs';
import {businessDoc,rolesCopy,systemFor,writeJson} from '../../organization/tests/helpers.mjs';
test('role must not materialize a new catalogue key by submitting legacy entry shape',async t=>{
const root=fs.mkdtempSync(join(tmpdir(),'materialize-'));t.after(()=>fs.rmSync(root,{recursive:true,force:true}));
const rolesDir=rolesCopy(root),doc=businessDoc(root,'demo'),file=join(root,'businesses/demo.json');
@@ -5,7 +5,7 @@ import {join} from 'node:path';
import {tmpdir} from 'node:os';
import {DatabaseSync} from 'node:sqlite';
import {startBroker} from '../src/runtime.mjs';
import {businessDoc,rolesCopy,systemFor,writeJson} from '../../business/tests/helpers.mjs';
import {businessDoc,rolesCopy,systemFor,writeJson} from '../../organization/tests/helpers.mjs';
const authority=JSON.parse(fs.readFileSync('./roles/chief-of-staff.json','utf8')).authority;
const oldEntry={provider:'zai',model:'fixture-old'};
const newEntry={provider:'zai',model:'fixture-new'};
+2 -2
View File
@@ -219,7 +219,7 @@ test('publishRefused files the code on the task\'s trail and drops the ticket',
test('git.push.remote is in neither ROUTED list and no shipped role grants it', async () => {
const { ACTIONS, HARD_WAITS } = await import('../src/broker.mjs');
const vocabulary = await import('../../business/src/vocabulary.mjs');
const vocabulary = await import('../../organization/src/vocabulary.mjs');
assert.ok(ACTIONS.includes('git.push.remote') && HARD_WAITS.includes('git.push.remote'));
assert.ok(!vocabulary.ROUTED.includes('git.push.remote'));
const { readdirSync, readFileSync } = await import('node:fs');
@@ -232,7 +232,7 @@ test('git.push.remote is in neither ROUTED list and no shipped role grants it',
// token is used only under an open publish ticket, keyed from the agent checkPublish checked.
test('runtime: withGitea keys from the open ticket only, and the store root is the organization dir', async (t) => {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const { chmodSync, mkdirSync } = await import('node:fs');
const root = mkdtempSync(join(tmpdir(), 'bus-publish-rt-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
+5 -5
View File
@@ -7,12 +7,12 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker } from '../src/broker.mjs';
import { busBusiness } from '../src/business.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { startBroker } from '../src/runtime.mjs';
import { Store } from '../src/store.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { mappingEntry } from '../../library/tests/helpers.mjs';
// The bus form of the business file, built as cli bootConfig builds it.
@@ -20,7 +20,7 @@ function busForm(root, rolesDir, id = 'demo') {
const b = loadBusiness(id, { dir: root, rolesDir });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(root), business: b, instance });
return busBusiness(b, resolved);
return busOrganization(b, resolved);
}
async function setup(t, mutate) {
@@ -13,9 +13,9 @@ import { prepareBootstrap } from '../src/request-bootstrap.mjs';
import { hostPreparation, staffFor } from '../src/request-host.mjs';
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { organizationWriter } from '../../business/src/organization.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { managedState } from '../../business/src/project-bootstrap.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { managedState } from '../../organization/src/project-bootstrap.mjs';
import { workingRepo } from '../../seat/src/working.mjs';
const INTENT = 'Build a single-file timer.\nKeep these exact bytes.\n';
+4 -4
View File
@@ -101,7 +101,7 @@ test('row 65 runtime: harnessLevels answers each module in the tree, and null fo
test('row 65 runtime: routes and routes.set reach the organization file with the human as actor, a new bind reads plugins.harness, and each refusal keeps the bytes and logs nothing', async (t) => {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = scratch(t, 'routes-runtime-');
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, 'demo');
@@ -150,7 +150,7 @@ test('row 65 runtime: routes and routes.set reach the organization file with the
});
test('fixture provider catalogue appears in actual routes and materializes the selected model', async t => {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = scratch(t, 'routes-catalogue-'), rolesDir = rolesCopy(root), doc = businessDoc(root, 'demo');
doc.plugins = { ...doc.plugins, harness: ['pi'], 'model-provider': ['cliproxy'] };
for (const r of Object.values(doc.roles)) r.vars = { harness: 'pi', model: Object.keys(doc.launch.models)[0], thinking: 'high', ...r.vars };
@@ -170,7 +170,7 @@ test('fixture provider catalogue appears in actual routes and materializes the s
for (const writer of ['limits.set','organization.set','instances.set']) test(`${writer} selects only available compatible existing provider models`, async t => {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root=scratch(t,'provider-writer-'),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo');
doc.plugins={...doc.plugins,harness:['pi','claude-code'],'model-provider':['cliproxy']};
doc.launch.models['fixture-persisted']={provider:'zai',model:'fixture-id',module:'cliproxy',upstream:'zai'};
@@ -192,7 +192,7 @@ for (const writer of ['limits.set','organization.set','instances.set']) test(`${
});
test('legacy routes follow enabled module mappings while unchanged unavailable selections remain saveable', async t => {
const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../business/tests/helpers.mjs');
const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../organization/tests/helpers.mjs');
const root=scratch(t,'legacy-catalogue-'),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo');
doc.plugins={...doc.plugins,harness:['pi','claude-code'],'model-provider':['cliproxy']};
doc.launch.models['legacy-one']={provider:'anthropic',model:'fixture-one'};doc.launch.models['legacy-two']={provider:'zai',model:'fixture-two'};
+2 -2
View File
@@ -9,7 +9,7 @@ import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker, TESTER_VERBS, TESTER_ORGANIZATION_VERBS } from '../src/broker.mjs';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
// Row 177 (#1675): thread.list left this list; the tester's lists its own threads (tester-threads.test.mjs).
@@ -104,7 +104,7 @@ test('row 175: W2 as the tester refuses credentials, protected definitions, huma
[{ instance: 'thread-2', op: 'add', definition: 'assistant' }, 'organization-definition-refused'],
[{ instance: 'human', op: 'add', definition: 'merge-gate' }, 'invalid-request'],
[{ instance: 'tester', op: 'add', definition: 'merge-gate' }, 'invalid-request'],
// bus.tester is a business-only var (business/src/vars.mjs), so no instance carries it.
// bus.tester is a business-only var (organization/src/vars.mjs), so no instance carries it.
[{ instance: 'gate-2', op: 'add', definition: 'merge-gate', vars: { 'bus.tester': true } }, 'invalid-request'],
[{ instance: 'coder', op: 'change', vars: { 'bus.tester': true } }, 'invalid-request'],
];
+4 -4
View File
@@ -9,7 +9,7 @@ import { Broker, BusError, WEB_UNTRUSTED } from '../src/broker.mjs';
import { serve } from '../src/server.mjs';
import { Client } from '../src/client.mjs';
import { webService, WEB_SEARCH_NOT_CONFIGURED } from '../src/web.mjs';
import { busBusiness } from '../src/business.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { WEB_REFUSAL, WebRefusal } from '../../discord/src/web.mjs';
// REQ-HARN-2: the web verbs on the broker. The search source and the network are fakes: the real
// SearXNG answers 403 for JSON until vikunja:19/291 is fixed, and no test touches the network.
@@ -272,11 +272,11 @@ test('over the socket: the server routes web verbs to the service; other roles a
await assert.rejects(new Client({ path: path2, cap: broker.bindLaunch({ business: 'demo', role: 'pm', run: 'run-pm2', harness: 'pi' }) }).call('web.search', { query: 'x' }), { code: 'not-holder' });
});
test('busBusiness: the web grant comes from "web" in the resolved tool limit', () => {
test('busOrganization: the web grant comes from "web" in the resolved tool limit', () => {
const resolved = (tools) => ({ business: 'demo', instance: 'a', definition: 'a', limits: { authority: { withinRole: [], crossRole: [] }, tools } });
const base = { id: 'demo', roles: { a: { definition: 'a' } }, human: 'jason' };
assert.equal(busBusiness(base, { a: resolved(['read', 'web']) }).roles.a.web, true);
assert.equal(busBusiness(base, { a: resolved(['read']) }).roles.a.web, false);
assert.equal(busOrganization(base, { a: resolved(['read', 'web']) }).roles.a.web, true);
assert.equal(busOrganization(base, { a: resolved(['read']) }).roles.a.web, false);
});
test('webServices: the business\'s search-source plug-in and web.searxng var make web.search work; without either it is refused as not configured', async (t) => {
+4 -4
View File
@@ -9,9 +9,9 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { busBusiness } from '../src/business.mjs';
import { resolveInstance, validateBusinessDocument } from '../../business/src/index.mjs';
import { businessDoc, REPO_ROLES, systemFor } from '../../business/tests/helpers.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { resolveInstance, validateBusinessDocument } from '../../organization/src/index.mjs';
import { businessDoc, REPO_ROLES, systemFor } from '../../organization/tests/helpers.mjs';
const TASK = 'vikunja:3/7';
const A = 'a'.repeat(40);
@@ -31,7 +31,7 @@ function setup(t, mutate = () => {}) {
const resolved = {};
for (const instance of Object.keys(business.roles))
resolved[instance] = resolveInstance({ system: systemFor(root), business, instance });
const config = busBusiness(business, resolved);
const config = busOrganization(business, resolved);
mutate(config);
const store = new Store(root);
const b = new Broker({ store, businesses: { demo: config } });
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@mosaic/business",
"version": "0.1.0",
"private": true,
"description": "Role definitions, business and project files, variable layers and the resolver (slice 1 row S1).",
"description": "Old name of @mosaic/organization (packages/organization). Every module here re-exports the moved implementation; nothing is duplicated.",
"license": "UNLICENSED",
"type": "module",
"engines": { "node": ">=24" },
+2 -174
View File
@@ -1,174 +1,2 @@
// The access file, <configDir>/access.json (federation design section 3.5,
// FED R1, #414). It names the extra users who may sign in to this instance
// and the grants that let them reach a business's threads. The validator is
// pure: providers, peers, businesses and the owner's sign-in subjects come in
// as arguments. Any failed rule refuses the whole file, which leaves every
// user with no grant (fail closed).
import { join } from "node:path";
import { refuse } from "./errors.mjs";
import { configDir } from "./business.mjs";
import { isPlainObject, requireObject, rejectUnknownKeys, requireId, requirePositiveInt, readJsonFile, deepFreeze } from "./util.mjs";
// FED R3 r5 (row 274): LEVELS, the all marker and covers() live in the vocabulary, which the session
// sandbox mounts, because the broker checks a user's grant with covers(). This file re-exports them.
import { LEVELS, GRANT_ALL as ALL, covers } from "./vocabulary.mjs";
export { LEVELS, covers };
// Ids a user may never take: the bus's own principals.
const RESERVED_USERS = Object.freeze(["human", "tester"]);
const LOCAL_HOME = "local";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
const SUB_MAX = 255;
export function accessFilePath(dir = configDir()) {
return join(dir, "access.json");
}
function requireArray(value, where) {
if (!Array.isArray(value)) refuse(`${where} must be an array`);
return value;
}
function requireText(value, where) {
if (typeof value !== "string" || value.length === 0 || value.length > SUB_MAX || value.includes("\0")) {
refuse(`${where} must be a non-empty string of at most ${SUB_MAX} characters`);
}
return value;
}
function subjectKey(provider, sub) {
return JSON.stringify([provider, sub]);
}
function checkContext(context) {
const where = "access context";
requireObject(context, where);
rejectUnknownKeys(context, ["providers", "peers", "businesses", "ssoSubjects"], where);
const providers = new Set(requireArray(context.providers, `${where}.providers`).map((p, i) => requireText(p, `${where}.providers[${i}]`)));
const peers = new Set(requireArray(context.peers, `${where}.peers`).map((p, i) => requireId(p, `${where}.peers[${i}]`)));
if (peers.has(LOCAL_HOME)) refuse(`${where}.peers must not name ${JSON.stringify(LOCAL_HOME)}`);
requireObject(context.businesses, `${where}.businesses`);
const businesses = new Map();
for (const [id, business] of Object.entries(context.businesses)) {
const at = `${where}.businesses.${id}`;
requireId(id, `${where}.businesses key`);
requireObject(business, at);
requireId(business.human, `${at}.human`);
const instances = requireArray(business.instances, `${at}.instances`).map((x, i) => requireId(x, `${at}.instances[${i}]`));
businesses.set(id, { human: business.human, instances: new Set(instances) });
}
const owners = new Set();
requireArray(context.ssoSubjects, `${where}.ssoSubjects`).forEach((s, i) => {
const at = `${where}.ssoSubjects[${i}]`;
requireObject(s, at);
owners.add(subjectKey(requireText(s.provider, `${at}.provider`), requireText(s.sub, `${at}.sub`)));
});
return { providers, peers, businesses, owners };
}
function checkUser(user, at, ctx, humans, seenSubjects) {
requireObject(user, at);
rejectUnknownKeys(user, ["id", "home", "subjects"], at);
requireId(user.id, `${at}.id`);
if (RESERVED_USERS.includes(user.id)) refuse(`${at}.id must not be ${JSON.stringify(user.id)}, a bus principal`);
if (humans.has(user.id)) refuse(`${at}.id must not be a business's human (${JSON.stringify(user.id)})`);
if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {
refuse(`${at}.home must be a configured peer or ${JSON.stringify(LOCAL_HOME)} (got ${JSON.stringify(user.home)})`);
}
const subjects = requireArray(user.subjects, `${at}.subjects`);
if (subjects.length === 0) refuse(`${at}.subjects must not be empty`);
subjects.forEach((s, i) => {
const sat = `${at}.subjects[${i}]`;
requireObject(s, sat);
rejectUnknownKeys(s, ["provider", "sub"], sat);
if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {
refuse(`${sat}.provider must be a configured provider (got ${JSON.stringify(s.provider)})`);
}
requireText(s.sub, `${sat}.sub`);
const key = subjectKey(s.provider, s.sub);
if (ctx.owners.has(key)) refuse(`${sat} is an owner sign-in subject (console.sso.subjects)`);
if (seenSubjects.has(key)) refuse(`${sat} appears more than once in the file`);
seenSubjects.add(key);
});
}
// Either ["*"] alone or a non-empty list of distinct entries, each checked.
function checkScope(value, where, check) {
const list = requireArray(value, where);
if (list.length === 0) refuse(`${where} must not be empty`);
if (list.includes(ALL)) {
if (list.length !== 1) refuse(`${where} must be ["*"] alone or list entries without "*"`);
return;
}
const seen = new Set();
list.forEach((entry, i) => {
check(entry, `${where}[${i}]`);
if (seen.has(entry)) refuse(`duplicate entry in ${where}: ${JSON.stringify(entry)}`);
seen.add(entry);
});
}
function checkExpires(value, where) {
if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z (got ${JSON.stringify(value)})`);
}
const iso = new Date(value).toISOString();
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time: ${value}`);
}
function checkGrant(grant, at, ctx, users, seenGrants) {
requireObject(grant, at);
rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);
requireId(grant.id, `${at}.id`);
if (seenGrants.has(grant.id)) refuse(`${at}.id is a duplicate grant id: ${grant.id}`);
seenGrants.add(grant.id);
if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(`${at}.user must name a user in this file (got ${JSON.stringify(grant.user)})`);
if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {
refuse(`${at}.business must name a declared business (got ${JSON.stringify(grant.business)})`);
}
const business = ctx.businesses.get(grant.business);
checkScope(grant.projects, `${at}.projects`, (p, where) => {
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID (got ${JSON.stringify(p)})`);
});
checkScope(grant.instances, `${at}.instances`, (x, where) => {
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of ${grant.business} (got ${JSON.stringify(x)})`);
});
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")} (got ${JSON.stringify(grant.level)})`);
if (grant.expires !== undefined) checkExpires(grant.expires, `${at}.expires`);
}
// Check a parsed access document against the instance's context. Returns the
// document frozen. Every refusal is a BusinessError naming the key path. An
// expired grant is not refused: covers() treats it as absent.
export function checkAccess(document, context) {
const ctx = checkContext(context);
const humans = new Set([...ctx.businesses.values()].map((b) => b.human));
if (!isPlainObject(document)) refuse("access file must be a JSON object");
rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");
if (document.accessVersion !== 1) refuse(`accessVersion must be 1 (got ${JSON.stringify(document.accessVersion)})`);
requirePositiveInt(document.revision, "revision");
const users = new Set();
const seenSubjects = new Set();
requireArray(document.users, "users").forEach((user, i) => {
checkUser(user, `users[${i}]`, ctx, humans, seenSubjects);
if (users.has(user.id)) refuse(`users[${i}].id is a duplicate user id: ${user.id}`);
users.add(user.id);
});
const seenGrants = new Set();
requireArray(document.grants, "grants").forEach((grant, i) => checkGrant(grant, `grants[${i}]`, ctx, users, seenGrants));
return deepFreeze(structuredClone(document));
}
// Read and check the access file. One descriptor, opened without following a
// link, serves the stat checks and the read (readJsonFile). The file holds
// authority, so it must belong to this uid and not be writable by group or
// other, as the business file is.
export function loadAccess(file, context, { uid = process.getuid() } = {}) {
const document = readJsonFile(file, "access file", (stat) => {
if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}: ${file}`);
if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
});
return checkAccess(document, context);
}
// Old path of packages/organization/src/access.mjs (rename design r2, stage B).
export * from "../../organization/src/access.mjs";
+2 -30
View File
@@ -1,30 +1,2 @@
// The organization file's `appearance` (stream S4, PLAN C5): { theme, mode }, which theme the
// Console shows for this organization and in which mode. Both are optional, and a file without
// `appearance` is valid: the Console then shows its built-in default theme in the system mode.
//
// `theme` is a module name (the plugin name pattern), checked for shape only. Whether a module of
// that name is installed is the Console's question at render time, not the file's: a file that
// names a theme which was since uninstalled stays valid and keeps its choice, and the Console
// shows the default with a warning until the module is back.
import { NAME } from "../../plugins/src/kinds.mjs";
import { refuse } from "./errors.mjs";
import { requireObject, rejectUnknownKeys } from "./util.mjs";
export const APPEARANCE_MODES = Object.freeze(["system", "light", "dim", "dark"]);
// The checked, frozen appearance, or undefined when the file has none.
export function checkAppearance(value, where) {
if (value === undefined) return undefined;
requireObject(value, where);
rejectUnknownKeys(value, ["theme", "mode"], where);
if (Object.hasOwn(value, "theme") && (typeof value.theme !== "string" || value.theme.length > 64 || !NAME.test(value.theme))) {
refuse(`${where}.theme must be an installed theme's name, matching ${NAME} (got ${JSON.stringify(value.theme)})`);
}
if (Object.hasOwn(value, "mode") && !APPEARANCE_MODES.includes(value.mode)) {
refuse(`${where}.mode must be one of ${APPEARANCE_MODES.join(", ")} (got ${JSON.stringify(value.mode)})`);
}
return Object.freeze({
...(Object.hasOwn(value, "theme") ? { theme: value.theme } : {}),
...(Object.hasOwn(value, "mode") ? { mode: value.mode } : {}),
});
}
// Old path of packages/organization/src/appearance.mjs (rename design r2, stage B).
export * from "../../organization/src/appearance.mjs";
+2 -345
View File
@@ -1,345 +1,2 @@
// The business file, <configDir>/businesses/<id>.json (REQ-ROLE-3, note
// 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 { checkModels, checkLimits, checkAccount } from "./launch.mjs";
import { checkVars } from "./vars.mjs";
import { parseCredentialRef } from "./credentials.mjs";
import { loadRole } from "./role.mjs";
import { END_PROTECTED } from "./vocabulary.mjs";
import { checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs";
import { checkMapping } from "../../library/src/mapping.mjs";
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
import { checkAppearance } from "./appearance.mjs";
import {
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
} from "./util.mjs";
const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
// How long a thread's session stays up with no turn before the launcher stops
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
export const THREAD_IDLE_SECONDS = 900;
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance"];
// The config directory: next to the system config, $MOSAIC_CONFIG or
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
export function configDir(env = process.env) {
const file = env.MOSAIC_CONFIG || join(homedir(), ".config", "mosaic-dev", "config.json");
return dirname(file);
}
export function businessFilePath(id, dir = configDir()) {
requireId(id, "business id");
return join(dir, "businesses", `${id}.json`);
}
export function checkBot(tracker, where) {
requireObject(tracker, where);
rejectUnknownKeys(tracker, ["bot", "botId"], where);
if (typeof tracker.bot !== "string" || !BOT_NAME.test(tracker.bot)) refuse(`${where}.bot must be a Vikunja bot username starting with "bot-"`);
requirePositiveInt(tracker.botId, `${where}.botId`);
return { bot: tracker.bot, botId: tracker.botId };
}
function checkCredentialMap(map, services, where, store = null) {
requireObject(map, where);
const names = Object.keys(map).sort();
const wanted = [...services].sort();
if (names.join(",") !== wanted.join(",")) {
refuse(`${where} must reference exactly the services its role definition needs (${wanted.join(", ") || "none"}; got ${names.join(", ") || "none"})`);
}
const out = {};
for (const service of names) out[service] = parseCredentialRef(map[service], service, `${where}.${service}`, { store });
return out;
}
// Row 199 (#1697, design D8): the agent's own git identity. The email is the one its Gitea user
// has, so Gitea links the commits to that user; gitea.url is the instance its pushes go to.
const EMAIL = /^[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,190}\.[A-Za-z]{2,24}$/;
const NAME = /^[^\0\n\r<>]{1,100}$/;
// Row 199 (design D9c): the parse is vocabulary.mjs's, where the broker reads it; this wraps it with
// the loader's refusals. Returns {url, owner, repo}.
export function httpsUrl(value, where, { repo = false } = {}) {
const parsed = parseGiteaUrl(value, { repo });
if (parsed.problem === "form") refuse(`${where} must be a normalized https URL with no user, query or fragment`);
if (parsed.problem === "instance") refuse(`${where} must name the Gitea instance only, https://HOST[:PORT]`);
if (parsed.problem === "repository") refuse(`${where} must name a repository, https://HOST/OWNER/REPO`);
return parsed;
}
function checkIdentity(identity, where) {
requireObject(identity, where);
rejectUnknownKeys(identity, ["name", "email", "gitea"], where);
if (typeof identity.name !== "string" || !NAME.test(identity.name)) refuse(`${where}.name must be 1 to 100 characters with no <, > or line break`);
if (typeof identity.email !== "string" || !EMAIL.test(identity.email)) refuse(`${where}.email must be an email address`);
requireObject(identity.gitea, `${where}.gitea`);
rejectUnknownKeys(identity.gitea, ["url", "user"], `${where}.gitea`);
httpsUrl(identity.gitea.url, `${where}.gitea.url`);
if (!giteaName(identity.gitea.user)) refuse(`${where}.gitea.user must be a Gitea user name`);
return { name: identity.name, email: identity.email, gitea: { url: identity.gitea.url, user: identity.gitea.user } };
}
function checkTracker(tracker, file) {
const where = `${file} tracker`;
requireObject(tracker, where);
rejectUnknownKeys(tracker, ["sync", "labels"], where);
requireObject(tracker.sync, `${where}.sync`);
rejectUnknownKeys(tracker.sync, ["bot", "botId", "credentials"], `${where}.sync`);
const sync = {
...checkBot({ bot: tracker.sync.bot, botId: tracker.sync.botId }, `${where}.sync`),
credentials: checkCredentialMap(tracker.sync.credentials, ["vikunja"], `${where}.sync.credentials`),
};
let labels = {};
if (tracker.labels !== undefined) {
requireObject(tracker.labels, `${where}.labels`);
// fromEntries defines own properties, so a "__proto__" title stays data.
labels = Object.fromEntries(Object.entries(tracker.labels).map(([title, id]) => [
requireString(title, `${where}.labels title`, { max: 100 }),
requirePositiveInt(id, `${where}.labels.${title}`),
]));
}
return { sync, labels };
}
// Row 213 (C2-08, Case 2 design 4): persona vars belong only to a creative instance, and come as a
// pair, since the bundle reads the file only against its digest.
function checkPersonaVars(vars, definition, at) {
const set = ["persona.name", "persona.sha256"].filter((key) => Object.hasOwn(vars, key));
if (set.length === 0) return;
if (!CREATIVE_DEFINITIONS.includes(definition)) refuse(`${at}.vars: persona vars need a creative definition (${definition} isn't one)`);
if (set.length === 1) refuse(`${at}.vars: persona.name and persona.sha256 are set together or not at all`);
}
// Row 234 (#1740, r6 S1): hosts and host grants are bus store records made by a human-approved grant,
// so the business file can't name them. The key stays known so the refusal says where they went.
const HOSTS_MOVED = "lives in the bus store now: enroll a host with a host-enroll grant (mosaic hosts status)";
function checkRoles(roles, file, rolesDir, business, hasLibrary) {
const where = `${file} roles`;
requireObject(roles, where);
if (Object.keys(roles).length === 0) refuse(`${where} must declare at least one role instance`);
const out = {};
const definitions = {};
const bots = new Set();
const botIds = new Set();
for (const [instance, entry] of Object.entries(roles)) {
requireId(instance, `${where} instance name`);
const at = `${where}.${instance}`;
requireObject(entry, at);
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish", "hosts"], at);
requireId(entry.definition, `${at}.definition`);
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition);
definitions[entry.definition] = definition;
if (definition.roleVersion !== 2) refuse(`${at}: role definition ${entry.definition} is version 1 and can't back a business role instance`);
const services = definition.credentials.map((c) => c.service);
if (entry.hosts !== undefined) refuse(`${at}.hosts ${HOSTS_MOVED}`);
// A host.<id> key reference comes from the agent's grant record at bus start (r6 X4), never from here.
// Row 285 amendment 1 (mos-1580): the chief needs its read reference only for a library.
// Other definitions keep the exact service list; checkCredentialMap itself is unchanged.
const kinds = services.filter((s) => s !== HOST_SERVICE && !(entry.definition === "chief-of-staff" && !hasLibrary && s === "gitea"));
if (entry.definition === "chief-of-staff" && hasLibrary && !Object.hasOwn(entry.credentials ?? {}, "gitea")) {
refuse(`chief-library-gitea-required: ${at}.credentials must reference gitea because this business declares a library`);
}
let tracker = null;
if (services.includes("vikunja")) {
if (entry.tracker === undefined) refuse(`${at} needs "tracker" with its Vikunja bot, because ${entry.definition} uses vikunja`);
tracker = checkBot(entry.tracker, `${at}.tracker`);
if (bots.has(tracker.bot)) refuse(`${at}.tracker.bot ${tracker.bot} is used by another instance`);
if (botIds.has(tracker.botId)) refuse(`${at}.tracker.botId ${tracker.botId} is used by another instance`);
bots.add(tracker.bot);
botIds.add(tracker.botId);
} else if (entry.tracker !== undefined) {
refuse(`${at} has "tracker" but ${entry.definition} uses no vikunja credential`);
}
out[instance] = {
definition: entry.definition,
holder: entry.holder === undefined ? null : requireId(entry.holder, `${at}.holder`),
vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`),
tracker,
credentials: checkCredentialMap(entry.credentials ?? {}, kinds, `${at}.credentials`,
{ configDir: dirname(dirname(file)), business, agent: instance }),
};
// Row 199 (#1697, design D8, D9 step 2): identity and publish are optional, so today's files
// load unchanged. publish needs both an identity and a gitea credential; the project ids it
// names are checked against projects once those are read.
if (entry.identity !== undefined) out[instance].identity = checkIdentity(entry.identity, `${at}.identity`);
if (entry.publish !== undefined) {
// N10: one message for both, naming the grant.
if (!out[instance].identity || !services.includes("gitea")) refuse(`${at}.publish needs identity and a gitea definition`);
out[instance].publish = requireDistinctList(entry.publish, `${at}.publish`, (p) => requireId(p, `${at}.publish entry`), { nonEmpty: true });
}
checkPersonaVars(out[instance].vars, entry.definition, at);
}
return { roles: out, definitions };
}
function checkLaunch(launch, roles, definitions, file) {
const where = `${file} launch`;
requireObject(launch, where);
if (Object.hasOwn(launch,"max")) refuse(`${where}.max needs migration: mosaic limits migrate --file FILE --models FILE`);
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account", "keep", "noHire", "grantScope"], 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];
if (!launcher.authority.withinRole.includes("role.launch")) {
refuse(`${where}.by names ${launch.by}, whose role ${launcher.name} doesn't hold role.launch within-role`);
}
const instances = requireDistinctList(launch.instances, `${where}.instances`, (name) => {
requireId(name, `${where}.instances entry`);
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 });
const models=checkModels(launch.models,`${where}.models`);
const limits=checkLimits(launch.limits===undefined?{}:launch.limits,`${where}.limits`);
for(const instance of Object.keys(limits.tokens?.instances??{}))if(!Object.hasOwn(roles,instance))refuse(`${where}.limits.tokens.instances names ${instance}, which the business file doesn't declare`);
// Row 126 (#1620): a daily quota for any account but launch.account would count nothing and still
// show as counted, so it refuses the read.
const account = launch.account === undefined ? undefined : checkAccount(launch.account, `${where}.account`);
for (const name of Object.keys(limits.tokens?.daily ?? {})) {
if (name !== account?.name) refuse(`${where}.limits.tokens.daily names ${name}, which isn't ${where}.account.name`);
}
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}`);
// No upper bound: limits have no ceiling in code (Q150).
const threadIdleSeconds = launch.threadIdleSeconds === undefined ? THREAD_IDLE_SECONDS : requirePositiveInt(launch.threadIdleSeconds, `${where}.threadIdleSeconds`);
// Row 194 (#1692, D2.4, D2.5): the human's per-agent forbid. keep names declared instances the
// chief can't fire, replace or end. noHire names definitions the chief can't hire, by the id
// pattern only, so a stale entry is harmless. Both are optional and only the human writes them.
const keep = launch.keep === undefined ? undefined : requireDistinctList(launch.keep, `${where}.keep`, (name) => {
requireId(name, `${where}.keep entry`);
if (!Object.hasOwn(roles, name)) refuse(`${where}.keep names ${name}, which the business file doesn't declare`);
if (END_PROTECTED.includes(roles[name].definition)) refuse(`${where}.keep names ${name}, whose definition ${roles[name].definition} can't be kept`);
});
const noHire = launch.noHire === undefined ? undefined
: requireDistinctList(launch.noHire, `${where}.noHire`, (name) => requireId(name, `${where}.noHire entry`));
return {
by: launch.by, instances, models, limits, threadIdleSeconds, ...(account ? { account } : {}),
...(keep ? { keep } : {}), ...(noHire ? { noHire } : {}),
// Row 298 (#1804, Q228): the chief's grant scope passes through unchecked. A bad value must not
// refuse the whole file; the broker's reader (grantScopeOf) falls back to the empty scope instead.
...(launch.grantScope !== undefined ? { grantScope: structuredClone(launch.grantScope) } : {}),
};
}
// Row 194 (#1692, V5): instance names a delete retired. None may be declared again, so a later
// create or hire can't reuse a name whose decisions outlive it.
function checkRetired(retired, roles, file) {
return requireDistinctList(retired, `${file} retired`, (name) => {
requireId(name, `${file} retired entry`);
if (Object.hasOwn(roles, name)) refuse(`${file} retired names ${name}, which roles still declares`);
});
}
// Validate a parsed business document read from `file`. `rolesDir` is where
// role definitions live. Returns a frozen business with its definitions.
export function validateBusinessDocument(document, file, { rolesDir }) {
requireObject(document, "business file");
rejectUnknownKeys(document, TOP_KEYS, "business file");
if (document.businessVersion !== 1) refuse(`business file "businessVersion" must be 1 (${file})`);
requireId(document.id, "business id");
const base = file.split("/").pop().replace(/\.json$/, "");
if (document.id !== base) refuse(`business "id" (${document.id}) must match its filename (${base}.json)`);
for (const key of ["human", "arbiters", "projects", "roles"]) {
if (document[key] === undefined) refuse(`business file requires "${key}" (${file})`);
}
const human = requireId(document.human, "business human");
const vars = checkVars(document.vars ?? {}, "business", `${file} vars`);
if (document.hosts !== undefined) refuse(`${file} hosts ${HOSTS_MOVED}`);
const hasLibrary = Object.values(document.projects ?? {}).some((project) => project?.kind === "library");
const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id, hasLibrary);
requireObject(document.arbiters, `${file} arbiters`);
rejectUnknownKeys(document.arbiters, ["delivery", "technical"], `${file} arbiters`);
const arbiters = {};
for (const kind of ["delivery", "technical"]) {
const name = document.arbiters[kind];
requireId(name, `${file} arbiters.${kind}`);
if (!Object.hasOwn(roles, name)) refuse(`${file} arbiters.${kind} names ${name}, which the business file doesn't declare`);
// Row 133 (#1627): an arbiter messages the raiser when it closes a decision, so it can't be a
// role whose definition limits who it sends to and hears from.
const definition = roles[name].definition;
if (definitions[definition].messaging) {
refuse(`${file} arbiters.${kind} names ${name}, whose role definition ${definition} declares messaging lists`);
}
arbiters[kind] = name;
}
requireObject(document.projects, `${file} projects`);
if (Object.keys(document.projects).length === 0) refuse(`${file} projects must name at least one project`);
const projects = {};
for (const [id, entry] of Object.entries(document.projects)) {
requireId(id, `${file} project id`);
requireObject(entry, `${file} projects.${id}`);
if (entry.kind === 'library') {
projects[id] = checkMapping(entry, `${file} projects.${id}`, { fail: (code, message) => refuse(`${code}: ${message}`) });
continue;
}
rejectUnknownKeys(entry, ["root", "repo", "remote", "git"], `${file} projects.${id}`);
if (typeof entry.root !== "string" || !isAbsolute(entry.root) || normalize(entry.root) !== entry.root || entry.root.includes("\0")) {
refuse(`${file} projects.${id}.root must be a normalized absolute path`);
}
if (Object.hasOwn(entry, "repo") && (typeof entry.repo !== "string" || !isAbsolute(entry.repo) ||
normalize(entry.repo) !== entry.repo || entry.repo.includes("\0"))) {
refuse(`${file} projects.${id}.repo must be a normalized absolute path`);
}
// Row 199 (#1697, design D9 step 2): the upstream; a granted agent's work goes to its fork.
if (Object.hasOwn(entry, "remote")) httpsUrl(entry.remote, `${file} projects.${id}.remote`, { repo: true });
if (Object.hasOwn(entry, "git")) {
requireObject(entry.git, `${file} projects.${id}.git`);
rejectUnknownKeys(entry.git, ["mode", "remote"], `${file} projects.${id}.git`);
if (!['local-only', 'remote'].includes(entry.git.mode)) refuse(`${file} projects.${id}.git.mode must be local-only or remote`);
if (entry.git.mode === 'remote') httpsUrl(entry.git.remote, `${file} projects.${id}.git.remote`, { repo: true });
else if (Object.hasOwn(entry.git, 'remote')) refuse(`${file} projects.${id}.git.remote is not allowed for local-only`);
}
projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}),
...(Object.hasOwn(entry, "remote") ? { remote: entry.remote } : {}),
...(Object.hasOwn(entry, "git") ? { git: { ...entry.git } } : {}) };
}
for (const [instance, role] of Object.entries(roles)) {
for (const project of role.publish ?? []) {
if (!Object.hasOwn(projects, project)) refuse(`${file} roles.${instance}.publish names ${project}, which the business file doesn't declare`);
// C2-21, sage-2916: retain the fence; only creative instances and the chief may hold library reach.
if (projects[project].kind === 'library' && !CREATIVE_DEFINITIONS.includes(role.definition) && role.definition !== 'chief-of-staff')
refuse(`${file} roles.${instance}.publish names library project ${project}; library publish requires a creative definition or chief-of-staff with gitea`);
if (!projects[project].remote) refuse(`${file} roles.${instance}.publish names ${project}, which has no "remote"`);
const problem = forkProblem(projects[project].remote, role.identity);
if (problem) refuse(`${file} roles.${instance}.publish names ${project}: ${problem}`);
}
}
const tracker = Object.hasOwn(document, "tracker") ? checkTracker(document.tracker, file) : null;
for (const [instance, role] of Object.entries(roles)) {
if (tracker && role.tracker && (role.tracker.bot === tracker.sync.bot || role.tracker.botId === tracker.sync.botId)) {
refuse(`${file} tracker.sync must use its own bot, not the one roles.${instance} uses`);
}
}
const launch = document.launch === undefined ? null : checkLaunch(document.launch, roles, definitions, file);
// REQ-PLUG-1: the module names switched on per kind. The names are checked
// here; whether each module loads is the loader's check, at load time.
const plugins = document.plugins === undefined ? {}
: structuredClone(checkPlugins(document.plugins, `${file} plugins`, { fail: (_code, message) => refuse(message) }));
const usage = document.usage === undefined ? undefined : checkUsageSettings(document.usage);
const appearance = checkAppearance(document.appearance, `${file} appearance`);
const retired = document.retired === undefined ? undefined : checkRetired(document.retired, roles, file);
return deepFreeze({
businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, plugins, definitions,
...(retired ? { retired } : {}), ...(usage === undefined ? {} : { usage }), ...(appearance === undefined ? {} : { appearance }),
});
}
// Load businesses/<id>.json from the config directory. The file holds
// authority, so it must belong to this user and not be writable by group
// or other.
export function loadBusiness(id, { dir = configDir(), rolesDir }) {
if (!rolesDir) throw new Error("loadBusiness needs rolesDir");
const file = businessFilePath(id, dir);
const document = readJsonFile(file, "business file", (stat) => {
if (stat.uid !== process.getuid()) refuse(`business file must belong to uid ${process.getuid()}: ${file}`);
if ((stat.mode & 0o022) !== 0) refuse(`business file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
});
return validateBusinessDocument(document, file, { rolesDir });
}
// Old path of packages/organization/src/organization-config.mjs (rename design r2, stage B).
export * from "../../organization/src/organization-config.mjs";
+4 -138
View File
@@ -1,139 +1,5 @@
#!/usr/bin/env node
// mosaic business validate <business>
// mosaic business resolve <business> <instance> [--project <id>]
//
// validate loads the business file, every role definition it uses, the
// project files that exist under its declared roots, and stat-checks every
// credential reference. resolve prints one role instance's resolved record.
// Both print JSON on stdout and problems on stderr.
//
// Exit codes: 0 ok; 2 invalid (business, role, project file or a credential
// reference); 3 system config problem; 4 usage, or a required file missing.
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { BusinessError } from "./errors.mjs";
import { loadBusiness } from "./business.mjs";
import { loadProject, projectFilePath } from "./project.mjs";
import { checkCredentialRef } from "./credentials.mjs";
import { systemVars, resolveInstance } from "./resolve.mjs";
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const USAGE = "usage: mosaic business validate <business> | resolve <business> <instance> [--project <id>]";
function fail(code, message) {
process.stderr.write(`mosaic business: ${message}\n`);
process.exit(code);
}
function loadSystem() {
const proc = spawnSync(process.execPath, [join(REPO, "scripts", "mosaic-config.mjs"), "validate"], {
encoding: "utf8",
maxBuffer: 1024 * 1024,
});
if (proc.status !== 0) fail(3, `system config problem (mosaic-config exit ${proc.status}): ${proc.stderr.trim()}`);
try {
return JSON.parse(proc.stdout);
} catch {
fail(3, "system config: mosaic-config printed something that isn't JSON");
}
}
function rolesDir() {
return resolve(process.env.MOSAIC_ROLES_DIR || join(REPO, "roles"));
}
// Check credential references; print warnings; return the problem list.
function checkRefs(refs, forbiddenRoots) {
const problems = [];
const warnings = [];
for (const ref of refs) {
const result = checkCredentialRef(ref, { forbiddenRoots });
problems.push(...result.problems);
warnings.push(...result.warnings);
}
return { problems, warnings };
}
function forbiddenRoots(config, business) {
return [REPO, config.dataRoot, ...Object.values(business.projects).filter(p => p.kind !== 'library').map((p) => p.root)];
}
function validate(args) {
if (args.length !== 1) fail(4, USAGE);
const config = loadSystem();
const system = systemVars(config);
const business = loadBusiness(args[0], { rolesDir: rolesDir() });
const projects = {};
const warnings = [];
for (const [id, entry] of Object.entries(business.projects)) {
if (entry.kind === 'library') { projects[id] = 'library'; continue; }
const file = projectFilePath(entry.root);
if (!existsSync(file)) {
projects[id] = "absent";
warnings.push(`project ${id}: no project file at ${file}; it has no project variables`);
continue;
}
const project = loadProject(entry.root);
if (project.id !== id) fail(2, `project file ${file} has id ${project.id}, but business ${business.id} declares it as ${id}`);
for (const instance of Object.keys(business.roles)) resolveInstance({ system, business, project, instance });
projects[id] = "valid";
}
const refs = [
...Object.values(business.tracker?.sync?.credentials ?? {}),
...Object.values(business.roles).flatMap((r) => Object.values(r.credentials)),
];
const checked = checkRefs(refs, forbiddenRoots(config, business));
warnings.push(...checked.warnings);
const instances = {};
for (const instance of Object.keys(business.roles)) {
instances[instance] = resolveInstance({ system, business, instance }).digest;
}
for (const w of warnings) process.stderr.write(`mosaic business: warning: ${w}\n`);
if (checked.problems.length > 0) {
for (const p of checked.problems) process.stderr.write(`mosaic business: ${p}\n`);
fail(2, `business ${business.id}: ${checked.problems.length} credential reference problem(s)`);
}
process.stdout.write(`${JSON.stringify({ business: business.id, file: business.file, projects, instances }, null, 2)}\n`);
}
function resolveCommand(args) {
let project = null;
const rest = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === "--project") {
if (!args[i + 1] || project !== null) fail(4, USAGE);
project = args[++i];
} else rest.push(args[i]);
}
if (rest.length !== 2) fail(4, USAGE);
const [businessId, instance] = rest;
const config = loadSystem();
const business = loadBusiness(businessId, { rolesDir: rolesDir() });
let loaded = null;
if (project !== null) {
const entry = Object.hasOwn(business.projects, project) ? business.projects[project] : null;
if (!entry) fail(2, `business ${business.id} declares no project ${JSON.stringify(project)}`);
if (entry.kind !== 'library') loaded = loadProject(entry.root);
}
const resolved = resolveInstance({ system: systemVars(config), business, project: loaded, instance });
const checked = checkRefs(Object.values(resolved.credentials), forbiddenRoots(config, business));
for (const w of checked.warnings) process.stderr.write(`mosaic business: warning: ${w}\n`);
if (checked.problems.length > 0) {
for (const p of checked.problems) process.stderr.write(`mosaic business: ${p}\n`);
fail(2, `${business.id} ${instance}: ${checked.problems.length} credential reference problem(s)`);
}
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
}
const [verb, ...args] = process.argv.slice(2);
try {
if (verb === "validate") validate(args);
else if (verb === "resolve") resolveCommand(args);
else fail(4, USAGE);
} catch (error) {
if (error instanceof BusinessError) fail(error.exitCode, error.message);
throw error;
}
// Old executable path of packages/organization/src/cli.mjs (rename design r2,
// stage B). Importing the moved CLI runs it with this process's arguments, so
// `node packages/business/src/cli.mjs validate ID` behaves exactly as before.
import "../../organization/src/cli.mjs";
+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/config-command.mjs (rename design r2, stage B).
export * from "../../organization/src/config-command.mjs";
+2 -125
View File
@@ -1,125 +1,2 @@
// Credential references (note section 2, addendum A section 7). A
// reference names where a token lives, never the token. The checks here use
// lstat and realpath only; nothing in this package opens a token file.
import { lstatSync, realpathSync } from "node:fs";
import { isAbsolute, join, normalize, relative, sep } from "node:path";
import { refuse } from "./errors.mjs";
import { credentialKind, isHostKind } from "./vocabulary.mjs";
import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs";
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
export const DATE_KEY = Object.freeze({ gitea: "rotateBy", vikunja: "expires" });
const WARN_DAYS = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
// Shape check for one reference. Returns a frozen { service, file | env,
// rotateBy | expires }. Row 199 (#1697, design D1 and D2): `store` is the
// file at <configDir>/secrets/agents/<business>/<agent>/<service>, derived
// here and never written, so an entry can't name another agent's file. A
// reference with no agent (the tracker's sync bot) passes store = null and
// can't use the form. A `file` reference into the agents' store must be the
// agent's own path (`credential-not-yours`).
export function parseCredentialRef(ref, service, where, { store = null } = {}) {
if (!credentialKind(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
requireObject(ref, where);
// Row 210 (#1707, design D1): an ssh key doesn't expire, so host.<id> takes rotateBy as gitea does.
const dateKey = isHostKind(service) ? "rotateBy" : DATE_KEY[service];
rejectUnknownKeys(ref, ["file", "env", "store", dateKey], where);
const hasFile = ref.file !== undefined;
const hasEnv = ref.env !== undefined;
const hasStore = ref.store !== undefined;
if (Number(hasFile) + Number(hasEnv) + Number(hasStore) !== 1) refuse(`${where} must name exactly one of "file", "env" or "store"`);
const out = { service };
if (hasStore) {
if (ref.store !== true) refuse(`${where}.store must be true`);
if (!store) refuse(`${where}: a "store" reference needs an agent`);
out.store = true;
out.file = storePath(store, service);
} else if (hasFile) {
if (typeof ref.file !== "string" || !isAbsolute(ref.file) || normalize(ref.file) !== ref.file || ref.file.includes("\0")) {
refuse(`${where}.file must be a normalized absolute path`);
}
out.file = ref.file;
if (store && inside(join(store.configDir, "secrets", "agents"), ref.file) && ref.file !== storePath(store, service)) {
refuse(`${where}.file credential-not-yours: ${ref.file} is in the agents' store but not this agent's path`);
}
} else {
if (typeof ref.env !== "string" || !ENV_NAME.test(ref.env)) refuse(`${where}.env must match ${ENV_NAME}`);
out.env = ref.env;
}
if (ref[dateKey] === undefined) refuse(`${where} needs "${dateKey}" (YYYY-MM-DD)`);
out[dateKey] = requireDate(ref[dateKey], `${where}.${dateKey}`);
return Object.freeze(out);
}
// The store path for one agent and service (design D1).
export function storePath({ configDir, business, agent }, service) {
return join(configDir, "secrets", "agents", business, agent, service);
}
function inside(root, path) {
const rel = relative(root, path);
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
}
// Check a parsed reference against the filesystem and the calendar.
// Returns { problems: [...], warnings: [...] }; a caller that finds any
// problem refuses. `forbiddenRoots` are directories a token file must not
// sit in (the repository, dataRoot). `now` is a Date.
export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(), env = process.env, uid = process.getuid() } = {}) {
const problems = [];
const warnings = [];
const label = `${ref.service} ${ref.file ? `file ${ref.file}` : `env ${ref.env}`}`;
let notIssued = false;
if (ref.file) {
let stat = null;
try {
stat = lstatSync(ref.file);
} catch (e) {
// Row 199 (design D3a): a store reference with no file is an agent not issued yet.
notIssued = Boolean(ref.store) && e.code === "ENOENT";
if (notIssued) warnings.push(`${label}: not issued`);
else problems.push(`${label}: not found`);
}
if (stat) {
if (stat.isSymbolicLink() || !stat.isFile()) problems.push(`${label}: must be a regular file, not a symbolic link`);
else {
if (stat.uid !== uid) problems.push(`${label}: owned by uid ${stat.uid}, not ${uid}`);
if ((stat.mode & 0o077) !== 0) problems.push(`${label}: mode ${(stat.mode & 0o777).toString(8)} gives group or other access; use 600`);
if (stat.size === 0) problems.push(`${label}: empty`);
let real = ref.file;
try {
real = realpathSync(ref.file);
} catch {
problems.push(`${label}: path can't be resolved`);
}
for (const root of forbiddenRoots) {
let realRoot = root;
try {
realRoot = realpathSync(root);
} catch {
// A root that doesn't exist yet can't contain the file.
}
if (inside(realRoot, real)) problems.push(`${label}: inside ${root}; token files live outside the repository and dataRoot`);
}
}
}
} else if (env[ref.env] === undefined || env[ref.env] === "") {
warnings.push(`${label}: not set in this environment; the launcher must provide it`);
}
// Row 194 (brief item 7, sage-1605 pick a): a human create writes the create day as the date,
// and issue writes the real one. Until then the date describes no token, so a store reference
// with no file skips both date checks. Every other reference keeps them.
if (notIssued) return { problems, warnings };
const days = (dateText) => Math.floor((Date.parse(`${dateText}T00:00:00Z`) - now.getTime()) / DAY_MS);
if (ref.expires) {
const left = days(ref.expires);
if (Date.parse(`${ref.expires}T00:00:00Z`) <= now.getTime()) problems.push(`${label}: expired on ${ref.expires}`);
else if (left < WARN_DAYS) warnings.push(`${label}: expires on ${ref.expires}`);
}
if (ref.rotateBy && Date.parse(`${ref.rotateBy}T00:00:00Z`) <= now.getTime()) {
warnings.push(`${label}: rotation was due on ${ref.rotateBy}`);
}
return { problems, warnings };
}
// Old path of packages/organization/src/credentials.mjs (rename design r2, stage B).
export * from "../../organization/src/credentials.mjs";
+2 -13
View File
@@ -1,13 +1,2 @@
// Exit codes follow docs/TOOLS.md: 2 invalid data or refused, 4 usage or a
// missing file. Every refusal in this package is a BusinessError.
export class BusinessError extends Error {
constructor(message, exitCode = 2) {
super(message);
this.name = "BusinessError";
this.exitCode = exitCode;
}
}
export function refuse(message, exitCode = 2) {
throw new BusinessError(message, exitCode);
}
// Old path of packages/organization/src/errors.mjs (rename design r2, stage B).
export * from "../../organization/src/errors.mjs";
+2 -86
View File
@@ -1,86 +1,2 @@
// Row 210 (#1707, design D3 and D6): the address, user and host key checks, and the argv allowlist the
// broker matches each host.run against. Row 234 (#1740, r6 S1) moved the host registry and the grants
// out of the business file into bus store records. The enrollment raise checks its fields with these,
// and row b's host grant raise checks its commands with checkCommands.
import { isIP } from "node:net";
import { refuse } from "./errors.mjs";
import { ARGV_MAX } from "./vocabulary.mjs";
// Row 210 r4 (#1707): the argv check lives in vocabulary.mjs, which the worker sandbox mounts
// and which imports nothing, so the broker can read it. Re-exported so the D6 checks keep one
// business home here.
export { ARGV_MAX, ARG_BYTES, ARGV_BYTES, validArgv, commandAllowed } from "./vocabulary.mjs";
const DNS = /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/;
export const USER = /^[a-z_][a-z0-9_-]{0,31}$/;
export const HOST_KEY = /^(ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa) [A-Za-z0-9+/]+={0,2}$/;
// D6: a prefix starting with one of these, by basename, would turn the grant back into "any".
// The list can't be complete. Under PRD 1.7 the control is the human's decision on each call (D4).
export const REFUSED_COMMANDS = Object.freeze([
"sh", "bash", "dash", "zsh", "ksh", "fish", "busybox",
"su", "sudo", "doas", "pkexec", "runuser", "env", "xargs", "nohup", "setsid", "timeout", "nice",
"systemd-run", "chroot", "nsenter", "unshare", "setpriv", "capsh", "flock", "stdbuf", "ionice", "chrt",
"taskset", "strace", "script", "watch",
"perl", "ruby", "node", "sed", "vi", "vim", "less",
]);
// Versioned names: python, python3, python3.12; awk, gawk, mawk, nawk; php, php8.2.
const REFUSED_FORMS = Object.freeze([/^python[0-9.]*$/, /^[gmn]?awk$/, /^php[0-9.]*$/]);
export function refusedCommand(first) {
const base = first.split("/").pop();
return REFUSED_COMMANDS.includes(base) || REFUSED_FORMS.some((form) => form.test(base));
}
// D3 step 2: true when the address is this machine. A host grant there would be root on the
// stack's own machine (section 6).
export function ownMachine(address) {
const version = isIP(address);
if (version === 4) {
const first = Number(address.split(".")[0]);
return first === 127 || first === 0;
}
if (version === 6) {
// The URL parser writes the canonical form, so 0:0:0:0:0:0:0:1 reads as [::1] and
// ::ffff:127.0.0.1 as [::ffff:7f00:1].
const canonical = new URL(`http://[${address}]`).hostname;
return canonical === "[::1]" || canonical === "[::]" || /^\[::ffff:(7f[0-9a-f]{2}|[0-9a-f]{1,2}):/.test(canonical);
}
return address === "localhost" || address.endsWith(".localhost");
}
export function checkAddress(address, where) {
if (typeof address !== "string") refuse(`${where} must be an IP address or a DNS name`);
const version = isIP(address);
if (version === 6 && address.includes("%")) refuse(`${where} must not carry an IPv6 zone`);
if (version === 0) {
if (!DNS.test(address) || address.includes("..")) refuse(`${where} must be an IP address or a DNS name`);
// 127.1, 2130706433 and 0177.0.0.1 pass the DNS pattern and the resolver reads them as IPv4.
if (/^[0-9.]+$/.test(address)) refuse(`${where} must be a full four-part IPv4 address`);
if (address.split(".").some((label) => label.startsWith("0x"))) refuse(`${where} must not hold a hex label`);
}
if (ownMachine(address)) refuse(`${where} names this machine (${address})`);
return address;
}
// D6: an argv allowlist and nothing else. No wildcard: "any", "*", {} and [] all refuse.
export function checkCommands(commands, where) {
if (!Array.isArray(commands)) refuse(`${where} must be a list of argv prefixes; there is no wildcard grant`);
if (commands.length === 0) refuse(`${where} must not be empty`);
const seen = new Set();
return commands.map((prefix, i) => {
const at = `${where}[${i}]`;
if (!Array.isArray(prefix) || prefix.length === 0) refuse(`${at} must be a non-empty argv prefix`);
if (prefix.length > ARGV_MAX) refuse(`${at} has more than ${ARGV_MAX} elements`);
for (const element of prefix) {
if (typeof element !== "string" || element === "" || element.includes("\0")) refuse(`${at} elements must be non-empty strings`);
if (/[*?[]/.test(element)) refuse(`${at} must not hold *, ? or [; the match is literal`);
}
if (prefix[0].includes("/") && !prefix[0].startsWith("/")) refuse(`${at}[0] must be a bare name or an absolute path`);
if (refusedCommand(prefix[0])) refuse(`${at} starts with ${prefix[0]}, a shell, runner or interpreter, which would grant any command`);
const key = JSON.stringify(prefix);
if (seen.has(key)) refuse(`${at} repeats a prefix`);
seen.add(key);
return [...prefix];
});
}
// Old path of packages/organization/src/hosts.mjs (rename design r2, stage B).
export * from "../../organization/src/hosts.mjs";
+2 -16
View File
@@ -1,16 +1,2 @@
// @mosaic/business: role definitions, the business and project files, the
// variable registry and the resolver (slice 1 row S1). Everything here reads
// files and refuses on a problem; nothing writes.
export { BusinessError } from "./errors.mjs";
export {
ACTIONS, GATED_ONLY, HIRE_ALLOWED, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
} from "./vocabulary.mjs";
export { validateRoleDocument, loadRoleFile, loadRole, unhireableAction } from "./role.mjs";
export { LAYERS, REGISTRY, THINKING, FALLBACK_VARS, checkVars, mergeVars } from "./vars.mjs";
export { parseCredentialRef, checkCredentialRef } from "./credentials.mjs";
export { configDir, businessFilePath, validateBusinessDocument, loadBusiness } from "./business.mjs";
export { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "./access.mjs";
export { projectFilePath, validateProjectDocument, loadProject } from "./project.mjs";
export { systemVars, resolveInstance, classify } from "./resolve.mjs";
export { loadSeatSets } from "./library-seats.mjs";
// Old path of packages/organization/src/index.mjs (rename design r2, stage B).
export * from "../../organization/src/index.mjs";
+2 -477
View File
@@ -1,477 +1,2 @@
// Row 165 (#1662, Q171): the human adds a role instance and changes an existing one's definition,
// vars and launch membership. It is human-only: the broker's instances and instances.set verbs
// reach it, and neither is in ORGANIZATION_KEYS, so the chief of staff's tool can't (sage-0918).
// holder, credentials and tracker stay hand edits. Row 194 (#1692) adds delete, the human's veto
// lists and the chief's hire and fire. Every write goes through the organization writer, which
// logs the actor and the old and new values.
import { existsSync, readdirSync } from "node:fs";
import { organizationWriter, OrganizationError } from "./organization.mjs";
import { validateBusinessDocument, businessFilePath, checkBot } from "./business.mjs";
import { DATE_KEY } from "./credentials.mjs";
import { checkRoutes, creativeHumanOnly } from "./launch-settings.mjs";
import { loadProject, projectFilePath } from "./project.mjs";
import { resolveInstance } from "./resolve.mjs";
import { loadRole } from "./role.mjs";
import { refuse } from "./errors.mjs";
import { isPlainObject, canonicalJson } from "./util.mjs";
import { CREATIVE_DEFINITIONS, CREATIVE_SERVICE, END_PROTECTED, HOST_SERVICE, ID_PATTERN, checkGrantScope, grantScopeOf } from "./vocabulary.mjs";
const SET_KEYS = ["revision", "instance", "op", "definition", "vars", "launch", "tracker"];
const VETO_KEYS = ["revision", "op", "name"];
const VETO_OPS = ["keep", "unkeep", "nohire", "hire"];
const SCOPE_KEYS = ["revision", "scope"];
const id = value => typeof value === "string" && ID_PATTERN.test(value);
const revisionOk = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
// Row 194 (D2.2, N3): the vars a hire may set, the ones the chief can already set on an instance.
// Every limits.* key merges by intersection, so it can only narrow.
const HIRE_VAR = key => key === "model" || key.startsWith("limits.");
// Row 256 (#1761, REQ-COS-3): the END_PROTECTED part of a delete, for one actor. A creative
// instance is the human's to delete, and any other actor is refused, as row 213 refuses it a
// change. The assistant is refused for every actor, the human included. The listing's
// endProtected is this same check for its reader, so the Agents page offers no Delete it would refuse.
function protectedRefusal(document, instance, actor) {
const { definition } = document.roles[instance];
if (!END_PROTECTED.includes(definition)) return null;
if (!CREATIVE_DEFINITIONS.includes(definition)) return { code: "organization-definition-refused" };
if (actor === document.human) return null;
return { code: "organization-creative-human-only", message: `${instance} is a ${definition} instance, which only the human deletes` };
}
// Row 194 (#1692, V5): why an instance can't be deleted by actor, checked in the design's order, or
// null. The last-listed check runs when the proposed file is validated (deletedDocument).
export function deleteRefusal(document, rolesDir, instance, actor) {
if (!Object.hasOwn(document.roles, instance)) return { code: "organization-unknown-instance" };
const entry = document.roles[instance];
const refused = protectedRefusal(document, instance, actor);
if (refused) return refused;
const references = [];
if (document.launch?.by === instance) references.push("launch.by");
for (const kind of ["delivery", "technical"]) if (document.arbiters?.[kind] === instance) references.push(`arbiters.${kind}`);
if (references.length > 0) {
return { code: "organization-instance-referenced", message: `${instance} is ${references.join(" and ")}; change that first` };
}
if (document.launch?.keep?.includes(instance)) {
return { code: "organization-instance-kept", message: `${instance} is kept; run veto unkeep first` };
}
// Row 194 (brief item 8): delete revokes the agent's store credentials before the write, so an
// instance whose every reference is `store` can go, its tracker bot included (sage-1610 b). A
// file or env reference stays a hand edit.
const refs = Object.values(entry.credentials ?? {});
if (refs.some(ref => ref?.store !== true)) {
return { code: "organization-credentials-out-of-scope", message: `${instance} holds a file or env credential; deleting it is a hand edit` };
}
return null;
}
// The document with `instance` deleted (V5 step 2). It removes roles.<instance> and the references
// that exist only for it, launch.instances and launch.limits.tokens.instances, and adds the name to
// retired. Every other reference (launch.by, the arbiters, launch.keep) refuses in deleteRefusal.
// A business whose only listed instance is this one refuses with a BusinessError.
export function deletedDocument(document, instance) {
const next = structuredClone(document);
delete next.roles[instance];
if (next.launch) {
if (next.launch.instances.length === 1 && next.launch.instances[0] === instance) {
refuse(`${instance} is the last name in launch.instances; a business needs one instance the chief may launch`);
}
next.launch.instances = next.launch.instances.filter(name => name !== instance);
const tokens = next.launch.limits?.tokens?.instances;
if (tokens && Object.hasOwn(tokens, instance)) delete tokens[instance];
}
next.retired = [...(next.retired ?? []), instance];
return next;
}
// A definition this writer can give an instance: version 2, not END_PROTECTED, and declaring no
// credential. An instance of one that declares credentials needs credentials and a tracker bot,
// which stay out of this row (sage-0918); the refusal names that, so a later row lifts it alone.
// Row 213 (C2-08, M10): a creative definition is END_PROTECTED, yet this writer is how the human
// adds and changes one, so it isn't closed here. The caller runs creativeHumanOnly first.
const closed = name => END_PROTECTED.includes(name) && !CREATIVE_DEFINITIONS.includes(name);
// Row 285 (mos-1586): derive creative from the reviewed authority data, not another role-name list.
const creativeDefinition = definition => definition.authority.withinRole.includes("library.write");
const credentialServices = definition => definition.credentials.map(c => c.service).sort();
const sameCredentialServices = (before, after) => credentialServices(before).join(",") === credentialServices(after).join(",");
// Row 194 (brief item 7): the human's add passes credentials true and lifts it. The tester's add,
// change and hire keep it.
function definitionFor(rolesDir, name, { credentials = false } = {}) {
if (closed(name)) throw new OrganizationError("organization-definition-refused");
const definition = loadRole(rolesDir, name);
if (definition.roleVersion !== 2) throw new OrganizationError("invalid-request", `role definition ${name} is version 1`);
if (!credentials && definition.credentials.length > 0) {
throw new OrganizationError("organization-credentials-out-of-scope",
`${name} declares ${definition.credentials.map(c => c.service).join(" and ")} credentials; binding them is a hand edit`);
}
return definition;
}
// Row 194 (brief item 7, sage-1605, sage-1610 a): the human's add of a credentialed definition.
// Each declared service gets a store reference dated the create day (UTC), and no file: the agent
// needs a bus restart, then row 199's issue. A vikunja definition needs args.tracker, an existing
// Vikunja bot the human names. The add makes no Vikunja account. Any other definition refuses it.
function createdCredentials(old, instance, definition, args) {
// webby-0094 N2: a host grant is no token reference (business.mjs checkRoles drops it too).
const services = definition.credentials.map(c => c.service).filter(service => service !== HOST_SERVICE);
const out = {};
if (services.includes("vikunja")) {
if (!Object.hasOwn(args, "tracker")) {
throw new OrganizationError("organization-tracker-required", `${definition.name} uses vikunja; add needs tracker {bot, botId}, an existing Vikunja bot`);
}
let tracker;
try {
tracker = checkBot(args.tracker, "tracker");
} catch (e) {
if (e.name === "BusinessError") throw new OrganizationError("invalid-request", e.message);
throw e;
}
// business.mjs checks the same after the write: no two instances and not the sync bot.
const taken = Object.entries(old.roles).filter(([name, entry]) => name !== instance && entry.tracker).map(([name, entry]) => [name, entry.tracker]);
if (old.tracker?.sync) taken.push(["tracker.sync", old.tracker.sync]);
for (const [name, other] of taken) {
if (other.bot === tracker.bot || other.botId === tracker.botId) {
throw new OrganizationError("organization-tracker-bot-taken", `${name} already uses bot ${other.bot} (botId ${other.botId})`);
}
}
out.tracker = tracker;
} else if (Object.hasOwn(args, "tracker")) {
throw new OrganizationError("invalid-request", `${definition.name} uses no vikunja credential, so it takes no tracker`);
}
if (services.length > 0) {
const today = new Date().toISOString().slice(0, 10);
out.credentials = Object.fromEntries(services.map(service => [service, { store: true, [DATE_KEY[service]]: today }]));
}
return out;
}
// levels(name): the thinking levels harness module `name` applies, or null (launch-settings.mjs
// checkRoutes, row 65). Row 194 (brief item 8, row 199 D13): revoke(business, instance, how,
// services) revokes the instance's credentials for a fire or a delete and throws
// credential-revoke-failed when it can't. Null in a writer with no bus (the CLI's), which revokes
// nothing. Row 239 (#1745): writer is the organization writer, injected only by tests.
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null, writer = organizationWriter({ dir, rolesDir }) }) {
// The definitions in rolesDir and whether add and set accept each one.
function definitions() {
const out = {};
for (const file of readdirSync(rolesDir).filter(f => f.endsWith(".json")).sort()) {
const name = file.slice(0, -5);
let definition;
try {
definition = loadRole(rolesDir, name);
} catch (e) {
if (e.name === "BusinessError") continue;
throw e;
}
if (definition.roleVersion !== 2) continue;
const services = definition.credentials.map(c => c.service);
// mos-1586: only creative gitea-store adds join the base no-credential listing.
const creativeAdd = creativeDefinition(definition) && services.every(service => service === CREATIVE_SERVICE);
out[name] = { services, accepted: !closed(name) && (services.length === 0 || creativeAdd) };
}
return out;
}
// Row 256: actor is the reader, so endProtected is the delete check for that reader. A read with
// no actor (the bus's reloads) answers as for any non-human.
function read(business, actor) {
const { document, revision } = writer.read(business);
validateBusinessDocument(document, businessFilePath(business, dir), { rolesDir });
const launch = document.launch ?? null;
const instances = Object.fromEntries(Object.entries(document.roles).map(([name, entry]) => [name, {
definition: entry.definition,
vars: entry.vars ?? {},
launched: launch ? launch.instances.includes(name) : false,
launcher: launch ? launch.by === name : false,
protected: closed(entry.definition),
kept: (launch?.keep ?? []).includes(name),
endProtected: protectedRefusal(document, name, actor) !== null,
}]));
// Row 194 (D1): the human's forbid lists and the retired names. The bus adds running runs.
return {
business, revision, launch: launch !== null, instances, definitions: definitions(),
noHire: [...(launch?.noHire ?? [])], retired: [...(document.retired ?? [])],
};
}
// args: { revision, instance, op: "add" | "change" | "delete", definition?, vars?, launch?: boolean }.
// add needs definition; change needs at least one of definition, vars and launch. vars replaces
// the instance's vars whole. Row 194 (D3): the writer's restart is false. The bus reloads the
// business after the write and sets restart from that reload (runtime.mjs).
function set(business, args, actor, catalogue = null) {
if (!isPlainObject(args) || Object.keys(args).some(key => !SET_KEYS.includes(key))) {
throw new OrganizationError("invalid-request");
}
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) {
throw new OrganizationError("invalid-request");
}
const { instance, op } = args;
if (!id(instance) || !["add", "change", "delete"].includes(op)) throw new OrganizationError("invalid-request");
// The broker refuses a business whose roles name human, tester or user (broker.mjs, the load
// check), so an instance under any of them would stop the bus at its next start (row 165 r2,
// Dewey F1; FED R3, dewey-0777).
if (instance === "human" || instance === "tester" || instance === "user") {
throw new OrganizationError("invalid-request", `${instance} names the bus's own sessions, never a role instance`);
}
if (Object.hasOwn(args, "definition") && !id(args.definition)) throw new OrganizationError("invalid-request");
if (Object.hasOwn(args, "vars") && !isPlainObject(args.vars)) throw new OrganizationError("invalid-request");
if (Object.hasOwn(args, "launch") && typeof args.launch !== "boolean") throw new OrganizationError("invalid-request");
const old = writer.read(business).document;
if (op === "delete") {
if (Object.keys(args).some(key => !["revision", "instance", "op"].includes(key))) throw new OrganizationError("invalid-request");
const services = Object.keys(old.roles[instance]?.credentials ?? {});
const beforeWrite = revoke ? () => revoke(business, instance, "delete", services) : null;
const result = commit(business, old, planDelete(old, instance, actor), { revision: args.revision, actor, beforeWrite, catalogue });
// sage-1610 (b): the bot user and any token it holds live in Vikunja, which this write
// can't reach (row 199 A5).
const bot = old.roles[instance]?.tracker?.bot;
return bot ? { ...result, tracker: { bot, removeByHand: `remove the Vikunja bot user ${bot} and any token it holds by hand in Vikunja` } } : result;
}
const next = structuredClone(old);
let creativeChange = null;
const exists = Object.hasOwn(next.roles, instance);
if (op === "add") {
if (!Object.hasOwn(args, "definition")) throw new OrganizationError("invalid-request");
if (exists) throw new OrganizationError("organization-instance-exists");
creativeHumanOnly(old, actor, instance, args.definition);
if ((old.retired ?? []).includes(instance)) throw new OrganizationError("organization-instance-retired");
// Only the human's add binds credentials. The tester keeps the refusal (row 175 W2): the bus
// names the tester's actor "tester", which no human or role may be called.
const definition = definitionFor(rolesDir, args.definition, { credentials: actor !== "tester" });
next.roles[instance] = { definition: args.definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
Object.assign(next.roles[instance], createdCredentials(old, instance, definition, args));
} else {
if (Object.hasOwn(args, "tracker")) throw new OrganizationError("invalid-request", "tracker is set only by add");
if (!["definition", "vars", "launch"].some(key => Object.hasOwn(args, key))) throw new OrganizationError("invalid-request");
if (!exists) throw new OrganizationError("organization-unknown-instance");
const entry = next.roles[instance];
// An assistant's settings change only through limits.set's assistants key (row 155), so
// nothing here touches a protected instance, its vars and launch membership included. A
// creative instance is the exception: the human changes it here, and only the human (M10).
creativeHumanOnly(old, actor, instance, entry.definition);
if (closed(entry.definition)) throw new OrganizationError("organization-definition-refused");
if (Object.hasOwn(args, "definition") && args.definition !== entry.definition) {
creativeHumanOnly(old, actor, instance, args.definition);
const before = loadRole(rolesDir, entry.definition);
// mos-1585: a declaration alone is no binding (the chief without a library holds none).
const holdsCredentials = Object.keys(entry.credentials ?? {}).length > 0;
const creative = holdsCredentials && creativeDefinition(before);
const after = definitionFor(rolesDir, args.definition, { credentials: creative });
if (holdsCredentials && !creative) {
throw new OrganizationError("organization-credentials-out-of-scope",
`${instance} holds ${entry.definition} credentials; moving them to another definition is a hand edit`);
}
if (creative) creativeChange = { before, after };
entry.definition = args.definition;
}
if (Object.hasOwn(args, "vars")) entry.vars = structuredClone(args.vars);
}
if (Object.hasOwn(args, "launch")) {
if (!next.launch) throw new OrganizationError("organization-launch-not-configured");
const listed = next.launch.instances;
if (!args.launch) next.launch.instances = listed.filter(name => name !== instance);
else if (!listed.includes(instance)) listed.push(instance);
}
if (creativeChange) {
// mos-1586: validate the complete proposal first, preserving base BusinessError refusals.
validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
if (!creativeDefinition(creativeChange.after) || !sameCredentialServices(creativeChange.before, creativeChange.after)) {
throw new OrganizationError("organization-credentials-out-of-scope",
`${instance} may retain credentials only between creative definitions with exactly the same services`);
}
}
return commit(business, old, next, { revision: args.revision, actor, catalogue });
}
// Row 194 (V5): the delete's checks against the file as it stands, and its document. The broker
// runs this at the verb with no write, and the launcher's pass runs set op delete after the take.
function planDelete(document, instance, actor) {
const refusal = deleteRefusal(document, rolesDir, instance, actor);
if (refusal) throw new OrganizationError(refusal.code, refusal.message);
return deletedDocument(document, instance);
}
function checkDelete(business, instance, actor) {
const { document, revision } = writer.read(business);
const next = planDelete(document, instance, actor);
validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
return { revision };
}
// Row 194 (V6): args { revision, op: keep | unkeep | nohire | hire, name }. keep needs a declared
// instance that isn't END_PROTECTED. nohire needs only the id pattern. Human-only at the broker.
function veto(business, args, actor) {
if (!isPlainObject(args) || Object.keys(args).some(key => !VETO_KEYS.includes(key))) throw new OrganizationError("invalid-request");
if (!revisionOk(args.revision) || !VETO_OPS.includes(args.op) || !id(args.name)) throw new OrganizationError("invalid-request");
const old = writer.read(business).document;
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
const next = structuredClone(old);
const { op, name } = args;
const key = op === "keep" || op === "unkeep" ? "keep" : "noHire";
const list = next.launch[key] ?? [];
if (op === "keep") {
if (!Object.hasOwn(next.roles, name)) throw new OrganizationError("organization-unknown-instance");
if (END_PROTECTED.includes(next.roles[name].definition)) throw new OrganizationError("organization-definition-refused");
}
const add = op === "keep" || op === "nohire";
if (add === list.includes(name)) throw new OrganizationError("organization-no-change");
next.launch[key] = add ? [...list, name] : list.filter(entry => entry !== name);
return commit(business, old, next, { revision: args.revision, actor });
}
// Row 298 (#1804, Q228): the chief's grant scope as the file stands. scope is the one the broker
// applies, so a bad value reads as {} with fallback true.
function grantScopeRead(business) {
const { document, revision } = writer.read(business);
if (!document.launch) throw new OrganizationError("organization-launch-not-configured");
return { business, revision, ...grantScopeOf(document.launch.grantScope) };
}
// Row 298: args { revision, scope }. Human-only at the broker (grants.scope.set), and the only
// writer of launch.grantScope: SET_KEYS and the organization.set keys don't name it. The scope is
// checked strictly here; {} removes the key. old is what the broker applied before the write,
// the fallback included.
function grantScope(business, args, actor) {
if (!isPlainObject(args) || Object.keys(args).some(key => !SCOPE_KEYS.includes(key))) throw new OrganizationError("invalid-request");
if (!revisionOk(args.revision)) throw new OrganizationError("invalid-request");
const scope = checkGrantScope(args.scope);
if (scope === null) throw new OrganizationError("invalid-request", "scope is not a grant scope");
const old = writer.read(business).document;
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
const next = structuredClone(old);
if (Object.keys(scope).length === 0) delete next.launch.grantScope;
else next.launch.grantScope = scope;
const before = grantScopeOf(old.launch.grantScope).scope;
return { ...commit(business, old, next, { revision: args.revision, actor }), scope, old: before };
}
// Row 194 (#1692, D2.2): the chief's hire, after the broker's identity, grant, allowlist and
// hireable checks. args { name, definition, vars? }. A new name is added and listed. A fired name
// (declared, not listed) is listed again only with its own definition and no vars. The writer
// reads the file as it stands, so a veto or retire written since the broker's copy still counts.
function prepareHire(business, args) {
const { document: old, revision } = writer.read(business);
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
const { name, definition } = args;
// FED R3 r4 (sage-2557): and user, the granted user's principal, which the broker's load check refuses.
if (!id(name) || !id(definition) || name === "human" || name === "tester" || name === "user") throw new OrganizationError("invalid-request");
if (Object.hasOwn(args, "vars") && (!isPlainObject(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) {
throw new OrganizationError("invalid-request", "hire vars may set only model and limits keys");
}
if ((old.launch.noHire ?? []).includes(definition)) throw new OrganizationError("definition-not-hireable");
// Row 213 (M10): definitionFor lets a creative definition through for the human's add and
// change. A hire is the chief's, so it refuses one here.
if (CREATIVE_DEFINITIONS.includes(definition)) {
throw new OrganizationError("definition-not-hireable", `${definition} is a creative definition, which only the human adds`);
}
if ((old.retired ?? []).includes(name)) throw new OrganizationError("organization-instance-retired");
if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept");
const next = structuredClone(old);
if (Object.hasOwn(old.roles, name)) {
const fired = !old.launch.instances.includes(name) && old.launch.by !== name;
if (!fired || old.roles[name].definition !== definition || Object.hasOwn(args, "vars")) {
throw new OrganizationError("organization-instance-exists");
}
} else {
next.roles[name] = { definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
}
// mos-1585: duplicates refuse before definition eligibility, with no file or credential effect.
definitionFor(rolesDir, definition);
next.launch.instances.push(name);
return { old, next, revision };
}
// Trusted read-only lifecycle preparation; the writer uses the same checks again.
function checkHire(business, args) {
return { revision: prepareHire(business, args).revision };
}
function hire(business, args, { actor, context }) {
const { old, next, revision } = prepareHire(business, args);
return commit(business, old, next, { revision, actor, context });
}
// Row 194 (D2.2 step B): the fire's delist. An instance already delisted is done, so a repeated
// fire after a failed step C goes on to consume (webby-0011 B2). The checks repeat against the file.
function fire(business, args, { actor, context }) {
const { document: old, revision } = writer.read(business);
const { name } = args;
fireChecks(old, name);
// Item 8: fire revokes each service of the entry and keeps the store directory (sage-1593).
const services = Object.keys(old.roles[name].credentials ?? {});
// webby-0094 N1: an instance already delisted (a hand edit, or a repeat after step B) still has
// its tokens revoked, with no write. A revoke is safe to repeat.
if (!old.launch.instances.includes(name)) {
const revoked = revoke ? revoke(business, name, "fire", services) : null;
return { ...read(business, actor), transaction: null, delisted: false, ...(revoked ? { credentials: revoked } : {}) };
}
const next = structuredClone(old);
next.launch.instances = old.launch.instances.filter(entry => entry !== name);
let revoked = null;
const beforeWrite = revoke ? () => { revoked = revoke(business, name, "fire", services); } : null;
const result = commit(business, old, next, { revision, actor, context, beforeWrite });
return { ...result, delisted: true, ...(revoked ? { credentials: revoked } : {}) };
}
// Row 194 b6 (webby-0094 B1): the broker runs fire's checks against the file before step A stops
// anything, so a keep its own copy hasn't loaded (a refused reload) refuses there and not at
// step B, which would leave the instance firing. No write.
function checkFire(business, name) {
const { document, revision } = writer.read(business);
fireChecks(document, name);
return { revision };
}
function fireChecks(old, name) {
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
if (!id(name) || !Object.hasOwn(old.roles, name)) throw new OrganizationError("organization-unknown-instance");
if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept");
if (END_PROTECTED.includes(old.roles[name].definition)) throw new OrganizationError("organization-definition-refused");
if (old.launch.by === name) throw new OrganizationError("organization-instance-referenced", `${name} is launch.by`);
if (old.launch.instances.includes(name) && old.launch.instances.length === 1) {
refuse(`${name} is the last name in launch.instances; a business needs one instance the chief may launch`);
}
}
// The checks every write runs, then the write, which runs beforeWrite (a fire's or a delete's
// revoke) under its lock (row 239). A check that refuses revokes nothing, and a revoke that throws
// writes nothing. beforeWrite must not write this business: the writer holds its lock.
function commit(business, old, next, { revision, actor, context, beforeWrite = null, catalogue = null }) {
if (canonicalJson(next) === canonicalJson(old)) throw new OrganizationError("organization-no-change");
// The checks mosaic business validate runs, before the writer publishes anything, then the
// route checks every write of the route vars runs (row 65). A file that no longer validates
// has every route checked.
const proposed = validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
let before = null;
try {
before = validateBusinessDocument(old, businessFilePath(business, dir), { rolesDir });
} catch (e) {
if (e.name !== "BusinessError") throw e;
}
checkRoutes({ proposed, old: before, levels, catalogue });
for (const [project, entry] of Object.entries(proposed.projects)) {
if (entry.kind === 'library') continue;
if (!existsSync(projectFilePath(entry.root))) continue;
const loaded = loadProject(entry.root);
if (loaded.id !== project) throw new OrganizationError("organization-project-mismatch");
for (const name of Object.keys(proposed.roles)) {
try {
resolveInstance({ system, business: proposed, project: loaded, instance: name });
} catch (e) {
if (e.name === "BusinessError") throw new OrganizationError("organization-unresolvable-instance", e.message);
throw e;
}
}
}
// Row 194 N1 (sage-1634): a delete writes on the revision its request read when it was raised,
// so a write since then makes it stale. This early check refuses it without taking the lock. The
// writer checks again under its lock, before the revoke (row 239), and that check is what keeps
// a stale fire or delete from revoking.
if (writer.read(business).revision !== revision) throw new OrganizationError("revision-conflict", "organization revision changed; read again");
const receipt = writer.write(business, next, { revision, actor, context, beforeWrite });
return { ...read(business, actor), transaction: receipt.transaction, restart: false };
}
return { writer, read, set, checkDelete, checkFire, checkHire, veto, hire, fire, grantScope, grantScopeRead };
}
// Old path of packages/organization/src/instance-settings.mjs (rename design r2, stage B).
export * from "../../organization/src/instance-settings.mjs";
+2 -407
View File
@@ -1,407 +1,2 @@
// 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 { canonicalJson, isPlainObject, requireObject, rejectUnknownKeys } from "./util.mjs";
import { FALLBACK_VARS } from "./vars.mjs";
import { THREAD_DEFINITION, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
// Row 213 (C2-08, Case 2 design 4, M10): a creative instance, its persona included, is the
// human's. A write that touches one is accepted only when its actor is the business file's
// human; the broker sets the actor to the human's id, "tester" or <definition>.<instance>.
export function creativeHumanOnly(document, actor, instance, definition) {
if (CREATIVE_DEFINITIONS.includes(definition) && actor !== document.human) {
throw new OrganizationError("organization-creative-human-only", `${instance} is a ${definition} instance, which only the human changes`);
}
}
// Row 194 (D2.4 B7, webby-0094 N3): only the human changes a kept instance's model, through the
// instance's own key or by re-pointing the launch.models entry that key names (the chief and the
// tester are refused, as row 213 refuses them a creative instance's). The check reads the file, so a
// keep the broker's copy hasn't loaded holds too.
function keptHumanOnly(document, actor, instance) {
if (actor !== document.human && (document.launch?.keep ?? []).includes(instance)) {
throw new OrganizationError("instance-kept", `${instance} is kept; only the human changes its model`);
}
}
// Row 155 (#1649, Jason's ruling Q169): the fields of an assistant instance's entry the human and
// the chief of staff may change. holder and credentials stay hand edits.
const ASSISTANT_FIELDS = ["vars", "tracker", "definition"];
// Every key a limits update takes. The broker's and the harness tool's
// ORGANIZATION_KEYS must match it (packages/harness/tests/tools.test.mjs).
export const SETTINGS_KEYS = Object.freeze(["revision", "limits", "models", "instances", "reset", "assistants"]);
const own = (value, key) => value !== null && typeof value === "object" && !Array.isArray(value) && Object.hasOwn(value, key);
const plain = value => value !== null && typeof value === "object" && !Array.isArray(value);
// Row 125 (#1619, REQ-LIMIT-3): token limits sit only at their schema depth, tokens.instances.X,
// tokens.providers.X, tokens.keys.X, tokens.models.P.M and (row 126) tokens.daily.X. Nothing else in launch.limits is one,
// so a concurrency provider or model named `limit`, or a token limit's subject named `limit`, is
// never mistaken for a leaf. The bus core's limitLeaves walks the same depths.
const DEPTH = { instances: 1, providers: 1, keys: 1, models: 2, daily: 1 };
function tokenLeaves(limits) {
const out = [];
const walk = (value, depth, path) => {
if (!plain(value)) return;
for (const [key, entry] of Object.entries(value)) {
if (depth > 1) walk(entry, depth - 1, [...path, key]);
else out.push({ path: [...path, key], entry });
}
};
const tokens = own(limits, "tokens") ? limits.tokens : undefined;
if (plain(tokens)) {
for (const [kind, value] of Object.entries(tokens)) if (Object.hasOwn(DEPTH, kind)) walk(value, DEPTH[kind], ["tokens", kind]);
}
return out;
}
const at = (value, path) => path.reduce((v, part) => (own(v, part) ? v[part] : undefined), value);
// The writer, not the caller, sets a token limit's `since`. A new limit gets now; a changed or
// unchanged one keeps its stored time; a caller-supplied time that differs from the stored one is
// refused, so only a reset moves it. An entry that isn't an object is left for the schema to refuse.
// Row 333 R4 (#1831): a new daily key quota carries its agent's count (carried, below).
function stamp(next, old, now, lineage) {
for (const { path, entry } of tokenLeaves(next)) {
if (!plain(entry)) continue;
const before = at(old, path);
const kept = plain(before) ? before.since : undefined;
if (own(entry, "since") && entry.since !== kept) throw new OrganizationError("invalid-request");
entry.since = kept ?? carried(path, entry, old, now, lineage) ?? now;
}
}
// Row 333 R4 (#1831): a new tokens.keys leaf in the daily quota shape is a quota on one agent's model
// key. It takes the earliest stored `since` of the old document's daily key quotas whose ids are in
// that key's lineage, so moving a quota from A to B after a rotation or re-issue keeps the day's
// count, whether or not the same write removes A. With none it is new and gets now (undefined
// here). lineage(id) answers the ids of the agent holding id, read from the bus's own credential
// records, or [] when no agent holds it. A lookup that throws or answers malformed refuses the write.
// A quota removed in one write and added back in a later one finds no old leaf, so it gets now.
const KEY_ID = /^[a-f0-9]{12}$/;
function carried(path, entry, old, now, lineage) {
const [, kind, id] = path;
if (kind !== "keys" || !isDailyModelQuota(id, { ...entry, since: now })) return undefined;
let ids;
try { ids = lineage(id); } catch { throw new OrganizationError("proxy-key-refused"); }
if (!Array.isArray(ids) || ids.some(x => typeof x !== "string" || !KEY_ID.test(x)) || (ids.length && !ids.includes(id))) {
throw new OrganizationError("proxy-key-refused");
}
const keys = at(old, ["tokens", "keys"]);
const times = Object.entries(plain(keys) ? keys : {})
.filter(([key, value]) => ids.includes(key) && isDailyModelQuota(key, value)).map(([, value]) => value.since);
return times.sort((a, b) => Date.parse(a) - Date.parse(b))[0];
}
// A reset names token limit paths, e.g. ["tokens", "instances", "coder-1"]; each gets since = now.
// Any other path, a concurrency limit's included, is refused.
function reset(limits, paths, now) {
if (!Array.isArray(paths) || !paths.length) throw new OrganizationError("invalid-request");
const leaves = tokenLeaves(limits);
for (const path of paths) {
if (!Array.isArray(path) || !path.length || path.some(part => typeof part !== "string")) {
throw new OrganizationError("invalid-request");
}
const leaf = leaves.find(l => l.path.length === path.length && l.path.every((part, i) => part === path[i]));
if (!leaf || !plain(leaf.entry)) throw new OrganizationError("invalid-request");
leaf.entry.since = now;
}
}
// Row 65 (REQ-PLUG-2): the checks every write of an instance's route vars runs: the routes table's,
// instances.set's and limits.set's. Each instance's model, and its fallback model when it states a
// fallback, must be a launch.models key. A route the write changes must name a harness that is
// switched on in plugins.harness (harness-off) and whose module applies its thinking level
// (thinking-unsupported). levels(name) answers the levels a harness module applies, or null when
// the module didn't load, which refuses harness-refused. An unchanged route isn't checked, so a
// model change still saves while plugins.harness is unset; that instance's launch refuses instead.
// A route var the instance doesn't set is left to the launch, which refuses no-route.
const ROUTES = Object.freeze([["harness", "model", "thinking"], FALLBACK_VARS]);
export function checkRoutes({ proposed, old = null, levels, catalogue = null }) {
const on = Array.isArray(proposed.plugins?.harness) ? proposed.plugins.harness : [];
for (const [instance, entry] of Object.entries(proposed.roles)) {
const vars = entry.vars;
const before = old && Object.hasOwn(old.roles, instance) ? old.roles[instance].vars : {};
for (const [harness, model, thinking] of ROUTES) {
// A missing model refuses no-route at launch, not here.
if (proposed.launch && Object.hasOwn(vars, model) && !Object.hasOwn(proposed.launch.models, vars[model])) {
throw new OrganizationError("unknown-model", `${instance} ${model} ${JSON.stringify(vars[model] ?? null)} isn't a launch.models key`);
}
const currentEntry = proposed.launch?.models?.[vars[model]];
const previousEntry = old?.launch?.models?.[before[model]];
const changed = [harness, model, thinking].some(field => vars[field] !== before[field]) || canonicalJson(currentEntry ?? null) !== canonicalJson(previousEntry ?? null);
if (changed && previousEntry?.module && !currentEntry?.module && vars[model] === before[model]) throw new OrganizationError('model-provider-unavailable');
if (changed && currentEntry?.module) {
if (catalogue?.status !== 'ready') throw new OrganizationError('model-provider-unavailable');
const offered = catalogue.models?.find(m => m.key === vars[model]);
if (!offered || !offered.available || !proposed.plugins?.['model-provider']?.includes(currentEntry.module) || canonicalJson(currentEntry) !== canonicalJson({ provider: offered.provider, model: offered.model, module: offered.module, upstream: offered.upstream })) throw new OrganizationError('model-provider-unavailable');
if (!offered.harnesses.includes(vars[harness])) throw new OrganizationError('model-harness-incompatible');
}
if (changed && currentEntry && !currentEntry.module && proposed.plugins?.['model-provider']?.length) {
if (catalogue?.status !== 'ready') throw new OrganizationError('model-provider-unavailable');
const mapping = catalogue.resolveLegacy ? catalogue.resolveLegacy(currentEntry) : catalogue.legacy?.[vars[model]];
const offered = mapping && catalogue.models?.find(m => m.module === mapping.module && m.upstream === mapping.upstream && m.model === currentEntry.model);
if (!offered?.available) throw new OrganizationError('model-provider-unavailable');
if (!offered.harnesses.includes(vars[harness])) throw new OrganizationError('model-harness-incompatible');
}
if (!Object.hasOwn(vars, harness) || (vars[harness] === before[harness] && vars[thinking] === before[thinking])) continue;
const name = vars[harness];
if (!on.includes(name)) throw new OrganizationError("harness-off", `${instance} ${harness} ${name} isn't switched on in plugins.harness`);
if (vars[thinking] === undefined) continue;
const applies = levels(name);
if (!Array.isArray(applies)) throw new OrganizationError("harness-refused", `harness module ${name} didn't load, so its thinking levels are unknown`);
if (!applies.includes(vars[thinking])) {
throw new OrganizationError("thinking-unsupported", `harness ${name} doesn't apply thinking ${vars[thinking]} (${instance} ${thinking}); it applies ${applies.join(", ")}`);
}
}
}
}
const ROUTE_FIELDS = Object.freeze(["harness", "model", "thinking"]);
// levels(name): the thinking levels harness module `name` applies, or null (checkRoutes).
// Row 333 R4 (#1831): keyLineage(business, keyId) answers the key ids in the lineage of the agent
// that holds keyId (carried, above). The bus runtime passes one read from its credential records.
// Without it, a write that adds a daily key quota refuses proxy-key-refused; no other write asks.
export function launchSettings({ dir, rolesDir, system, sessions = () => [], now = () => new Date().toISOString(), levels = () => null,
keyLineage = () => { throw new OrganizationError("proxy-key-refused"); } }) {
const writer = organizationWriter({ dir, rolesDir });
// The validated business as the file stands now (row 125: the limit checks read it at each ask).
function current(id) {
return validateBusinessDocument(writer.read(id).document, businessFilePath(id, dir), { rolesDir });
}
// The file before a write, validated, for checkRoutes; null when it no longer validates, so every
// route is checked.
function before(id, document) {
try {
return validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
} catch (e) {
if (e.name === "BusinessError") return null;
throw e;
}
}
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;
// No model var reads as unset; the launch refuses it no-route.
if (key === undefined) return [instance, { harness: vars.harness ?? null, provider: null, model: null, modelKey: null }];
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 ?? null, ...entry, modelKey: key }];
}));
// Each assistant instance's entry fields as the file has them, so a change replaces what it shows.
const assistants = Object.fromEntries(Object.entries(document.roles)
.filter(([, entry]) => entry.definition === THREAD_DEFINITION)
.map(([instance, entry]) => [instance, {
definition: entry.definition, vars: entry.vars ?? {}, tracker: entry.tracker ?? null,
}]));
// model is the external id; modelKey is the table selection.
return {
business: id, revision, models: business.launch.models,
limits: business.launch.limits, instances, assistants, usage: usageCounts(sessions(), id),
};
}
function set(id, args, actor, catalogue = null) {
requireObject(args, "limits update");
rejectUnknownKeys(args, SETTINGS_KEYS, "limits update");
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) {
throw new OrganizationError("invalid-request");
}
if (!SETTINGS_KEYS.some(key => key !== "revision" && 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");
const at = now();
if (Object.hasOwn(args, "limits")) {
next.launch.limits = structuredClone(args.limits);
stamp(next.launch.limits, old.document.launch.limits, at, key => keyLineage(id, key));
}
if (Object.hasOwn(args, "reset")) reset(next.launch.limits, args.reset, at);
if (Object.hasOwn(args, "models")) {
if (actor !== old.document.human && plain(args.models)) {
for (const [key, entry] of Object.entries(args.models)) {
if (entry?.module && canonicalJson(entry) !== canonicalJson(old.document.launch.models[key] ?? null)) throw new OrganizationError('organization-model-provider-human-only');
// Resolve changed legacy entries with the trusted module resolver; a table key
// alone does not authorize introducing a different catalogue identity.
const previous = old.document.launch.models[key];
if (canonicalJson(entry) !== canonicalJson(previous ?? null) && plain(entry) && !entry.module && old.document.plugins?.['model-provider']?.length) {
if (catalogue?.status !== 'ready' || typeof catalogue.resolveLegacy !== 'function' || !Array.isArray(catalogue.models)) throw new OrganizationError('model-provider-unavailable');
const mapping = catalogue.resolveLegacy(entry);
const prior = previous?.module
? { module: previous.module, upstream: previous.upstream, model: previous.model }
: plain(previous) ? catalogue.resolveLegacy(previous) : null;
if (mapping && canonicalJson(mapping) !== canonicalJson(prior) && catalogue.models.some(model => model.module === mapping.module && model.upstream === mapping.upstream && model.model === entry.model)) {
throw new OrganizationError('organization-model-provider-human-only');
}
}
}
}
// M10: replacing launch.models can change the model behind a key a creative instance names
// without touching the instance, so a change to or removal of such a key is the human's.
for (const [instance, entry] of Object.entries(old.document.roles)) {
const vars = entry.vars ?? {};
for (const key of [vars.model, vars["fallback.model"]]) {
if (typeof key !== "string") continue;
const was = own(old.document.launch.models, key) ? canonicalJson(old.document.launch.models[key]) : null;
const is = own(args.models, key) ? canonicalJson(args.models[key]) : null;
if (was !== is) {
creativeHumanOnly(old.document, actor, instance, entry.definition);
keptHumanOnly(old.document, actor, instance);
}
}
}
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");
}
// M10: a creative instance's model changes through instances.set or routes.set, whoever asks.
if (CREATIVE_DEFINITIONS.includes(next.roles[instance].definition)) {
throw new OrganizationError("organization-creative-instance", `${instance}'s model changes through instances.set or routes.set`);
}
keptHumanOnly(old.document, actor, instance);
next.roles[instance].vars = { ...next.roles[instance].vars, model: key };
}
}
// Each field given replaces that field; tracker null removes it. Only an assistant instance is a
// target, and its definition stays one the broker treats as the assistant, so the change can't
// strip END_PROTECTED's protection.
if (Object.hasOwn(args, "assistants")) {
requireObject(args.assistants, "assistant settings");
for (const [instance, entry] of Object.entries(args.assistants)) {
if (!Object.hasOwn(next.roles, instance)) throw new OrganizationError("invalid-request");
if (next.roles[instance].definition !== THREAD_DEFINITION) throw new OrganizationError("organization-not-assistant");
requireObject(entry, `assistant settings ${instance}`);
rejectUnknownKeys(entry, ASSISTANT_FIELDS, `assistant settings ${instance}`);
if (Object.keys(entry).length === 0) throw new OrganizationError("invalid-request");
if (Object.hasOwn(entry, "definition")) {
if (entry.definition !== THREAD_DEFINITION) throw new OrganizationError("organization-definition-refused");
next.roles[instance].definition = entry.definition;
}
if (Object.hasOwn(entry, "vars")) next.roles[instance].vars = requireObject(entry.vars, `assistant settings ${instance}.vars`);
if (entry.tracker === null) delete next.roles[instance].tracker;
else if (Object.hasOwn(entry, "tracker")) next.roles[instance].tracker = entry.tracker;
}
}
// No key may change another instance's definition, so none gives an instance an END_PROTECTED one
// after a decision about it was resolved (dewey-0546).
for (const [instance, entry] of Object.entries(old.document.roles)) {
if (next.roles[instance]?.definition !== entry.definition) throw new OrganizationError("organization-definition-refused");
}
// Check every instance's route before publishing the complete document.
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir });
checkRoutes({ proposed, old: before(id, old.document), levels, catalogue });
const receipt = writer.write(id, next, { revision: args.revision, actor });
return { ...view(id), transaction: receipt.transaction };
}
// Row 65 (mos-0840): the routing table. One row per instance with its harness, model, thinking
// and fallback, and what each cell offers: the switched-on harnesses, the launch.models keys and
// the levels each harness applies (null for a module that didn't load). A route var the instance
// doesn't set reads null; its launch refuses no-route.
function routes(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 harnesses = Array.isArray(business.plugins?.harness) ? [...business.plugins.harness] : [];
const named = new Set(harnesses);
const instances = Object.fromEntries(Object.entries(business.roles).map(([instance, { vars }]) => {
for (const key of ["harness", "fallback.harness"]) if (Object.hasOwn(vars, key)) named.add(vars[key]);
return [instance, {
harness: vars.harness ?? null, model: vars.model ?? null, thinking: vars.thinking ?? null,
fallback: Object.hasOwn(vars, "fallback.harness")
? { harness: vars["fallback.harness"], model: vars["fallback.model"], thinking: vars["fallback.thinking"] } : null,
}];
}));
return {
business: id, revision, harnesses, models: Object.keys(business.launch.models),
providerModels: Object.fromEntries(Object.entries(business.launch.models).filter(([, entry]) => entry.module).map(([key, entry]) => [key, { module: entry.module, upstream: entry.upstream, model: entry.model }])),
levels: Object.fromEntries([...named].sort().map(name => [name, levels(name)])), instances,
};
}
// args: { revision, routes: { <instance>: { harness, model, thinking, fallback } } }, fallback
// being null or { harness, model, thinking }. Each named instance's route vars are replaced in one
// organization write; a null fallback clears all three. It is human-only and outside
// ORGANIZATION_KEYS (the broker's routes.set). The chief of staff's tool changes the model key,
// and the assistant's whole vars (Q169), through limits.set, which runs the same checkRoutes.
function setRoutes(id, args, actor, catalogue = null) {
requireObject(args, "routes update");
rejectUnknownKeys(args, ["revision", "routes"], "routes update");
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) {
throw new OrganizationError("invalid-request");
}
if (!isPlainObject(args.routes) || Object.keys(args.routes).length === 0) throw new OrganizationError("invalid-request");
const old = writer.read(id);
const next = structuredClone(old.document);
if (!next.launch) throw new OrganizationError("launch-not-configured");
const exact = (value, keys) => isPlainObject(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
for (const [instance, route] of Object.entries(args.routes)) {
if (!Object.hasOwn(next.roles, instance)) throw new OrganizationError("organization-unknown-instance");
creativeHumanOnly(old.document, actor, instance, next.roles[instance].definition);
// webby-0095 N1, sage-2248: a route is a second write of a kept instance's model.
keptHumanOnly(old.document, actor, instance);
if (!exact(route, [...ROUTE_FIELDS, "fallback"]) || ROUTE_FIELDS.some(key => typeof route[key] !== "string")) {
throw new OrganizationError("invalid-request");
}
const { fallback } = route;
if (fallback !== null && (!exact(fallback, ROUTE_FIELDS) || ROUTE_FIELDS.some(key => typeof fallback[key] !== "string"))) {
throw new OrganizationError("organization-partial-fallback", `${instance}'s fallback sets all of harness, model and thinking or is cleared`);
}
// The client supplies only a key. New entries come from the trusted snapshot.
const priorVars = old.document.roles[instance].vars ?? {};
for (const [choice, prefix] of [[route, ''], [fallback, 'fallback.']]) {
if (!choice) continue;
const unchanged = ROUTE_FIELDS.every(field => priorVars[prefix + field] === choice[field]);
const offered = catalogue?.models?.find(m => m.key === choice.model);
const existing = next.launch.models[choice.model];
const entry = offered ? { provider: offered.provider, model: offered.model, module: offered.module, upstream: offered.upstream } : null;
if (!existing && !offered && catalogue?.reason === 'model-key-collision') throw new OrganizationError('model-key-collision');
if (!existing && !offered && catalogue?.status === 'unavailable') throw new OrganizationError('model-catalogue-unavailable');
if (!existing && !offered) throw new OrganizationError('unknown-model', `${instance} model isn't a launch.models key`);
if (existing && offered && canonicalJson(existing) !== canonicalJson(entry) && !unchanged) throw new OrganizationError('model-key-collision');
if (!existing || existing.module) {
if (unchanged) continue;
if (catalogue?.status !== 'ready') throw new OrganizationError('model-catalogue-unavailable');
if (!offered || !offered.available || !Array.isArray(next.plugins?.['model-provider']) || !next.plugins['model-provider'].includes(offered.module)) throw new OrganizationError('model-provider-unavailable');
if (!offered.harnesses.includes(choice.harness)) throw new OrganizationError('model-harness-incompatible');
if (existing && canonicalJson(existing) !== canonicalJson(entry)) throw new OrganizationError('model-key-collision');
if (!existing) {
if (actor !== old.document.human) throw new OrganizationError('organization-model-provider-human-only');
next.launch.models[choice.model] = entry;
}
}
}
const vars = { ...next.roles[instance].vars };
for (const key of FALLBACK_VARS) delete vars[key];
for (const key of ROUTE_FIELDS) {
vars[key] = route[key];
if (fallback !== null) vars[`fallback.${key}`] = fallback[key];
}
next.roles[instance].vars = vars;
}
if (canonicalJson(next) === canonicalJson(old.document)) throw new OrganizationError("organization-no-change");
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir });
checkRoutes({ proposed, old: before(id, old.document), levels, catalogue });
const receipt = writer.write(id, next, { revision: args.revision, actor });
return { ...routes(id), transaction: receipt.transaction };
}
return { writer, read: view, set, current, routes, setRoutes };
}
// Old path of packages/organization/src/launch-settings.mjs (rename design r2, stage B).
export * from "../../organization/src/launch-settings.mjs";
+2 -176
View File
@@ -1,176 +1,2 @@
// REQ-LIMIT-1: editable concurrency policy, never a compiled-in capacity ceiling.
import { requireObject, requireId, requireString, rejectUnknownKeys } from "./util.mjs";
import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
import { BusinessError, 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}`);
const moduleBacked = Object.hasOwn(entry, "module") || Object.hasOwn(entry, "upstream");
rejectUnknownKeys(entry, moduleBacked ? ["provider", "model", "module", "upstream"] : entry.provider === "proxy" ? ["provider", "model", "route"] : ["provider", "model"], `${where}.${key}`);
if (moduleBacked) {
requireId(entry.module, `${where}.${key}.module`);
requireId(entry.upstream, `${where}.${key}.upstream`);
if (typeof entry.model !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(entry.model)) refuse(`${where}.${key}.model is invalid`);
}
if (entry.provider === "proxy" && !moduleBacked) {
if (!Object.hasOwn(entry, "route")) throw Object.assign(new BusinessError(`${where}.${key}.route is required`), { code: "proxy-route-missing" });
if (typeof entry.route !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(entry.route)) throw Object.assign(new BusinessError(`${where}.${key}.route is invalid`), { code: "proxy-route-unknown" });
}
return [key, {
provider: name(entry.provider, `${where}.${key}.provider`),
model: name(entry.model, `${where}.${key}.model`),
...(moduleBacked ? { module: entry.module, upstream: entry.upstream } : {}),
...(entry.provider === "proxy" && !moduleBacked ? { route: entry.route } : {}),
}];
}));
}
// Row 125 (#1619, REQ-LIMIT-3): one token limit, a count of 0 or more and the
// canonical time it was set or last reset. The settings writer stamps `since`.
const SINCE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
const tokenLimit = (value, at) => {
requireObject(value, at);
rejectUnknownKeys(value, ["limit", "since"], at);
const since = value.since;
if (typeof since !== "string" || !SINCE.test(since) || Number.isNaN(Date.parse(since)) ||
new Date(since).toISOString() !== since) refuse(`${at}.since must be a canonical timestamp`);
return { limit: count(value.limit, `${at}.limit`), since };
};
// Row 126 (#1620, REQ-LIMIT-2): a daily quota is a token limit with the wall-clock time its day
// starts and the IANA zone that time is read in. A zone must be a name (an offset such as +05:00
// is refused) that Intl accepts.
const RESET = /^([01]\d|2[0-3]):[0-5]\d$/;
const ZONE = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/;
const dailyQuota = (value, at) => {
requireObject(value, at);
rejectUnknownKeys(value, ["limit", "since", "reset", "timeZone"], at);
const { limit, since } = tokenLimit({ limit: value.limit, since: value.since }, at);
if (typeof value.reset !== "string" || !RESET.test(value.reset)) refuse(`${at}.reset must be a time of day, HH:MM`);
const timeZone = value.timeZone;
let known = typeof timeZone === "string" && ZONE.test(timeZone);
if (known) {
try { new Intl.DateTimeFormat("en-US", { timeZone }); } catch { known = false; }
}
if (!known) refuse(`${at}.timeZone must be an IANA time zone name`);
return { limit, since, reset: value.reset, timeZone };
};
const tokenMap = (value, at, key, each) => {
requireObject(value, at);
return Object.fromEntries(Object.entries(value).map(([k, v]) => [key(k, at), each(v, `${at}.${k}`)]));
};
// launch.limits.tokens: instances, providers, models (per provider), keys and daily, each optional.
export function checkTokenLimits(value, where = "launch.limits.tokens") {
requireObject(value, where);
rejectUnknownKeys(value, ["instances", "providers", "models", "keys", "daily"], where);
const out = {};
if (Object.hasOwn(value, "instances")) out.instances = tokenMap(value.instances, `${where}.instances`, requireId, tokenLimit);
if (Object.hasOwn(value, "providers")) out.providers = tokenMap(value.providers, `${where}.providers`, name, tokenLimit);
if (Object.hasOwn(value, "models")) {
out.models = tokenMap(value.models, `${where}.models`, name, (models, at) => tokenMap(models, at, name, tokenLimit));
}
// P5 adds daily model leaves, including digit-leading IDs; plain keys keep
// their existing account-name schema and cumulative account semantics.
if (Object.hasOwn(value, "keys")) {
requireObject(value.keys, `${where}.keys`);
out.keys = Object.fromEntries(Object.entries(value.keys).map(([key, entry]) => {
const at = `${where}.keys.${key}`;
if (isDailyModelQuota(key, entry)) return [key, dailyQuota(entry, at)];
return [requireId(key, `${where}.keys`), tokenLimit(entry, at)];
}));
}
// One quota per account; the business read checks each key names launch.account.
if (Object.hasOwn(value, "daily")) out.daily = tokenMap(value.daily, `${where}.daily`, requireId, dailyQuota);
return out;
}
// Row 126 (#1620): the account a business's claude-code launches use. `credential` names the
// environment variables of one credential set, joined with "+", so a secret can't pass the check.
// Which sets exist is the launcher's to check (seat imports business, not the other way).
const CREDENTIAL = /^[A-Z][A-Z0-9_]*(\+[A-Z][A-Z0-9_]*)*$/;
export function checkAccount(value, where = "launch.account") {
requireObject(value, where);
rejectUnknownKeys(value, ["name", "credential"], where);
const accountName = requireId(value.name, `${where}.name`);
if (typeof value.credential !== "string" || (value.credential !== "proxy" && !CREDENTIAL.test(value.credential))) {
refuse(`${where}.credential must name environment variables, joined with "+"`);
}
return { name: accountName, credential: value.credential };
}
export function checkLimits(value = {}, where = "launch.limits") {
requireObject(value, where);
rejectUnknownKeys(value, ["total", "providers", "models", "tokens"], 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}`)];
}))];
}));
}
if (Object.hasOwn(value, "tokens")) out.tokens = checkTokenLimits(value.tokens, `${where}.tokens`);
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;
}
// Old path of packages/organization/src/launch.mjs (rename design r2, stage B).
export * from "../../organization/src/launch.mjs";
+2 -51
View File
@@ -1,51 +1,2 @@
// Case 2 design 5.1 (M7, M8, row 220): which seats work in a library business,
// and which of those hold a creative definition. Read from the business files on
// every call, so an edit takes effect without a restart. Fail closed: a business
// file that can't be read or validated throws. So does anything at the businesses
// path that can't be listed: a file, a dangling symlink, or a directory that can't
// be read. Only a path with nothing at it (lstat ENOENT) holds no library business,
// so both sets are empty. No caller turns a throw into an empty set.
import { lstatSync, readdirSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { BusinessError } from "./errors.mjs";
import { configDir, loadBusiness } from "./business.mjs";
import { CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
export function defaultRolesDir(env = process.env) {
return resolve(env.MOSAIC_ROLES_DIR || join(REPO, "roles"));
}
// Returns { librarySeats, creativeSeats }: Sets of seat names (the role keys of
// each business). A seat in two businesses counts when either one puts it in a
// set. creativeSeats is a subset of librarySeats.
export function loadSeatSets({ dir = configDir(), rolesDir = defaultRolesDir() } = {}) {
// lstat first: only nothing at the path is an absent directory. Anything else
// there (a file, a dangling symlink, a directory) must list, or it throws.
const businesses = join(dir, "businesses");
try {
lstatSync(businesses);
} catch (err) {
if (err.code === "ENOENT") return { librarySeats: new Set(), creativeSeats: new Set() };
throw new BusinessError(`cannot list businesses in ${dir}: ${err.message}`);
}
let names;
try {
names = readdirSync(businesses).filter((name) => name.endsWith(".json"));
} catch (err) {
throw new BusinessError(`cannot list businesses in ${dir}: ${err.message}`);
}
const librarySeats = new Set();
const creativeSeats = new Set();
for (const name of names.sort()) {
const business = loadBusiness(name.slice(0, -".json".length), { dir, rolesDir });
if (!Object.values(business.projects).some((project) => project.kind === "library")) continue;
for (const [seat, role] of Object.entries(business.roles)) {
librarySeats.add(seat);
if (CREATIVE_DEFINITIONS.includes(role.definition)) creativeSeats.add(seat);
}
}
return { librarySeats, creativeSeats };
}
// Old path of packages/organization/src/library-seats.mjs (rename design r2, stage B).
export * from "../../organization/src/library-seats.mjs";
+2 -94
View File
@@ -1,94 +1,2 @@
// 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;
}
// Old path of packages/organization/src/migrate-launch.mjs (rename design r2, stage B).
export * from "../../organization/src/migrate-launch.mjs";
+2 -214
View File
@@ -1,214 +1,2 @@
import { constants, openSync, closeSync, fstatSync, lstatSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
import * as fs from 'node:fs/promises';
import { dirname, isAbsolute, join, normalize, parse, resolve } from 'node:path';
import { createHash, randomBytes } from 'node:crypto';
import { processState } from '../../seat/src/process-state.mjs';
import { ID_PATTERN } from './vocabulary.mjs';
export class OrgError extends Error {
constructor(code, message, path) { super(message); this.name = 'OrgError'; this.code = code; if (path) this.path = path; }
}
const fail = (code, message, path) => { throw new OrgError(code, message, path); };
function idCheck(id) { if (typeof id !== 'string' || !ID_PATTERN.test(id)) fail('org-invalid', 'invalid organization id'); }
export function orgRoot(dataRoot, id) { idCheck(id); if (!isAbsolute(dataRoot) || normalize(dataRoot) !== dataRoot) fail('org-invalid', 'data root must be a normalized absolute path'); return join(dataRoot, 'org-runtime', id); }
async function safeDir(path, create = false) {
if (!isAbsolute(path) || resolve(path) !== path) fail('org-mismatch', 'directory must be a normalized absolute path', path);
let current = parse(path).root;
for (const part of path.slice(current.length).split('/').filter(Boolean)) {
current = join(current, part);
try { const st = await fs.lstat(current); if (!st.isDirectory() || st.isSymbolicLink()) fail('org-mismatch', 'directory path contains a symlink or non-directory', current); }
catch (e) { if (e.code !== 'ENOENT' || !create) throw e; await fs.mkdir(current, { mode: 0o700 }).catch(err => { if (err.code !== 'EEXIST') throw err; }); const st = await fs.lstat(current); if (!st.isDirectory() || st.isSymbolicLink()) fail('org-mismatch', 'unsafe directory', current); }
}
}
async function readPrivate(file) {
const fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe metadata file', file); return JSON.parse(await fd.readFile('utf8')); } finally { await fd.close(); }
}
async function privateDir(path) { const st = await fs.lstat(path); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe private directory', path); }
function validateRegistry(doc, file) {
if (!doc || doc.version !== 1 || typeof doc.instance !== 'string' || !doc.orgs || typeof doc.orgs !== 'object' || Array.isArray(doc.orgs)) fail('org-mismatch', 'invalid registry', file);
for (const [id, entry] of Object.entries(doc.orgs)) { idCheck(id); if (!entry || entry.id !== id || typeof entry.root !== 'string' || !['reserved', 'active', 'deleting', 'archived'].includes(entry.state) || !Number.isSafeInteger(entry.revision) || entry.revision < 1 || typeof entry.created !== 'string') fail('org-mismatch', 'invalid registry entry', file); }
return doc;
}
export async function readRegistry({ configDir }) {
await safeDir(configDir); await privateDir(configDir); const file = join(configDir, 'org-runtime.json'); let doc;
try { doc = await readPrivate(file); } catch (e) { if (e.code === 'ENOENT') return null; if (e instanceof OrgError) throw e; fail('org-mismatch', `invalid registry: ${e.message}`, file); }
return validateRegistry(doc, file);
}
export async function listOrgs(ctx) { const doc = await readRegistry(ctx); return doc ? Object.values(doc.orgs) : []; }
export async function withRegistry(ctx, fn, { write = true } = {}) {
await safeDir(ctx.configDir); await privateDir(ctx.configDir); const lock = join(ctx.configDir, 'org-runtime.lock'); let fd;
for (let n = 0; n < 200; n++) { try { fd = await fs.open(lock, 'wx', 0o600); break; } catch (e) { if (e.code !== 'EEXIST') throw e; await new Promise(r => setTimeout(r, 10)); } }
if (!fd) fail('org-locked', 'registry lock is held', lock);
try {
const registry = await readRegistry(ctx) ?? { version: 1, instance: ctx.instance, orgs: {} };
if (registry.instance !== ctx.instance) fail('org-mismatch', 'registry instance mismatch');
const result = await fn(registry);
if (write) {
const file = join(ctx.configDir, 'org-runtime.json'); const temp = join(ctx.configDir, `.org-runtime-${randomBytes(12).toString('hex')}`);
try { await fs.writeFile(temp, JSON.stringify(registry, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); await fs.rename(temp, file); } finally { await fs.rm(temp, { force: true }); }
}
return result;
} finally { await fd.close(); await fs.unlink(lock); }
}
// The broker's existing organization writer is synchronous. It shares the
// registry lock with the async service and refuses contention rather than
// blocking the event loop. Legacy configs without a registry stay unchanged.
export function withRegistryWriter(configDir, fn) {
const file = join(configDir, 'org-runtime.json');
const lock = join(configDir, 'org-runtime.lock'); let lockFd;
try { lockFd = openSync(lock, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
catch (e) { if (e.code === 'EEXIST') fail('org-locked', 'registry lock is held', lock); throw e; }
const release = () => { closeSync(lockFd); unlinkSync(lock); };
let pending = false;
try {
let fd, registry = null;
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
catch (e) { if (e.code !== 'ENOENT') throw e; }
if (fd !== undefined) {
try {
const dir = lstatSync(configDir);
if (!dir.isDirectory() || dir.isSymbolicLink() || dir.uid !== process.getuid() || (dir.mode & 0o077)) fail('org-mismatch', 'unsafe private directory', configDir);
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe metadata file', file);
registry = validateRegistry(JSON.parse(readFileSync(fd, 'utf8')), file);
} finally { closeSync(fd); }
}
const publish = () => {
const temp = join(configDir, `.org-runtime-${randomBytes(12).toString('hex')}`);
try { writeFileSync(temp, JSON.stringify(registry, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); renameSync(temp, file); }
finally { try { unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') throw e; } }
};
const result = fn(registry, publish);
if (result !== null && typeof result === 'object' && typeof result.then === 'function') {
pending = true;
return Promise.resolve(result).finally(release);
}
return result;
} finally { if (!pending) release(); }
}
export async function reserveOrg(ctx, id) {
const root = orgRoot(ctx.dataRoot, id);
return withRegistry(ctx, async registry => {
if (Object.hasOwn(registry.orgs, id)) fail('org-exists', 'organization already reserved', root);
for (const entry of Object.values(registry.orgs)) if (entry.root === root || root.startsWith(entry.root + '/') || entry.root.startsWith(root + '/')) fail('org-overlap', 'organization roots overlap', root);
await safeDir(ctx.dataRoot); await privateDir(ctx.dataRoot);
await safeDir(dirname(root), true); await privateDir(dirname(root));
try { await fs.mkdir(root, { mode: 0o700 }); } catch (e) { if (e.code === 'EEXIST') fail('org-exists', 'organization root already exists', root); throw e; }
await fs.writeFile(join(root, '.mosaic-org.json'), JSON.stringify({ instance: ctx.instance, id }) + '\n', { mode: 0o600, flag: 'wx' });
const entry = { id, root, state: 'reserved', created: new Date().toISOString(), revision: 1 }; registry.orgs[id] = entry; return { ...entry };
});
}
export async function resolveOrg(ctx, id) {
const root = orgRoot(ctx.dataRoot, id); const registry = await readRegistry(ctx); const entry = registry?.orgs?.[id];
if (!entry) fail('org-unknown', 'unknown organization');
if (registry.instance !== ctx.instance || entry.root !== root) fail('org-mismatch', 'organization binding mismatch', root);
try { await safeDir(root); await privateDir(ctx.dataRoot); await privateDir(dirname(root)); const st = await fs.lstat(root); if (st.uid !== process.getuid() || (st.mode & 0o777) !== 0o700) fail('org-mismatch', 'unsafe organization root', root); const marker = await readPrivate(join(root, '.mosaic-org.json')); if (marker.instance !== ctx.instance || marker.id !== id || Object.keys(marker).sort().join(',') !== 'id,instance') fail('org-mismatch', 'organization marker mismatch', root); }
catch (e) { if (e instanceof OrgError) throw e; fail('org-mismatch', 'cannot verify organization root or marker', root); }
return { id, root, state: entry.state };
}
export async function activateOrg(ctx, id) {
return withRegistry(ctx, async registry => {
const found = await resolveOrg(ctx, id);
if (found.state !== 'reserved') fail('org-exists', 'organization already active');
// An existing business document must join the same digest fence as a
// document created by the organization service. Some reservations have
// no document yet; preserve the registry's existing activation contract.
const dir = join(ctx.configDir, 'businesses'), file = join(dir, `${id}.json`);
try {
await safeDir(dir);
const fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const st = await fd.stat();
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o022)) fail('org-mismatch', 'unsafe organization document', file);
registry.orgs[id].digest = createHash('sha256').update(await fd.readFile()).digest('hex');
} finally { await fd.close(); }
} catch (e) { if (e.code !== 'ENOENT') throw e; }
registry.orgs[id].state = 'active';
return { ...registry.orgs[id] };
});
}
// Serialize bus launch with organization lifecycle operations.
// A leftover lock is never guessed stale; the operator must inspect it.
export async function withOrgLifecycle(ctx, id, fn) {
idCheck(id); await safeDir(ctx.configDir); await privateDir(ctx.configDir);
const path = join(ctx.configDir, `org-lifecycle-${id}.lock`); let fd;
try { fd = await fs.open(path, 'wx', 0o600); }
catch (e) { if (e.code === 'EEXIST') fail('org-locked', 'organization lifecycle lock is held; inspect the lock and resume'); throw e; }
try { await fd.writeFile(JSON.stringify({ pid: process.pid, at: new Date().toISOString() }) + '\n'); return await fn(); }
finally { await fd.close(); await fs.unlink(path); }
}
// Lifecycle locks carry {pid, at}; registry locks are empty and cannot be
// attributed safely. Only competing clear operations need this short fence:
// a new lifecycle holder cannot acquire the existing lock until it is removed.
function inspectRecoveryFence(path) {
let fd;
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
catch (e) { if (e.code === 'ENOENT') return null; return { path, unreadable: true }; }
try {
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600 || st.nlink !== 1) return { path, unreadable: true };
let holder; try { holder = JSON.parse(readFileSync(fd, 'utf8')); } catch { return { path, unreadable: true }; }
if (!Number.isSafeInteger(holder?.pid) || holder.pid <= 0 || typeof holder.startTime !== 'string' || !/^\d+$/.test(holder.startTime)) return { path, unreadable: true };
let status; try { status = processState(holder.pid); } catch { return { path, pid: holder.pid, startTime: holder.startTime, alive: null }; }
return { path, pid: holder.pid, startTime: holder.startTime, alive: status.alive && status.startTime === holder.startTime, dev: st.dev, ino: st.ino };
} finally { closeSync(fd); }
}
export async function orgLock(ctx, id, { clear = false } = {}) {
idCheck(id); await safeDir(ctx.configDir); await privateDir(ctx.configDir);
const path = join(ctx.configDir, `org-lifecycle-${id}.lock`);
const guard = `${path}.clear`; let guardFd, self;
if (clear) {
self = processState(process.pid);
if (!self.alive || !self.startTime) fail('org-mismatch', 'clearer process identity is uncertain', guard);
for (;;) {
try { guardFd = openSync(guard, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); break; }
catch (e) {
if (e.code !== 'EEXIST') throw e;
const holder = inspectRecoveryFence(guard);
if (!holder) continue;
if (holder.unreadable) fail('org-locked', `unreadable recovery fence ${guard}; remove it by hand`, guard);
if (holder.alive !== false) fail('org-locked', `lock recovery is already in progress by pid ${holder.pid}`, guard);
let current; try { current = lstatSync(guard); } catch (err) { if (err.code === 'ENOENT') continue; throw err; }
if (current.dev !== holder.dev || current.ino !== holder.ino) fail('org-locked', 'recovery fence changed during recovery', guard);
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
try { renameSync(guard, `${guard}.stale-${stamp}-${randomBytes(4).toString('hex')}`); }
catch (err) { if (err.code === 'ENOENT') continue; throw err; }
}
}
}
try {
if (guardFd !== undefined) writeFileSync(guardFd, JSON.stringify({ pid: process.pid, startTime: self.startTime }) + '\n');
const fence = !clear ? inspectRecoveryFence(guard) : null;
const reportFence = result => { if (fence) { const { dev, ino, ...visible } = fence; result.fence = visible; } return result; };
let fd;
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
catch (e) { if (e.code === 'ENOENT') return reportFence(clear ? { held: false, cleared: false } : { held: false }); throw e; }
try {
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600 || st.nlink !== 1) fail('org-mismatch', 'unsafe lifecycle lock', path);
let holder; try { holder = JSON.parse(readFileSync(fd, 'utf8')); } catch { fail('org-mismatch', 'unreadable lifecycle lock', path); }
if (!Number.isSafeInteger(holder?.pid) || holder.pid <= 0 || typeof holder.at !== 'string' || !Number.isFinite(Date.parse(holder.at))) fail('org-mismatch', 'invalid lifecycle lock holder', path);
let status; try { status = processState(holder.pid); } catch { fail('org-mismatch', 'lifecycle lock holder is uncertain', path); }
const result = { held: true, pid: holder.pid, start: holder.at, alive: status.alive };
if (clear) {
if (status.alive) fail('org-locked', `lifecycle lock held by pid ${holder.pid} since ${holder.at} (alive)`, path);
const current = lstatSync(path);
if (current.dev !== st.dev || current.ino !== st.ino) fail('org-locked', 'lifecycle lock changed during recovery', path);
unlinkSync(path); result.cleared = true;
}
return reportFence(result);
} finally { closeSync(fd); }
} finally {
if (guardFd !== undefined) {
const owned = fstatSync(guardFd);
try {
const current = lstatSync(guard);
if (owned.dev !== current.dev || owned.ino !== current.ino) fail('org-locked', 'recovery fence changed during recovery', guard);
unlinkSync(guard);
} finally { closeSync(guardFd); }
}
}
}
// Old path of packages/organization/src/org-registry.mjs (rename design r2, stage B).
export * from "../../organization/src/org-registry.mjs";
+2 -386
View File
@@ -1,386 +1,2 @@
import { constants } from 'node:fs';
import * as fs from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { createHash, randomBytes } from 'node:crypto';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { validateBusinessDocument, httpsUrl } from './business.mjs';
import { ID_PATTERN } from './vocabulary.mjs';
import { OrgError, orgRoot, readRegistry, reserveOrg, resolveOrg, withRegistry, withOrgLifecycle } from './org-registry.mjs';
import { loadInstalled } from '../../plugins/src/loader.mjs';
import { BINDING_NAME } from '../../discord/src/binding.mjs';
const rolesDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../../roles');
const owner = Object.freeze({ provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' });
const fail = (code, message, path) => { throw new OrgError(code, message, path); };
const same = (a, b) => !!a && !!b && ['provider', 'iss', 'sub'].every(k => a[k] === b[k]);
const digest = text => createHash('sha256').update(text).digest('hex');
const encode = doc => JSON.stringify(doc, null, 2) + '\n';
const projectPath = (root, project) => join(root, 'projects', project);
function authorize(ctx) { if (!ctx || typeof ctx.instance !== 'string' || !/^[a-z][a-z0-9]{1,15}$/.test(ctx.instance) || ctx.instance === 'dev' || !same(ctx.principal, owner)) fail('org-forbidden', 'authenticated local development owner required'); }
function checkId(id) { if (typeof id !== 'string' || !ID_PATTERN.test(id)) fail('org-invalid', 'invalid id'); }
async function privateRead(file) { const fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe file', file); return await fd.readFile('utf8'); } finally { await fd.close(); } }
async function atomic(file, content, exclusive = false) {
const tmp = join(dirname(file), `.org-write-${randomBytes(12).toString('hex')}`);
try { await fs.writeFile(tmp, content, { mode: 0o600, flag: 'wx' }); if (exclusive) await fs.link(tmp, file); else await fs.rename(tmp, file); } finally { await fs.rm(tmp, { force: true }); }
}
async function ensurePrivateDir(path) { try { await fs.mkdir(path, { mode: 0o700 }); } catch (e) { if (e.code !== 'EEXIST') throw e; } const st = await fs.lstat(path); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe directory', path); }
async function receipt(root, op, org, principal, fields = {}) {
const result = { op, org, at: new Date().toISOString(), subject: { ...principal }, ...fields };
const file = join(root, 'receipts.jsonl'); const fd = await fs.open(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe receipts', file); await fd.writeFile(JSON.stringify(result) + '\n'); } finally { await fd.close(); } return result;
}
async function receipts(root) {
try { const text = await privateRead(join(root, 'receipts.jsonl')); return text.trim().split('\n').map(line => JSON.parse(line)); }
catch { fail('org-mismatch', 'organization receipts cannot be read'); }
}
function normalizeDoc(ctx, id, input, existing) {
if (!input || input.id !== id) fail('org-invalid', 'document id must match organization id', '$.id');
const doc = structuredClone(input); const root = orgRoot(ctx.dataRoot, id);
for (const [project, entry] of Object.entries(doc.projects ?? {})) {
checkId(project); if (!entry || entry.kind === 'library') fail('org-invalid', 'configured projects must be Git projects', `$.projects.${project}`);
const expected = projectPath(root, project), placeholder = `/__mosaic_org__/${id}/projects/${project}`;
if (entry.root !== expected && entry.root !== placeholder) fail('org-invalid', 'project root must be derived from the organization', `$.projects.${project}.root`);
if (entry.repo !== undefined && entry.repo !== expected) fail('org-invalid', 'project repo must be derived from the organization', `$.projects.${project}.repo`);
const previous = existing?.projects?.[project];
if (entry.git && JSON.stringify(entry.git) !== JSON.stringify(previous?.git)) fail('org-invalid', 'project git binding is established by repo init', `$.projects.${project}.git`);
if (previous?.git && JSON.stringify(entry.git) !== JSON.stringify(previous.git)) fail('org-invalid', 'initialized git binding cannot be changed', `$.projects.${project}.git`);
entry.root = expected; if (entry.repo !== undefined) entry.repo = expected;
}
if (existing) for (const [p, entry] of Object.entries(existing.projects)) if (entry.git && !doc.projects?.[p]) fail('org-invalid', 'initialized project cannot be removed', `$.projects.${p}`);
return doc;
}
// Presets are supplied by installed modules; reject malformed or duplicate names
// rather than silently choosing a different module's configuration.
async function discoverPresets() {
const presets = new Map();
for (const kind of ['harness', 'model-provider']) {
const { modules, refused } = await loadInstalled(kind);
if (refused.length) fail('org-invalid', 'installed plugin could not be loaded');
for (const { module } of modules) {
if (module.orgPresets === undefined) continue;
if (typeof module.orgPresets !== 'function') fail('org-invalid', 'invalid preset provider');
const list = await module.orgPresets();
if (!Array.isArray(list)) fail('org-invalid', 'invalid preset provider');
for (const p of list) {
if (!p || typeof p.name !== 'string' || !/^[a-z][a-z0-9-]*$/.test(p.name) || presets.has(p.name) ||
typeof p.label !== 'string' || !p.label || typeof p.description !== 'string' || !p.description ||
!Array.isArray(p.thinking?.levels) || !p.thinking.levels.length ||
p.thinking.levels.some(v => typeof v !== 'string' || !/^[a-z][a-z0-9-]*$/.test(v)) ||
new Set(p.thinking.levels).size !== p.thinking.levels.length || !p.thinking.levels.includes(p.thinking.default) ||
typeof p.available !== 'function' || typeof p.overlay !== 'function') fail('org-invalid', 'invalid or duplicate preset');
presets.set(p.name, p);
}
}
}
return presets;
}
export async function orgPresets({ env = process.env } = {}) {
const presets = await discoverPresets();
return Promise.all([...presets.values()].map(async p => {
const availability = await p.available({ env });
if (!availability || typeof availability.ok !== 'boolean' || (!availability.ok && (typeof availability.reason !== 'string' || !availability.reason))) fail('org-invalid', 'invalid preset availability');
return { name: p.name, label: p.label, description: p.description, thinking: structuredClone(p.thinking), available: availability.ok ? { ok: true } : { ok: false, reason: availability.reason } };
}));
}
export async function templateRender(id, { fixture, preset, thinking } = {}) {
if (fixture !== undefined && fixture !== 'local') fail('org-invalid', 'unknown fixture');
if (fixture !== undefined && preset !== undefined) fail('org-invalid', 'choose fixture or preset');
if (fixture === 'local') preset = 'fixture';
checkId(id);
const doc = JSON.parse(await fs.readFile(new URL('./org-template.json', import.meta.url), 'utf8'));
doc.id = id; doc.projects.work.root = `/__mosaic_org__/${id}/projects/work`;
if (preset !== undefined) {
if (typeof preset !== 'string') fail('org-invalid', 'unknown preset');
const p = (await discoverPresets()).get(preset);
if (!p) fail('org-invalid', 'unknown preset');
const level = thinking === undefined ? p.thinking.default : thinking;
if (!p.thinking.levels.includes(level)) fail('org-invalid', 'unknown thinking level');
const overlay = await p.overlay({ thinking: level });
if (!overlay || typeof overlay !== 'object' || !overlay.plugins || !overlay.launch || !overlay.assistantVars) fail('org-invalid', 'invalid preset overlay');
doc.plugins = structuredClone(overlay.plugins); doc.launch = structuredClone(overlay.launch);
doc.roles.assistant.vars = structuredClone(overlay.assistantVars);
if (overlay.vars !== undefined) doc.vars = structuredClone(overlay.vars);
} else if (thinking !== undefined) fail('org-invalid', 'thinking requires a preset');
return doc;
}
export async function validateOrg(doc) {
try {
checkId(doc?.id); validateBusinessDocument(doc, join('/businesses', `${doc.id}.json`), { rolesDir });
for (const [project, entry] of Object.entries(doc.projects)) {
const suffix = `/org-runtime/${doc.id}/projects/${project}`;
const placeholder = `/__mosaic_org__/${doc.id}/projects/${project}`;
if (entry.kind === 'library' || (entry.root !== placeholder && !entry.root.endsWith(suffix))) fail('org-invalid', 'project root must be derived from the organization', `$.projects.${project}.root`);
if (entry.repo !== undefined && entry.repo !== entry.root) fail('org-invalid', 'project repo must match its root', `$.projects.${project}.repo`);
}
return { ok: true, errors: [] };
}
catch (e) {
const match = /(?:^|[ /])(?:projects|roles|arbiters)\.([a-z0-9._-]+(?:\.[a-z0-9._-]+)*)/.exec(e.message);
const field = /(?:business file requires |business file ")"?([a-zA-Z][a-zA-Z0-9]*)/.exec(e.message);
const path = match ? '$.' + match[0].trim().replace(/^.*\//, '') : field ? '$.' + field[1] : /business id/.test(e.message) ? '$.id' : /business human/.test(e.message) ? '$.human' : '$';
return { ok: false, errors: [{ path: e.path ?? path, message: e.message }] };
}
}
async function checked(doc) { const result = await validateOrg(doc); if (!result.ok) fail('org-invalid', result.errors[0].message, result.errors[0].path); }
async function readCurrentUnlocked(ctx, id, { includeArchived = false } = {}) {
const found = await resolveOrg(ctx, id);
if (found.state === 'archived' && !includeArchived) fail('org-archived', 'organization is archived');
if (found.state !== 'active' && !(includeArchived && found.state === 'archived')) fail('org-unavailable', 'organization is not active');
const principal = JSON.parse(await privateRead(join(found.root, 'owner.json'))); if (!same(principal, ctx.principal)) fail('org-forbidden', 'organization owner required');
// From here the caller is the verified owner. A failure carries that proof so a list may
// show this org as broken to its owner; earlier failures prove nothing and stay hidden.
try {
const entry = (await readRegistry(ctx)).orgs[id]; const raw = await privateRead(join(ctx.configDir, 'businesses', `${id}.json`));
if (!entry.digest || entry.digest !== digest(raw)) fail('org-mismatch', 'organization document and registry disagree');
const doc = JSON.parse(raw); await checked(doc); return { ...found, revision: entry.revision, doc, principal };
} catch (e) { if (e && typeof e === 'object') e.ownerVerified = true; throw e; }
}
// Readers do not take the writer lock. Require unchanged registry snapshots and
// no in-progress publication on either side of the read; retry a raced write.
async function readCurrent(ctx, id, opts) {
const locked = async () => { try { await fs.lstat(join(ctx.configDir, 'org-runtime.lock')); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } };
for (let n = 0; n < 200; n++) {
if (await locked()) { await new Promise(r => setTimeout(r, 10)); continue; }
const before = JSON.stringify(await readRegistry(ctx));
let result, error;
try { result = await readCurrentUnlocked(ctx, id, opts); } catch (e) { error = e; }
const after = JSON.stringify(await readRegistry(ctx));
if (!(await locked()) && before === after) { if (error) throw error; return result; }
await new Promise(r => setTimeout(r, 10));
}
fail('org-locked', 'registry changed or was locked during organization read');
}
export async function createOrg(ctx, id, input) {
authorize(ctx); checkId(id); if (input?.human !== ctx.principal.sub) fail('org-forbidden', 'document human must name the local owner', '$.human');
const doc = normalizeDoc(ctx, id, input); await checked(doc);
await readRegistry(ctx);
const businessDir = join(ctx.configDir, 'businesses'); await ensurePrivateDir(businessDir);
const file = join(businessDir, `${id}.json`); try { await fs.lstat(file); fail('org-exists', 'business file already exists', file); } catch (e) { if (e.code !== 'ENOENT') throw e; }
const entry = await reserveOrg(ctx, id); const raw = encode(doc);
await atomic(join(entry.root, 'owner.json'), encode(ctx.principal), true); await atomic(file, raw, true);
const notify = join(entry.root, 'notify'); await ensurePrivateDir(notify);
const bindingDir = join(notify, id); await ensurePrivateDir(bindingDir);
await atomic(join(bindingDir, 'notify.json'), encode({ notifyVersion: 1, binding: null }), true);
const result = await receipt(entry.root, 'org.create', id, ctx.principal, { root: entry.root, revision: entry.revision, notifications: 'none' });
await withRegistry(ctx, async registry => { const found = await resolveOrg(ctx, id); if (found.state !== 'reserved') fail('org-mismatch', 'reservation changed'); registry.orgs[id].digest = digest(raw); registry.orgs[id].state = 'active'; }); return result;
}
export async function showOrg(ctx, id, opts = {}) { authorize(ctx); const { root, revision, doc, principal, state } = await readCurrent(ctx, id, opts); return { id, root, revision, doc, principal, state }; }
export async function notificationShow(ctx, id) {
authorize(ctx); const current = await readCurrent(ctx, id);
for (const path of [join(current.root, 'notify'), join(current.root, 'notify', id)]) {
const st = await fs.lstat(path);
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe notifier directory');
}
const file = join(current.root, 'notify', id, 'notify.json');
const st = await fs.lstat(file);
if (!st.isFile() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe notifier configuration');
const binding = JSON.parse(await privateRead(file));
if (!binding || Object.keys(binding).sort().join(',') !== 'binding,notifyVersion' || binding.notifyVersion !== 1 || !(binding.binding === null || typeof binding.binding === 'string' && BINDING_NAME.test(binding.binding))) fail('org-mismatch', 'invalid notifier configuration');
const record = (await receipts(current.root)).find(r => r.op === 'org.create' && r.org === id && same(r.subject, current.principal));
if (!record || record.notifications !== 'none') fail('org-mismatch', 'notification choice receipt unavailable');
return { ...record, currentNotifications: binding.binding === null ? 'none' : 'configured' };
}
export async function updateOrg(ctx, id, input, { ifRevision } = {}) {
authorize(ctx); return withRegistry(ctx, async registry => {
const current = await readCurrentUnlocked(ctx, id); if (!Number.isSafeInteger(ifRevision) || ifRevision !== current.revision) fail('revision-conflict', 'organization revision changed');
const doc = normalizeDoc(ctx, id, input, current.doc); if (doc.human !== ctx.principal.sub) fail('org-forbidden', 'document human must name the local owner'); await checked(doc);
const raw = encode(doc), revision = current.revision + 1; await atomic(join(ctx.configDir, 'businesses', `${id}.json`), raw); registry.orgs[id].digest = digest(raw); registry.orgs[id].revision = revision;
return receipt(current.root, 'org.update', id, ctx.principal, { root: current.root, revision });
});
}
function options({ mode, remote, acceptLocalLossRisk }) {
if (mode === 'local-only') { if (acceptLocalLossRisk !== true || remote !== undefined) fail('org-risk', 'local-only requires explicit loss-risk acceptance and no remote'); return { mode, riskAccepted: true }; }
if (mode === 'remote') { if (acceptLocalLossRisk || typeof remote !== 'string') fail('org-invalid', 'remote mode requires a remote URL'); try { httpsUrl(remote, 'remote', { repo: true }); } catch (e) { fail('org-invalid', e.message); } return { mode, remote }; }
fail('org-invalid', 'select local-only or remote');
}
async function git(root, args) {
const env = { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: '/nonexistent', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' };
return new Promise((resolve, reject) => { const p = spawn('git', ['-c', 'user.name=Mosaic Local', '-c', '[email protected]', '-c', 'core.hooksPath=/dev/null', '-C', root, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] }); let out = '', err = ''; p.stdout.on('data', d => out += d); p.stderr.on('data', d => err += d); p.on('error', reject); p.on('close', code => code === 0 ? resolve(out.trim()) : reject(new OrgError('org-git', `git failed: ${err.trim()}`))); });
}
async function initGit(root) { await fs.mkdir(root, { mode: 0o700 }); await git(root, ['init', '--quiet', '--template=']); await git(root, ['commit', '--quiet', '--allow-empty', '-m', 'Genesis']); return git(root, ['rev-parse', 'HEAD']); }
async function setup(ctx, id, project, opts) {
authorize(ctx); const selection = options(opts); if (project !== null) checkId(project);
return withOrgLifecycle(ctx, id, async () => {
const current = await withRegistry(ctx, async () => {
const current = await readCurrentUnlocked(ctx, id); if (project !== null && !Object.hasOwn(current.doc.projects, project)) fail('org-unknown', 'unknown project');
const op = project === null ? 'org.queue.init' : 'org.project.repo.init';
if ((await receipts(current.root)).some(r => r.op === op && (project === null || r.project === project))) fail('org-exists', 'repository was already initialized; inspect its receipt');
return current;
}, { write: false });
const parent = project === null ? current.root : join(current.root, 'projects'); if (project !== null) await ensurePrivateDir(parent);
const root = project === null ? join(current.root, 'checkout') : projectPath(current.root, project);
const commit = await initGit(root); const fields = { ...selection, root, commit, ...(project === null ? {} : { project }) };
return withRegistry(ctx, async registry => {
const latest = await readCurrentUnlocked(ctx, id);
if (latest.revision !== current.revision) fail('revision-conflict', 'organization changed during setup; inspect the initialized repository before retrying');
if (project !== null) { latest.doc.projects[project].git = selection.mode === 'remote' ? { mode: 'remote', remote: selection.remote } : { mode: 'local-only' }; await checked(latest.doc); const raw = encode(latest.doc); await atomic(join(ctx.configDir, 'businesses', `${id}.json`), raw); registry.orgs[id].digest = digest(raw); registry.orgs[id].revision++; }
return receipt(current.root, project === null ? 'org.queue.init' : 'org.project.repo.init', id, ctx.principal, fields);
});
});
}
export async function queueInit(ctx, id, opts = {}) { return setup(ctx, id, null, opts); }
export async function projectRepoInit(ctx, id, project, opts = {}) { return setup(ctx, id, project, opts); }
async function showSetup(ctx, id, project) {
authorize(ctx); const current = await readCurrent(ctx, id); if (project !== null) { checkId(project); if (!Object.hasOwn(current.doc.projects, project)) fail('org-unknown', 'unknown project'); }
const records = await receipts(current.root);
const op = project === null ? 'org.queue.init' : 'org.project.repo.init'; const item = records.findLast(r => r.op === op && (project === null || r.project === project));
const root = project === null ? join(current.root, 'checkout') : projectPath(current.root, project);
if (!item || item.root !== root || !same(item.subject, current.principal)) fail('org-unavailable', 'setup receipt unavailable');
if (project !== null) { const parent = await fs.lstat(join(current.root, 'projects')); if (!parent.isDirectory() || parent.isSymbolicLink() || parent.uid !== process.getuid() || (parent.mode & 0o077)) fail('org-mismatch', 'unsafe projects directory'); }
const st = await fs.lstat(root); const dotgit = await fs.lstat(join(root, '.git')); if (!st.isDirectory() || st.isSymbolicLink() || !dotgit.isDirectory() || dotgit.isSymbolicLink()) fail('org-mismatch', 'unsafe git repository');
await git(root, ['rev-parse', '--verify', 'HEAD']); return item;
}
export async function queueShow(ctx, id) { return showSetup(ctx, id, null); }
export async function projectRepoShow(ctx, id, project) { return showSetup(ctx, id, project); }
// Archive and restore share the bus-start/delete fence, but do not hold the
// registry lock while waiting for the bus host to stop.
async function lifecycleChange(ctx, id, { ifRevision, busStop }, operation) {
authorize(ctx); checkId(id);
if (operation === 'archive' && typeof busStop !== 'function') fail('org-invalid', 'archive needs a bus stop function');
return withOrgLifecycle(ctx, id, async () => {
const expected = operation === 'archive' ? 'active' : 'archived';
const inspect = async () => {
const current = await readCurrentUnlocked(ctx, id, { includeArchived: true });
if (current.state !== expected) fail(current.state === 'archived' ? 'org-archived' : 'org-invalid', `organization is not ${expected}`);
if (!Number.isSafeInteger(ifRevision) || current.revision !== ifRevision) fail('revision-conflict', 'organization revision changed');
return current;
};
await withRegistry(ctx, inspect, { write: false });
const bus = operation === 'archive' ? await busStop(ctx, id) : null;
if (operation === 'archive' && (!bus || typeof bus !== 'object' || typeof bus.stopped !== 'boolean')) fail('org-unavailable', 'bus stop outcome is uncertain');
return withRegistry(ctx, async registry => {
const current = await inspect();
if (!Number.isSafeInteger(current.revision + 1)) fail('org-mismatch', 'organization revision cannot be incremented');
await deleteSafeRoot(ctx.dataRoot);
const record = { op: `org.${operation}`, org: id, actor: { ...ctx.principal }, time: new Date().toISOString(), revision: current.revision + 1, bus };
let fd;
try { fd = await fs.open(join(ctx.dataRoot, 'org-lifecycle.log'), constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600); }
catch (e) { if (e.code === 'ENXIO') fail('org-mismatch', 'unsafe organization lifecycle log'); throw e; }
try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe organization lifecycle log'); await fd.writeFile(JSON.stringify(record) + '\n'); }
finally { await fd.close(); }
registry.orgs[id].state = operation === 'archive' ? 'archived' : 'active';
registry.orgs[id].revision = record.revision;
return { ...record, outcome: 'done', step: operation };
});
});
}
export async function archiveOrg(ctx, id, opts = {}) { return lifecycleChange(ctx, id, opts, 'archive'); }
export async function restoreOrg(ctx, id, opts = {}) { return lifecycleChange(ctx, id, opts, 'restore'); }
// Deletion keeps a durable marker in the registry and a receipt beside the
// moved data. It never opens the contents of organization secret trees.
async function deleteInspect(path, { secret = false } = {}) {
let count = 0;
async function visit(p) {
const st = await fs.lstat(p);
if (st.isSymbolicLink() || (!st.isDirectory() && !st.isFile() && !st.isSocket())) fail('org-mismatch', 'unsafe owned path');
if (secret && (st.uid !== process.getuid() || (st.mode & 0o077) || (!st.isDirectory() && !st.isFile()))) fail('org-mismatch', 'unsafe organization secret directory');
if (st.isDirectory()) for (const name of await fs.readdir(p)) { count++; await visit(join(p, name)); }
}
try { await fs.lstat(path); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
await visit(path); return count;
}
async function deleteSafeRoot(root) {
if (typeof root !== 'string' || !root.startsWith('/') || resolve(root) !== root) fail('org-mismatch', 'invalid data root');
let path = '/';
for (const part of root.split('/').filter(Boolean)) {
path = join(path, part); const st = await fs.lstat(path);
if (!st.isDirectory() || st.isSymbolicLink()) fail('org-mismatch', 'unsafe data root');
if (path === root && (st.uid !== process.getuid() || (st.mode & 0o077))) fail('org-mismatch', 'unsafe data root');
}
}
async function deleteParents(base, relative) {
let path = base;
const parts = relative.split('/'); parts.pop();
for (const part of parts) {
path = join(path, part);
try { const st = await fs.lstat(path); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe owned path parent'); }
catch (e) { if (e.code === 'ENOENT') return false; throw e; }
}
return true;
}
// busStop comes from the caller (the CLI's busStopForDelete), so business code does not import the bus host.
export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop } = {}) {
authorize(ctx); checkId(id);
if (typeof busStop !== 'function') fail('org-invalid', 'deletion needs a bus stop function');
if (confirm !== id) fail('org-confirm', 'confirmation must match the organization ID');
return withOrgLifecycle(ctx, id, async () => {
const deletion = await withRegistry(ctx, async registry => {
const entry = registry.orgs[id];
if (!entry) fail('org-unknown', 'unknown organization');
if (entry.root !== orgRoot(ctx.dataRoot, id)) fail('org-mismatch', 'organization binding mismatch');
if (entry.state === 'deleting') {
if (!entry.deletion || !same(entry.deletion.actor, ctx.principal) || !/^[a-zA-Z0-9._-]+$/.test(entry.deletion.folder) || !entry.deletion.folder.startsWith(`${id}-`)) fail('org-mismatch', 'invalid deletion marker');
return entry.deletion;
}
const current = await readCurrentUnlocked(ctx, id, { includeArchived: true });
if (!Number.isSafeInteger(ifRevision) || current.revision !== ifRevision) fail('revision-conflict', 'organization revision changed');
const marker = { actor: { ...ctx.principal }, time: new Date().toISOString(), revision: current.revision, folder: `${id}-${new Date().toISOString().replace(/[:.]/g, '-')}-${randomBytes(4).toString('hex')}` };
entry.state = 'deleting'; entry.deletion = marker;
return marker;
});
const deleted = join(ctx.dataRoot, 'deleted'), destination = join(deleted, deletion.folder), receiptFile = join(destination, 'receipt.json');
let registryRemoved = false, result, phase = 'prepare';
try {
await deleteSafeRoot(ctx.dataRoot);
await ensurePrivateDir(deleted); await ensurePrivateDir(destination);
try { result = JSON.parse(await privateRead(receiptFile)); }
catch (e) { if (e.code !== 'ENOENT') throw e; result = { op: 'org.delete', org: id, actor: deletion.actor, time: deletion.time, revision: deletion.revision, destination, bus: null, moves: [], secrets: [], note: 'Purging deleted data and remote-side revocation are out of scope.' }; await atomic(receiptFile, encode(result), true); }
if (result.org !== id || result.destination !== destination || result.revision !== deletion.revision || !Array.isArray(result.moves) || !Array.isArray(result.secrets)) fail('org-mismatch', 'invalid deletion receipt');
if (!result.bus) {
phase = 'bus';
result.bus = await busStop(ctx, id);
await atomic(receiptFile, encode(result));
}
phase = 'inspect';
await withRegistry(ctx, async registry => {
const entry = registry.orgs[id];
if (!entry || entry.state !== 'deleting' || entry.deletion?.folder !== deletion.folder) fail('org-mismatch', 'deletion marker changed');
const owned = [
{ base: ctx.dataRoot, rel: `org-runtime/${id}`, to: 'runtime', required: true },
{ base: ctx.configDir, rel: `businesses/${id}.json`, to: 'organization.json', required: true },
{ base: ctx.configDir, rel: `businesses/.history/${id}`, to: 'history' },
...['agents', 'grants', 'hosts'].map(kind => ({ base: ctx.configDir, rel: `secrets/${kind}/${id}`, to: `secrets-${kind}`, secret: true })),
];
// Inspect all sources and destinations before moving the first one.
for (const item of owned) {
item.from = join(item.base, item.rel); item.dest = join(destination, item.to);
const parents = await deleteParents(item.base, item.rel);
item.count = parents ? await deleteInspect(item.from, { secret: item.secret }) : null;
const prior = result.moves.find(m => m.from === item.from);
const destCount = await deleteInspect(item.dest, { secret: item.secret });
if (prior && (item.count !== null || destCount === null)) fail('org-mismatch', 'moved path does not match receipt');
if (!prior && item.count !== null && destCount !== null) fail('org-mismatch', 'deletion destination already exists');
if (!prior && item.count === null && destCount !== null) item.recovered = true;
if (!prior && item.count === null && destCount === null && item.required) fail('org-mismatch', 'required organization path is missing');
if (item.recovered) item.count = destCount;
}
phase = 'move';
for (const item of owned) {
if (result.moves.some(m => m.from === item.from) || item.count === null) continue;
if (!item.recovered) await fs.rename(item.from, item.dest);
result.moves.push({ from: item.from, to: item.dest });
if (item.secret) result.secrets.push({ path: item.from, entryCount: item.count });
await atomic(receiptFile, encode(result));
}
phase = 'registry';
const log = join(deleted, 'deleted.log');
const fd = await fs.open(log, constants.O_RDWR | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
try {
const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe deletion log');
const lines = (await fd.readFile('utf8')).split('\n');
if (!lines.some(line => { try { return JSON.parse(line).destination === destination; } catch { return false; } })) await fd.writeFile(JSON.stringify({ org: id, destination, time: deletion.time, actor: deletion.actor, revision: deletion.revision }) + '\n');
} finally { await fd.close(); }
result.registry = 'removal pending'; await atomic(receiptFile, encode(result));
delete registry.orgs[id];
});
registryRemoved = true; result.registry = 'removed'; await atomic(receiptFile, encode(result)); return result;
} catch (e) {
e.deleteStep = phase; if (result) e.deleteReceipt = result;
if (registryRemoved) { e.message += `; registry removal completed but the receipt could not be finalized at ${receiptFile}`; throw e; }
e.message += `; deletion remains in progress. Inspect ${destination}/receipt.json and rerun mosaic org delete ${id} --confirm ${id}`;
throw e;
}
});
}
// Old path of packages/organization/src/org-service.mjs (rename design r2, stage B).
export * from "../../organization/src/org-service.mjs";
+2 -444
View File
@@ -1,444 +1,2 @@
// 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";
import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
import { checkReplyLimits } from "../../library/src/limits.mjs";
import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
import { remoteProblem, remoteTarget } from "./project-remotes.mjs";
import { withRegistryWriter } from "./org-registry.mjs";
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,
}];
}
// P5/Q222: every writer protects per-model-key quotas, including removal/reset.
function modelQuotaFence(old, document, actor) {
const keys = doc => Object.fromEntries(Object.entries(doc?.launch?.limits?.tokens?.keys ?? {})
.filter(([key, entry]) => isDailyModelQuota(key, entry)));
if (canonicalJson(keys(old)) === canonicalJson(keys(document))) return;
const agents = Object.entries(old.roles ?? {}).flatMap(([instance, entry]) => [instance, `${entry?.definition}.${instance}`]);
if (actor === old.human && actor !== "tester" && !agents.includes(actor)) return;
fail("organization-model-quota-human-only");
}
// P5: the optional queue reader and polling setting are human changes even on
// the tester organization path. This guards every writer, not only the UI verb.
function usageFence(old, document, actor) {
const setting = doc => ({ plugin: doc?.plugins?.usage ?? null, usage: doc?.usage ?? null });
if (canonicalJson(setting(old)) === canonicalJson(setting(document))) return;
const agents = Object.entries(old.roles ?? {}).flatMap(([instance, entry]) => [instance, `${entry?.definition}.${instance}`]);
if (actor === old.human && actor !== "tester" && !agents.includes(actor)) return;
fail("organization-usage-human-only");
}
// C2-06 M11: replyLimits is the human's alone. Any setter that changes it refuses unless the actor is
// the business's human as the file stood before the write, and no agent or the tester under that
// name. Compared as M9 compares projects, on the document and again on what lands on disk.
function replyLimitsFence(old, document, actor) {
if (canonicalJson(document?.replyLimits) === canonicalJson(old.replyLimits)) return;
const agents = Object.entries(old.roles ?? {}).map(([instance, entry]) => `${entry?.definition}.${instance}`);
if (actor === old.human && actor !== "tester" && !agents.includes(actor)) return;
// The reason names each changed key and its value in force (the brief's M11).
const plain = v => v !== null && typeof v === "object" && !Array.isArray(v);
const before = old.replyLimits, after = document?.replyLimits;
const named = plain(before) && plain(after)
? [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(k => canonicalJson(before[k]) !== canonicalJson(after[k]))
.map(k => `replyLimits.${k} is ${canonicalJson(before[k]) ?? "unset"}`)
: [`replyLimits is ${canonicalJson(before) ?? "unset"}`];
fail("organization-reply-limits-human-only", `${named.join(", ").slice(0, 240)}; only the human changes it`);
}
// C2-06: library.limits.set, the human's verb. It replaces the whole replyLimits object; a key left out
// is its default. An unknown key or a value outside the table refuses invalid-request, and the write's
// M11 fence refuses any actor but the human.
export function setReplyLimits(writer, id, args, actor) {
const replyLimits = checkReplyLimits(args?.replyLimits);
if (replyLimits === null || typeof args?.revision !== "string") fail("invalid-request", "replyLimits takes requireCitation and citationsPerReply only");
const { document } = writer.read(id);
return writer.write(id, { ...document, replyLimits }, { revision: args.revision, actor });
}
export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
const evidence = join(dir, "businesses", ".history");
const logFile = id => join(evidence, id, "changes.jsonl");
// Keep the writer's established busy refusal when the shared registry lock
// is held, including when another document hold is already in progress.
function withWriterRegistry(fn) {
try { return withRegistryWriter(dir, fn); }
catch (e) { if (e.code === "org-locked") fail("organization-busy", "organization writer is busy"); throw e; }
}
// 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) };
}
// C2-06: the citation guard reads replyLimits fresh on every check, from the raw file, so a business
// file that fails validation still yields its limits. { readable: false } when the file is missing,
// unreadable, not JSON or not an object.
function readRaw(id) {
try {
const document = JSON.parse(regular(businessFilePath(id, dir)));
return document !== null && typeof document === "object" && !Array.isArray(document)
? { readable: true, document } : { readable: false };
} catch {
return { readable: false };
}
}
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");
}
const release = () => {
rmSync(lock, { recursive: true });
syncDir(home);
};
let pending = false;
try {
const out = fn({ home, parent });
if (out !== null && typeof out === "object" && typeof out.then === "function") {
pending = true;
return Promise.resolve(out).finally(release);
}
return out;
} finally {
if (!pending) release();
}
}
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(),
});
}
}
// Row 199 (#1697, design section 6): `detail` says what a credential verb did (verb, agent, kind,
// date, reason), never a value; the other setters pass none. context, when given, is logged with
// the change: row 194's hire and fire carry the chief's run and the decision there (D2.2).
// Row 239 (#1745): beforeWrite (a fire's or a delete's credential revoke) runs under the lock,
// after the revision check and the validations, so a refusal there revokes nothing. An error
// after it returns carries revoked: true, the lock's release included (duckworth-0165 C2), and the
// bus names it so the human reads that the tokens may already be revoked. beforeWrite must not
// write this business itself: it would find its own lock and refuse organization-busy.
// The one commit path. `guard(old, document)` is what a caller may do to `projects`: write() keeps
// them whole (C2-04 M9) and addProject() allows one exact addition. `document` may be a function of
// the old document, for a writer that builds what it publishes from what it read under the lock.
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, guard }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
let revoked = false;
try {
return withWriterRegistry((registry, publishRegistry) => withLock(id, ({ home, parent }) => {
recover(id);
const file = businessFilePath(id, dir);
const oldBytes = regular(file);
const before = sha256(oldBytes);
const entry = registry?.orgs?.[id];
if (entry && (entry.state !== 'active' || entry.digest !== before || !Number.isSafeInteger(entry.revision + 1))) {
fail('org-mismatch', 'organization document and registry disagree');
}
if (revision !== before) fail("revision-conflict", "organization revision changed; read again");
const old = JSON.parse(oldBytes);
if (typeof document === "function") document = document(old);
guard(old, document);
replyLimitsFence(old, document, actor);
modelQuotaFence(old, document, actor);
usageFence(old, document, actor);
validateBusinessDocument(document, file, { rolesDir });
const bytes = JSON.stringify(document, null, 2) + "\n";
// Check and log exactly what lands on disk, for every setter: a value JSON
// can't carry (a Date, a Map) serializes as something else (row 131, sage-0314).
const written = JSON.parse(bytes);
guard(old, written);
replyLimitsFence(old, written, actor);
modelQuotaFence(old, written, actor);
usageFence(old, written, actor);
validateBusinessDocument(written, file, { rolesDir });
if (beforeWrite) {
beforeWrite();
revoked = true;
}
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, ...(context === undefined ? {} : { context }), business: id, before, after,
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), 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);
if (entry) { entry.digest = after; entry.revision++; publishRegistry(); }
barrier("renamed");
log(id, { ...event, state: "committed" });
return { revision: after, transaction, previous };
}));
} catch (e) {
if (revoked && e instanceof Error) e.revoked = true;
throw e;
}
}
// C2-04 M9: every organization setter preserves the whole project mapping, including library
// playgrounds and hybrid universes.
const projectsProtected = (old, document) => {
if (canonicalJson(document?.projects) !== canonicalJson(old.projects)) fail("organization-projects-protected");
};
function write(id, document, options) {
return commit(id, document, { ...options, guard: projectsProtected });
}
// Lifecycle Task 2: the only writer that adds a project mapping, and only the exact bootstrap
// addition: one new key under the managed product layout, a receipt naming the request and plan, and
// no other change. It compares both the document it built and the bytes it will publish. A stale
// revision refuses revision-conflict: the owner confirms a fresh preview, never a silent rebase.
function addProject(id, { revision, project, entry, receipt }, actor) {
const guard = (old, document) => {
const problem = additionProblem({ old, next: document, project, entry, receipt });
if (problem) fail("organization-project-add-refused", problem);
};
return commit(id, old => ({ ...old, projects: { ...old.projects, [project]: entry } }), {
revision, actor, guard,
detail: { verb: ADDITION_VERB, request: receipt?.request, plan_digest: receipt?.plan_digest, project },
});
}
// The only exception to the projects fence for an existing software mapping.
// Build from the locked document and check both the candidate and serialized bytes.
function setProjectRemote(id, { revision, project, remote }, actor) {
const guard = (old, document) => {
const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]);
if (actor !== old.human || actor === "tester" || agents.includes(actor)) fail("human-required");
const problem = remoteProblem(old, document, project, remote);
if (problem) fail(problem);
};
return commit(id, old => {
remoteTarget(old, project, remote);
return { ...old, projects: { ...old.projects, [project]: { ...old.projects[project], remote } } };
}, { revision, actor, guard, detail: { verb: "project.remote.set", project } });
}
// Runs `fn({ revision, document })` under the writer lock, after recovery, and nothing else may
// write this business meanwhile. `fn` must be synchronous: the lock is a directory held for its
// duration, and an awaited call would leave a half-checked world to whoever finds the lock gone.
function hold(id, fn) {
requireId(id, "organization id");
return withWriterRegistry(() => withLock(id, () => {
recover(id);
const { document, revision } = read(id);
const out = fn({ document, revision });
if (out !== null && typeof out === "object" && typeof out.then === "function") {
out.catch?.(() => {});
fail("organization-hold-sync", "a held check must be synchronous");
}
return out;
}));
}
// Like hold, but the lock stays on disk until an asynchronous effect settles, including on failure.
// The effect must not call an organization writer: it would find this same lock and refuse busy.
function holdAsync(id, fn) {
requireId(id, "organization id");
return withWriterRegistry(() => withLock(id, () => {
recover(id);
return fn(read(id));
}));
}
// The committed addition of this request and plan, if there is one. A prepared receipt whose
// `after` is the file's current hash counts: the rename landed and the commit line did not.
function findAddition(id, { request, plan_digest, project }) {
let text;
try {
text = regular(logFile(id));
} catch (e) {
if (e.code === "ENOENT") return null;
throw e;
}
if (text && !text.endsWith("\n")) fail("organization-uncertain", "torn organization receipt");
const last = new Map();
for (const e of text.trim() ? text.trim().split("\n").map(JSON.parse) : []) last.set(e.transaction, e);
const hash = sha256(regular(businessFilePath(id, dir)));
for (const e of last.values()) {
const d = e.detail;
if (d?.verb !== ADDITION_VERB || d.request !== request || d.plan_digest !== plan_digest || d.project !== project) continue;
if (e.state === "committed" || (e.state === "prepared" && e.after === hash))
return { after: e.after, before: e.before, transaction: e.transaction };
}
return null;
}
// Row 199: a receipt with no document change, for a credential verb that changes only the agents'
// store (a revoke or delete of store references). Committed at once; there is nothing to recover.
function record(id, { actor, detail }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
return withLock(id, () => {
recover(id);
const hash = sha256(regular(businessFilePath(id, dir)));
const transaction = randomUUID();
log(id, {
transaction, actor, business: id, before: hash, after: hash, changes: [], detail,
at: new Date().toISOString(), state: "committed",
});
return { revision: hash, transaction };
});
}
return { logFile, read, readRaw, write, addProject, setProjectRemote, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
}
// Old path of packages/organization/src/organization.mjs (rename design r2, stage B).
export * from "../../organization/src/organization.mjs";
+2 -70
View File
@@ -1,70 +1,2 @@
// Host setter for the organization file's `plugins` switch (REQ-PLUG-1,
// REQ-ROLE-3). It is the only writer of that key: no broker verb, mosaic
// command, Settings form or chief-of-staff write reaches it (sage-0301).
// Writes go through the organization writer, which logs old and new names
// like a limit change. A write is a patch per kind (see set()): kinds it doesn't name are kept. A refused write throws a code and logs nothing.
import { organizationWriter, OrganizationError } from "./organization.mjs";
import { isPlainObject } from "./util.mjs";
import { KINDS, PluginError, checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs";
// `exists(kind, name)`, when given, refuses a name no module answers to (the host passes the
// loader's hasModule). Without it the setter checks only the shape, as before.
export function pluginSettings({ dir, rolesDir, exists = null }) {
const writer = organizationWriter({ dir, rolesDir });
function read(id) {
const { document, revision } = writer.read(id);
return { business: id, revision, plugins: structuredClone(document.plugins ?? {}),
...(document.usage === undefined ? {} : { usage: structuredClone(document.usage) }) };
}
function set(id, args, actor) {
if (!isPlainObject(args) || Object.keys(args).some(key => key !== "revision" && key !== "plugins" && key !== "usage")) {
throw new OrganizationError("invalid-request");
}
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) {
throw new OrganizationError("invalid-request");
}
// A Date or a Map has no own keys and would pass checkPlugins, then serialize
// as something else (Filbert F1, sage-0314). Only a JSON-shaped object passes.
const shaped = args.plugins !== null && typeof args.plugins === "object";
const proto = shaped ? Object.getPrototypeOf(args.plugins) : undefined;
if (proto !== Object.prototype && proto !== null) {
throw new OrganizationError("invalid-plugins", "plugins must be a plain object with one key per kind");
}
// The patch names the kinds it changes (vikunja:19/309): a kind with a value is switched to it,
// a kind with null is removed, and every kind it doesn't name keeps its setting. An empty patch
// changes nothing and is refused rather than written as a no-op.
const patch = Object.entries(args.plugins);
if (patch.length === 0) throw new OrganizationError("invalid-plugins", "the patch names no kind; to remove a kind give it null");
const removals = patch.filter(([, setting]) => setting === null).map(([kind]) => kind);
const sets = Object.fromEntries(patch.filter(([, setting]) => setting !== null));
for (const kind of removals) {
if (!Object.hasOwn(KINDS, kind)) throw new OrganizationError("invalid-plugins", `plugins names unknown kind ${JSON.stringify(kind)}; adding a kind is a core change`);
}
try {
checkPlugins(sets, "plugins");
} catch (e) {
if (e instanceof PluginError) throw new OrganizationError("invalid-plugins", e.message);
throw e;
}
if (exists) {
for (const [kind, setting] of Object.entries(sets)) {
for (const name of Array.isArray(setting) ? setting : [setting]) {
if (!exists(kind, name)) throw new OrganizationError("unknown-plugin", `no ${kind} module named ${name} in this tree`);
}
}
}
const next = structuredClone(writer.read(id).document);
if (Object.hasOwn(args, "usage")) {
if (args.usage === null) delete next.usage;
else next.usage = checkUsageSettings(args.usage);
}
next.plugins = { ...structuredClone(next.plugins ?? {}), ...structuredClone(sets) };
for (const kind of removals) delete next.plugins[kind];
const receipt = writer.write(id, next, { revision: args.revision, actor });
return { ...read(id), transaction: receipt.transaction };
}
return { writer, read, set };
}
// Old path of packages/organization/src/plugin-settings.mjs (rename design r2, stage B).
export * from "../../organization/src/plugin-settings.mjs";
+2 -79
View File
@@ -1,79 +1,2 @@
// Lifecycle Task 2 (#797 / Gitea #1839): what an exact project addition looks like. The organization
// writer's addProject calls additionProblem on the document it is about to publish, and again on the
// bytes it will write. The bootstrap executor names the paths it reserves with managedPaths, so the
// writer and the executor cannot disagree about the layout.
import { lstatSync, readFileSync } from "node:fs";
import { isAbsolute, join, normalize } from "node:path";
import { canonicalJson } from "./util.mjs";
import { ID_PATTERN } from "./vocabulary.mjs";
export const ADDITION_VERB = "project.add";
const RECEIPT_KEYS = ["data_root", "plan_digest", "project", "request"];
const ENTRY_KEYS = ["remote", "repo", "root"];
const plain = x => x !== null && typeof x === "object" && !Array.isArray(x);
const REQUEST_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/;
// A managed product's code checkout and its records repository, from the data root alone.
export function managedPaths({ dataRoot, business, project }) {
return { root: join(dataRoot, "products", business, project, "code"), repo: join(dataRoot, "projects", business, project) };
}
// null when `next` is `old` plus exactly the planned `projects[project]` entry, else the reason.
export function additionProblem({ old, next, project, entry, receipt }) {
if (typeof project !== "string" || !ID_PATTERN.test(project)) return "project key is not an id";
if (!plain(receipt) || Object.keys(receipt).sort().join() !== RECEIPT_KEYS.join()) return "receipt has the wrong keys";
if (typeof receipt.request !== "string" || receipt.request.length > 160 || !REQUEST_ID.test(receipt.request)) return "receipt request is not an id";
if (typeof receipt.plan_digest !== "string" || !/^[a-f0-9]{64}$/.test(receipt.plan_digest)) return "receipt plan digest is not a digest";
if (receipt.project !== project) return "receipt names another project";
if (typeof receipt.data_root !== "string" || !isAbsolute(receipt.data_root) || normalize(receipt.data_root) !== receipt.data_root) return "receipt data root is not absolute";
if (!plain(entry) || Object.keys(entry).some(k => !ENTRY_KEYS.includes(k))) return "entry holds more than root, repo and remote";
if (typeof entry.root !== "string" || typeof entry.repo !== "string" || ("remote" in entry && typeof entry.remote !== "string")) return "entry root and repo are required strings";
if (!plain(old?.projects) || !plain(next?.projects)) return "business has no project mapping";
const expected = managedPaths({ dataRoot: receipt.data_root, business: old.id, project });
if (entry.root !== expected.root || entry.repo !== expected.repo) return "entry is not the managed product layout";
if (Object.hasOwn(old.projects, project)) return "the business already declares this project";
if (canonicalJson(next.projects) !== canonicalJson({ ...old.projects, [project]: entry })) return "mapping differs from the old mapping plus the entry";
const { projects: _next, ...rest } = next, { projects: _old, ...was } = old;
if (canonicalJson(rest) !== canonicalJson(was)) return "a key outside projects changed";
return null;
}
// The product marker, <dataRoot>/products/<business>/<project>/bootstrap.json. The bootstrap executor writes it
// `reserved` before the mapping exists and `complete` once every step before the activation passed, just before the
// bus admits the project, so a process that cannot read the request journal (the launcher and the boot config) can
// tell a half-built product from a finished one. `complete` alone is not admission: the bus also needs the
// committed activate receipt (#802).
const MARKER_STATES = ["reserved", "complete"];
export const markerFile = ({ dataRoot, business, project }) => join(dataRoot, "products", business, project, "bootstrap.json");
export function readMarker(file) {
let bytes;
try {
if (!lstatSync(file).isFile()) return { state: "invalid" };
bytes = readFileSync(file, "utf8");
} catch (e) {
if (e.code === "ENOENT") return null;
return { state: "invalid" };
}
try {
const m = JSON.parse(bytes);
const keys = Object.keys(m ?? {}).sort().join();
if (plain(m) && keys === "plan_digest,project,request,state,version" && m.version === 1 && MARKER_STATES.includes(m.state) &&
typeof m.project === "string" && typeof m.request === "string" && /^[a-f0-9]{64}$/.test(m.plan_digest)) return m;
} catch { /* falls through */ }
return { state: "invalid" };
}
export const markerBytes = ({ state, request, plan_digest, project }) =>
JSON.stringify({ version: 1, state, request, plan_digest, project }, null, 2) + "\n";
// Whether an entry is a managed product is a fact of its layout, never of the marker: the mapping a bootstrap
// published sits at managedPaths, and the organization writer refuses any other writer that tries to put an entry
// there. So 'unmanaged' only for an entry off that layout; 'complete' only for a readable, matching `complete`
// marker; 'reserved' for everything else at the layout, including a product whose marker is missing or unreadable.
// Neither state means admitted: the bus admits a managed product only on its committed activation receipt, and
// the launcher asks the bus before it clones one.
export function managedState({ dataRoot, business, project, entry }) {
const here = managedPaths({ dataRoot, business, project });
if (!plain(entry) || entry.root !== here.root || entry.repo !== here.repo) return "unmanaged";
const marker = readMarker(markerFile({ dataRoot, business, project }));
return marker?.state === "complete" && marker.project === project ? "complete" : "reserved";
}
// Old path of packages/organization/src/project-bootstrap.mjs (rename design r2, stage B).
export * from "../../organization/src/project-bootstrap.mjs";
+2 -31
View File
@@ -1,31 +1,2 @@
// Narrow, offline validation for a first repository URL. Never include the supplied
// URL in a refusal: it may contain credentials.
import { parseGiteaUrl } from './vocabulary.mjs';
import { canonicalJson } from './util.mjs';
export function remoteTarget(document, project, remote) {
const reject = code => { const error = new Error(code); error.code = code; throw error; };
if (typeof project !== 'string' || !Object.hasOwn(document.projects ?? {}, project)) reject('project-remote-unknown');
const entry = document.projects[project];
if (entry.kind === 'library') reject('project-remote-library');
if (Object.hasOwn(entry, 'remote')) reject('project-remote-configured');
if (!parseGiteaUrl(remote, { repo: true }).url) reject('project-remote-invalid');
}
export function remoteProblem(old, next, project, remote) {
try { remoteTarget(old, project, remote); } catch (error) { return error.code; }
const expected = { ...old, projects: { ...old.projects, [project]: { ...old.projects[project], remote } } };
return canonicalJson(expected) === canonicalJson(next) ? null : 'project-remote-protected';
}
export function getProjectRemote(writer, business, project) {
const { document, revision } = writer.read(business);
if (typeof project !== 'string' || !Object.hasOwn(document.projects ?? {}, project)) {
const error = new Error('project-remote-unknown'); error.code = error.message; throw error;
}
const entry = document.projects[project];
if (entry.kind === 'library') {
const error = new Error('project-remote-library'); error.code = error.message; throw error;
}
return { project, remote: entry.remote ?? null, revision, firstSaveAllowed: !Object.hasOwn(entry, 'remote') };
}
// Old path of packages/organization/src/project-remotes.mjs (rename design r2, stage B).
export * from "../../organization/src/project-remotes.mjs";
+2 -36
View File
@@ -1,36 +1,2 @@
// The project file, <root>/.mosaic/project.json. It lives in the project's
// repository and changes by reviewed commits there. The stack only reads it.
import { isAbsolute, join, normalize } from "node:path";
import { refuse } from "./errors.mjs";
import { checkVars } from "./vars.mjs";
import { requireObject, rejectUnknownKeys, requireId, readJsonFile, deepFreeze } from "./util.mjs";
export function projectFilePath(root) {
return join(root, ".mosaic", "project.json");
}
export function validateProjectDocument(document, file) {
requireObject(document, "project file");
rejectUnknownKeys(document, ["projectVersion", "id", "vars", "roles"], "project file");
if (document.projectVersion !== 1) refuse(`project file "projectVersion" must be 1 (${file})`);
requireId(document.id, "project id");
const vars = checkVars(document.vars ?? {}, "project", `${file} vars`);
const roles = {};
if (document.roles !== undefined) {
requireObject(document.roles, `${file} roles`);
for (const [instance, entry] of Object.entries(document.roles)) {
requireId(instance, `${file} role instance`);
requireObject(entry, `${file} roles.${instance}`);
rejectUnknownKeys(entry, ["vars"], `${file} roles.${instance}`);
roles[instance] = { vars: checkVars(entry.vars ?? {}, "project", `${file} roles.${instance}.vars`) };
}
}
return deepFreeze({ projectVersion: 1, id: document.id, file, vars, roles });
}
export function loadProject(root) {
if (typeof root !== "string" || !isAbsolute(root) || normalize(root) !== root) refuse(`project root must be a normalized absolute path (got ${JSON.stringify(root)})`);
const file = projectFilePath(root);
return validateProjectDocument(readJsonFile(file, "project file"), file);
}
// Old path of packages/organization/src/project.mjs (rename design r2, stage B).
export * from "../../organization/src/project.mjs";
+2 -117
View File
@@ -1,117 +1,2 @@
// The resolver (REQ-VAR-1 and 2). It joins one role instance's definition,
// the business file, the optional project file and the system config into
// the record a launcher or the broker uses. Plain values: the most specific
// layer wins. Limits: every layer narrows, nothing widens.
import { refuse } from "./errors.mjs";
import { ACTIONS, NETWORKS, isHostKind } from "./vocabulary.mjs";
import { checkVars, mergeVars } from "./vars.mjs";
import { canonicalJson, sha256, deepFreeze } from "./util.mjs";
import { projectFilePath } from "./project.mjs";
// The system layer from `mosaic-config.mjs validate` output.
export function systemVars(config) {
return checkVars({
environment: config.environment,
dataRoot: config.dataRoot,
"execution.backend": config.execution.backend,
"execution.provider": config.execution.provider,
"execution.model": config.execution.model,
"execution.adapter": config.execution.adapter,
}, "system", "system config");
}
function narrowerNetwork(a, b) {
return NETWORKS.indexOf(a) <= NETWORKS.indexOf(b) ? a : b;
}
// resolveInstance({ system, business, project, instance })
// system systemVars(...) output
// business loadBusiness(...) output
// project loadProject(...) output, or null
// instance a role instance the business file declares
export function resolveInstance({ system, business, project = null, instance }) {
const entry = Object.hasOwn(business.roles, instance) ? business.roles[instance] : null;
if (!entry) refuse(`business ${business.id} declares no role instance ${JSON.stringify(instance)}`);
const definition = business.definitions[entry.definition];
const layers = [
{ layer: "system", source: "system", vars: system },
{ layer: "business", source: `business:${business.id}`, vars: business.vars },
];
let projectId = null;
if (project) {
const declared = Object.entries(business.projects).find(([, p]) => p.kind !== 'library' && projectFilePath(p.root) === project.file);
if (!declared || declared[0] !== project.id) {
refuse(`project ${project.id} (${project.file}) isn't declared under that id in business ${business.id}`);
}
projectId = project.id;
for (const name of Object.keys(project.roles)) {
if (!Object.hasOwn(business.roles, name)) refuse(`project ${project.id} sets vars for role instance ${name}, which business ${business.id} doesn't declare`);
}
layers.push({ layer: "project", source: `project:${project.id}`, vars: project.vars });
if (Object.hasOwn(project.roles, instance)) {
layers.push({ layer: "project", source: `project:${project.id}:roles.${instance}`, vars: project.roles[instance].vars });
}
}
layers.push({ layer: "agent", source: `business:${business.id}:roles.${instance}`, vars: entry.vars });
const { vars, provenance } = mergeVars(layers);
let tools = [...definition.tools];
let network = definition.network;
let withinRole = [...definition.authority.withinRole];
let crossRole = [...definition.authority.crossRole];
if (vars["limits.tools"]) tools = tools.filter((t) => vars["limits.tools"].includes(t));
if (vars["limits.network"]) network = narrowerNetwork(network, vars["limits.network"]);
if (vars["limits.authority"]) {
withinRole = withinRole.filter((a) => vars["limits.authority"].includes(a));
crossRole = crossRole.filter((a) => vars["limits.authority"].includes(a));
}
// role.launch needs the business file's launch block naming this
// instance (addendum A section 8), and the verb must survive
// limits.authority. Otherwise the verb is gated and `launch` is null,
// so the two never disagree.
const launch = business.launch && business.launch.by === instance && withinRole.includes("role.launch") ? business.launch : null;
if (!launch) {
withinRole = withinRole.filter((a) => a !== "role.launch");
crossRole = crossRole.filter((a) => a !== "role.launch");
}
const resolved = {
business: business.id,
project: projectId,
instance,
definition: definition.name,
holder: entry.holder,
contract: definition.contractPath,
vars,
provenance,
limits: { tools, network, authority: { withinRole, crossRole } },
// Row 210 (#1707, design D5): a host key is used only by the bus child, never by the session,
// so it stays out of the session's credentials and the harness's needs.
credentials: Object.fromEntries(Object.entries(entry.credentials).filter(([kind]) => !isHostKind(kind))),
tracker: entry.tracker,
launch,
// #797 relay R2: an owner-started assistant cannot infer its coordinator
// from launchedBy or running claims. Only the validated business chooses
// this address; it grants no launch authority or messaging exception.
...(definition.name === "assistant" ? { coordinator: business.launch ? {
instance: business.launch.by,
name: business.roles[business.launch.by].identity?.name ?? null,
} : null } : {}),
// REQ-HARN-2: the search-source plug-in the organization file switches on, by name.
webSource: business.plugins?.["search-source"] ?? null,
// The role file's skill names. The bundle resolves each against <dataRoot>/skills-enabled.
skills: [...definition.skills],
};
return deepFreeze({ ...resolved, digest: sha256(canonicalJson(resolved)) });
}
// Classify one action for a resolved instance: "within", "cross" or
// "gated". An action outside the vocabulary refuses.
export function classify(resolved, action) {
if (!ACTIONS.includes(action)) refuse(`unknown action: ${JSON.stringify(action)}`);
if (resolved.limits.authority.withinRole.includes(action)) return "within";
if (resolved.limits.authority.crossRole.includes(action)) return "cross";
return "gated";
}
// Old path of packages/organization/src/resolve.mjs (rename design r2, stage B).
export * from "../../organization/src/resolve.mjs";
+2 -232
View File
@@ -1,232 +1,2 @@
// Role definitions, roles/<name>.json. Version 2 (REQ-ROLE-1) adds a
// contract, an authority map over the closed vocabulary and the
// credentials the role needs, and keeps version 1's tool and network
// ceilings. Version 1 files still load: they carry no authority, so every
// vocabulary action is gated for them.
import { existsSync, lstatSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import { refuse } from "./errors.mjs";
import {
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, HOST_SERVICE, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
CREATIVE_DEFINITIONS, CREATIVE_TOOLS, CREATIVE_ACTIONS, CREATIVE_SERVICE, unhireableAction,
} from "./vocabulary.mjs";
import {
requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze,
} from "./util.mjs";
const CONTRACT_NAME = /^[a-z0-9][a-z0-9._-]{0,60}\.md$/;
const V1_KEYS = ["roleVersion", "name", "tools", "network"];
const V2_REQUIRED = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
const V2_KEYS = [...V2_REQUIRED, "messaging", "skills", "hireable"];
const SKILL_NAME = /^[A-Za-z0-9._-]+$/;
function checkTools(tools) {
if (!Array.isArray(tools) || tools.length === 0) refuse('role "tools" must be a non-empty array of tool names');
return requireDistinctList(tools, "role tools", (tool) => {
if (!TOOLS.includes(tool)) refuse(`unsupported tool: ${JSON.stringify(tool)} (supported: ${TOOLS.join(", ")})`);
});
}
function checkNetwork(network) {
if (!NETWORKS.includes(network)) refuse(`role "network" must be one of: ${NETWORKS.join(", ")}`);
return network;
}
function checkAuthority(authority) {
requireObject(authority, 'role "authority"');
rejectUnknownKeys(authority, ["withinRole", "crossRole"], 'role "authority"');
const lists = {};
for (const key of ["withinRole", "crossRole"]) {
lists[key] = requireDistinctList(authority[key], `role authority.${key}`, (action) => {
if (!ACTIONS.includes(action)) refuse(`unknown action in authority.${key}: ${JSON.stringify(action)}`);
if (GATED_ONLY.includes(action)) refuse(`authority.${key} lists ${action}, which is always gated`);
});
}
const both = lists.withinRole.filter((a) => lists.crossRole.includes(a));
if (both.length > 0) refuse(`action listed as both withinRole and crossRole: ${both.join(", ")}`);
return lists;
}
// REQ-ASSIST-2. Optional: a role with no "messaging" key may message anyone and be messaged by
// anyone the broker's other rules allow. With it, the broker refuses a send to a role outside
// sendTo and a send from a role outside receiveFrom. Entries name role definitions, since a role
// file can't know a business's instance names; "human" names the user. The tester is never a
// role, so it can't be listed.
function checkMessaging(messaging) {
requireObject(messaging, 'role "messaging"');
rejectUnknownKeys(messaging, ["sendTo", "receiveFrom"], 'role "messaging"');
const lists = {};
for (const key of ["sendTo", "receiveFrom"]) {
lists[key] = requireDistinctList(messaging[key], `role messaging.${key}`, (entry) => {
requireId(entry, `role messaging.${key} entry`);
if (entry === "tester") refuse(`messaging.${key} lists tester, which is never a role`);
});
}
return lists;
}
// Optional: the skills a session of this role gets, by their directory name under
// <dataRoot>/skills-enabled. "." and ".." match the pattern but name no skill directory.
function checkSkills(skills) {
return requireDistinctList(skills, "role skills", (name) => {
if (typeof name !== "string" || !SKILL_NAME.test(name) || name === "." || name === "..") {
refuse(`role skill must be a skill directory name matching ${SKILL_NAME} (got ${JSON.stringify(name)})`);
}
});
}
// Row 194 (#1692, D2.3): unhireableAction is vocabulary.mjs's, exported here as before.
export { unhireableAction };
// Row 194 (#1692, D2.3): optional. The role definitions this role may hire, by name. Each one must
// be a version 2 file in the same directory whose every action is on HIRE_ALLOWED. The named file
// is validated without its own "hireable" check, so a list can't recurse. A creative definition
// (row 213, M10) is never hireable: only the human adds one.
function checkHireable(hireable, file) {
const dir = dirname(file);
return requireDistinctList(hireable, "role hireable", (name) => {
requireId(name, "role hireable entry");
if (CREATIVE_DEFINITIONS.includes(name)) refuse(`hireable names ${name}, a creative definition only the human adds`);
const path = join(dir, `${name}.json`);
if (!existsSync(path)) refuse(`hireable names ${name}, which has no role file`);
const named = validateRoleDocument(readJsonFile(path, "role file"), path, { hireable: false });
if (named.roleVersion !== 2) refuse(`hireable names ${name}, which is role version 1`);
const outside = unhireableAction(named);
if (outside !== null) refuse(`hireable names ${name}, which holds ${outside}`);
});
}
function checkGiteaScopes(scopes) {
const list = requireDistinctList(scopes, "gitea scopes", (scope) => {
const m = typeof scope === "string" ? /^(read|write):([a-z]+)$/.exec(scope) : null;
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2])) {
refuse(`unsupported gitea scope: ${JSON.stringify(scope)} (expected read:<category> or write:<category>; categories: ${GITEA_SCOPE_CATEGORIES.join(", ")})`);
}
}, { nonEmpty: true });
const categories = list.map((s) => s.split(":")[1]);
const twice = categories.filter((c, i) => categories.indexOf(c) !== i);
if (twice.length > 0) refuse(`gitea scopes name ${twice[0]} twice; a token holds one level per category`);
return list;
}
function checkVikunjaScopes(scopes) {
requireObject(scopes, "vikunja scopes");
if (Object.keys(scopes).length === 0) refuse("vikunja scopes must name at least one route group");
const out = {};
for (const [group, verbs] of Object.entries(scopes)) {
const allowed = VIKUNJA_GRANTABLE[group];
if (!allowed) refuse(`vikunja route group not grantable to a role: ${JSON.stringify(group)}`);
out[group] = requireDistinctList(verbs, `vikunja scopes.${group}`, (verb) => {
if (!allowed.includes(verb)) refuse(`vikunja verb not grantable to a role: ${group}.${JSON.stringify(verb)}`);
}, { nonEmpty: true });
}
return out;
}
function checkCredentials(credentials) {
if (!Array.isArray(credentials)) refuse('role "credentials" must be an array');
const seen = new Set();
return credentials.map((entry, i) => {
requireObject(entry, `role credentials[${i}]`);
// Row 210 (#1707, design D1): "host" takes no scopes; the business file names the hosts.
const host = entry.service === HOST_SERVICE;
rejectUnknownKeys(entry, host ? ["service"] : ["service", "scopes"], `role credentials[${i}]`);
if (!host && !SERVICES.includes(entry.service)) {
refuse(`role credentials[${i}].service must be one of: ${[...SERVICES, HOST_SERVICE].join(", ")}`);
}
if (seen.has(entry.service)) refuse(`role credentials name ${entry.service} twice`);
seen.add(entry.service);
if (host) return { service: HOST_SERVICE };
const scopes = entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes);
return { service: entry.service, scopes };
});
}
function checkContract(contract, file) {
if (typeof contract !== "string" || !CONTRACT_NAME.test(contract)) {
refuse(`role "contract" must be a Markdown file name in the role's directory, matching ${CONTRACT_NAME} (got ${JSON.stringify(contract)})`);
}
const path = join(dirname(file), contract);
let stat;
try {
stat = lstatSync(path);
} catch {
refuse(`role contract not found: ${path}`);
}
if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0) refuse(`role contract must be a non-empty regular file: ${path}`);
return path;
}
// Row 213 (C2-08, Case 2 design 4, M1): a creative role only reads and answers the human. Its file
// may name no tool outside CREATIVE_TOOLS, and must carry a messaging key that names only the human,
// so it can't relay through another role. Row 285 (C2-08b, mos-1309 item 5) lets it write supporting
// docs: its authority is withinRole CREATIVE_ACTIONS at most, and its one credential is gitea.
function checkCreative(role) {
const wider = role.tools.filter((tool) => !CREATIVE_TOOLS.includes(tool));
if (wider.length > 0) refuse(`creative role ${role.name} may not name tools ${wider.join(", ")} (allowed: ${CREATIVE_TOOLS.join(", ")})`);
if (role.authority.crossRole.length > 0 || role.authority.withinRole.some((a) => !CREATIVE_ACTIONS.includes(a))) {
refuse(`creative role ${role.name} may hold only withinRole ${CREATIVE_ACTIONS.join(" and ")}`);
}
const services = role.credentials.map((c) => c.service).filter((service) => service !== CREATIVE_SERVICE);
if (services.length > 0) refuse(`creative role ${role.name} may hold only the ${CREATIVE_SERVICE} credential (got ${services.join(", ")})`);
if (role.messaging === null) refuse(`creative role ${role.name} requires "messaging"`);
for (const key of ["sendTo", "receiveFrom"]) {
if (role.messaging[key].some((entry) => entry !== "human")) {
refuse(`creative role ${role.name} messaging.${key} may name only human`);
}
}
return role;
}
// Validate a parsed role document read from `file`. Returns a frozen role:
// { roleVersion, name, title, contract, contractPath, tools, network,
// authority: { withinRole, crossRole }, credentials: [{ service, scopes }],
// messaging: { sendTo, receiveFrom } | null, skills: [name], hireable: [name] }.
// options.hireable false skips the hireable list's checks of other files (D2.3) and keeps the list.
export function validateRoleDocument(document, file, options = {}) {
requireObject(document, "role");
if (document.roleVersion !== 1 && document.roleVersion !== 2) refuse('role "roleVersion" must be 1 or 2');
rejectUnknownKeys(document, document.roleVersion === 1 ? V1_KEYS : V2_KEYS, "role");
requireId(document.name, "role name");
const base = basename(file).replace(/\.json$/, "");
if (document.name !== base) refuse(`role "name" (${document.name}) must match its filename (${base}.json)`);
const tools = checkTools(document.tools);
if (document.roleVersion === 1) {
if (CREATIVE_DEFINITIONS.includes(document.name)) refuse(`creative role ${document.name} must be version 2`);
const network = document.network === undefined ? "none" : checkNetwork(document.network);
return deepFreeze({
roleVersion: 1, name: document.name, title: null, contract: null, contractPath: null, tools, network,
authority: { withinRole: [], crossRole: [] }, credentials: [], messaging: null, skills: [], hireable: [],
});
}
for (const key of V2_REQUIRED) {
if (document[key] === undefined) refuse(`role version 2 requires "${key}"`);
}
const role = {
roleVersion: 2,
name: document.name,
title: requireString(document.title, 'role "title"', { max: 80 }),
contract: document.contract,
contractPath: checkContract(document.contract, file),
tools,
network: checkNetwork(document.network),
authority: checkAuthority(document.authority),
credentials: checkCredentials(document.credentials),
messaging: document.messaging === undefined ? null : checkMessaging(document.messaging),
skills: document.skills === undefined ? [] : checkSkills(document.skills),
hireable: document.hireable === undefined ? []
: options.hireable === false ? requireDistinctList(document.hireable, "role hireable", (name) => requireId(name, "role hireable entry"))
: checkHireable(document.hireable, file),
};
return deepFreeze(CREATIVE_DEFINITIONS.includes(role.name) ? checkCreative(role) : role);
}
export function loadRoleFile(file) {
return validateRoleDocument(readJsonFile(file, "role file"), file);
}
export function loadRole(rolesDir, name) {
requireId(name, "role name");
return loadRoleFile(join(rolesDir, `${name}.json`));
}
// Old path of packages/organization/src/role.mjs (rename design r2, stage B).
export * from "../../organization/src/role.mjs";
+2 -54
View File
@@ -1,54 +1,2 @@
// Organization selector options (rename design r2, "Selector scopes and parser matrix").
// scripts/selector.sh is the shell half and keeps the same rules; packages/business/tests/
// selector.test.mjs runs one table against both.
//
// --org and --business are one option family, and --org-file and --business-file are another.
// A scope is one parser's own options: the parser that knows which tokens its other options
// consume calls take() for a selector token it meets, so a value or positional text that happens
// to read "--org" never reaches this file. Within one scope each spelling may appear once, and a
// second spelling must carry exactly the same value. A missing value, an empty one, one that
// starts with "-", an invalid one (by the parser's own predicate) and differing values refuse
// through the parser's own refuse function, so each entry point keeps its exit code and wording.
// The file family compares the strings given; its paths are checked later under existing rules.
export const SELECTOR_FAMILIES = Object.freeze({
id: Object.freeze({ canonical: '--business', spellings: Object.freeze(['--business', '--org']) }),
file: Object.freeze({ canonical: '--business-file', spellings: Object.freeze(['--business-file', '--org-file']) }),
});
// Each refusal names one of these kinds, so a parser can map it to its own usage error.
export const SELECTOR_REFUSALS = Object.freeze(['missing', 'flag', 'repeated', 'invalid', 'differ']);
export function isSelector(token, family = 'id') {
return SELECTOR_FAMILIES[family]?.spellings.includes(token) ?? false;
}
// refuse(kind, message) must throw; take() throws its own error if it returns.
export function selectorScope({ family = 'id', valid = null, refuse }) {
const spec = SELECTOR_FAMILIES[family];
if (!spec) throw new TypeError(`unknown selector family ${family}`);
if (typeof refuse !== 'function') throw new TypeError('selectorScope needs a refuse function');
const seen = [];
let value;
const fail = (kind, message) => {
refuse(kind, message);
throw new Error(`selector refusal returned: ${message}`);
};
return Object.freeze({
family,
canonical: spec.canonical,
owns: token => spec.spellings.includes(token),
take(flag, given) {
if (!spec.spellings.includes(flag)) throw new TypeError(`${flag} is not a ${family} selector`);
if (seen.includes(flag)) fail('repeated', `${flag} given twice`);
if (typeof given !== 'string' || given === '') fail('missing', `${flag} needs one value`);
if (given.startsWith('-')) fail('flag', `${flag} needs one value, not ${given}`);
if (valid && !valid(given)) fail('invalid', `${flag} ${given} is not valid`);
if (seen.length && given !== value) fail('differ', `${seen[0]} ${value} and ${flag} ${given} conflict`);
seen.push(flag);
value = given;
},
get value() { return value; },
get given() { return seen.length > 0; },
});
}
// Old path of packages/organization/src/selector.mjs (rename design r2, stage B).
export * from "../../organization/src/selector.mjs";
+2 -109
View File
@@ -1,109 +1,2 @@
import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { refuse } from "./errors.mjs";
import { ID_PATTERN } from "./vocabulary.mjs";
export function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function requireObject(value, where) {
if (!isPlainObject(value)) refuse(`${where} must be a JSON object`);
return value;
}
export function rejectUnknownKeys(object, allowed, where) {
for (const key of Object.keys(object)) {
if (!allowed.includes(key)) refuse(`unsupported ${where} key: ${JSON.stringify(key)}`);
}
}
export function requireId(value, where) {
if (typeof value !== "string" || !ID_PATTERN.test(value)) {
refuse(`${where} must match ${ID_PATTERN} (got ${JSON.stringify(value)})`);
}
return value;
}
export function requireString(value, where, { max = 200 } = {}) {
if (typeof value !== "string" || value.trim().length === 0 || value.length > max || value.includes("\0")) {
refuse(`${where} must be a non-empty string of at most ${max} characters`);
}
return value;
}
export function requirePositiveInt(value, where) {
if (!Number.isSafeInteger(value) || value < 1) refuse(`${where} must be a positive integer (got ${JSON.stringify(value)})`);
return value;
}
// A list of distinct strings, each checked by `check`.
export function requireDistinctList(value, where, check, { nonEmpty = false } = {}) {
if (!Array.isArray(value)) refuse(`${where} must be an array`);
if (nonEmpty && value.length === 0) refuse(`${where} must not be empty`);
const seen = new Set();
for (const item of value) {
check(item);
if (seen.has(item)) refuse(`duplicate entry in ${where}: ${JSON.stringify(item)}`);
seen.add(item);
}
return [...seen];
}
// Calendar date YYYY-MM-DD that exists (2026-02-30 refuses).
export function requireDate(value, where) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) refuse(`${where} must be a date YYYY-MM-DD (got ${JSON.stringify(value)})`);
const d = new Date(`${value}T00:00:00Z`);
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value) refuse(`${where} is not a real date: ${value}`);
return value;
}
// Read a JSON file that must be a regular file, not a symbolic link.
// Missing or not a regular file is 4; unparseable is 2. One descriptor,
// opened without following a link, serves every check and the read.
// `checkStat` sees that descriptor's stat before the read, so the file
// can't be swapped between the check and the read.
export function readJsonFile(file, what, checkStat) {
let fd;
try {
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
} catch (error) {
if (error.code === "ENOENT") refuse(`${what} not found: ${file}`, 4);
refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4);
}
let text;
try {
const stat = fstatSync(fd);
if (!stat.isFile()) refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4);
checkStat?.(stat);
text = readFileSync(fd, "utf8");
} finally {
closeSync(fd);
}
try {
return JSON.parse(text);
} catch (error) {
refuse(`${what} is not valid JSON (${file}): ${error.message}`);
}
}
// JSON with object keys sorted at every level, for digests.
export function canonicalJson(value) {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (isPlainObject(value)) {
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
}
return JSON.stringify(value);
}
export function sha256(text) {
return createHash("sha256").update(text).digest("hex");
}
export function deepFreeze(value) {
if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
Object.freeze(value);
for (const v of Object.values(value)) deepFreeze(v);
}
return value;
}
// Old path of packages/organization/src/util.mjs (rename design r2, stage B).
export * from "../../organization/src/util.mjs";
+2 -223
View File
@@ -1,223 +1,2 @@
// The variable key registry (REQ-VAR-1, note section 2, addendum A section
// 7). Every key is declared here once, with its type, the layers allowed to
// set it and its merge rule. A key that isn't here refuses, and so does a
// key set at a layer it isn't allowed in.
//
// Layers, least to most specific:
// system ~/.config/mosaic-dev/config.json, resolved by mosaic-config.mjs
// business vars in businesses/<id>.json
// project vars in <root>/.mosaic/project.json, then that file's
// roles.<instance>.vars (still the project layer, applied after)
// agent roles.<instance>.vars in the business file
//
// Merge rules:
// replace the most specific layer that sets the key wins
// intersect every layer that sets the key narrows it; nothing widens
import { refuse } from "./errors.mjs";
import { ACTIONS, NETWORKS, TOOLS } from "./vocabulary.mjs";
import { isPlainObject, requireDistinctList, requirePositiveInt, requireString } from "./util.mjs";
import { NAME as MODULE_NAME } from "../../plugins/src/kinds.mjs";
export const LAYERS = Object.freeze(["system", "business", "project", "agent"]);
const BRANCH = /^(?!-)(?!.*\.\.)(?!.*\/\/)(?!.*\.lock$)[A-Za-z0-9._/-]{1,100}(?<![./])$/;
function string(max = 200) {
return (v, key) => requireString(v, `variable ${key}`, { max });
}
function oneOf(values) {
return (v, key) => {
if (!values.includes(v)) refuse(`variable ${key} must be one of: ${values.join(", ")} (got ${JSON.stringify(v)})`);
return v;
};
}
// A harness plug-in module's name, in the loader's pattern (row 65, REQ-PLUG-2). Whether the
// module exists or is switched on is checked at launch and at a write, never at load.
function moduleName(v, key) {
if (typeof v !== "string" || !MODULE_NAME.test(v)) refuse(`variable ${key} must be a harness module name (got ${JSON.stringify(v)})`);
return v;
}
function integer(min) {
return (v, key) => {
if (!Number.isSafeInteger(v) || v < min) refuse(`variable ${key} must be an integer of at least ${min} (got ${JSON.stringify(v)})`);
return v;
};
}
// http or https, no user info, query or fragment, no trailing slash.
function baseUrl(v, key) {
requireString(v, `variable ${key}`, { max: 300 });
let url;
try {
url = new URL(v);
} catch {
refuse(`variable ${key} must be an http or https URL (got ${JSON.stringify(v)})`);
}
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || v.endsWith("/")) {
refuse(`variable ${key} must be an http or https base URL with no credentials, query, fragment or trailing slash`);
}
return v;
}
// The SearXNG address: https, or http on loopback only, as the search code allows
// (discord/src/web.mjs loadWebConfig), so a bad address is refused when the file loads and not on
// every search.
function searxngUrl(v, key) {
baseUrl(v, key);
const url = new URL(v);
const loopback = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
if (url.protocol === "http:" && !loopback) refuse(`variable ${key} must be https, or http on loopback (got ${JSON.stringify(v)})`);
return v;
}
function branch(v, key) {
if (typeof v !== "string" || !BRANCH.test(v)) refuse(`variable ${key} must be a git branch name (got ${JSON.stringify(v)})`);
return v;
}
function list(check, { nonEmpty = true } = {}) {
return (v, key) => requireDistinctList(v, `variable ${key}`, (item) => check(item, `${key} entry`), { nonEmpty });
}
function boolean(v, key) {
if (typeof v !== "boolean") refuse(`variable ${key} must be true or false (got ${JSON.stringify(v)})`);
return v;
}
function absolutePath(v, key) {
if (typeof v !== "string" || !v.startsWith("/") || v.includes("\0")) refuse(`variable ${key} must be an absolute path`);
return v;
}
// Every level some harness applies; each module exports the ones it applies (row 65).
export const THINKING = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
export const FALLBACK_VARS = Object.freeze(["fallback.harness", "fallback.model", "fallback.thinking"]);
const def = (layers, check, extra = {}) => Object.freeze({ layers: Object.freeze(layers), merge: "replace", check, default: undefined, ...extra });
export const REGISTRY = Object.freeze({
// System. mosaic-config.mjs owns their validation; these checks only
// keep the resolver honest about types.
environment: def(["system"], string(64)),
dataRoot: def(["system"], absolutePath),
"execution.backend": def(["system"], string(64)),
"execution.provider": def(["system"], string(64)),
"execution.model": def(["system"], string(200)),
"execution.adapter": def(["system"], string(64)),
// Business.
"tracker.kind": def(["business"], oneOf(["vikunja"]), { default: "vikunja" }),
"tracker.baseUrl": def(["business"], baseUrl),
"tracker.reconcileMinutes": def(["business"], integer(1), { default: 60 }),
"tracker.pollSeconds": def(["business", "project"], integer(10), { default: 30 }),
"human.discordUserId": def(["business"], (v, key) => {
if (typeof v !== "string" || !/^[1-9][0-9]{16,19}$/.test(v)) refuse(`variable ${key} must be a Discord user id, 17 to 20 digits as a string`);
return v;
}),
"gitea.baseUrl": def(["business"], baseUrl),
// The SearXNG instance the "searxng-url" search-source plug-in queries (REQ-PLUG-2).
"web.searxng": def(["business"], searxngUrl),
// On: the bus binds a tester from tester-cli.mjs for PM tests (row 110). Read at bus start.
"bus.tester": def(["business"], boolean, { default: false }),
// Project.
"tracker.project": def(["project"], (v, key) => requirePositiveInt(v, `variable ${key}`)),
// On: the tracker project is the root of a tree, and sync reads its descendants too.
"tracker.descendants": def(["project"], boolean, { default: false }),
"git.workingBranch": def(["project"], branch),
"git.protectedBranches": def(["project"], list(branch)),
suites: def(["project"], list(string(300))),
"issues.repo": def(["project"], (v, key) => {
if (typeof v !== "string" || !/^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/.test(v)) refuse(`variable ${key} must be owner/name`);
return v;
}),
// Agent: one role instance. A managed session's route: the harness module, a launch.models key
// and a thinking level, which the harness module must apply (row 65). The fallback route is all
// three or none (FALLBACK_VARS).
harness: def(["agent"], moduleName),
model: def(["agent"], string(200)),
thinking: def(["agent"], oneOf(THINKING)),
"fallback.harness": def(["agent"], moduleName),
"fallback.model": def(["agent"], string(200)),
"fallback.thinking": def(["agent"], oneOf(THINKING)),
// Row 213 (C2-08, Case 2 design 4): a creative instance's persona. The name the user sees, one line,
// and the sha256 of <configDir>/personas/<business>/<instance>.md. The text stays out of the
// business file; the bundle reads the file at launch and refuses it off this digest.
"persona.name": def(["agent"], (v, key) => {
requireString(v, `variable ${key}`, { max: 40 });
if (/[\u0000-\u001f\u007f\u2028\u2029]/.test(v)) refuse(`variable ${key} must be one line with no control characters`);
return v;
}),
"persona.sha256": def(["agent"], (v, key) => {
if (typeof v !== "string" || !/^[0-9a-f]{64}$/.test(v)) refuse(`variable ${key} must be 64 lowercase hex characters`);
return v;
}),
// Limits narrow the role definition's ceilings. limits.authority is an
// allowlist: a role action it doesn't name becomes gated.
"limits.tools": def(["business", "project", "agent"], list((v, key) => {
if (!TOOLS.includes(v)) refuse(`variable ${key} names an unsupported tool: ${JSON.stringify(v)}`);
}, { nonEmpty: false }), { merge: "intersect" }),
"limits.network": def(["business", "project", "agent"], oneOf(NETWORKS), { merge: "intersect" }),
"limits.authority": def(["business", "project", "agent"], list((v, key) => {
if (!ACTIONS.includes(v)) refuse(`variable ${key} names an unknown action: ${JSON.stringify(v)}`);
}, { nonEmpty: false }), { merge: "intersect" }),
});
// Check one layer's vars object. `where` names the file and path for the
// message. Returns a frozen copy with checked values.
export function checkVars(vars, layer, where) {
if (!LAYERS.includes(layer)) throw new Error(`unknown layer ${layer}`);
if (!isPlainObject(vars)) refuse(`${where} must be a JSON object`);
const out = {};
for (const [key, value] of Object.entries(vars)) {
const entry = Object.hasOwn(REGISTRY, key) ? REGISTRY[key] : null;
if (!entry) refuse(`${where}: unknown variable ${JSON.stringify(key)}`);
if (!entry.layers.includes(layer)) refuse(`${where}: variable ${key} can't be set at the ${layer} layer (allowed: ${entry.layers.join(", ")})`);
out[key] = entry.check(value, key);
}
const fallback = FALLBACK_VARS.filter((key) => Object.hasOwn(out, key));
if (fallback.length > 0 && fallback.length < FALLBACK_VARS.length) {
refuse(`${where}: a fallback sets all of ${FALLBACK_VARS.join(", ")} or none (missing ${FALLBACK_VARS.filter((key) => !fallback.includes(key)).join(", ")})`);
}
return Object.freeze(out);
}
function narrower(a, b) {
return NETWORKS.indexOf(a) <= NETWORKS.indexOf(b) ? a : b;
}
// Merge checked layers, given least specific first as [{ layer, source,
// vars }]. Returns { vars, provenance }. provenance[key] is the source
// that set a replace key, or the list of sources that narrowed a limit.
export function mergeVars(layers) {
const vars = {};
const provenance = {};
for (const [key, entry] of Object.entries(REGISTRY)) {
if (entry.default !== undefined) {
vars[key] = entry.default;
provenance[key] = "default";
}
}
for (const { source, vars: layerVars } of layers) {
for (const [key, value] of Object.entries(layerVars)) {
const entry = REGISTRY[key];
if (entry.merge === "replace" || vars[key] === undefined) {
vars[key] = value;
provenance[key] = entry.merge === "replace" ? source : [source];
} else if (key === "limits.network") {
vars[key] = narrower(vars[key], value);
provenance[key].push(source);
} else {
vars[key] = vars[key].filter((item) => value.includes(item));
provenance[key].push(source);
}
}
}
return { vars, provenance };
}
// Old path of packages/organization/src/vars.mjs (rename design r2, stage B).
export * from "../../organization/src/vars.mjs";
+2 -374
View File
@@ -1,374 +1,2 @@
// The closed action vocabulary and the other frozen lists a role file is
// checked against. Design: agents/darkwing/work/slice1-data-model-2026-10-04.md
// section 1.4, addendum A sections 2 and 8, addendum B section 2.
//
// A change to any list here is a reviewed commit to this file. Role files
// that name something outside these lists are refused.
// Every action with an outside effect that slice 1 touches. Routine work
// (editing files, running tests, writing docs, retrying) has no outside
// effect and isn't listed.
export const ACTIONS = Object.freeze([
"task.create",
"project.bootstrap",
"task.assign",
"task.schedule",
"task.update.assigned",
"task.close",
"task.reassign",
"task.scope.change",
"task.priority.change",
"git.push.working",
"git.push.protected",
"git.merge.protected",
// Row 199 (#1697, mos-1027 Q-M2): the host pushes an agent's recorded work commit to the fork its
// business grants it. A hard wait under REQ-WAIT-3 until a PRD version routes it.
"git.push.remote",
// Row 210 (#1707, design D4): an agent runs a command with sudo on a host its business grants it.
// A hard wait under REQ-WAIT-3 until a PRD version routes it, so Jason decides each call.
"host.sudo",
"review.request",
"review.verdict",
"record.write",
"message.send",
"message.external",
"role.launch",
"role.revoke",
// Row 123 (#1617, REQ-COS-1): the chief of staff ends a run with a reason and no decision,
// and changes the organization file through the shared write path. Neither is gated-only,
// so the grant lives in a role file. No shipped role is granted role.revoke, so it stays gated.
"role.end",
"organization.change",
// Row 141 (#1635): sets a role instance's name. Routed under PRD 1.5 REQ-MSG-2, which has the chief of
// staff set names once its role file grants that within-role.
"instance.name",
// Row 194 (#1692): the chief of staff hires (adds and lists a role instance) and fires (ends its
// runs and delists it). Hard waits under PRD 1.6 (mos-1017), routed under PRD 1.7 REQ-COS-3.
"role.hire",
"role.fire",
"credential.mint",
"spend",
"deploy",
// PRD 1.5 REQ-WAIT-1 (row 139, #1633): new hard waits. data.delete deletes data; network.expose
// opens a listener, port, tunnel, DNS record or proxy route reachable from outside the host;
// account.create opens an external account.
"data.delete",
"network.expose",
"account.create",
"policy.change",
"prd.approve",
"decision.resolve.technical",
// Row 285 (C2-08b, #1790): a creative agent writes a supporting Markdown file in the human's library.
// Added as a hard wait and routed under PRD 1.16 REQ-WAIT-3 and REQ-LIB-6; role.mjs grants it within-role
// to the creative roles only.
"library.write",
]);
// The actions that are not hard waits. PRD 1.5 REQ-WAIT-3: an action added
// to ACTIONS is a hard wait until a PRD version says it isn't, so a new
// action joins this list only by the reviewed commit that names it routed.
// git.merge.protected, policy.change, prd.approve and role.revoke left the
// hard waits in 1.5 and route by REQ-DEC-2. instance.name (row 141) is routed
// under REQ-MSG-2 (1.5). role.hire and role.fire left the hard waits in 1.7 (REQ-COS-3).
export const ROUTED = Object.freeze([
"task.create",
"task.assign",
"task.schedule",
"task.update.assigned",
"task.close",
"task.reassign",
"task.scope.change",
"task.priority.change",
"git.push.working",
"git.merge.protected",
"review.request",
"review.verdict",
"record.write",
"message.send",
"role.launch",
"role.revoke",
"role.end",
"organization.change",
"instance.name",
"policy.change",
"prd.approve",
"decision.resolve.technical",
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
"role.hire",
"role.fire",
// PRD 1.16 REQ-WAIT-3 and REQ-LIB-6 (row 285, #1790): a creative agent's library write, within the
// bounds that paragraph names.
"library.write",
]);
// The hard waits (PRD 1.5 REQ-WAIT-1): always gated, always the human's,
// cited spec or not. A role file may not list these under withinRole or
// crossRole (REQ-WAIT-2). Today this is spend, data.delete, network.expose,
// account.create, credential.mint, message.external, git.push.protected,
// git.push.remote, host.sudo (row 210) and deploy; a test pins the list.
export const GATED_ONLY = Object.freeze(ACTIONS.filter((action) => !ROUTED.includes(action)));
// Row 194 (#1692, D2.3): the chief of staff hires only a definition whose every withinRole and
// crossRole action is on this list. An allowlist, so an action added to ACTIONS later is never
// hireable until a reviewed commit adds it here. git.push.remote (row 199) is not on it.
export const HIRE_ALLOWED = Object.freeze([
"message.send",
"record.write",
"review.request",
"task.update.assigned",
"task.scope.change",
"git.push.working",
]);
// Row 194 (#1692, D2.3): the first action of a definition's authority that isn't on
// HIRE_ALLOWED, or null when every one is. role.mjs's hireable check and the broker's hire check
// use it. It lives here because the session sandbox mounts this file and not role.mjs
// (seat/src/sandbox.mjs RUNNER_FILES), and the runner imports broker.mjs.
export function unhireableAction(role) {
for (const key of ["withinRole", "crossRole"]) {
const outside = role.authority[key].find((action) => !HIRE_ALLOWED.includes(action));
if (outside !== undefined) return outside;
}
return null;
}
// pi's documented built-in tools; the same list scripts/mosaic-task.mjs
// accepts for tasks.
export const BUILTIN_TOOLS = Object.freeze(["read", "write", "edit", "bash", "grep", "find", "ls"]);
// The role file's tool names: the built-ins plus "web", the grant for the broker's web tool
// (REQ-HARN-2). "web" is not a harness built-in; the bundle turns it into the typed tools
// web_search and web_fetch, and no harness built-in web tool is ever on.
export const WEB_GRANT = "web";
export const TOOLS = Object.freeze([...BUILTIN_TOOLS, WEB_GRANT]);
// Ordered from narrowest to widest. Intersecting two values keeps the
// narrower one.
export const NETWORKS = Object.freeze(["none", "api-only", "open"]);
export const SERVICES = Object.freeze(["gitea", "vikunja"]);
// Row 210 (#1707, design D1): a role definition declares {"service": "host"} once, and the business
// file binds each host. A host key's kind is host.<hostId>, one key per agent and host.
export const HOST_SERVICE = "host";
export const HOST_ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
export const HOST_PREFIX = "host.";
export const isHostKind = (name) =>
typeof name === "string" && name.startsWith(HOST_PREFIX) && HOST_ID.test(name.slice(HOST_PREFIX.length));
// Every place that checks a credential kind asks this, so the closed set has one definition.
export const credentialKind = (name) => SERVICES.includes(name) || isHostKind(name);
// Row 210 r4 (#1707): the broker checks argv with these. They sit here, not in hosts.mjs,
// because broker.mjs runs in the worker sandbox and reads only mounted files that need nothing else.
export const ARGV_MAX = 256;
export const ARG_BYTES = 4096;
export const ARGV_BYTES = 32768;
// D6: a call's argv. 1 to 256 strings, each 1 to 4096 bytes with no NUL, 32768 bytes in all, and
// argv[0] a bare name or an absolute path. Also the broker's check, at raise and at call.
export function validArgv(argv) {
if (!Array.isArray(argv) || argv.length === 0 || argv.length > ARGV_MAX) return false;
let total = 0;
for (const element of argv) {
if (typeof element !== "string" || element === "" || element.includes("\0")) return false;
const bytes = Buffer.byteLength(element);
if (bytes > ARG_BYTES) return false;
total += bytes;
}
return total <= ARGV_BYTES && (!argv[0].includes("/") || argv[0].startsWith("/"));
}
// True when argv starts with one of the grant's prefixes, element by element, literally.
export function commandAllowed(argv, commands) {
return commands.some((prefix) => prefix.length <= argv.length && prefix.every((element, i) => element === argv[i]));
}
// Gitea token scopes are <read|write>:<category>. "all" and the admin
// category are never granted to a role.
export const GITEA_SCOPE_CATEGORIES = Object.freeze([
"activitypub", "issue", "misc", "notification", "organization", "package", "repository", "user",
]);
// Vikunja 2.7.0 route groups and verbs a role token may hold. Each pair was
// minted or used on a scratch 2.7.0 (Researcher's probes, addendum B
// section 2). Anything not listed, including every verb addendum B lists
// as never granted, is refused. A pair missing here that a role really
// needs gets added after a probe shows Vikunja accepts it.
export const VIKUNJA_GRANTABLE = Object.freeze({
projects: Object.freeze(["read_one", "views_buckets", "views_buckets_tasks", "views_buckets_tasks_get"]),
projects_views: Object.freeze(["read_all"]),
tasks: Object.freeze(["read_all", "read_one", "create", "update"]),
tasks_comments: Object.freeze(["read_all", "create"]),
tasks_assignees: Object.freeze(["create", "delete"]),
tasks_relations: Object.freeze(["create", "delete"]),
tasks_labels: Object.freeze(["create", "delete"]),
labels: Object.freeze(["read_all"]),
});
export const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
// Row 121 (#1615): an instance of this definition talks with the user in threads (REQ-THREAD-1).
// Row 155 (#1649) moves it here from the broker, so the organization writer reads the same name.
export const THREAD_DEFINITION = "assistant";
// Row 213 (C2-08, #1714, Case 2 design 4): the creative roles. Each reads and answers the human, and
// row 285 (C2-08b, #1790) lets it write supporting docs; its instances are the human's alone (M10).
export const CREATIVE_DEFINITIONS = Object.freeze(["companion", "ideas", "research", "proofreader", "fact-checker", "continuity"]);
// Row 285 (mos-1309 item 5): the only authority a creative role file may name, all withinRole, and
// the one credential service it may name, the definition credential the Gitea grant path reads.
export const CREATIVE_ACTIONS = Object.freeze(["message.send", "library.write"]);
export const CREATIVE_SERVICE = "gitea";
// The only tools a creative role file may name (M1): reading, and the broker's web grant.
export const CREATIVE_TOOLS = Object.freeze(["read", "grep", "find", "ls", "web"]);
// The definitions that may hold a thread: the assistant and the creative roles. The definition the
// chief of staff may set stays THREAD_DEFINITION.
export const THREAD_DEFINITIONS = Object.freeze([THREAD_DEFINITION, ...CREATIVE_DEFINITIONS]);
// Row 139 (#1633), Jason's ruling Q169 (PRD 1.6): definitions only the chief of staff, or a decision
// the human resolved through the CLI, may end or revoke. Row 155 moves the list here, and no
// organization write may give an instance one of them, except the human's instances.set for a
// creative definition (row 213).
export const END_PROTECTED = THREAD_DEFINITIONS;
// Row 199 (#1697, design D9c): the Gitea URL parse. It lives here because the broker reads it and
// this file imports nothing. Gitea's name rule for users and repositories: letters, digits, "_", "."
// and "-", not "." or "..".
export const giteaName = (name) =>
typeof name === "string" && /^[A-Za-z0-9_.-]{1,100}$/.test(name) && name !== "." && name !== "..";
// An https URL that is already its own normalized form, so a dot segment, an encoded dot, userinfo, a
// query, a fragment, an upper-case host or an explicit :443 each fail, and the path checked is the
// path used. With repo, the path is exactly /OWNER/REPO (one ".git" stripped); without, it is "/".
// Returns {url, owner, repo}, or {problem} naming the rule that failed: form, instance or repository.
export function parseGiteaUrl(value, { repo = false } = {}) {
let url = null;
try {
url = typeof value === "string" ? new URL(value) : null;
} catch {
url = null;
}
const normal = url && (repo ? [url.origin + url.pathname] : [url.origin, url.origin + "/"]);
if (!url || url.protocol !== "https:" || !normal.includes(value)) return { problem: "form" };
if (!repo) return url.pathname === "/" ? { url } : { problem: "instance" };
const segments = url.pathname.split("/").slice(1);
const name = segments[1]?.endsWith(".git") ? segments[1].slice(0, -4) : segments[1];
if (segments.length !== 2 || !giteaName(segments[0]) || !giteaName(name)) return { problem: "repository" };
return { url, owner: segments[0], repo: name };
}
// Design D9 step 2: the agent's fork of an upstream, https://HOST[:PORT]/<user>/REPO, or null for an
// upstream that doesn't parse. The owner is always the agent's own Gitea user; nothing else names it.
// URL.host drops a default port, so 443 counts the same on both sides.
export function forkUrl(upstream, user) {
const { url, repo } = parseGiteaUrl(upstream, { repo: true });
return url ? `https://${url.host}/${user}/${repo}` : null;
}
// The refusal code for publishing `upstream` as an agent with `identity`, or null. Gitea compares
// names without case (lower_name), so the owner check does too. An upstream or gitea.url that doesn't
// parse is remote-not-granted: the business loader refuses both, so only a hand-built business gets
// here.
export function forkProblem(upstream, identity) {
const { url, owner } = parseGiteaUrl(upstream, { repo: true });
const instance = parseGiteaUrl(identity?.gitea?.url).url;
if (!url || !instance || typeof identity.gitea.user !== "string") return "remote-not-granted";
if (url.host !== instance.host) return "remote-host-mismatch";
if (owner.toLowerCase() === identity.gitea.user.toLowerCase()) return "remote-not-fork";
return null;
}
// FED R3 r5 (row 274, #414): the access grant levels, the all marker and covers(), from access.mjs,
// which re-exports them. They live here because the session sandbox mounts this file and not
// access.mjs (seat/src/sandbox.mjs RUNNER_FILES), and the runner imports broker.mjs, which checks a
// user's grant with covers().
// The grant levels, most to least (row 193a, #1691): a viewer reads threads shared with it and drives
// no instance. covers() relies on this order.
export const LEVELS = Object.freeze(["collaborator", "viewer"]);
// A grant's projects or instances list that is just this covers every one.
export const GRANT_ALL = "*";
// Does a checked grant cover a thread? `target` is the thread's business, bus
// project id and instance. `level` is required (row 193a, Duckworth's N1 on R1):
// it is the least level the action needs (LEVELS runs from most to least), so `viewer` asks for
// read and any grant covers it, while `collaborator` asks to drive the instance
// and a viewer grant never covers it. An expired grant covers nothing.
export function covers(grant, { business, project, instance }, now, { level } = {}) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError("covers needs a valid Date for now");
if (!LEVELS.includes(level)) throw new TypeError(`covers needs a level, one of ${LEVELS.join(", ")} (got ${JSON.stringify(level)})`);
if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;
if (grant.business !== business) return false;
if (!(grant.projects[0] === GRANT_ALL || grant.projects.includes(project))) return false;
if (!(grant.instances[0] === GRANT_ALL || grant.instances.includes(instance))) return false;
// A grant whose own level is unknown covers nothing, even if it skipped checkAccess.
if (!LEVELS.includes(grant.level) || LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;
return true;
}
// Row 298 (#1804, Q228): the chief of staff's grant scope, launch.grantScope in the organization
// file. Only the human writes it (grants.scope.set). A Gitea or host grant the enrolled chief raises
// inside it is issued with no per-grant Approve; anything else waits for the human as before. The
// broker and the writer read these, and they live here because the session sandbox mounts this file
// (seat/src/sandbox.mjs RUNNER_FILES) and the runner loads broker.mjs.
//
// Shape: { gitea?: { agents, projects }, host?: { agents, hosts, commands } }.
// Each list is distinct and nonempty. A commands entry covers a requested prefix equal to it,
// token for token and with equal length. No entry matches part of a token, so
// ["ls"] never covers ["lsblk"]. Enrollment (host-enroll) is never in scope. {} is the empty scope.
export const GRANT_SCOPE_EMPTY = Object.freeze({});
const SCOPE_IDS = (value) =>
Array.isArray(value) && value.length > 0 && value.length <= 256 &&
value.every((x) => typeof x === "string" && ID_PATTERN.test(x)) && new Set(value).size === value.length;
const SCOPE_ARGVS = (value) =>
Array.isArray(value) && value.length > 0 && value.length <= 256 && value.every(validArgv) &&
new Set(value.map((argv) => JSON.stringify(argv))).size === value.length;
const SCOPE_PLAIN = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
// The scope, rebuilt in a fixed key order so JSON.stringify of it is stable, or null when the value
// isn't one. The writer refuses null; the readers fall back (grantScopeOf).
export function checkGrantScope(value) {
if (!SCOPE_PLAIN(value) || Object.keys(value).some((k) => k !== "gitea" && k !== "host")) return null;
const out = {};
if (value.gitea !== undefined) {
const g = value.gitea;
if (!SCOPE_PLAIN(g) || Object.keys(g).some((k) => k !== "agents" && k !== "projects")) return null;
if (!SCOPE_IDS(g.agents) || !SCOPE_IDS(g.projects)) return null;
out.gitea = { agents: [...g.agents], projects: [...g.projects] };
}
if (value.host !== undefined) {
const h = value.host;
if (!SCOPE_PLAIN(h) || Object.keys(h).some((k) => !["agents", "hosts", "commands"].includes(k))) return null;
if (!SCOPE_IDS(h.agents) || !Array.isArray(h.hosts) || !h.hosts.every((x) => typeof x === "string" && HOST_ID.test(x)) || !SCOPE_IDS(h.hosts)) return null;
if (!SCOPE_ARGVS(h.commands)) return null;
out.host = {
agents: [...h.agents], hosts: [...h.hosts],
commands: h.commands.map((argv) => [...argv]),
};
}
return out;
}
// What the readers use: a missing key is the empty scope, and a bad value falls back to it, with
// fallback true. The empty scope puts every grant in front of the human, so a bad value fails closed.
export function grantScopeOf(value) {
if (value === undefined) return { scope: GRANT_SCOPE_EMPTY, fallback: false };
const scope = checkGrantScope(value);
return scope === null ? { scope: GRANT_SCOPE_EMPTY, fallback: true } : { scope, fallback: false };
}
// Whether a raise's grant body is inside a checked scope. Only gitea and host bodies can be.
export function grantInScope(scope, body) {
if (body?.method === "gitea") {
const g = scope.gitea;
return Boolean(g) && g.agents.includes(body.agent) &&
Array.isArray(body.target?.projects) && body.target.projects.length > 0 &&
body.target.projects.every((p) => g.projects.includes(p));
}
if (body?.method === "host") {
const h = scope.host;
const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
return Boolean(h) && h.agents.includes(body.agent) && h.hosts.includes(body.host) &&
Array.isArray(body.commands) && body.commands.length > 0 &&
body.commands.every((argv) =>
h.commands.some((c) => same(argv, c)));
}
return false;
}
// Old path of packages/organization/src/vocabulary.mjs (rename design r2, stage B).
export * from "../../organization/src/vocabulary.mjs";
+4 -108
View File
@@ -1,108 +1,4 @@
// Shared fixtures. Every test works in its own temporary directory and
// points MOSAIC_CONFIG there; nothing reads the real ~/.config.
import { chmodSync, cpSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
export const REPO_ROLES = join(REPO, "roles");
export const FAR_DATE = "2099-01-01";
export function tmp() {
return mkdtempSync(join(tmpdir(), "mosaic-business-"));
}
export function writeJson(file, value, mode = 0o600) {
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, typeof value === "string" ? value : `${JSON.stringify(value, null, 2)}\n`);
chmodSync(file, mode);
return file;
}
// A token file with a placeholder value; the package never reads it.
export function tokenFile(dir, name) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
const file = join(dir, name);
writeFileSync(file, "placeholder-not-a-token");
chmodSync(file, 0o600);
return file;
}
// A copy of the shipped role files in a scratch roles directory.
export function rolesCopy(root) {
const dir = join(root, "roles");
cpSync(REPO_ROLES, dir, { recursive: true });
return dir;
}
// A complete valid business document for the shipped roles, with
// token files under `<root>/secrets`, and a project root under `<root>`.
export function businessDoc(root, id = "acme") {
const secrets = join(root, "secrets");
const ref = (role, service) => service === "gitea"
? { file: tokenFile(secrets, `${role}-gitea.token`), rotateBy: FAR_DATE }
: { file: tokenFile(secrets, `${role}-vikunja.token`), expires: FAR_DATE };
const role = (definition, botId, extra = {}) => ({
definition,
tracker: { bot: `bot-${id}-${definition}`, botId },
credentials: { gitea: ref(definition, "gitea"), vikunja: ref(definition, "vikunja") },
...extra,
});
const projectRoot = join(root, "project");
mkdirSync(projectRoot, { recursive: true });
return {
businessVersion: 1,
id,
human: "jason",
arbiters: { delivery: "pm", technical: "cto" },
projects: { stack: { root: projectRoot } },
vars: { "tracker.baseUrl": "http://127.0.0.1:3456", "gitea.baseUrl": "https://git.example", "tracker.pollSeconds": 60 },
tracker: {
sync: { bot: `bot-${id}-sync`, botId: 9, credentials: { vikunja: ref("sync", "vikunja") } },
labels: {},
},
roles: {
pm: role("pm", 3, { holder: "sage", vars: { harness: "pi", model: "claude-opus-5-5" } }),
cto: role("cto", 4, { holder: "darkwing", vars: { harness: "claude-code" } }),
coder: role("coder", 5, { vars: { harness: "pi", "limits.network": "none" } }),
reviewer: role("reviewer", 6),
// The chief of staff launches (REQ-LAUNCH-1, decision 139); it holds no
// credential and no tracker bot.
chief: { definition: "chief-of-staff" },
},
launch: launchDoc(),
};
}
// The system config file mosaic-config.mjs validates, under `root`.
export function systemConfig(root) {
const file = join(root, "config", "config.json");
writeJson(file, {
configVersion: 1,
environment: "development",
dataRoot: join(root, "data"),
execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash", adapter: "mock" },
});
return file;
}
// The checked system layer, as systemVars() returns it, without spawning.
export function systemFor(root) {
return {
environment: "development",
dataRoot: join(root, "data"),
"execution.backend": "docker",
"execution.provider": "zai",
"execution.model": "glm-5.3-flash",
"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}}};
}
// Old path of packages/organization/tests/helpers.mjs (rename design r2,
// stage B). rename-v1-contract.test.mjs stays here unchanged and checks the
// old deep paths and the old executable through this shim package.
export * from "../../organization/tests/helpers.mjs";
+32 -13
View File
@@ -194,9 +194,10 @@ mosaic launches list [--json]
(`tester-off`), each exit 3. Like the human commands, `--tester` works
only from the tree the bus runs from ("One pinned copy" below).
Row 175 (#1673, Q173, mos-0870 N2) adds the reads `usage`, `names`,
`limits`, `plugins`, `threads`, `project list` and `business instances`,
which print what the human's forms print, and the writes `plugins set`,
`business instance add` and `set`, `name`, `project create` and
`limits`, `plugins`, `threads`, `project list` and `org instances` (old
spelling `business instances`), which print what the human's forms print,
and the writes `plugins set`, `org instance add` and `set` (old spelling
`business instance`), `name`, `project create` and
`project init` (below), each recorded as `tester`.
`routes.set` has no CLI form yet. `talk --tester` sends any text. When no run holds the role
(row 170, #1667), the bus refuses the message with
@@ -511,7 +512,7 @@ threads (`threadsState`) and acts on each one:
tick. Each new message or open starts at most one session: a session
that ends without taking the work waits for the next message or open.
- A running thread session stops once the thread has had no message or
open for `launch.threadIdleSeconds` (`packages/business/README.md`) and
open for `launch.threadIdleSeconds` (`packages/organization/README.md`) and
the run is between turns. The broker's `threadIdle` decides that from
the store, not from the tick's snapshot: it releases the claim only with
nothing delivered and unread and nothing waiting. If a message came in
@@ -1012,13 +1013,20 @@ the re-pin follows in the same window (decision 145).
Row 165 (#1662, Q171, REQ-ROLE-3).
```
mosaic business instances BUSINESS [--json] [--tester]
mosaic business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV [--tester]
mosaic business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester]
mosaic org instances ORG [--json] [--tester]
mosaic org instance add ORG INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV [--tester]
mosaic org instance set ORG INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester]
```
`scripts/mosaic` sends `business instances` and `business instance` here and
every other `business` command to `packages/business`. `instances` prints the
`mosaic business instances|instance|veto` is the old spelling, with the same
arguments. `scripts/mosaic` sends `business instances`, `business instance`
and `business veto` here and every other `business` command to
`packages/organization`; `org instances|instance|veto` come here with the rest
of `org`. Both spellings run `instancesCommand` (`src/instances.mjs`) over the
human-proof bus transport, so the principal checks, refusals, output and exit
codes are the same. Only a usage error differs: it prints the usage of the
spelling you used. These commands never use the dev-local-owner context that
`mosaic org create|show|update` use. `instances` prints the
file's revision, each instance with its definition and its marks (`launcher`,
`launched`, `protected`), the definitions `add` and `set` accept, and the
definitions that declare credentials. `add` and `set` send the bus's
@@ -1027,7 +1035,7 @@ human-only `instances.set` verb. `set` needs at least one of `--definition`,
instance's vars whole. `--launch` and `--no-launch` add the instance to
`launch.instances` or remove it.
The business package's instance writer runs the checks `mosaic business
The organization package's instance writer runs the checks `mosaic org config
validate` runs before it writes, and logs the change in `changes.jsonl` with
the human as actor. It refuses, and the file keeps its bytes:
@@ -1039,7 +1047,7 @@ the human as actor. It refuses, and the file keeps its bytes:
| `organization-definition-refused` | an assistant definition or instance; those change through `mosaic limits set` |
| `organization-launch-not-configured` | a launch flag when the file has no launch block |
| `organization-no-change` | the file already says that |
| `organization-project-mismatch`, `organization-unresolvable-instance` | the change would fail `mosaic business validate` against a project file |
| `organization-project-mismatch`, `organization-unresolvable-instance` | the change would fail `mosaic org config validate` against a project file |
| `revision-conflict` | the file changed since `--revision` |
The bus sends a refusal's code alone, so the command adds the reason. A
@@ -1244,14 +1252,25 @@ local --output FILE`, `mosaic org validate FILE`, and `mosaic org create ID
--if-revision N` requires the revision from show. Each configured project gets
its own derived root.
`mosaic org validate FILE` checks a draft by its path and prints `valid`; it
never looks up an installed organization. `mosaic org config validate ID` and
`mosaic org config resolve ID INSTANCE [--project ID]` read the installed
organization `ID` and print the same JSON as `mosaic business validate|resolve`
(`packages/organization/README.md`). A draft file in the working directory
named like an installed ID changes neither: each command reads only its own
kind of input. `mosaic org instances|instance|veto` are the role instance
commands under "Role instances" above. The config and instances commands
never use the dev-local-owner context, so they don't need a named development
instance.
`mosaic org queue init ID --local-only --accept-local-loss-risk` initializes
its checkout; `mosaic org project repo init ID PROJECT --local-only
--accept-local-loss-risk` initializes a project. For either command `--remote
URL` selects remote mode instead. These commands never push or overwrite an
existing repository. Local-only acceptance records the risk of losing the only
copy. Read back with `mosaic org queue show ID [--json]` or `mosaic org project
repo show ID PROJECT [--json]`. See the Business README for the layout and
scope. On existing CLI commands, `--org ID` is an alias for `--business ID`;
repo show ID PROJECT [--json]`. See `packages/organization/README.md` for the
layout and scope. On existing CLI commands, `--org ID` is an alias for `--business ID`;
if both are supplied they must name the same ID.
`mosaic org delete ID --confirm ID --if-revision N [--json]` stops the
+1 -1
View File
@@ -5,7 +5,7 @@ import { dirname, isAbsolute, join, resolve, relative } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createHash, randomUUID } from 'node:crypto';
import { loadSystem, bootConfig, rolesDir } from '../src/config.mjs';
import { loadBusiness } from '../../business/src/index.mjs';
import { loadBusiness } from '../../organization/src/index.mjs';
import { readHostState } from '../src/host.mjs';
import { humanTransport, refuseInsideAgent } from '../src/transport.mjs';
import { staffFor } from '../../bus/src/request-host.mjs';
+1 -1
View File
@@ -10,7 +10,7 @@
import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, openSync, writeSync, fsyncSync, closeSync, existsSync, unlinkSync } from "node:fs";
import { randomBytes } from "node:crypto";
import { join, dirname } from "node:path";
import { BusinessError, accessFilePath, checkAccess, configDir, loadAccess, loadBusiness, LEVELS } from "../../business/src/index.mjs";
import { BusinessError, accessFilePath, checkAccess, configDir, loadAccess, loadBusiness, LEVELS } from "../../organization/src/index.mjs";
import { rolesDir } from "./config.mjs";
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
+25 -6
View File
@@ -22,17 +22,17 @@ import { proxyCommand } from "./model-proxy.mjs";
import { credentialsCommand, CREDENTIALS_USAGE } from "./credentials.mjs";
import { hostsCommand, HOSTS_USAGE } from "./hosts.mjs";
import { grantsCommand, GRANTS_USAGE } from "./grants.mjs";
import { instancesCommand, INSTANCES_USAGE } from "./instances.mjs";
import { instancesCommand, orgInstancesCommand, ORG_INSTANCES_USAGE } from "./instances.mjs";
import { accessCommand, accessContextFor } from "./access.mjs";
import { createChannelCaller } from "../../plugins/src/channel.mjs";
import { PluginError } from "../../plugins/src/kinds.mjs";
import { CliError } from "./errors.mjs";
import { CliError, CliExit } from "./errors.mjs";
import { readNotifyConfig } from "./notify-config.mjs";
export { readNotifyConfig } from "./notify-config.mjs";
import { DEV_OWNER, devOwnerInstance, devLanOptions } from "./dev-local-owner.mjs";
import { bootConfig, loadSystem, orgPaths, socketPath } from "./config.mjs";
import { bootLine, readBootStatus, recordBootOk, recordBootRefused } from "./boot-status.mjs";
import { businessFilePath, configDir } from "../../business/src/index.mjs";
import { businessFilePath, configDir } from "../../organization/src/index.mjs";
import { busExit, humanTransport, refuseInsideAgent, testerTransport } from "./transport.mjs";
import { formatAgents, formatDecision, formatInbox, formatTasks, formatTrail, shortId } from "./format.mjs";
import { hostStatus, readHostState, startHost, stopHost } from "./host.mjs";
@@ -40,13 +40,14 @@ import { createLauncher } from "./launcher.mjs";
import { quitLine, runApp } from "./app.mjs";
import { readSessions, stopSession } from "../../seat/src/session.mjs";
import { SeatError } from "../../seat/src/seat.mjs";
import { OrgError, resolveOrg, withOrgLifecycle } from "../../business/src/org-registry.mjs";
import { OrgError, resolveOrg, withOrgLifecycle } from "../../organization/src/org-registry.mjs";
import { orgSelector } from "./org-selector.mjs";
import { busStart, busStatus, busStop, busRestart, busRecover } from "./org-bus.mjs";
import { consoleStart, consoleStatus, consoleStop } from "./org-console.mjs";
import { usageCommand, USAGE_USAGE } from "./usage.mjs";
import { orgCommand, ORG_USAGE } from "./org.mjs";
import { ORG_CONFIG, runConfigCommand } from "../../organization/src/config-command.mjs";
import { devCommand, DEV_USAGE, fixtureLaunchEnv, fixtureLauncherOptions } from "./dev-fixture.mjs";
import { runCommand, RUN_USAGE } from "./run-evidence.mjs";
@@ -59,7 +60,8 @@ export const USAGE = `usage:
mosaic proxy fetch | fence status [--json] | fence clear --proxy-restarted | init [--port N] [--business ID] [--rotate-grace-minutes N] [--first-output-seconds N]
${HOSTS_USAGE}
${GRANTS_USAGE}
${INSTANCES_USAGE}
${ORG_INSTANCES_USAGE}
mosaic business validate|resolve|instances|instance|veto: the old spelling of mosaic org config validate|resolve and mosaic org instances|instance|veto, same arguments
${PROJECT_USAGE}
${PROJECT_BUS_USAGE}
${NORTH_STAR_USAGE}
@@ -689,9 +691,23 @@ async function app(io, deps) {
io.stdout.write(`${quitLine(result)}\n`);
}
// `mosaic org config validate|resolve`: the service writes its own stdout and stderr lines (the
// usage line names `mosaic org config`; every other line keeps `mosaic business: `) and returns
// the exit code; run() turns a CliExit into that code.
function orgConfig(args, io) {
const code = runConfigCommand(args, { stdout: io.stdout, stderr: io.stderr, env: io.env, spelling: ORG_CONFIG });
if (code !== 0) throw new CliExit(code);
}
export async function main(argv, io = { env: process.env, stdin: process.stdin, stdout: process.stdout, stderr: process.stderr }, deps = {}) {
if (argv.length === 0) return app(io, deps);
const [verb, ...rest] = argv;
// Rename r2 stage B: `org config validate|resolve` runs the installed-config service that
// `mosaic business validate|resolve` runs, and `org instances|instance|veto` the proof-bearing
// instances command `mosaic business` runs below. Neither reaches the dev-owner context orgCommand
// builds; `org validate FILE` stays orgCommand's draft check.
if (verb === 'org' && rest[0] === 'config') return orgConfig(rest.slice(1), io);
if (verb === 'org' && ["instances", "instance", "veto"].includes(rest[0])) return orgInstancesCommand(rest, io, (p) => callFor(p, io, deps));
if (verb === 'org') return orgCommand(rest, io, deps);
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
if (verb === "project" && rest[0] === "north-star") return northStarCommand(rest.slice(1), io, (p) => callFor(p, io, deps));
@@ -719,7 +735,9 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (verb === "hosts") return hostsCommand(rest, io, (p) => callFor(p, io, deps));
// Row 236 (#1742, #436b r5): the Gitea grant path; admin set reads the admin token on stdin.
if (verb === "grants") return grantsCommand(rest, io, (p) => callFor(p, io, deps));
// Row 165 (#1662): scripts/mosaic sends only `business instances`, `business instance` and (row 194) `business veto` here.
// Row 165 (#1662): scripts/mosaic sends only `business instances`, `business instance` and (row 194) `business veto` here;
// `org instances|instance|veto` arrive with the rest of `org`.
// Rename r2 stage B: the old spelling of `org instances|instance|veto`, kept with its own usage text.
if (verb === "business") return instancesCommand(rest, io, (p) => callFor(p, io, deps));
if (verb === "talk") return talk(rest, io, deps);
if (verb === "follow") return talk(rest, io, deps, true);
@@ -748,6 +766,7 @@ export async function run(argv, io = { env: process.env, stdin: process.stdin, s
await main(argv, io, deps);
return 0;
} catch (error) {
if (error instanceof CliExit) return error.exitCode;
if (error instanceof OrgError) {
io.stderr.write(`mosaic: ${error.code}: ${error.message}\n`);
return 3;
+4 -4
View File
@@ -8,9 +8,9 @@ import { spawnSync } from "node:child_process";
import { existsSync, lstatSync } from "node:fs";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { managedState } from "../../business/src/project-bootstrap.mjs";
import { BusinessError, accessFilePath, configDir, loadAccess, loadBusiness, loadProject, projectFilePath, resolveInstance, systemVars } from "../../business/src/index.mjs";
import { busBusiness } from "../../bus/src/business.mjs";
import { managedState } from "../../organization/src/project-bootstrap.mjs";
import { BusinessError, accessFilePath, configDir, loadAccess, loadBusiness, loadProject, projectFilePath, resolveInstance, systemVars } from "../../organization/src/index.mjs";
import { busOrganization } from "../../bus/src/organization-context.mjs";
import { BusError } from "../../bus/src/broker.mjs";
import { CliError } from "./errors.mjs";
import { accessContextFor } from "./access.mjs";
@@ -154,7 +154,7 @@ export function bootConfig({ system, businessId, orgRoot = system.dataRoot, env
dataRoot: orgRoot,
...(orgRoot === system.dataRoot ? {} : { instanceRoot: system.dataRoot, org: { id: b.id }, instanceDataRoot: system.dataRoot, instanceConfigDir: configDir(env) }),
...(system.proxy ? { proxy: system.proxy } : {}),
businesses: { [b.id]: busBusiness(b, resolved) },
businesses: { [b.id]: busOrganization(b, resolved) },
organization: {dir:configDir(env),rolesDir:rolesDir(env),system:vars},
launches: [],
readers: [b.id],
+1 -1
View File
@@ -2,7 +2,7 @@ import { realpathSync } from 'node:fs';
import { isIP } from 'node:net';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import { configDir } from '../../business/src/business.mjs';
import { configDir } from '../../organization/src/organization-config.mjs';
// Reserved outside the HTTPS issuer namespace accepted for configured SSO providers.
export const DEV_OWNER = Object.freeze({ provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' });
+11
View File
@@ -10,3 +10,14 @@ export class CliError extends Error {
this.exitCode = exitCode;
}
}
// Rename r2 stage B: `mosaic org config validate|resolve` writes its own messages
// (packages/organization/src/config-command.mjs) and then ends with this exit code; run()
// returns it without printing anything more.
export class CliExit extends Error {
constructor(exitCode) {
super(`exit ${exitCode}`);
this.name = "CliExit";
this.exitCode = exitCode;
}
}
+19
View File
@@ -4,6 +4,9 @@
// refuses an agent and a reader; the business package's instance writer refuses a stale revision
// and every document `mosaic business validate` would refuse, and logs the change with its actor.
// Row 194 (#1692, D1): `business instance launch|terminate|regenerate|delete` and `business veto`.
// Rename r2 stage B: `mosaic org instances|instance|veto` is the primary spelling of all of these,
// with the same arguments and this same code (orgInstancesCommand). Only the usage text differs;
// the refusal reasons below still name the old spelling, which keeps working.
// Launch, regenerate and delete are requests the launcher's pass carries out. The command polls
// instance.request until the request closes or --wait runs out (D1.1 item 5).
import { readFileSync } from "node:fs";
@@ -17,6 +20,12 @@ export const INSTANCES_USAGE = "mosaic business instances BUSINESS [--json] [--t
"business instance delete BUSINESS INSTANCE --revision REV [--wait SECONDS] | " +
"business veto BUSINESS keep|unkeep INSTANCE --revision REV | business veto BUSINESS nohire|hire DEFINITION --revision REV";
// Rename r2 stage B: the primary spelling. `mosaic org instances|instance|veto` takes the same
// arguments and runs the same command through the same proof-bearing call; only the usage text it
// prints for bad arguments reads `org`. Output and refusals are byte for byte the old spelling's.
export const ORG_INSTANCES_USAGE = INSTANCES_USAGE.replace(/^mosaic business /, "mosaic org ")
.replaceAll(" | business ", " | org ").replaceAll("BUSINESS", "ORG");
// The bus answers a refusal with its code alone, so the reason a human reads is here.
export const REASONS = {
"organization-credentials-out-of-scope": "only add binds credentials, as store references; set refuses a definition that " +
@@ -218,6 +227,16 @@ export function formatInstances(view) {
return `${lines.join("\n")}\n`;
}
// args are the words after `mosaic org`; the same command as instancesCommand, with the org usage.
export async function orgInstancesCommand(args, io, callFor) {
try {
return await instancesCommand(args, io, callFor);
} catch (error) {
if (error instanceof CliError && error.message === INSTANCES_USAGE) throw new CliError(ORG_INSTANCES_USAGE, error.exitCode);
throw error;
}
}
// args are the words after `mosaic business`. callFor(parsed) returns the human's call(verb, args).
export async function instancesCommand(args, io, callFor) {
const parsed = parse(args);
Loaded 100 of 284 files, more files were not shown because too many files have changed in this diff. Show more