diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 3e8b8b5a3..fd208c059 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -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 -scripts/mosaic business resolve [--project ] -scripts/mosaic business instances [--json] -scripts/mosaic business instance add --definition [--vars ] [--launch] --revision -scripts/mosaic business instance set [--definition ] [--vars ] [--launch | --no-launch] --revision +scripts/mosaic org config validate +scripts/mosaic org config resolve [--project ] +scripts/mosaic org instances [--json] +scripts/mosaic org instance add --definition [--vars ] [--launch] --revision +scripts/mosaic org instance set [--definition ] [--vars ] [--launch | --no-launch] --revision ``` +`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/.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 diff --git a/docs/design/IMPLEMENTING.md b/docs/design/IMPLEMENTING.md index b4a3f8bdb..3ff4133bf 100644 --- a/docs/design/IMPLEMENTING.md +++ b/docs/design/IMPLEMENTING.md @@ -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 | diff --git a/docs/guides/host-run-sheet.md b/docs/guides/host-run-sheet.md index 19e746abe..7ffa4dcb3 100644 --- a/docs/guides/host-run-sheet.md +++ b/docs/guides/host-run-sheet.md @@ -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 `/.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 : no project file at ; 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 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; diff --git a/packages/bus/README.md b/packages/bus/README.md index 816c2e08e..b294706d1 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -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 diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index b5eb7cd31..070462198 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -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([ diff --git a/packages/bus/src/business.mjs b/packages/bus/src/business.mjs index 3c3591c44..3c8d8c6ed 100644 --- a/packages/bus/src/business.mjs +++ b/packages/bus/src/business.mjs @@ -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. 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'; diff --git a/packages/bus/src/credential-verbs.mjs b/packages/bus/src/credential-verbs.mjs index 940b05fe8..4640b54c4 100644 --- a/packages/bus/src/credential-verbs.mjs +++ b/packages/bus/src/credential-verbs.mjs @@ -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 diff --git a/packages/bus/src/credentials.mjs b/packages/bus/src/credentials.mjs index caf35f21e..1c2a21a60 100644 --- a/packages/bus/src/credentials.mjs +++ b/packages/bus/src/credentials.mjs @@ -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); }; diff --git a/packages/bus/src/gitea-grant.mjs b/packages/bus/src/gitea-grant.mjs index 07c3c3126..d110ac48a 100644 --- a/packages/bus/src/gitea-grant.mjs +++ b/packages/bus/src/gitea-grant.mjs @@ -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); diff --git a/packages/bus/src/host-grant.mjs b/packages/bus/src/host-grant.mjs index e4a152ccf..57b61e00b 100644 --- a/packages/bus/src/host-grant.mjs +++ b/packages/bus/src/host-grant.mjs @@ -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); diff --git a/packages/bus/src/index.mjs b/packages/bus/src/index.mjs index c8d9951f1..d03af9189 100644 --- a/packages/bus/src/index.mjs +++ b/packages/bus/src/index.mjs @@ -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'; diff --git a/packages/bus/src/organization-context.mjs b/packages/bus/src/organization-context.mjs new file mode 100644 index 000000000..6efb1ac2b --- /dev/null +++ b/packages/bus/src/organization-context.mjs @@ -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. 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; +} diff --git a/packages/bus/src/request-bootstrap.mjs b/packages/bus/src/request-bootstrap.mjs index b3f5523e5..6a628fb17 100644 --- a/packages/bus/src/request-bootstrap.mjs +++ b/packages/bus/src/request-bootstrap.mjs @@ -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'; diff --git a/packages/bus/src/request-host.mjs b/packages/bus/src/request-host.mjs index db1865f07..286e1c3ab 100644 --- a/packages/bus/src/request-host.mjs +++ b/packages/bus/src/request-host.mjs @@ -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; diff --git a/packages/bus/src/request-store.mjs b/packages/bus/src/request-store.mjs index 566c7f2b7..1da9b3172 100644 --- a/packages/bus/src/request-store.mjs +++ b/packages/bus/src/request-store.mjs @@ -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; } diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index af9a788f5..ec5e4d509 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -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); diff --git a/packages/bus/tests/bootstrap-origin-probe.test.mjs b/packages/bus/tests/bootstrap-origin-probe.test.mjs index cb5085425..90b44eb5a 100644 --- a/packages/bus/tests/bootstrap-origin-probe.test.mjs +++ b/packages/bus/tests/bootstrap-origin-probe.test.mjs @@ -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'; diff --git a/packages/bus/tests/broker.test.mjs b/packages/bus/tests/broker.test.mjs index 6e5beea08..74cc3c0e1 100644 --- a/packages/bus/tests/broker.test.mjs +++ b/packages/bus/tests/broker.test.mjs @@ -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' }); diff --git a/packages/bus/tests/chief-of-staff.test.mjs b/packages/bus/tests/chief-of-staff.test.mjs index e8979328b..39c61cc5e 100644 --- a/packages/bus/tests/chief-of-staff.test.mjs +++ b/packages/bus/tests/chief-of-staff.test.mjs @@ -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'), diff --git a/packages/bus/tests/citation-guard.test.mjs b/packages/bus/tests/citation-guard.test.mjs index 56786be84..7d7bbba7a 100644 --- a/packages/bus/tests/citation-guard.test.mjs +++ b/packages/bus/tests/citation-guard.test.mjs @@ -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'; diff --git a/packages/bus/tests/citation-helpers.mjs b/packages/bus/tests/citation-helpers.mjs index 1ffdfc046..2a5964951 100644 --- a/packages/bus/tests/citation-helpers.mjs +++ b/packages/bus/tests/citation-helpers.mjs @@ -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'; diff --git a/packages/bus/tests/citation-runtime.test.mjs b/packages/bus/tests/citation-runtime.test.mjs index b1a2de155..8e3619209 100644 --- a/packages/bus/tests/citation-runtime.test.mjs +++ b/packages/bus/tests/citation-runtime.test.mjs @@ -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 => { diff --git a/packages/bus/tests/creative.test.mjs b/packages/bus/tests/creative.test.mjs index 92365a8e8..facd370fc 100644 --- a/packages/bus/tests/creative.test.mjs +++ b/packages/bus/tests/creative.test.mjs @@ -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. diff --git a/packages/bus/tests/credential-verbs.test.mjs b/packages/bus/tests/credential-verbs.test.mjs index c60af514a..3adfc3a40 100644 --- a/packages/bus/tests/credential-verbs.test.mjs +++ b/packages/bus/tests/credential-verbs.test.mjs @@ -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'; diff --git a/packages/bus/tests/daily-quota-runtime.test.mjs b/packages/bus/tests/daily-quota-runtime.test.mjs index 12bf1c08c..0970d1041 100644 --- a/packages/bus/tests/daily-quota-runtime.test.mjs +++ b/packages/bus/tests/daily-quota-runtime.test.mjs @@ -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' }; diff --git a/packages/bus/tests/dev-local-owner.test.mjs b/packages/bus/tests/dev-local-owner.test.mjs index 4e7f73346..f05cf844e 100644 --- a/packages/bus/tests/dev-local-owner.test.mjs +++ b/packages/bus/tests/dev-local-owner.test.mjs @@ -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' }; diff --git a/packages/bus/tests/grant-host.test.mjs b/packages/bus/tests/grant-host.test.mjs index 70a70a237..e58206b95 100644 --- a/packages/bus/tests/grant-host.test.mjs +++ b/packages/bus/tests/grant-host.test.mjs @@ -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)); diff --git a/packages/bus/tests/grant-scope-runtime.test.mjs b/packages/bus/tests/grant-scope-runtime.test.mjs index 3220693b9..4465e9904 100644 --- a/packages/bus/tests/grant-scope-runtime.test.mjs +++ b/packages/bus/tests/grant-scope-runtime.test.mjs @@ -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 => { diff --git a/packages/bus/tests/grant-scope.test.mjs b/packages/bus/tests/grant-scope.test.mjs index 559e9ac24..30abd1a48 100644 --- a/packages/bus/tests/grant-scope.test.mjs +++ b/packages/bus/tests/grant-scope.test.mjs @@ -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'; diff --git a/packages/bus/tests/hard-waits.test.mjs b/packages/bus/tests/hard-waits.test.mjs index ed9e05579..8a2ec361a 100644 --- a/packages/bus/tests/hard-waits.test.mjs +++ b/packages/bus/tests/hard-waits.test.mjs @@ -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'; diff --git a/packages/bus/tests/host-run.test.mjs b/packages/bus/tests/host-run.test.mjs index f7eebd45e..3670f29db 100644 --- a/packages/bus/tests/host-run.test.mjs +++ b/packages/bus/tests/host-run.test.mjs @@ -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(); diff --git a/packages/bus/tests/instances-verb.test.mjs b/packages/bus/tests/instances-verb.test.mjs index 08c10eb03..cb9ed99f4 100644 --- a/packages/bus/tests/instances-verb.test.mjs +++ b/packages/bus/tests/instances-verb.test.mjs @@ -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')); diff --git a/packages/bus/tests/launch-notice.test.mjs b/packages/bus/tests/launch-notice.test.mjs index da50b32b5..1f871591c 100644 --- a/packages/bus/tests/launch-notice.test.mjs +++ b/packages/bus/tests/launch-notice.test.mjs @@ -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 diff --git a/packages/bus/tests/library-credential.test.mjs b/packages/bus/tests/library-credential.test.mjs index bc14937e4..68552ff50 100644 --- a/packages/bus/tests/library-credential.test.mjs +++ b/packages/bus/tests/library-credential.test.mjs @@ -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); diff --git a/packages/bus/tests/lifecycle-revoke.test.mjs b/packages/bus/tests/lifecycle-revoke.test.mjs index 56c68ddc1..e7832a97d 100644 --- a/packages/bus/tests/lifecycle-revoke.test.mjs +++ b/packages/bus/tests/lifecycle-revoke.test.mjs @@ -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 () => { diff --git a/packages/bus/tests/lifecycle-verbs.test.mjs b/packages/bus/tests/lifecycle-verbs.test.mjs index ba56bc251..f76c637d7 100644 --- a/packages/bus/tests/lifecycle-verbs.test.mjs +++ b/packages/bus/tests/lifecycle-verbs.test.mjs @@ -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 }); diff --git a/packages/bus/tests/limits-runtime.test.mjs b/packages/bus/tests/limits-runtime.test.mjs index 099e33b53..30f6f6491 100644 --- a/packages/bus/tests/limits-runtime.test.mjs +++ b/packages/bus/tests/limits-runtime.test.mjs @@ -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 } } } }; diff --git a/packages/bus/tests/limits.test.mjs b/packages/bus/tests/limits.test.mjs index 4dc548c6c..fe3389018 100644 --- a/packages/bus/tests/limits.test.mjs +++ b/packages/bus/tests/limits.test.mjs @@ -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'); diff --git a/packages/bus/tests/model-catalogue.test.mjs b/packages/bus/tests/model-catalogue.test.mjs index b344650d2..7425c8aff 100644 --- a/packages/bus/tests/model-catalogue.test.mjs +++ b/packages/bus/tests/model-catalogue.test.mjs @@ -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'}; diff --git a/packages/bus/tests/model-hire-ownership-fixture.mjs b/packages/bus/tests/model-hire-ownership-fixture.mjs index c675e8a16..9561b9d39 100644 --- a/packages/bus/tests/model-hire-ownership-fixture.mjs +++ b/packages/bus/tests/model-hire-ownership-fixture.mjs @@ -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()setTimeout(r,20));}throw Error(message); }; export const pause = ms => new Promise(r=>setTimeout(r,ms)); diff --git a/packages/bus/tests/model-historical-ipc.test.mjs b/packages/bus/tests/model-historical-ipc.test.mjs index 95bbf968c..0966fe7da 100644 --- a/packages/bus/tests/model-historical-ipc.test.mjs +++ b/packages/bus/tests/model-historical-ipc.test.mjs @@ -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' }, } }); diff --git a/packages/bus/tests/model-keys.test.mjs b/packages/bus/tests/model-keys.test.mjs index 3ddcfa2b9..c9b8cb362 100644 --- a/packages/bus/tests/model-keys.test.mjs +++ b/packages/bus/tests/model-keys.test.mjs @@ -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()); diff --git a/packages/bus/tests/model-orphan-hire.test.mjs b/packages/bus/tests/model-orphan-hire.test.mjs index 981c17154..d46cffaff 100644 --- a/packages/bus/tests/model-orphan-hire.test.mjs +++ b/packages/bus/tests/model-orphan-hire.test.mjs @@ -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'}); diff --git a/packages/bus/tests/model-pending-fixture.mjs b/packages/bus/tests/model-pending-fixture.mjs index f148bb021..9f8ecb850 100644 --- a/packages/bus/tests/model-pending-fixture.mjs +++ b/packages/bus/tests/model-pending-fixture.mjs @@ -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{ diff --git a/packages/bus/tests/model-restart.test.mjs b/packages/bus/tests/model-restart.test.mjs index e322cb862..f8b770498 100644 --- a/packages/bus/tests/model-restart.test.mjs +++ b/packages/bus/tests/model-restart.test.mjs @@ -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; }; diff --git a/packages/bus/tests/model-review-fixtures.mjs b/packages/bus/tests/model-review-fixtures.mjs index 13c78ea9d..f784fa8a4 100644 --- a/packages/bus/tests/model-review-fixtures.mjs +++ b/packages/bus/tests/model-review-fixtures.mjs @@ -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; }; diff --git a/packages/bus/tests/model-revocation-fixture.mjs b/packages/bus/tests/model-revocation-fixture.mjs index 5c8581db7..4e76442fb 100644 --- a/packages/bus/tests/model-revocation-fixture.mjs +++ b/packages/bus/tests/model-revocation-fixture.mjs @@ -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; diff --git a/packages/bus/tests/business.test.mjs b/packages/bus/tests/organization-context.test.mjs similarity index 61% rename from packages/bus/tests/business.test.mjs rename to packages/bus/tests/organization-context.test.mjs index 462c0bfc2..0e2db4d91 100644 --- a/packages/bus/tests/business.test.mjs +++ b/packages/bus/tests/organization-context.test.mjs @@ -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); }); diff --git a/packages/bus/tests/p5-key-compatibility.test.mjs b/packages/bus/tests/p5-key-compatibility.test.mjs index 5d53b03fb..efb1212b0 100644 --- a/packages/bus/tests/p5-key-compatibility.test.mjs +++ b/packages/bus/tests/p5-key-compatibility.test.mjs @@ -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),[]); diff --git a/packages/bus/tests/p5-key-lineage.test.mjs b/packages/bus/tests/p5-key-lineage.test.mjs index 9c8ed5706..2988011da 100644 --- a/packages/bus/tests/p5-key-lineage.test.mjs +++ b/packages/bus/tests/p5-key-lineage.test.mjs @@ -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'); diff --git a/packages/bus/tests/p5-runtime.test.mjs b/packages/bus/tests/p5-runtime.test.mjs index 543ee5c65..6eed7adbf 100644 --- a/packages/bus/tests/p5-runtime.test.mjs +++ b/packages/bus/tests/p5-runtime.test.mjs @@ -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'); diff --git a/packages/bus/tests/p5-telemetry-fixture.mjs b/packages/bus/tests/p5-telemetry-fixture.mjs index 02188ed91..a9e61bac1 100644 --- a/packages/bus/tests/p5-telemetry-fixture.mjs +++ b/packages/bus/tests/p5-telemetry-fixture.mjs @@ -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. diff --git a/packages/bus/tests/plugins-verb.test.mjs b/packages/bus/tests/plugins-verb.test.mjs index 3cb92e08d..c0c2f45f7 100644 --- a/packages/bus/tests/plugins-verb.test.mjs +++ b/packages/bus/tests/plugins-verb.test.mjs @@ -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}); diff --git a/packages/bus/tests/project-runtime.test.mjs b/packages/bus/tests/project-runtime.test.mjs index 342e0df96..425f67c9a 100644 --- a/packages/bus/tests/project-runtime.test.mjs +++ b/packages/bus/tests/project-runtime.test.mjs @@ -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/); }); diff --git a/packages/bus/tests/provider-materialization-guard.test.mjs b/packages/bus/tests/provider-materialization-guard.test.mjs index 037b01581..7c8152e63 100644 --- a/packages/bus/tests/provider-materialization-guard.test.mjs +++ b/packages/bus/tests/provider-materialization-guard.test.mjs @@ -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'); diff --git a/packages/bus/tests/provider-repoint-guard.test.mjs b/packages/bus/tests/provider-repoint-guard.test.mjs index 9ae27d69d..26c25a12d 100644 --- a/packages/bus/tests/provider-repoint-guard.test.mjs +++ b/packages/bus/tests/provider-repoint-guard.test.mjs @@ -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'}; diff --git a/packages/bus/tests/publish.test.mjs b/packages/bus/tests/publish.test.mjs index 30a227310..088994bfd 100644 --- a/packages/bus/tests/publish.test.mjs +++ b/packages/bus/tests/publish.test.mjs @@ -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 })); diff --git a/packages/bus/tests/reload.test.mjs b/packages/bus/tests/reload.test.mjs index 342d578d0..fa8f86600 100644 --- a/packages/bus/tests/reload.test.mjs +++ b/packages/bus/tests/reload.test.mjs @@ -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) { diff --git a/packages/bus/tests/request-bootstrap.test.mjs b/packages/bus/tests/request-bootstrap.test.mjs index 613d09e30..fc8cfe044 100644 --- a/packages/bus/tests/request-bootstrap.test.mjs +++ b/packages/bus/tests/request-bootstrap.test.mjs @@ -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'; diff --git a/packages/bus/tests/routes-verb.test.mjs b/packages/bus/tests/routes-verb.test.mjs index 712ff4343..24d238aa6 100644 --- a/packages/bus/tests/routes-verb.test.mjs +++ b/packages/bus/tests/routes-verb.test.mjs @@ -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'}; diff --git a/packages/bus/tests/tester-writes.test.mjs b/packages/bus/tests/tester-writes.test.mjs index 5beae2699..cfaa4a100 100644 --- a/packages/bus/tests/tester-writes.test.mjs +++ b/packages/bus/tests/tester-writes.test.mjs @@ -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'], ]; diff --git a/packages/bus/tests/web.test.mjs b/packages/bus/tests/web.test.mjs index 5e25dd7b9..415c298a4 100644 --- a/packages/bus/tests/web.test.mjs +++ b/packages/bus/tests/web.test.mjs @@ -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) => { diff --git a/packages/bus/tests/workflow-roles.test.mjs b/packages/bus/tests/workflow-roles.test.mjs index 26a278f1d..d9669d6c6 100644 --- a/packages/bus/tests/workflow-roles.test.mjs +++ b/packages/bus/tests/workflow-roles.test.mjs @@ -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 } }); diff --git a/packages/business/package.json b/packages/business/package.json index e59b9dd97..ad64c99ea 100644 --- a/packages/business/package.json +++ b/packages/business/package.json @@ -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" }, diff --git a/packages/business/src/access.mjs b/packages/business/src/access.mjs index 57c5c1182..85643bf48 100644 --- a/packages/business/src/access.mjs +++ b/packages/business/src/access.mjs @@ -1,174 +1,2 @@ -// The access file, /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"; diff --git a/packages/business/src/appearance.mjs b/packages/business/src/appearance.mjs index 67219c455..2d9f069b2 100644 --- a/packages/business/src/appearance.mjs +++ b/packages/business/src/appearance.mjs @@ -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"; diff --git a/packages/business/src/business.mjs b/packages/business/src/business.mjs index b2063716b..c88b9b858 100644 --- a/packages/business/src/business.mjs +++ b/packages/business/src/business.mjs @@ -1,345 +1,2 @@ -// The business file, /businesses/.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. 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/.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"; diff --git a/packages/business/src/cli.mjs b/packages/business/src/cli.mjs index 8880d37ca..11eb09927 100644 --- a/packages/business/src/cli.mjs +++ b/packages/business/src/cli.mjs @@ -1,139 +1,5 @@ #!/usr/bin/env node -// mosaic business validate -// mosaic business resolve [--project ] -// -// 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 | resolve [--project ]"; - -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"; diff --git a/packages/business/src/config-command.mjs b/packages/business/src/config-command.mjs new file mode 100644 index 000000000..5c0ed240c --- /dev/null +++ b/packages/business/src/config-command.mjs @@ -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"; diff --git a/packages/business/src/credentials.mjs b/packages/business/src/credentials.mjs index 52f95c085..9267053c5 100644 --- a/packages/business/src/credentials.mjs +++ b/packages/business/src/credentials.mjs @@ -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 /secrets/agents///, 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. 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"; diff --git a/packages/business/src/errors.mjs b/packages/business/src/errors.mjs index fdc4b1424..4ac6217fa 100644 --- a/packages/business/src/errors.mjs +++ b/packages/business/src/errors.mjs @@ -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"; diff --git a/packages/business/src/hosts.mjs b/packages/business/src/hosts.mjs index 3d0d116dc..f14e4d159 100644 --- a/packages/business/src/hosts.mjs +++ b/packages/business/src/hosts.mjs @@ -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"; diff --git a/packages/business/src/index.mjs b/packages/business/src/index.mjs index 3872d22a2..57765ace0 100644 --- a/packages/business/src/index.mjs +++ b/packages/business/src/index.mjs @@ -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"; diff --git a/packages/business/src/instance-settings.mjs b/packages/business/src/instance-settings.mjs index b42c3a855..bba4aa1bb 100644 --- a/packages/business/src/instance-settings.mjs +++ b/packages/business/src/instance-settings.mjs @@ -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. 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"; diff --git a/packages/business/src/launch-settings.mjs b/packages/business/src/launch-settings.mjs index 992da2185..32f54601e 100644 --- a/packages/business/src/launch-settings.mjs +++ b/packages/business/src/launch-settings.mjs @@ -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 .. -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: { : { 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"; diff --git a/packages/business/src/launch.mjs b/packages/business/src/launch.mjs index 3aa3caa57..ab231b207 100644 --- a/packages/business/src/launch.mjs +++ b/packages/business/src/launch.mjs @@ -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"; diff --git a/packages/business/src/library-seats.mjs b/packages/business/src/library-seats.mjs index 7b4474010..19d3fdb20 100644 --- a/packages/business/src/library-seats.mjs +++ b/packages/business/src/library-seats.mjs @@ -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"; diff --git a/packages/business/src/migrate-launch.mjs b/packages/business/src/migrate-launch.mjs index 654d95f62..9f79580cd 100644 --- a/packages/business/src/migrate-launch.mjs +++ b/packages/business/src/migrate-launch.mjs @@ -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"; diff --git a/packages/business/src/org-registry.mjs b/packages/business/src/org-registry.mjs index 06d3378e7..71e3fc6c9 100644 --- a/packages/business/src/org-registry.mjs +++ b/packages/business/src/org-registry.mjs @@ -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"; diff --git a/packages/business/src/org-service.mjs b/packages/business/src/org-service.mjs index 58f8ea57f..b0e465517 100644 --- a/packages/business/src/org-service.mjs +++ b/packages/business/src/org-service.mjs @@ -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', 'user.email=mosaic@localhost.invalid', '-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"; diff --git a/packages/business/src/organization.mjs b/packages/business/src/organization.mjs index e94ec3fc5..6e612b200 100644 --- a/packages/business/src/organization.mjs +++ b/packages/business/src/organization.mjs @@ -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"; diff --git a/packages/business/src/plugin-settings.mjs b/packages/business/src/plugin-settings.mjs index 88c383e57..0d66551af 100644 --- a/packages/business/src/plugin-settings.mjs +++ b/packages/business/src/plugin-settings.mjs @@ -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"; diff --git a/packages/business/src/project-bootstrap.mjs b/packages/business/src/project-bootstrap.mjs index db3488865..1567dd3bd 100644 --- a/packages/business/src/project-bootstrap.mjs +++ b/packages/business/src/project-bootstrap.mjs @@ -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, /products///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"; diff --git a/packages/business/src/project-remotes.mjs b/packages/business/src/project-remotes.mjs index 224875ed2..51414e2cc 100644 --- a/packages/business/src/project-remotes.mjs +++ b/packages/business/src/project-remotes.mjs @@ -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"; diff --git a/packages/business/src/project.mjs b/packages/business/src/project.mjs index 320ec018c..61029946b 100644 --- a/packages/business/src/project.mjs +++ b/packages/business/src/project.mjs @@ -1,36 +1,2 @@ -// The project file, /.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"; diff --git a/packages/business/src/resolve.mjs b/packages/business/src/resolve.mjs index 09c04ae43..b9b385d5e 100644 --- a/packages/business/src/resolve.mjs +++ b/packages/business/src/resolve.mjs @@ -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 /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"; diff --git a/packages/business/src/role.mjs b/packages/business/src/role.mjs index 2db4aada5..2e57a4c19 100644 --- a/packages/business/src/role.mjs +++ b/packages/business/src/role.mjs @@ -1,232 +1,2 @@ -// Role definitions, roles/.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 -// /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: or write:; 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"; diff --git a/packages/business/src/selector.mjs b/packages/business/src/selector.mjs index 8df7e4ba8..c1f94353b 100644 --- a/packages/business/src/selector.mjs +++ b/packages/business/src/selector.mjs @@ -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"; diff --git a/packages/business/src/util.mjs b/packages/business/src/util.mjs index fa6519d4b..56e31c23f 100644 --- a/packages/business/src/util.mjs +++ b/packages/business/src/util.mjs @@ -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"; diff --git a/packages/business/src/vars.mjs b/packages/business/src/vars.mjs index 409d8c927..780f9f2cf 100644 --- a/packages/business/src/vars.mjs +++ b/packages/business/src/vars.mjs @@ -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/.json -// project vars in /.mosaic/project.json, then that file's -// roles..vars (still the project layer, applied after) -// agent roles..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}(? 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 /personas//.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"; diff --git a/packages/business/src/vocabulary.mjs b/packages/business/src/vocabulary.mjs index 3e6e4d971..f49d9a0ea 100644 --- a/packages/business/src/vocabulary.mjs +++ b/packages/business/src/vocabulary.mjs @@ -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., 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 :. "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]//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"; diff --git a/packages/business/tests/helpers.mjs b/packages/business/tests/helpers.mjs index cca29ef94..fb65756e1 100644 --- a/packages/business/tests/helpers.mjs +++ b/packages/business/tests/helpers.mjs @@ -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 `/secrets`, and a project root under ``. -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"; diff --git a/packages/cli/README.md b/packages/cli/README.md index 7a514028f..b1ee81e03 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -192,9 +192,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 @@ -509,7 +510,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 @@ -1010,13 +1011,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 @@ -1025,7 +1033,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: @@ -1037,7 +1045,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 @@ -1242,14 +1250,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 diff --git a/packages/cli/live/request-lifecycle.mjs b/packages/cli/live/request-lifecycle.mjs index a075c7610..ef85a0653 100644 --- a/packages/cli/live/request-lifecycle.mjs +++ b/packages/cli/live/request-lifecycle.mjs @@ -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'; diff --git a/packages/cli/src/access.mjs b/packages/cli/src/access.mjs index 2426b7f95..85f8755d8 100644 --- a/packages/cli/src/access.mjs +++ b/packages/cli/src/access.mjs @@ -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"; diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index ec37e138a..1a4c50f68 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -21,17 +21,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"; @@ -39,13 +39,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"; @@ -58,7 +59,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} ${THREADS_USAGE} @@ -686,9 +688,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") return ["create", "list", "archive", "unarchive"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps)); @@ -714,7 +730,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); @@ -743,6 +761,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; diff --git a/packages/cli/src/config.mjs b/packages/cli/src/config.mjs index af5c748ca..f17368306 100644 --- a/packages/cli/src/config.mjs +++ b/packages/cli/src/config.mjs @@ -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], diff --git a/packages/cli/src/dev-local-owner.mjs b/packages/cli/src/dev-local-owner.mjs index c0cf17843..6f68a2ac7 100644 --- a/packages/cli/src/dev-local-owner.mjs +++ b/packages/cli/src/dev-local-owner.mjs @@ -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' }); diff --git a/packages/cli/src/errors.mjs b/packages/cli/src/errors.mjs index a5d554a6a..8ac27f4fe 100644 --- a/packages/cli/src/errors.mjs +++ b/packages/cli/src/errors.mjs @@ -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; + } +} diff --git a/packages/cli/src/instances.mjs b/packages/cli/src/instances.mjs index c883ffade..cf4dcc1d0 100644 --- a/packages/cli/src/instances.mjs +++ b/packages/cli/src/instances.mjs @@ -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); diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs index 267b3f152..178f786f8 100644 --- a/packages/cli/src/launcher.mjs +++ b/packages/cli/src/launcher.mjs @@ -143,12 +143,12 @@ import { createHash } from 'node:crypto'; import { watchModelOutput } from './model-stall.mjs'; import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join, relative } from "node:path"; -import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs"; -import { organizationWriter } from "../../business/src/organization.mjs"; -import { businessFilePath, validateBusinessDocument } from "../../business/src/business.mjs"; +import { configDir, loadBusiness, resolveInstance, systemVars } from "../../organization/src/index.mjs"; +import { organizationWriter } from "../../organization/src/organization.mjs"; +import { businessFilePath, validateBusinessDocument } from "../../organization/src/organization-config.mjs"; import { selectModelProvider } from "../../plugins/src/model-provider-selection.mjs"; -import { THREAD_DEFINITIONS } from "../../business/src/vocabulary.mjs"; -import { limitHit } from "../../business/src/launch.mjs"; +import { THREAD_DEFINITIONS } from "../../organization/src/vocabulary.mjs"; +import { limitHit } from "../../organization/src/launch.mjs"; import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs"; import { createLoader } from "../../plugins/src/loader.mjs"; import { diff --git a/packages/cli/src/limits.mjs b/packages/cli/src/limits.mjs index 35033b5dd..175b99794 100644 --- a/packages/cli/src/limits.mjs +++ b/packages/cli/src/limits.mjs @@ -2,10 +2,10 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { loadSystem } from "./config.mjs"; -import { systemVars } from "../../business/src/index.mjs"; +import { systemVars } from "../../organization/src/index.mjs"; import { CliError } from "./errors.mjs"; import { orgSelector } from "./org-selector.mjs"; -import { migrateLaunch } from "../../business/src/migrate-launch.mjs"; +import { migrateLaunch } from "../../organization/src/migrate-launch.mjs"; // Row 155 (#1649, Q169): a PATCH may carry `assistants`, the assistant's vars, tracker and definition. // C2-06 M11: `replies --file LIMITS` replaces replyLimits with the file's object, at the current revision. diff --git a/packages/cli/src/model-proxy.mjs b/packages/cli/src/model-proxy.mjs index e2c449158..37511e746 100644 --- a/packages/cli/src/model-proxy.mjs +++ b/packages/cli/src/model-proxy.mjs @@ -1,5 +1,5 @@ import { join } from 'node:path'; -import { configDir } from '../../business/src/business.mjs'; +import { configDir } from '../../organization/src/organization-config.mjs'; import { createProxyLock } from '../../bus/src/proxy-lock.mjs'; import { loadManagementClient } from '../../model-proxy/src/client.mjs'; import { CliError } from './errors.mjs'; diff --git a/packages/cli/src/org-bus.mjs b/packages/cli/src/org-bus.mjs index 3a52ef44a..6377f7ef2 100644 --- a/packages/cli/src/org-bus.mjs +++ b/packages/cli/src/org-bus.mjs @@ -6,7 +6,7 @@ import { constants, closeSync, lstatSync, mkdirSync, openSync, readFileSync, fst import { connect } from 'node:net'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { resolveOrg, withOrgLifecycle } from '../../business/src/org-registry.mjs'; +import { resolveOrg, withOrgLifecycle } from '../../organization/src/org-registry.mjs'; import { orgPaths } from './config.mjs'; import { cmdlineOf, hostFile, hostLock, hostStatus, readHostState, startTimeOf, stopHost } from './host.mjs'; import { CliError } from './errors.mjs'; diff --git a/packages/cli/src/org-console.mjs b/packages/cli/src/org-console.mjs index 16c7ec23a..27dbf379a 100644 --- a/packages/cli/src/org-console.mjs +++ b/packages/cli/src/org-console.mjs @@ -5,7 +5,7 @@ import { spawn } from 'node:child_process'; import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { listOrgs, resolveOrg } from '../../business/src/org-registry.mjs'; +import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs'; import { cmdlineOf, startTimeOf } from './host.mjs'; import { CliError } from './errors.mjs'; import { devLanOptions } from './dev-local-owner.mjs'; diff --git a/packages/cli/src/org-selector.mjs b/packages/cli/src/org-selector.mjs index 03119ec8a..377eccfc0 100644 --- a/packages/cli/src/org-selector.mjs +++ b/packages/cli/src/org-selector.mjs @@ -1,11 +1,11 @@ // The organization selector for the CLI's own parsers (rename design r2, "Selector scopes and -// parser matrix"): --org and --business in one parser's scope, under packages/business/src/ +// parser matrix"): --org and --business in one parser's scope, under packages/organization/src/ // selector.mjs. An id outside the organization grammar refuses as resolveOrg does, OrgError // org-invalid with exit 3, but while parsing, so no verb reads stdin, prompts for a key or reads a // business file first. Every other refusal is the parser's own usage error. -import { ID_PATTERN } from '../../business/src/vocabulary.mjs'; -import { OrgError } from '../../business/src/org-registry.mjs'; -import { selectorScope } from '../../business/src/selector.mjs'; +import { ID_PATTERN } from '../../organization/src/vocabulary.mjs'; +import { OrgError } from '../../organization/src/org-registry.mjs'; +import { selectorScope } from '../../organization/src/selector.mjs'; // usageError(message) returns the parser's usage error for a missing, flag-shaped, repeated or // conflicting selector. diff --git a/packages/cli/src/org.mjs b/packages/cli/src/org.mjs index 750962aaf..405873c79 100644 --- a/packages/cli/src/org.mjs +++ b/packages/cli/src/org.mjs @@ -1,12 +1,12 @@ import { readFile, writeFile } from 'node:fs/promises'; -import { configDir } from '../../business/src/business.mjs'; -import { listOrgs, orgLock } from '../../business/src/org-registry.mjs'; -import { templateRender, validateOrg, createOrg, showOrg, updateOrg, queueInit, queueShow, projectRepoInit, projectRepoShow, deleteOrg, archiveOrg, restoreOrg } from '../../business/src/org-service.mjs'; +import { configDir } from '../../organization/src/organization-config.mjs'; +import { listOrgs, orgLock } from '../../organization/src/org-registry.mjs'; +import { templateRender, validateOrg, createOrg, showOrg, updateOrg, queueInit, queueShow, projectRepoInit, projectRepoShow, deleteOrg, archiveOrg, restoreOrg } from '../../organization/src/org-service.mjs'; import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs'; import { loadSystem } from './config.mjs'; import { CliError } from './errors.mjs'; import { busStopForDelete } from './org-bus.mjs'; -export const ORG_USAGE = 'mosaic org lock show|clear ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json]'; +export const ORG_USAGE = 'mosaic org lock show|clear ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]'; function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; } async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e.message}`, 2); } } export async function orgCommand(args, io, deps = {}) { diff --git a/packages/cli/src/project.mjs b/packages/cli/src/project.mjs index 0884820a5..c8c4a604e 100644 --- a/packages/cli/src/project.mjs +++ b/packages/cli/src/project.mjs @@ -1,4 +1,4 @@ -import { configDir, loadBusiness } from '../../business/src/index.mjs'; +import { configDir, loadBusiness } from '../../organization/src/index.mjs'; import { checkProject, initProject, ProjectError, testerProjectArea, recordContext } from '../../project/src/index.mjs'; import { loadSystem, rolesDir } from './config.mjs'; import { CliError } from './errors.mjs'; @@ -6,7 +6,7 @@ import { shortId } from './format.mjs'; import { parseArgs } from './thread.mjs'; import { basename, join } from 'node:path'; import { lstatSync } from 'node:fs'; -import { readRegistry, resolveOrg } from '../../business/src/org-registry.mjs'; +import { readRegistry, resolveOrg } from '../../organization/src/org-registry.mjs'; import { refuseInsideAgent } from './transport.mjs'; export const PROJECT_USAGE = 'mosaic project init [--tester] | check '; diff --git a/packages/cli/src/run-evidence.mjs b/packages/cli/src/run-evidence.mjs index d50825d3a..fe4b63f44 100644 --- a/packages/cli/src/run-evidence.mjs +++ b/packages/cli/src/run-evidence.mjs @@ -11,8 +11,8 @@ import { createHash } from 'node:crypto'; import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync } from 'node:fs'; import { dirname, join, relative, resolve, sep } from 'node:path'; -import { configDir } from '../../business/src/business.mjs'; -import { resolveOrg, OrgError } from '../../business/src/org-registry.mjs'; +import { configDir } from '../../organization/src/organization-config.mjs'; +import { resolveOrg, OrgError } from '../../organization/src/org-registry.mjs'; import { launchLogFile, runsDir } from '../../seat/src/session.mjs'; import * as cliConfig from './config.mjs'; import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs'; diff --git a/packages/cli/src/thread.mjs b/packages/cli/src/thread.mjs index ff7e80f1a..f4199234a 100644 --- a/packages/cli/src/thread.mjs +++ b/packages/cli/src/thread.mjs @@ -9,7 +9,7 @@ import { CliError } from "./errors.mjs"; import { orgSelector } from "./org-selector.mjs"; import { shortId } from "./format.mjs"; -import { configDir, loadBusiness } from "../../business/src/index.mjs"; +import { configDir, loadBusiness } from "../../organization/src/index.mjs"; import { rolesDir } from "./config.mjs"; export const THREADS_USAGE = "mosaic threads [--project ] [--business ] [--json] [--tester]"; diff --git a/packages/cli/tests/access-boot.test.mjs b/packages/cli/tests/access-boot.test.mjs index 7c4444c0b..c9337d6a4 100644 --- a/packages/cli/tests/access-boot.test.mjs +++ b/packages/cli/tests/access-boot.test.mjs @@ -9,7 +9,7 @@ import assert from "node:assert/strict"; import { chmodSync, symlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { startBroker } from "../../bus/src/runtime.mjs"; -import { checkAccess } from "../../business/src/index.mjs"; +import { checkAccess } from "../../organization/src/index.mjs"; import { accessContextFor } from "../src/access.mjs"; import { bootConfig, loadSystem } from "../src/config.mjs"; import { fixture, tmp } from "./helpers.mjs"; diff --git a/packages/cli/tests/boot-status.test.mjs b/packages/cli/tests/boot-status.test.mjs index bbf3f3e1e..6bd1dad97 100644 --- a/packages/cli/tests/boot-status.test.mjs +++ b/packages/cli/tests/boot-status.test.mjs @@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url"; import { fixture, tmp } from "./helpers.mjs"; import { bootEventsFile, bootLine, bootStatusFile, readBootStatus, recordBootOk, recordBootRefused } from "../src/boot-status.mjs"; import { hostFile } from "../src/host.mjs"; -import { reserveOrg, activateOrg } from "../../business/src/org-registry.mjs"; +import { reserveOrg, activateOrg } from "../../organization/src/org-registry.mjs"; const CLI = fileURLToPath(new URL("../src/cli.mjs", import.meta.url)); const run = (f, ...args) => spawnSync(process.execPath, [CLI, ...args], { env: f.env, encoding: "utf8", timeout: 40000 }); diff --git a/packages/cli/tests/business-imports.mjs b/packages/cli/tests/business-imports.mjs index 4cc223780..a6b45a061 100644 --- a/packages/cli/tests/business-imports.mjs +++ b/packages/cli/tests/business-imports.mjs @@ -1,147 +1,3 @@ -// Row 333 R2 (#1831): the files outside packages/business/src that business code imports, found by -// reading its imports. A copy fixture carries these beside packages/business/src, so a new import of -// a plug-in source file (row 333's model-quota.mjs) does not break the release tests the way it did -// when each fixture named kinds.mjs alone. A real copy ships all of packages/plugins (scripts/ -// stack-copy.sh COPY_PATHS); a fixture models a smaller one and takes only what the loader reads. -// -// Vikunja #779: the helper reads imports with a regex, not a parser, so it stays strict about what -// it cannot read. Comments are blanked before matching, so an import in a comment is not one, and an -// import form inside a string or a regular expression is not one either. A dynamic import written -// with a space, `import ('./x.mjs')`, counts, and so does one after a spread, `{...import('./x.mjs')}`; -// a property call such as `x.import(...)` or `x?.import(...)` does not. `1..import(...)`, a property -// call on a number, reads as an import too, which errs toward refusal. These throw: a dynamic import -// whose argument is not a plain string, a dynamic import inside a template substitution (${...}), a -// file with an unterminated string, comment, template or regular expression, a target that is missing -// or not a regular file, and a target whose real path leaves the repository. A substitution with no -// dynamic import is blanked with the rest of the template, as is `${import.meta.url}`; the text of a -// template and a substitution that holds a template of its own are read to their ends. -// The scanner knows line and block comments, quoted strings, template literals, regular expression -// literals (by the usual guess from the previous token) and a hashbang. The guess is wrong for a -// regular expression after `)` or a keyword, as in `return /x/.test(y)`. After `++` or `--` a slash -// is division when the character before the pair ends an operand (`n++ / 2`) and starts a regular -// expression when it does not (`x = ++/re/.lastIndex`). That misreads one rare form as division: a -// prefix `++` or `--` applied to a regular expression's member right after `)`, `]`, a word or a -// line break, as in `if (c) ++/re/.lastIndex`. -import {readdirSync,readFileSync,realpathSync,statSync} from 'node:fs'; -import {dirname,join,relative,resolve,sep} from 'node:path'; - -// `import` as a word that is not a property name. The one rule for both patterns below: a word or $ -// before it, or a single dot (x.import, x?.import), makes it a property call; a dot that follows -// another dot is the end of a spread (...import), which evaluates the call, so it is not excluded. -const IMPORT_WORD=/(?//index.mjs after checking that path, and a real copy ships all of -// packages/plugins, so a fixture that never loads a plug-in needs nothing more from it. -const LOADER_FILE='packages/plugins/src/loader.mjs',LOADER_IMPORT=/^import\(pathToFileURL\(expected\)\.href\)/; -const REGEX_AFTER='(,=:[!&|?{};+-*%<>~^'; -const inert=(literals,index)=>literals.some(([from,to])=>index>=from&&indexreaddirSync(dir,{withFileTypes:true}).flatMap((e)=>e.isDirectory()?sources(join(dir,e.name)):e.name.endsWith('.mjs')?[join(dir,e.name)]:[]); - -// The text with every comment blanked and every template literal emptied, and the ranges of the -// quoted strings and regular expressions left in it, as [from,to) offsets into the returned code. -// Throws on a string, comment, template or regular expression that never ends, and on a dynamic -// import inside a template substitution. -function scan(text){ - let code='',i=0,previous='',before=''; - const literals=[],open=[]; - const fail=(what)=>{throw new Error(`unterminated ${what}`);}; - const mark=(token)=>{before=previous;previous=token;}; - const keep=(end,last)=>{literals.push([code.length,code.length+end-i]);code+=text.slice(i,end);i=end;mark(last);}; - // Template text from i to the closing backtick, or to the ${ that opens a substitution, which is then read as code. - const template=()=>{ - for(;i{ - for(const m of code.slice(start).matchAll(DYNAMIC))if(!inert(literals,start+m.index))throw new Error('unsupported import inside a template substitution'); - code=code.slice(0,start); - while(literals.length&&literals[literals.length-1][0]>=start)literals.pop(); - }; - if(text.startsWith('#!')){const end=text.indexOf('\n');i=end<0?text.length:end;} - while(i=text.length)fail('string'); - keep(j+1,c);continue; - } - if(c==='`'){code+='``';i++;template();continue;} - if(c==='/'&&(previous===''||REGEX_AFTER.includes(previous))){ - let j=i+1,inClass=false; - for(;j=text.length)fail('regular expression'); - keep(j+1,'/');continue; - } - if(open.length&&(c==='{'||c==='}')){ - const top=open[open.length-1]; - if(c==='{')top.braces++; - else if(top.braces===0){close(open.pop());i++;template();continue;} - else top.braces--; - } - code+=c;i++; - if(!/\s/.test(c)){ - const postfix=(c==='+'||c==='-')&&previous===c&&text[i-2]===c&&before!==''&&!REGEX_AFTER.includes(before); - mark(postfix?c+c:c); - } - } - if(open.length)fail('template'); - return {code,literals}; -} - -// The specifiers a source file imports by a relative path. A dynamic import of anything but a plain -// string throws, as does a file the scanner cannot read to its end. -function specifiers(file,text){ - let scanned; - try{scanned=scan(text);} - catch(error){throw new Error(`${file} cannot be read for imports: ${error.message}`);} - const {code,literals}=scanned,live=(m)=>!inert(literals,m.index); - for(const m of code.matchAll(DYNAMIC)){ - const at=code.slice(m.index,m.index+512); - if(file===LOADER_FILE&&LOADER_IMPORT.test(at))continue; - if(live(m)&&!DYNAMIC_LITERAL.test(at))throw new Error(`${file} has a dynamic import that is not a plain string literal`); - } - return [...code.matchAll(IMPORT)].filter(live).map((m)=>m[1]??m[2]??m[3]); -} - -// Repo-relative paths, sorted: every file that a relative import from packages/business/src reaches -// outside it, following the imports of those files in turn. An import of a missing file, of anything -// but a regular file, or of a file whose real path is outside the repository throws, wherever the -// importing file sits, so an import of a missing file inside packages/business/src throws too. -export function businessImportedFiles(repo){ - const own=join(repo,'packages','business','src')+sep,found=new Set(),checked=new Set(),todo=sources(own),root=realpathSync(repo)+sep; - for(let file;(file=todo.pop())!==undefined;){ - for(const spec of specifiers(relative(repo,file),readFileSync(file,'utf8'))){ - const target=resolve(dirname(file),spec),from=`${relative(repo,file)} imports ${spec}`; - if(!checked.has(target)){ - let info,real; - try{info=statSync(target);real=realpathSync(target);} - catch(error){throw error.code==='ENOENT'||error.code==='ENOTDIR'?new Error(`${from}, which does not exist`):error;} - if(!info.isFile())throw new Error(`${from}, which is not a regular file`); - if(!real.startsWith(root))throw new Error(`${from}, which is outside the repository`); - checked.add(target); - } - if(target.startsWith(own)||found.has(target))continue; - found.add(target); - if(target.endsWith('.mjs'))todo.push(target); - } - } - return [...found].map((f)=>relative(repo,f)).sort(); -} +// Old name of organization-imports.mjs (rename r2 stage B, "preserving needed test entry +// compatibility"): the same walker, not a copy. +export { organizationImportedFiles, organizationImportedFiles as businessImportedFiles } from './organization-imports.mjs'; diff --git a/packages/cli/tests/business-imports.test.mjs b/packages/cli/tests/business-imports.test.mjs deleted file mode 100644 index dfa4e09b7..000000000 --- a/packages/cli/tests/business-imports.test.mjs +++ /dev/null @@ -1,291 +0,0 @@ -// Vikunja #779: the business-imports helper on synthetic trees. Every tree is built under a private -// temp directory with the repository one level down, so a test can put a file outside the repository. -import test, {after} from 'node:test'; -import assert from 'node:assert/strict'; -import {mkdirSync,mkdtempSync,rmSync,symlinkSync,writeFileSync} from 'node:fs'; -import {tmpdir} from 'node:os'; -import {dirname,join} from 'node:path'; -import {fileURLToPath} from 'node:url'; -import {businessImportedFiles} from './business-imports.mjs'; - -const ROOT=fileURLToPath(new URL('../../..',import.meta.url)).replace(/\/$/,''); -const base=mkdtempSync(join(tmpdir(),'bi-')); -after(()=>rmSync(base,{recursive:true,force:true})); -const BIZ='repo/packages/business/src/',EXT='repo/packages/other/src/',EMPTY='export {};\n'; -let count=0; -// files: path under the tree's top directory -> text. links: path -> target path, both under it. -function tree(files,links={}){ - const top=join(base,`t${++count}`); - for(const [path,text] of Object.entries(files)){mkdirSync(dirname(join(top,path)),{recursive:true});writeFileSync(join(top,path),text);} - for(const [path,target] of Object.entries(links)){mkdirSync(dirname(join(top,path)),{recursive:true});symlinkSync(join(top,target),join(top,path));} - return join(top,'repo'); -} -const withBusiness=(text,extra={})=>tree({[`${BIZ}a.mjs`]:text,...extra}); - -test('the real tree gives the plug-in files the loader reads', ()=>{ - const found=businessImportedFiles(ROOT); - assert.ok(found.includes('packages/plugins/src/kinds.mjs'),found.join(' ')); - assert.ok(found.includes('packages/plugins/src/model-quota.mjs'),found.join(' ')); - assert.deepEqual(found,[...found].sort()); -}); - -test('the loader exception holds only for the loader file and its exact computed import', ()=>{ - const text="const module=await import(pathToFileURL(expected).href);\n"; - const at=(file)=>tree({[`${BIZ}a.mjs`]:"import '../../plugins/src/loader.mjs';\n",[`repo/${file}`]:text}); - assert.deepEqual(businessImportedFiles(at('packages/plugins/src/loader.mjs')),['packages/plugins/src/loader.mjs']); - assert.throws(()=>businessImportedFiles(tree({[`${BIZ}a.mjs`]:"import '../../plugins/src/other.mjs';\n",'repo/packages/plugins/src/other.mjs':text})),/not a plain string literal/); - assert.throws(()=>businessImportedFiles(tree({[`${BIZ}a.mjs`]:"import '../../plugins/src/loader.mjs';\n",'repo/packages/plugins/src/loader.mjs':"await import(x);\n"})),/not a plain string literal/); -}); - -test('every static form and a literal dynamic import is found, and the imports of what it finds in turn', ()=>{ - const repo=withBusiness([ - "import x from '../../other/src/from.mjs';", - "import {\n y,\n z,\n} from '../../other/src/multi.mjs';", - "import '../../other/src/bare.mjs';", - "export * from '../../other/src/reexport.mjs';", - "export {w} from '../../other/src/named.mjs';", - "const d=await import('../../other/src/dynamic.mjs');", - "const n=await import(\n '../../other/src/newline.mjs'\n);", - ].join('\n'),{ - [`${EXT}from.mjs`]:"import '../../third/src/deep.mjs';\n", - 'repo/packages/third/src/deep.mjs':EMPTY, - ...Object.fromEntries(['multi','bare','reexport','named','dynamic','newline'].map(n=>[`${EXT}${n}.mjs`,EMPTY])), - }); - assert.deepEqual(businessImportedFiles(repo),[ - ...['bare','dynamic','from','multi','named','newline','reexport'].map(n=>`packages/other/src/${n}.mjs`), - 'packages/third/src/deep.mjs', - ]); -}); - -test('#779 a dynamic import written with a space or a line break before the bracket is found', ()=>{ - const repo=withBusiness("const s=await import ('../../other/src/spaced.mjs');\nconst t=await import\n ('../../other/src/broken.mjs');\n", - {[`${EXT}spaced.mjs`]:EMPTY,[`${EXT}broken.mjs`]:EMPTY}); - assert.deepEqual(businessImportedFiles(repo),['packages/other/src/broken.mjs','packages/other/src/spaced.mjs']); -}); - -test('#779 an import in a line or block comment is not an import, and one after a comment is', ()=>{ - const repo=withBusiness([ - "// import '../../other/src/missing1.mjs';", - "/* import x from '../../other/src/missing2.mjs'; */", - "/*\n import('../../other/src/missing3.mjs')\n*/", - "const x=1; // import '../../other/src/missing4.mjs'", - "import '../../other/src/real.mjs'; // import('../../other/src/missing5.mjs')", - "// import(name)", - "/* import(name) */ import '../../other/src/after.mjs';", - ].join('\n'),{[`${EXT}real.mjs`]:EMPTY,[`${EXT}after.mjs`]:EMPTY}); - assert.deepEqual(businessImportedFiles(repo),['packages/other/src/after.mjs','packages/other/src/real.mjs']); -}); - -test('#779 comment markers inside strings, templates and regular expressions do not hide an import', ()=>{ - const repo=withBusiness([ - "const url='http://example.invalid/a'; import '../../other/src/after-url.mjs';", - "const glob=\"/* open\"; import '../../other/src/after-glob.mjs';", - "const tpl=`// not a comment ${1}`; import '../../other/src/after-template.mjs';", - "const re=/['\"]\\/\\/x/; import '../../other/src/after-regex.mjs';", - "const half=a / 2;\nconst mid=(a+b) / 2;\nimport '../../other/src/after-divide.mjs';", - ].join('\n'),Object.fromEntries(['url','glob','template','regex','divide'].map(n=>[`${EXT}after-${n}.mjs`,EMPTY]))); - assert.deepEqual(businessImportedFiles(repo),['url','glob','template','regex','divide'].map(n=>`packages/other/src/after-${n}.mjs`).sort()); -}); - -test('#779 an import form inside a string or a regular expression is not an import', ()=>{ - const repo=withBusiness([ - "const a=\"import '../../other/src/missing1.mjs'\";", - "const b='import(name)';", - "const c=/import (name)/;", - "const d=\"export {x} from '../../other/src/missing2.mjs'\";", - "import '../../other/src/real.mjs';", - ].join('\n'),{[`${EXT}real.mjs`]:EMPTY}); - assert.deepEqual(businessImportedFiles(repo),['packages/other/src/real.mjs']); -}); - -test('#779 a hashbang line is skipped and a bare slash in it does not start a regular expression', ()=>{ - const repo=withBusiness("#!/usr/bin/env node\nimport '../../other/src/real.mjs';\n",{[`${EXT}real.mjs`]:EMPTY}); - assert.deepEqual(businessImportedFiles(repo),['packages/other/src/real.mjs']); - const bare=withBusiness("#!/odd\nimport '../../other/src/real.mjs';\n",{[`${EXT}real.mjs`]:EMPTY}); - assert.deepEqual(businessImportedFiles(bare),['packages/other/src/real.mjs']); -}); - -test('#779 a dynamic import of anything but a plain string throws, and what only looks like one does not', ()=>{ - for(const argument of ['name',"'../../other/src/' + name",'`../../other/src/${name}.mjs`',"'../../other/src/a.mjs', {with:{type:'json'}}","'../../other/src/a.mjs' + ''"]){ - const repo=withBusiness(`const m=await import(${argument});\n`,{[`${EXT}a.mjs`]:EMPTY}); - assert.throws(()=>businessImportedFiles(repo),/business\/src\/a\.mjs has a dynamic import that is not a plain string literal/,argument); - } - const repo=withBusiness([ - 'const u=import.meta.url;','loader.import(thing);','reimport(thing);','$import(thing);', - "const fs=await import('node:fs');","const s='import(name)';", - ].join('\n')); - assert.deepEqual(businessImportedFiles(repo),[]); -}); - -test('#779 a file the scanner cannot read to its end throws and names the file, business or external', ()=>{ - for(const [what,text] of [['string',"const a='open;\n"],['string','const a="open'],['comment',"/* open\nimport '../../other/src/a.mjs';\n"],['template','const a=`open'],['regular expression','const a=/open\n']]){ - const repo=withBusiness(text,{[`${EXT}a.mjs`]:EMPTY}); - assert.throws(()=>businessImportedFiles(repo),new RegExp(`packages/business/src/a\\.mjs cannot be read for imports: unterminated ${what}`),text); - } - const repo=withBusiness("import '../../other/src/a.mjs';\n",{[`${EXT}a.mjs`]:"/* never closed\n"}); - assert.throws(()=>businessImportedFiles(repo),/packages\/other\/src\/a\.mjs cannot be read for imports: unterminated comment/); -}); - -test('#779 an import of a file that is missing throws, inside packages/business/src or out of it', ()=>{ - const inside=withBusiness("import './missing.mjs';\n"); - assert.throws(()=>businessImportedFiles(inside),/packages\/business\/src\/a\.mjs imports \.\/missing\.mjs, which does not exist/); - const outside=withBusiness("import '../../other/src/missing.mjs';\n"); - assert.throws(()=>businessImportedFiles(outside),/imports \.\.\/\.\.\/other\/src\/missing\.mjs, which does not exist/); - const broken=tree({[`${BIZ}a.mjs`]:"import '../../other/src/link.mjs';\n"},{[`${EXT}link.mjs`]:'gone.mjs'}); - assert.throws(()=>businessImportedFiles(broken),/which does not exist/); - const beyond=withBusiness("import '../../other/src/a.mjs/b.mjs';\n",{[`${EXT}a.mjs`]:EMPTY}); - assert.throws(()=>businessImportedFiles(beyond),/which does not exist/); -}); - -test('#779 an import of a directory throws', ()=>{ - const repo=withBusiness("import '../../other/src/dir';\n",{[`${EXT}dir/index.mjs`]:EMPTY}); - assert.throws(()=>businessImportedFiles(repo),/imports \.\.\/\.\.\/other\/src\/dir, which is not a regular file/); -}); - -test('#779 a target outside the repository throws, by a path that climbs out or by a symlink', ()=>{ - const climbs=tree({[`${BIZ}a.mjs`]:"import '../../../../outside.mjs';\n",'outside.mjs':EMPTY}); - assert.throws(()=>businessImportedFiles(climbs),/imports \.\.\/\.\.\/\.\.\/\.\.\/outside\.mjs, which is outside the repository/); - const sibling=tree({[`${BIZ}a.mjs`]:"import '../../../../repo2/x.mjs';\n",'repo2/x.mjs':EMPTY}); - assert.throws(()=>businessImportedFiles(sibling),/which is outside the repository/); - const linked=tree({[`${BIZ}a.mjs`]:"import '../../other/src/link.mjs';\n",'outside.mjs':EMPTY},{[`${EXT}link.mjs`]:'outside.mjs'}); - assert.throws(()=>businessImportedFiles(linked),/imports \.\.\/\.\.\/other\/src\/link\.mjs, which is outside the repository/); - const folder=tree({[`${BIZ}a.mjs`]:"import '../../other/src/out/x.mjs';\n",'outside/x.mjs':EMPTY},{[`${EXT}out`]:'outside'}); - assert.throws(()=>businessImportedFiles(folder),/which is outside the repository/); -}); - -test('#779 a symlink that stays inside the repository, and a repository reached by a symlink, are fine', ()=>{ - const repo=tree({[`${BIZ}a.mjs`]:"import '../../other/src/link.mjs';\n",'repo/packages/third/real.mjs':EMPTY},{[`${EXT}link.mjs`]:'repo/packages/third/real.mjs'}); - assert.deepEqual(businessImportedFiles(repo),['packages/other/src/link.mjs']); - const via=join(base,`via${++count}`); - symlinkSync(repo,via); - assert.deepEqual(businessImportedFiles(via),['packages/other/src/link.mjs']); -}); - -const SUBSTITUTION=/packages\/business\/src\/a\.mjs cannot be read for imports: unsupported import inside a template substitution/; - -test('#779 R2 a dynamic import inside a template substitution throws, whether the target exists, is missing or is outside', ()=>{ - const exists=withBusiness("const v=`${await import('../../other/src/real.mjs')}`;\n",{[`${EXT}real.mjs`]:EMPTY}); - assert.throws(()=>businessImportedFiles(exists),SUBSTITUTION); - const missing=withBusiness("const v=`${await import('../../other/src/real.mjs')}`;\n"); - assert.throws(()=>businessImportedFiles(missing),SUBSTITUTION); - const outside=tree({[`${BIZ}a.mjs`]:"const v=`${await import('../../../../outside.mjs')}`;\n",'outside.mjs':EMPTY}); - assert.throws(()=>businessImportedFiles(outside),SUBSTITUTION); -}); - -test('#779 R2 a dynamic import inside a substitution throws however the substitution is written', ()=>{ - for(const source of [ - "const v=`a ${b} ${await import ('../../other/src/real.mjs')} c`;", - "const v=`${await import(name)}`;", - "const v=`${/* c */ import('../../other/src/real.mjs') /* d */}`;", - "const v=`x ${`y ${await import('../../other/src/real.mjs')}`}`;", - "const v=`${ {a:1}.a + '}' + (await import('../../other/src/real.mjs')) }`;", - "const v=`${\n await import(\n '../../other/src/real.mjs'\n )\n}`;", - ]){ - const repo=withBusiness(`${source}\n`,{[`${EXT}real.mjs`]:EMPTY}); - assert.throws(()=>businessImportedFiles(repo),SUBSTITUTION,source); - } - const external=withBusiness("import '../../other/src/a.mjs';\n",{[`${EXT}a.mjs`]:"const v=`${await import('./b.mjs')}`;\n",[`${EXT}b.mjs`]:EMPTY}); - assert.throws(()=>businessImportedFiles(external),/packages\/other\/src\/a\.mjs cannot be read for imports: unsupported import inside a template substitution/); -}); - -test('#779 R2 a substitution without a dynamic import is allowed, and the import around it is found', ()=>{ - const repo=withBusiness([ - "const a=`plain ${b} text ${c.d({e:1})} end`;", - "const u=`${import.meta.url}/${x.import}/${loader.import('y')}`;", - "const s=`${'}'}${\"import('../../other/src/missing1.mjs')\"}${/import('..\\/x')/.source}`;", - "const t=`text import('../../other/src/missing2.mjs') ${a} import '../../other/src/missing3.mjs'`;", - "const e=`\\${await import('../../other/src/missing4.mjs')}`;", - "const n=`outer ${`inner ${a}`} tail`;", - "import '../../other/src/real.mjs';", - ].join('\n'),{[`${EXT}real.mjs`]:EMPTY}); - assert.deepEqual(businessImportedFiles(repo),['packages/other/src/real.mjs']); -}); - -test('#779 R2 a substitution or the template text after it that never ends throws', ()=>{ - for(const text of ['const a=`${b','const a=`${b} open','const a=`${`${b}']){ - const repo=withBusiness(text); - assert.throws(()=>businessImportedFiles(repo),/packages\/business\/src\/a\.mjs cannot be read for imports: unterminated template/,text); - } -}); - -// Each case is its own test, so one failing case does not hide the next. -for(const [what,make] of [ - ['an existing target',()=>withBusiness("const v=`${({...import('../../other/src/real.mjs')})}`;\n",{[`${EXT}real.mjs`]:EMPTY})], - ['a missing target',()=>withBusiness("const v=`${({...import('../../other/src/real.mjs')})}`;\n")], - ['a target outside the repository',()=>tree({[`${BIZ}a.mjs`]:"const v=`${({...import('../../../../outside.mjs')})}`;\n",'outside.mjs':EMPTY})], - ['an array spread of an existing target',()=>withBusiness("const v=`${[...import('../../other/src/real.mjs')]}`;\n",{[`${EXT}real.mjs`]:EMPTY})], -]){ - test(`#779 R3 a spread before import inside a template substitution throws: ${what}`,()=>{ - assert.throws(()=>businessImportedFiles(make()),SUBSTITUTION); - }); -} - -for(const [what,source,files] of [ - ['object spread',"const o={...import('../../other/src/real.mjs')};",{[`${EXT}real.mjs`]:EMPTY}], - ['array spread',"const l=[...import('../../other/src/real.mjs')];",{[`${EXT}real.mjs`]:EMPTY}], -]){ - test(`#779 R3 a spread before import in plain code is a dynamic import and is found when its argument is a plain string: ${what}`,()=>{ - assert.deepEqual(businessImportedFiles(withBusiness(`${source}\n`,files)),['packages/other/src/real.mjs']); - }); -} - -test('#779 R3 a spread before import in plain code reaches the target checks: a missing target throws', ()=>{ - const missing=withBusiness("const o={...import('./missing.mjs')};\n"); - assert.throws(()=>businessImportedFiles(missing),/packages\/business\/src\/a\.mjs imports \.\/missing\.mjs, which does not exist/); -}); - -for(const source of ['const o={...import(name)};','const l=[...import(name)];']){ - test(`#779 R3 a spread before import in plain code is refused when its argument is not a plain string: ${source}`,()=>{ - const repo=withBusiness(`${source}\n`); - assert.throws(()=>businessImportedFiles(repo),/business\/src\/a\.mjs has a dynamic import that is not a plain string literal/); - }); -} - -test('#779 R3 a property call of import, x.import or x?.import, in plain code is not an import', ()=>{ - const repo=withBusiness([ - "loader.import('../../other/src/missing1.mjs');", - "loader?.import('../../other/src/missing2.mjs');", - 'loader.import(name);', - 'loader?.import(name);', - "import '../../other/src/real.mjs';", - ].join('\n'),{[`${EXT}real.mjs`]:EMPTY}); - assert.deepEqual(businessImportedFiles(repo),['packages/other/src/real.mjs']); -}); - -test('#779 R3 a property call of import, x.import or x?.import, in a substitution is not an import and is not refused', ()=>{ - const repo=withBusiness([ - "const u=`${loader.import('../../other/src/missing3.mjs')}${loader?.import('../../other/src/missing4.mjs')}`;", - 'const w=`${loader.import(name)}${loader?.import(name)}`;', - "import '../../other/src/real.mjs';", - ].join('\n'),{[`${EXT}real.mjs`]:EMPTY}); - assert.deepEqual(businessImportedFiles(repo),['packages/other/src/real.mjs']); -}); - -test('#779 R2 a slash after a postfix ++ or -- is division, not a regular expression', ()=>{ - for(const op of ['++','--']){ - const repo=withBusiness([ - 'let n=2;', - `const half=n${op} / 2;`, - `const spaced=n ${op}/2;`, - `const member=arr[0]${op} / 2;`, - `const grouped=(n)${op} / 2 / 3;`, - "import '../../other/src/real.mjs';", - ].join('\n'),{[`${EXT}real.mjs`]:EMPTY}); - assert.deepEqual(businessImportedFiles(repo),['packages/other/src/real.mjs'],op); - } -}); - -test('#779 R2 a regular expression after an operator, a bracket or a prefix ++ or -- is still a regular expression', ()=>{ - // Each body holds a quote, which would start an unterminated string if the slash were read as division. - const repo=withBusiness([ - "const a = /it's/;", - "if (/it's/.test(s)) {}", - "const b = f(1, /it's \\/ x/);", - "const c = a + /it's/.source;", - "const d = a++ + /it's/.source;", - "const e = ++/it's/.lastIndex;", - "const f = 1 + --/it's/.lastIndex;", - "import '../../other/src/real.mjs';", - ].join('\n'),{[`${EXT}real.mjs`]:EMPTY}); - assert.deepEqual(businessImportedFiles(repo),['packages/other/src/real.mjs']); -}); diff --git a/packages/cli/tests/case2-install.test.mjs b/packages/cli/tests/case2-install.test.mjs index da4678084..5dbcd2264 100644 --- a/packages/cli/tests/case2-install.test.mjs +++ b/packages/cli/tests/case2-install.test.mjs @@ -71,7 +71,7 @@ function world(t, { profile = "library" } = {}) { script(join(bin, "stack-copy"), `echo "stack-copy $*" >>'${log}' [ "$1" = copy ] && [ "$3" = --copies ] || exit 4 mkdir -p "$4/$2/scripts" && : >"$4/$2/scripts/mosaic" && echo "copied: $4/$2" -for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/business/src/vocabulary.mjs packages/business/src/selector.mjs packages/plugins/model-provider/cliproxy/index.mjs; do +for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/business/src/vocabulary.mjs packages/business/src/selector.mjs packages/organization/src/vocabulary.mjs packages/organization/src/selector.mjs packages/plugins/model-provider/cliproxy/index.mjs; do mkdir -p "$4/$2/$(dirname "$file")" && cp '${REPO}'"$file" "$4/$2/$file" || exit 1 done mkdir -p "$4/$2/node_modules" && cp -R '${REPO}node_modules/yaml' "$4/$2/node_modules/yaml"`); diff --git a/packages/cli/tests/channel-fixture.mjs b/packages/cli/tests/channel-fixture.mjs index 3fe9056d4..c3454dbbd 100644 --- a/packages/cli/tests/channel-fixture.mjs +++ b/packages/cli/tests/channel-fixture.mjs @@ -6,8 +6,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { fixture, BUSINESSES } from "./helpers.mjs"; -import { systemFor } from "../../business/tests/helpers.mjs"; -import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs'; +import { systemFor } from "../../organization/tests/helpers.mjs"; +import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs'; export async function childResult(file, args, env, input) { const child = spawn(file, args, { env, stdio: ["pipe", "pipe", "pipe"] }); diff --git a/packages/cli/tests/cli.test.mjs b/packages/cli/tests/cli.test.mjs index ec692e7d6..2485fce17 100644 --- a/packages/cli/tests/cli.test.mjs +++ b/packages/cli/tests/cli.test.mjs @@ -5,7 +5,7 @@ import { main, readNotifyConfig } from "../src/cli.mjs"; import { CliError } from "../src/errors.mjs"; import { hostDir, hostFile, startTimeOf } from "../src/host.mjs"; import { broker, io, notifyConfig, tmp } from "./helpers.mjs"; -import { writeJson } from "../../business/tests/helpers.mjs"; +import { writeJson } from "../../organization/tests/helpers.mjs"; import { join } from "node:path"; const exitOf = async (p) => { diff --git a/packages/cli/tests/compactions-launch.test.mjs b/packages/cli/tests/compactions-launch.test.mjs index 5b697ce17..4f8484067 100644 --- a/packages/cli/tests/compactions-launch.test.mjs +++ b/packages/cli/tests/compactions-launch.test.mjs @@ -20,7 +20,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, rmSync, truncateSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { launchDoc } from "../../business/tests/helpers.mjs"; +import { launchDoc } from "../../organization/tests/helpers.mjs"; import { BusError } from "../../bus/src/broker.mjs"; import { startBroker } from "../../bus/src/runtime.mjs"; import { Store } from "../../bus/src/store.mjs"; diff --git a/packages/cli/tests/config.test.mjs b/packages/cli/tests/config.test.mjs index 8d86daa1e..dd3d64507 100644 --- a/packages/cli/tests/config.test.mjs +++ b/packages/cli/tests/config.test.mjs @@ -2,7 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { join } from "node:path"; import { bootConfig, loadSystem } from "../src/config.mjs"; -import { writeJson } from "../../business/tests/helpers.mjs"; +import { writeJson } from "../../organization/tests/helpers.mjs"; import { fixture, tmp } from "./helpers.mjs"; test("bootConfig builds the broker's boot message for one business, with no trackers key when no project names one", (t) => { diff --git a/packages/cli/tests/federation-acceptance.test.mjs b/packages/cli/tests/federation-acceptance.test.mjs index a873c16fd..e37d1d170 100644 --- a/packages/cli/tests/federation-acceptance.test.mjs +++ b/packages/cli/tests/federation-acceptance.test.mjs @@ -10,7 +10,7 @@ import { closeSync, cpSync, existsSync, mkdirSync, openSync, readFileSync, write import { request } from 'node:http'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { businessDoc, launchDoc, writeJson } from '../../business/tests/helpers.mjs'; +import { businessDoc, launchDoc, writeJson } from '../../organization/tests/helpers.mjs'; import { REPO_SKILLS } from '../../harness/tests/helpers.mjs'; import { DatabaseSync } from 'node:sqlite'; import { SESSION_COOKIE } from '../../webui/src/sso.mjs'; diff --git a/packages/cli/tests/fixtures/request-agent.mjs b/packages/cli/tests/fixtures/request-agent.mjs index f208fecd9..027d5ed04 100644 --- a/packages/cli/tests/fixtures/request-agent.mjs +++ b/packages/cli/tests/fixtures/request-agent.mjs @@ -8,8 +8,8 @@ import { DatabaseSync } from 'node:sqlite'; import { startBroker } from '../../../bus/src/runtime.mjs'; import { createRequestStore } from '../../../bus/src/request-store.mjs'; import { bootConfig, loadSystem } from '../../src/config.mjs'; -import { loadBusiness, resolveInstance, systemVars } from '../../../business/src/index.mjs'; -import { limitHit } from '../../../business/src/launch.mjs'; +import { loadBusiness, resolveInstance, systemVars } from '../../../organization/src/index.mjs'; +import { limitHit } from '../../../organization/src/launch.mjs'; import { tasksAdapter } from '../../../tasks/src/adapter.mjs'; import { createGitSocket } from '../../src/git-socket.mjs'; import { pushWorking, fetchReview } from '../../../harness/src/git-tools.mjs'; diff --git a/packages/cli/tests/git-socket.test.mjs b/packages/cli/tests/git-socket.test.mjs index 6150c1d81..a5d4fb29d 100644 --- a/packages/cli/tests/git-socket.test.mjs +++ b/packages/cli/tests/git-socket.test.mjs @@ -16,7 +16,7 @@ import { tmpdir } from "node:os"; import { Duplex } from "node:stream"; import { join } from "node:path"; import { Client } from "../../bus/src/client.mjs"; -import { configDir, loadBusiness } from "../../business/src/index.mjs"; +import { configDir, loadBusiness } from "../../organization/src/index.mjs"; import { gitEnv } from "../../seat/src/checkout.mjs"; import { gitSocketPath } from "../../seat/src/session.mjs"; import { workingRepo } from "../../seat/src/working.mjs"; diff --git a/packages/cli/tests/helpers.mjs b/packages/cli/tests/helpers.mjs index 94eeb4a0f..3146ed8c5 100644 --- a/packages/cli/tests/helpers.mjs +++ b/packages/cli/tests/helpers.mjs @@ -9,12 +9,12 @@ import { PassThrough } from "node:stream"; import { pathToFileURL } from "node:url"; import { BusError, Broker } from "../../bus/src/broker.mjs"; import { Store } from "../../bus/src/store.mjs"; -import { businessDoc, rolesCopy, systemConfig, writeJson } from "../../business/tests/helpers.mjs"; +import { businessDoc, rolesCopy, systemConfig, writeJson } from "../../organization/tests/helpers.mjs"; import { REPO_SKILLS } from "../../harness/tests/helpers.mjs"; import { tree } from "../../plugins/tests/helpers.mjs"; import { SESSION_CREDENTIALS } from "../../seat/src/session.mjs"; import { AGENT_MARKERS } from "../src/transport.mjs"; -import { reserveOrg, activateOrg } from "../../business/src/org-registry.mjs"; +import { reserveOrg, activateOrg } from "../../organization/src/org-registry.mjs"; // Row 332 R2 (#1830): every host variable a launch reads as a login. The // session login names come from the seat; the other three are what pi's diff --git a/packages/cli/tests/host.test.mjs b/packages/cli/tests/host.test.mjs index 10fd1e1c2..f2343ca40 100644 --- a/packages/cli/tests/host.test.mjs +++ b/packages/cli/tests/host.test.mjs @@ -11,7 +11,7 @@ import { Client } from "../../bus/src/client.mjs"; import { bootConfig, loadSystem, REPO } from "../src/config.mjs"; import { claimHostLock, hostDir, hostFile, hostLock, hostStatus, readHostState, startHost, startTimeOf, stopHost, watchChildren } from "../src/host.mjs"; import { journalPath } from "../src/notifier.mjs"; -import { reserveOrg, activateOrg } from "../../business/src/org-registry.mjs"; +import { reserveOrg, activateOrg } from "../../organization/src/org-registry.mjs"; import { busStart, busStatus, busStop } from "../src/org-bus.mjs"; import { IDS, makeDeployment } from "../../discord/tests/helpers.mjs"; import { fixture, notifyConfig, OPTIONS, tmp } from "./helpers.mjs"; diff --git a/packages/cli/tests/idle-stop-race.test.mjs b/packages/cli/tests/idle-stop-race.test.mjs index 46dfe4b54..f67e376de 100644 --- a/packages/cli/tests/idle-stop-race.test.mjs +++ b/packages/cli/tests/idle-stop-race.test.mjs @@ -13,7 +13,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { launchDoc } from "../../business/tests/helpers.mjs"; +import { launchDoc } from "../../organization/tests/helpers.mjs"; import { BusError } from "../../bus/src/broker.mjs"; import { startBroker } from "../../bus/src/runtime.mjs"; import { Store } from "../../bus/src/store.mjs"; diff --git a/packages/cli/tests/launch-limits.test.mjs b/packages/cli/tests/launch-limits.test.mjs index 55b9d4bbf..0e1202d36 100644 --- a/packages/cli/tests/launch-limits.test.mjs +++ b/packages/cli/tests/launch-limits.test.mjs @@ -6,7 +6,7 @@ import {readFileSync,writeFileSync} from 'node:fs'; import {createLauncher} from '../src/launcher.mjs'; import {initializeControl} from '../../processing-control/src/state.mjs'; import {fixture,tmp,withoutHostCredentials} from './helpers.mjs'; -import {systemFor,writeJson} from '../../business/tests/helpers.mjs'; +import {systemFor,writeJson} from '../../organization/tests/helpers.mjs'; import {launchLogFile} from '../../seat/src/session.mjs'; test('each launch rereads limits and model selections; lowered limits keep sessions and log exact hit',async t=>{ const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars.model='claude-sonnet-5-5';return doc;}),file=join(root,'config/businesses/acme.json'),ops=[]; diff --git a/packages/cli/tests/launcher-final-race-probe.test.mjs b/packages/cli/tests/launcher-final-race-probe.test.mjs index 9d460d6dc..de845863f 100644 --- a/packages/cli/tests/launcher-final-race-probe.test.mjs +++ b/packages/cli/tests/launcher-final-race-probe.test.mjs @@ -19,7 +19,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { launchDoc } from '../../business/tests/helpers.mjs'; +import { launchDoc } from '../../organization/tests/helpers.mjs'; import { BusError } from '../../bus/src/broker.mjs'; import { Client } from '../../bus/src/client.mjs'; import { startBroker } from '../../bus/src/runtime.mjs'; diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs index 5fc181ee0..0105db26a 100644 --- a/packages/cli/tests/launcher.test.mjs +++ b/packages/cli/tests/launcher.test.mjs @@ -1,4 +1,4 @@ -import { FAR_DATE, launchDoc } from "../../business/tests/helpers.mjs"; +import { FAR_DATE, launchDoc } from "../../organization/tests/helpers.mjs"; // The S6 launcher inside a real bus host: real broker child, real runners // in the bwrap sandbox, the fake adapter from packages/harness standing in for the // harness. The chief of staff launches a coder through its `launch` tool (row 119: the diff --git a/packages/cli/tests/lifecycle.test.mjs b/packages/cli/tests/lifecycle.test.mjs index 77d2d97d3..d317e4f9b 100644 --- a/packages/cli/tests/lifecycle.test.mjs +++ b/packages/cli/tests/lifecycle.test.mjs @@ -16,7 +16,7 @@ import { DatabaseSync } from "node:sqlite"; import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { launchDoc } from "../../business/tests/helpers.mjs"; +import { launchDoc } from "../../organization/tests/helpers.mjs"; import { BusError } from "../../bus/src/broker.mjs"; import { startBroker } from "../../bus/src/runtime.mjs"; import { Store } from "../../bus/src/store.mjs"; diff --git a/packages/cli/tests/limits.test.mjs b/packages/cli/tests/limits.test.mjs index 48d9bdc42..f8654cb58 100644 --- a/packages/cli/tests/limits.test.mjs +++ b/packages/cli/tests/limits.test.mjs @@ -37,7 +37,7 @@ test('limits set passes an assistants patch through unchanged, and the usage nam }); test('post-119 example and Jason fixture migrate with checked inherited models; wrong or absent config refuses',async t=>{ - const {execFileSync}=await import('node:child_process');const {validateBusinessDocument}=await import('../../business/src/business.mjs'); + const {execFileSync}=await import('node:child_process');const {validateBusinessDocument}=await import('../../organization/src/organization-config.mjs'); const root=tmp(t),f=fixture(root,'acme'),doc=JSON.parse(execFileSync('git',['show','94e41ae29:packages/business/examples/mosaic-stack.example.json'],{encoding:'utf8'})); doc.id='acme';let bot=10;for(const r of Object.values(doc.roles))if(r.tracker)r.tracker.botId=bot++; doc.tracker.sync.botId=bot;for(const r of [...Object.values(doc.roles),{credentials:doc.tracker.sync.credentials}])for(const ref of Object.values(r.credentials??{})){if(ref.rotateBy)ref.rotateBy='2099-01-01';if(ref.expires)ref.expires='2099-01-01';} diff --git a/packages/cli/tests/optional-tracker.test.mjs b/packages/cli/tests/optional-tracker.test.mjs index 70868fb08..e00529985 100644 --- a/packages/cli/tests/optional-tracker.test.mjs +++ b/packages/cli/tests/optional-tracker.test.mjs @@ -6,8 +6,8 @@ import { join } from "node:path"; import { bootConfig } from "../src/config.mjs"; import { startBroker } from "../../bus/src/runtime.mjs"; import { Client } from "../../bus/src/client.mjs"; -import { loadBusiness } from "../../business/src/index.mjs"; -import { businessDoc, launchDoc, REPO_ROLES, systemConfig, tmp, writeJson } from "../../business/tests/helpers.mjs"; +import { loadBusiness } from "../../organization/src/index.mjs"; +import { businessDoc, launchDoc, REPO_ROLES, systemConfig, tmp, writeJson } from "../../organization/tests/helpers.mjs"; import { businessView } from "../../webui/src/reads.mjs"; function fixture(t, { present = false } = {}) { diff --git a/packages/cli/tests/org-archive-race.test.mjs b/packages/cli/tests/org-archive-race.test.mjs index 04ecf5fcc..3f933e592 100644 --- a/packages/cli/tests/org-archive-race.test.mjs +++ b/packages/cli/tests/org-archive-race.test.mjs @@ -4,8 +4,8 @@ import { spawn, spawnSync } from 'node:child_process'; import { mkdtemp, writeFile, rm, access } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; -import * as org from '../../business/src/org-service.mjs'; -import { listOrgs } from '../../business/src/org-registry.mjs'; +import * as org from '../../organization/src/org-service.mjs'; +import { listOrgs } from '../../organization/src/org-registry.mjs'; import { busStart, busStopForDelete } from '../src/org-bus.mjs'; import { readHostState } from '../src/host.mjs'; const repo = resolve(import.meta.dirname, '../../..'); diff --git a/packages/cli/tests/org-bus.test.mjs b/packages/cli/tests/org-bus.test.mjs index 04ce1b979..48db54e78 100644 --- a/packages/cli/tests/org-bus.test.mjs +++ b/packages/cli/tests/org-bus.test.mjs @@ -4,8 +4,8 @@ import { spawnSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs'; -import { businessDoc, rolesCopy, writeJson } from '../../business/tests/helpers.mjs'; +import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs'; +import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs'; import { humanEnv, notifyConfig } from './helpers.mjs'; import { busStart, busStatus, busStop } from '../src/org-bus.mjs'; diff --git a/packages/cli/tests/org-delete.test.mjs b/packages/cli/tests/org-delete.test.mjs index f89da377a..c0151112f 100644 --- a/packages/cli/tests/org-delete.test.mjs +++ b/packages/cli/tests/org-delete.test.mjs @@ -4,7 +4,7 @@ import { chmodSync, mkdirSync, renameSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { fixture, tmp } from './helpers.mjs'; import { busStart, busStop, busStopForDelete } from '../src/org-bus.mjs'; -import { createOrg, deleteOrg, templateRender } from '../../business/src/org-service.mjs'; +import { createOrg, deleteOrg, templateRender } from '../../organization/src/org-service.mjs'; test('deletion stops the recorded running bus before moving its runtime', { timeout: 60000 }, async t => { const root = tmp(t, 'd-'), f = fixture(root), dir = join(root, '.config', 'mosaic-test'); diff --git a/packages/cli/tests/org-recovery.test.mjs b/packages/cli/tests/org-recovery.test.mjs index ccbbac33e..1f9664d6e 100644 --- a/packages/cli/tests/org-recovery.test.mjs +++ b/packages/cli/tests/org-recovery.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, symlinkSync, unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs'; +import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs'; import { startTimeOf } from '../src/host.mjs'; import { humanEnv } from './helpers.mjs'; import { execFile, spawn } from 'node:child_process'; @@ -84,7 +84,7 @@ test('lock recovery reports fences and recovers a dead clearer while refusing li writeFileSync(fence, ''); await assert.rejects(f.call('org', 'lock', 'clear', 'alpha'), e => e.message.includes(fence) && /remove.*hand/i.test(e.message)); unlinkSync(fence); - const registry = new URL('../../business/src/org-registry.mjs', import.meta.url).href; + const registry = new URL('../../organization/src/org-registry.mjs', import.meta.url).href; const code = `import fs from 'node:fs'; import { syncBuiltinESMExports } from 'node:module'; const real = fs.writeFileSync; fs.writeFileSync = (...args) => { const value = real(...args); if (typeof args[0] === 'number') process.kill(process.pid, 'SIGKILL'); return value; }; syncBuiltinESMExports(); const { orgLock } = await import(${JSON.stringify(registry)}); await orgLock(${JSON.stringify(f.ctx)}, 'alpha', { clear: true });`; diff --git a/packages/cli/tests/organization-imports.mjs b/packages/cli/tests/organization-imports.mjs new file mode 100644 index 000000000..30cc53266 --- /dev/null +++ b/packages/cli/tests/organization-imports.mjs @@ -0,0 +1,153 @@ +// Row 333 R2 (#1831): the files outside the organization package roots that its code imports, found +// by reading its imports. Rename r2 stage B: the roots are packages/organization/src, the +// implementation, and packages/business/src, the old-path shims of it; both must exist. A copy +// fixture carries these beside both roots, so a new import of +// a plug-in source file (row 333's model-quota.mjs) does not break the release tests the way it did +// when each fixture named kinds.mjs alone. A real copy ships all of packages/plugins (scripts/ +// stack-copy.sh COPY_PATHS); a fixture models a smaller one and takes only what the loader reads. +// +// Vikunja #779: the helper reads imports with a regex, not a parser, so it stays strict about what +// it cannot read. Comments are blanked before matching, so an import in a comment is not one, and an +// import form inside a string or a regular expression is not one either. A dynamic import written +// with a space, `import ('./x.mjs')`, counts, and so does one after a spread, `{...import('./x.mjs')}`; +// a property call such as `x.import(...)` or `x?.import(...)` does not. `1..import(...)`, a property +// call on a number, reads as an import too, which errs toward refusal. These throw: a dynamic import +// whose argument is not a plain string, a dynamic import inside a template substitution (${...}), a +// file with an unterminated string, comment, template or regular expression, a target that is missing +// or not a regular file, and a target whose real path leaves the repository. A substitution with no +// dynamic import is blanked with the rest of the template, as is `${import.meta.url}`; the text of a +// template and a substitution that holds a template of its own are read to their ends. +// The scanner knows line and block comments, quoted strings, template literals, regular expression +// literals (by the usual guess from the previous token) and a hashbang. The guess is wrong for a +// regular expression after `)` or a keyword, as in `return /x/.test(y)`. After `++` or `--` a slash +// is division when the character before the pair ends an operand (`n++ / 2`) and starts a regular +// expression when it does not (`x = ++/re/.lastIndex`). That misreads one rare form as division: a +// prefix `++` or `--` applied to a regular expression's member right after `)`, `]`, a word or a +// line break, as in `if (c) ++/re/.lastIndex`. +import {readdirSync,readFileSync,realpathSync,statSync} from 'node:fs'; +import {dirname,join,relative,resolve,sep} from 'node:path'; + +// `import` as a word that is not a property name. The one rule for both patterns below: a word or $ +// before it, or a single dot (x.import, x?.import), makes it a property call; a dot that follows +// another dot is the end of a spread (...import), which evaluates the call, so it is not excluded. +const IMPORT_WORD=/(?//index.mjs after checking that path, and a real copy ships all of +// packages/plugins, so a fixture that never loads a plug-in needs nothing more from it. +const LOADER_FILE='packages/plugins/src/loader.mjs',LOADER_IMPORT=/^import\(pathToFileURL\(expected\)\.href\)/; +const REGEX_AFTER='(,=:[!&|?{};+-*%<>~^'; +const inert=(literals,index)=>literals.some(([from,to])=>index>=from&&indexreaddirSync(dir,{withFileTypes:true}).flatMap((e)=>e.isDirectory()?sources(join(dir,e.name)):e.name.endsWith('.mjs')?[join(dir,e.name)]:[]); + +// The text with every comment blanked and every template literal emptied, and the ranges of the +// quoted strings and regular expressions left in it, as [from,to) offsets into the returned code. +// Throws on a string, comment, template or regular expression that never ends, and on a dynamic +// import inside a template substitution. +function scan(text){ + let code='',i=0,previous='',before=''; + const literals=[],open=[]; + const fail=(what)=>{throw new Error(`unterminated ${what}`);}; + const mark=(token)=>{before=previous;previous=token;}; + const keep=(end,last)=>{literals.push([code.length,code.length+end-i]);code+=text.slice(i,end);i=end;mark(last);}; + // Template text from i to the closing backtick, or to the ${ that opens a substitution, which is then read as code. + const template=()=>{ + for(;i{ + for(const m of code.slice(start).matchAll(DYNAMIC))if(!inert(literals,start+m.index))throw new Error('unsupported import inside a template substitution'); + code=code.slice(0,start); + while(literals.length&&literals[literals.length-1][0]>=start)literals.pop(); + }; + if(text.startsWith('#!')){const end=text.indexOf('\n');i=end<0?text.length:end;} + while(i=text.length)fail('string'); + keep(j+1,c);continue; + } + if(c==='`'){code+='``';i++;template();continue;} + if(c==='/'&&(previous===''||REGEX_AFTER.includes(previous))){ + let j=i+1,inClass=false; + for(;j=text.length)fail('regular expression'); + keep(j+1,'/');continue; + } + if(open.length&&(c==='{'||c==='}')){ + const top=open[open.length-1]; + if(c==='{')top.braces++; + else if(top.braces===0){close(open.pop());i++;template();continue;} + else top.braces--; + } + code+=c;i++; + if(!/\s/.test(c)){ + const postfix=(c==='+'||c==='-')&&previous===c&&text[i-2]===c&&before!==''&&!REGEX_AFTER.includes(before); + mark(postfix?c+c:c); + } + } + if(open.length)fail('template'); + return {code,literals}; +} + +// The specifiers a source file imports by a relative path. A dynamic import of anything but a plain +// string throws, as does a file the scanner cannot read to its end. +function specifiers(file,text){ + let scanned; + try{scanned=scan(text);} + catch(error){throw new Error(`${file} cannot be read for imports: ${error.message}`);} + const {code,literals}=scanned,live=(m)=>!inert(literals,m.index); + for(const m of code.matchAll(DYNAMIC)){ + const at=code.slice(m.index,m.index+512); + if(file===LOADER_FILE&&LOADER_IMPORT.test(at))continue; + if(live(m)&&!DYNAMIC_LITERAL.test(at))throw new Error(`${file} has a dynamic import that is not a plain string literal`); + } + return [...code.matchAll(IMPORT)].filter(live).map((m)=>m[1]??m[2]??m[3]); +} + +// The package roots a copy fixture carries whole. A missing root throws (readdirSync), so a tree +// with only one of them is refused rather than walked by half. +const ROOTS=[['packages','organization','src'],['packages','business','src']]; + +// Repo-relative paths, sorted: every file that a relative import from either root reaches outside +// both, following the imports of those files in turn. An import of a missing file, of anything +// but a regular file, or of a file whose real path is outside the repository throws, wherever the +// importing file sits, so an import of a missing file inside a root throws too. +export function organizationImportedFiles(repo){ + const owns=ROOTS.map((parts)=>join(repo,...parts)+sep),found=new Set(),checked=new Set(),todo=owns.flatMap((own)=>sources(own)),root=realpathSync(repo)+sep; + for(let file;(file=todo.pop())!==undefined;){ + for(const spec of specifiers(relative(repo,file),readFileSync(file,'utf8'))){ + const target=resolve(dirname(file),spec),from=`${relative(repo,file)} imports ${spec}`; + if(!checked.has(target)){ + let info,real; + try{info=statSync(target);real=realpathSync(target);} + catch(error){throw error.code==='ENOENT'||error.code==='ENOTDIR'?new Error(`${from}, which does not exist`):error;} + if(!info.isFile())throw new Error(`${from}, which is not a regular file`); + if(!real.startsWith(root))throw new Error(`${from}, which is outside the repository`); + checked.add(target); + } + if(owns.some((own)=>target.startsWith(own))||found.has(target))continue; + found.add(target); + if(target.endsWith('.mjs'))todo.push(target); + } + } + return [...found].map((f)=>relative(repo,f)).sort(); +} diff --git a/packages/cli/tests/organization-imports.test.mjs b/packages/cli/tests/organization-imports.test.mjs new file mode 100644 index 000000000..0859b2cc7 --- /dev/null +++ b/packages/cli/tests/organization-imports.test.mjs @@ -0,0 +1,323 @@ +// Vikunja #779: the organization-imports helper (business-imports before rename r2 stage B) on +// synthetic trees. Every tree is built under a private temp directory with the repository one level +// down, so a test can put a file outside the repository. Each tree has both package roots: +// packages/organization/src holds the file under test and packages/business/src, the shim root, is +// empty unless a test fills it. +import test, {after} from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdirSync,mkdtempSync,rmSync,symlinkSync,writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname,join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {organizationImportedFiles} from './organization-imports.mjs'; +import * as oldName from './business-imports.mjs'; + +const ROOT=fileURLToPath(new URL('../../..',import.meta.url)).replace(/\/$/,''); +const base=mkdtempSync(join(tmpdir(),'bi-')); +after(()=>rmSync(base,{recursive:true,force:true})); +const ORG='repo/packages/organization/src/',SHIM='repo/packages/business/src/',EXT='repo/packages/other/src/',EMPTY='export {};\n'; +let count=0; +// files: path under the tree's top directory -> text. links: path -> target path, both under it. +function tree(files,links={},{roots=[ORG,SHIM]}={}){ + const top=join(base,`t${++count}`); + for(const r of roots)mkdirSync(join(top,r),{recursive:true}); + for(const [path,text] of Object.entries(files)){mkdirSync(dirname(join(top,path)),{recursive:true});writeFileSync(join(top,path),text);} + for(const [path,target] of Object.entries(links)){mkdirSync(dirname(join(top,path)),{recursive:true});symlinkSync(join(top,target),join(top,path));} + return join(top,'repo'); +} +const withOrg=(text,extra={})=>tree({[`${ORG}a.mjs`]:text,...extra}); + +test('the real tree gives the plug-in files the loader reads, and nothing inside either root', ()=>{ + const found=organizationImportedFiles(ROOT); + assert.ok(found.includes('packages/plugins/src/kinds.mjs'),found.join(' ')); + assert.deepEqual(found.filter((f)=>f.startsWith('packages/organization/src/')||f.startsWith('packages/business/src/')),[]); + assert.ok(found.includes('packages/plugins/src/model-quota.mjs'),found.join(' ')); + assert.deepEqual(found,[...found].sort()); +}); + +test('the loader exception holds only for the loader file and its exact computed import', ()=>{ + const text="const module=await import(pathToFileURL(expected).href);\n"; + const at=(file)=>tree({[`${ORG}a.mjs`]:"import '../../plugins/src/loader.mjs';\n",[`repo/${file}`]:text}); + assert.deepEqual(organizationImportedFiles(at('packages/plugins/src/loader.mjs')),['packages/plugins/src/loader.mjs']); + assert.throws(()=>organizationImportedFiles(tree({[`${ORG}a.mjs`]:"import '../../plugins/src/other.mjs';\n",'repo/packages/plugins/src/other.mjs':text})),/not a plain string literal/); + assert.throws(()=>organizationImportedFiles(tree({[`${ORG}a.mjs`]:"import '../../plugins/src/loader.mjs';\n",'repo/packages/plugins/src/loader.mjs':"await import(x);\n"})),/not a plain string literal/); +}); + +test('every static form and a literal dynamic import is found, and the imports of what it finds in turn', ()=>{ + const repo=withOrg([ + "import x from '../../other/src/from.mjs';", + "import {\n y,\n z,\n} from '../../other/src/multi.mjs';", + "import '../../other/src/bare.mjs';", + "export * from '../../other/src/reexport.mjs';", + "export {w} from '../../other/src/named.mjs';", + "const d=await import('../../other/src/dynamic.mjs');", + "const n=await import(\n '../../other/src/newline.mjs'\n);", + ].join('\n'),{ + [`${EXT}from.mjs`]:"import '../../third/src/deep.mjs';\n", + 'repo/packages/third/src/deep.mjs':EMPTY, + ...Object.fromEntries(['multi','bare','reexport','named','dynamic','newline'].map(n=>[`${EXT}${n}.mjs`,EMPTY])), + }); + assert.deepEqual(organizationImportedFiles(repo),[ + ...['bare','dynamic','from','multi','named','newline','reexport'].map(n=>`packages/other/src/${n}.mjs`), + 'packages/third/src/deep.mjs', + ]); +}); + +test('#779 a dynamic import written with a space or a line break before the bracket is found', ()=>{ + const repo=withOrg("const s=await import ('../../other/src/spaced.mjs');\nconst t=await import\n ('../../other/src/broken.mjs');\n", + {[`${EXT}spaced.mjs`]:EMPTY,[`${EXT}broken.mjs`]:EMPTY}); + assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/broken.mjs','packages/other/src/spaced.mjs']); +}); + +test('#779 an import in a line or block comment is not an import, and one after a comment is', ()=>{ + const repo=withOrg([ + "// import '../../other/src/missing1.mjs';", + "/* import x from '../../other/src/missing2.mjs'; */", + "/*\n import('../../other/src/missing3.mjs')\n*/", + "const x=1; // import '../../other/src/missing4.mjs'", + "import '../../other/src/real.mjs'; // import('../../other/src/missing5.mjs')", + "// import(name)", + "/* import(name) */ import '../../other/src/after.mjs';", + ].join('\n'),{[`${EXT}real.mjs`]:EMPTY,[`${EXT}after.mjs`]:EMPTY}); + assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/after.mjs','packages/other/src/real.mjs']); +}); + +test('#779 comment markers inside strings, templates and regular expressions do not hide an import', ()=>{ + const repo=withOrg([ + "const url='http://example.invalid/a'; import '../../other/src/after-url.mjs';", + "const glob=\"/* open\"; import '../../other/src/after-glob.mjs';", + "const tpl=`// not a comment ${1}`; import '../../other/src/after-template.mjs';", + "const re=/['\"]\\/\\/x/; import '../../other/src/after-regex.mjs';", + "const half=a / 2;\nconst mid=(a+b) / 2;\nimport '../../other/src/after-divide.mjs';", + ].join('\n'),Object.fromEntries(['url','glob','template','regex','divide'].map(n=>[`${EXT}after-${n}.mjs`,EMPTY]))); + assert.deepEqual(organizationImportedFiles(repo),['url','glob','template','regex','divide'].map(n=>`packages/other/src/after-${n}.mjs`).sort()); +}); + +test('#779 an import form inside a string or a regular expression is not an import', ()=>{ + const repo=withOrg([ + "const a=\"import '../../other/src/missing1.mjs'\";", + "const b='import(name)';", + "const c=/import (name)/;", + "const d=\"export {x} from '../../other/src/missing2.mjs'\";", + "import '../../other/src/real.mjs';", + ].join('\n'),{[`${EXT}real.mjs`]:EMPTY}); + assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/real.mjs']); +}); + +test('#779 a hashbang line is skipped and a bare slash in it does not start a regular expression', ()=>{ + const repo=withOrg("#!/usr/bin/env node\nimport '../../other/src/real.mjs';\n",{[`${EXT}real.mjs`]:EMPTY}); + assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/real.mjs']); + const bare=withOrg("#!/odd\nimport '../../other/src/real.mjs';\n",{[`${EXT}real.mjs`]:EMPTY}); + assert.deepEqual(organizationImportedFiles(bare),['packages/other/src/real.mjs']); +}); + +test('#779 a dynamic import of anything but a plain string throws, and what only looks like one does not', ()=>{ + for(const argument of ['name',"'../../other/src/' + name",'`../../other/src/${name}.mjs`',"'../../other/src/a.mjs', {with:{type:'json'}}","'../../other/src/a.mjs' + ''"]){ + const repo=withOrg(`const m=await import(${argument});\n`,{[`${EXT}a.mjs`]:EMPTY}); + assert.throws(()=>organizationImportedFiles(repo),/organization\/src\/a\.mjs has a dynamic import that is not a plain string literal/,argument); + } + const repo=withOrg([ + 'const u=import.meta.url;','loader.import(thing);','reimport(thing);','$import(thing);', + "const fs=await import('node:fs');","const s='import(name)';", + ].join('\n')); + assert.deepEqual(organizationImportedFiles(repo),[]); +}); + +test('#779 a file the scanner cannot read to its end throws and names the file, in a root or external', ()=>{ + for(const [what,text] of [['string',"const a='open;\n"],['string','const a="open'],['comment',"/* open\nimport '../../other/src/a.mjs';\n"],['template','const a=`open'],['regular expression','const a=/open\n']]){ + const repo=withOrg(text,{[`${EXT}a.mjs`]:EMPTY}); + assert.throws(()=>organizationImportedFiles(repo),new RegExp(`packages/organization/src/a\\.mjs cannot be read for imports: unterminated ${what}`),text); + } + const repo=withOrg("import '../../other/src/a.mjs';\n",{[`${EXT}a.mjs`]:"/* never closed\n"}); + assert.throws(()=>organizationImportedFiles(repo),/packages\/other\/src\/a\.mjs cannot be read for imports: unterminated comment/); +}); + +test('#779 an import of a file that is missing throws, inside a package root or out of it', ()=>{ + const inside=withOrg("import './missing.mjs';\n"); + assert.throws(()=>organizationImportedFiles(inside),/packages\/organization\/src\/a\.mjs imports \.\/missing\.mjs, which does not exist/); + const outside=withOrg("import '../../other/src/missing.mjs';\n"); + assert.throws(()=>organizationImportedFiles(outside),/imports \.\.\/\.\.\/other\/src\/missing\.mjs, which does not exist/); + const broken=tree({[`${ORG}a.mjs`]:"import '../../other/src/link.mjs';\n"},{[`${EXT}link.mjs`]:'gone.mjs'}); + assert.throws(()=>organizationImportedFiles(broken),/which does not exist/); + const beyond=withOrg("import '../../other/src/a.mjs/b.mjs';\n",{[`${EXT}a.mjs`]:EMPTY}); + assert.throws(()=>organizationImportedFiles(beyond),/which does not exist/); +}); + +test('#779 an import of a directory throws', ()=>{ + const repo=withOrg("import '../../other/src/dir';\n",{[`${EXT}dir/index.mjs`]:EMPTY}); + assert.throws(()=>organizationImportedFiles(repo),/imports \.\.\/\.\.\/other\/src\/dir, which is not a regular file/); +}); + +test('#779 a target outside the repository throws, by a path that climbs out or by a symlink', ()=>{ + const climbs=tree({[`${ORG}a.mjs`]:"import '../../../../outside.mjs';\n",'outside.mjs':EMPTY}); + assert.throws(()=>organizationImportedFiles(climbs),/imports \.\.\/\.\.\/\.\.\/\.\.\/outside\.mjs, which is outside the repository/); + const sibling=tree({[`${ORG}a.mjs`]:"import '../../../../repo2/x.mjs';\n",'repo2/x.mjs':EMPTY}); + assert.throws(()=>organizationImportedFiles(sibling),/which is outside the repository/); + const linked=tree({[`${ORG}a.mjs`]:"import '../../other/src/link.mjs';\n",'outside.mjs':EMPTY},{[`${EXT}link.mjs`]:'outside.mjs'}); + assert.throws(()=>organizationImportedFiles(linked),/imports \.\.\/\.\.\/other\/src\/link\.mjs, which is outside the repository/); + const folder=tree({[`${ORG}a.mjs`]:"import '../../other/src/out/x.mjs';\n",'outside/x.mjs':EMPTY},{[`${EXT}out`]:'outside'}); + assert.throws(()=>organizationImportedFiles(folder),/which is outside the repository/); +}); + +test('#779 a symlink that stays inside the repository, and a repository reached by a symlink, are fine', ()=>{ + const repo=tree({[`${ORG}a.mjs`]:"import '../../other/src/link.mjs';\n",'repo/packages/third/real.mjs':EMPTY},{[`${EXT}link.mjs`]:'repo/packages/third/real.mjs'}); + assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/link.mjs']); + const via=join(base,`via${++count}`); + symlinkSync(repo,via); + assert.deepEqual(organizationImportedFiles(via),['packages/other/src/link.mjs']); +}); + +const SUBSTITUTION=/packages\/organization\/src\/a\.mjs cannot be read for imports: unsupported import inside a template substitution/; + +test('#779 R2 a dynamic import inside a template substitution throws, whether the target exists, is missing or is outside', ()=>{ + const exists=withOrg("const v=`${await import('../../other/src/real.mjs')}`;\n",{[`${EXT}real.mjs`]:EMPTY}); + assert.throws(()=>organizationImportedFiles(exists),SUBSTITUTION); + const missing=withOrg("const v=`${await import('../../other/src/real.mjs')}`;\n"); + assert.throws(()=>organizationImportedFiles(missing),SUBSTITUTION); + const outside=tree({[`${ORG}a.mjs`]:"const v=`${await import('../../../../outside.mjs')}`;\n",'outside.mjs':EMPTY}); + assert.throws(()=>organizationImportedFiles(outside),SUBSTITUTION); +}); + +test('#779 R2 a dynamic import inside a substitution throws however the substitution is written', ()=>{ + for(const source of [ + "const v=`a ${b} ${await import ('../../other/src/real.mjs')} c`;", + "const v=`${await import(name)}`;", + "const v=`${/* c */ import('../../other/src/real.mjs') /* d */}`;", + "const v=`x ${`y ${await import('../../other/src/real.mjs')}`}`;", + "const v=`${ {a:1}.a + '}' + (await import('../../other/src/real.mjs')) }`;", + "const v=`${\n await import(\n '../../other/src/real.mjs'\n )\n}`;", + ]){ + const repo=withOrg(`${source}\n`,{[`${EXT}real.mjs`]:EMPTY}); + assert.throws(()=>organizationImportedFiles(repo),SUBSTITUTION,source); + } + const external=withOrg("import '../../other/src/a.mjs';\n",{[`${EXT}a.mjs`]:"const v=`${await import('./b.mjs')}`;\n",[`${EXT}b.mjs`]:EMPTY}); + assert.throws(()=>organizationImportedFiles(external),/packages\/other\/src\/a\.mjs cannot be read for imports: unsupported import inside a template substitution/); +}); + +test('#779 R2 a substitution without a dynamic import is allowed, and the import around it is found', ()=>{ + const repo=withOrg([ + "const a=`plain ${b} text ${c.d({e:1})} end`;", + "const u=`${import.meta.url}/${x.import}/${loader.import('y')}`;", + "const s=`${'}'}${\"import('../../other/src/missing1.mjs')\"}${/import('..\\/x')/.source}`;", + "const t=`text import('../../other/src/missing2.mjs') ${a} import '../../other/src/missing3.mjs'`;", + "const e=`\\${await import('../../other/src/missing4.mjs')}`;", + "const n=`outer ${`inner ${a}`} tail`;", + "import '../../other/src/real.mjs';", + ].join('\n'),{[`${EXT}real.mjs`]:EMPTY}); + assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/real.mjs']); +}); + +test('#779 R2 a substitution or the template text after it that never ends throws', ()=>{ + for(const text of ['const a=`${b','const a=`${b} open','const a=`${`${b}']){ + const repo=withOrg(text); + assert.throws(()=>organizationImportedFiles(repo),/packages\/organization\/src\/a\.mjs cannot be read for imports: unterminated template/,text); + } +}); + +// Each case is its own test, so one failing case does not hide the next. +for(const [what,make] of [ + ['an existing target',()=>withOrg("const v=`${({...import('../../other/src/real.mjs')})}`;\n",{[`${EXT}real.mjs`]:EMPTY})], + ['a missing target',()=>withOrg("const v=`${({...import('../../other/src/real.mjs')})}`;\n")], + ['a target outside the repository',()=>tree({[`${ORG}a.mjs`]:"const v=`${({...import('../../../../outside.mjs')})}`;\n",'outside.mjs':EMPTY})], + ['an array spread of an existing target',()=>withOrg("const v=`${[...import('../../other/src/real.mjs')]}`;\n",{[`${EXT}real.mjs`]:EMPTY})], +]){ + test(`#779 R3 a spread before import inside a template substitution throws: ${what}`,()=>{ + assert.throws(()=>organizationImportedFiles(make()),SUBSTITUTION); + }); +} + +for(const [what,source,files] of [ + ['object spread',"const o={...import('../../other/src/real.mjs')};",{[`${EXT}real.mjs`]:EMPTY}], + ['array spread',"const l=[...import('../../other/src/real.mjs')];",{[`${EXT}real.mjs`]:EMPTY}], +]){ + test(`#779 R3 a spread before import in plain code is a dynamic import and is found when its argument is a plain string: ${what}`,()=>{ + assert.deepEqual(organizationImportedFiles(withOrg(`${source}\n`,files)),['packages/other/src/real.mjs']); + }); +} + +test('#779 R3 a spread before import in plain code reaches the target checks: a missing target throws', ()=>{ + const missing=withOrg("const o={...import('./missing.mjs')};\n"); + assert.throws(()=>organizationImportedFiles(missing),/packages\/organization\/src\/a\.mjs imports \.\/missing\.mjs, which does not exist/); +}); + +for(const source of ['const o={...import(name)};','const l=[...import(name)];']){ + test(`#779 R3 a spread before import in plain code is refused when its argument is not a plain string: ${source}`,()=>{ + const repo=withOrg(`${source}\n`); + assert.throws(()=>organizationImportedFiles(repo),/organization\/src\/a\.mjs has a dynamic import that is not a plain string literal/); + }); +} + +test('#779 R3 a property call of import, x.import or x?.import, in plain code is not an import', ()=>{ + const repo=withOrg([ + "loader.import('../../other/src/missing1.mjs');", + "loader?.import('../../other/src/missing2.mjs');", + 'loader.import(name);', + 'loader?.import(name);', + "import '../../other/src/real.mjs';", + ].join('\n'),{[`${EXT}real.mjs`]:EMPTY}); + assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/real.mjs']); +}); + +test('#779 R3 a property call of import, x.import or x?.import, in a substitution is not an import and is not refused', ()=>{ + const repo=withOrg([ + "const u=`${loader.import('../../other/src/missing3.mjs')}${loader?.import('../../other/src/missing4.mjs')}`;", + 'const w=`${loader.import(name)}${loader?.import(name)}`;', + "import '../../other/src/real.mjs';", + ].join('\n'),{[`${EXT}real.mjs`]:EMPTY}); + assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/real.mjs']); +}); + +test('#779 R2 a slash after a postfix ++ or -- is division, not a regular expression', ()=>{ + for(const op of ['++','--']){ + const repo=withOrg([ + 'let n=2;', + `const half=n${op} / 2;`, + `const spaced=n ${op}/2;`, + `const member=arr[0]${op} / 2;`, + `const grouped=(n)${op} / 2 / 3;`, + "import '../../other/src/real.mjs';", + ].join('\n'),{[`${EXT}real.mjs`]:EMPTY}); + assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/real.mjs'],op); + } +}); + +test('#779 R2 a regular expression after an operator, a bracket or a prefix ++ or -- is still a regular expression', ()=>{ + // Each body holds a quote, which would start an unterminated string if the slash were read as division. + const repo=withOrg([ + "const a = /it's/;", + "if (/it's/.test(s)) {}", + "const b = f(1, /it's \\/ x/);", + "const c = a + /it's/.source;", + "const d = a++ + /it's/.source;", + "const e = ++/it's/.lastIndex;", + "const f = 1 + --/it's/.lastIndex;", + "import '../../other/src/real.mjs';", + ].join('\n'),{[`${EXT}real.mjs`]:EMPTY}); + assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/real.mjs']); +}); + +// Rename r2 stage B: the walker reads both roots, the implementation and its old-path shims. +test('rename r2 B: a shim that re-exports the implementation adds nothing, and an outside import from a shim is found', ()=>{ + const repo=tree({ + [`${ORG}a.mjs`]:"import '../../other/src/from-org.mjs';\n", + [`${SHIM}a.mjs`]:"export * from '../../organization/src/a.mjs';\n", + [`${SHIM}b.mjs`]:"import '../../other/src/from-shim.mjs';\n", + [`${EXT}from-org.mjs`]:EMPTY,[`${EXT}from-shim.mjs`]:EMPTY, + }); + assert.deepEqual(organizationImportedFiles(repo),['packages/other/src/from-org.mjs','packages/other/src/from-shim.mjs']); +}); + +test('rename r2 B: a shim that imports a missing implementation file throws and names the shim', ()=>{ + const repo=tree({[`${SHIM}a.mjs`]:"export * from '../../organization/src/gone.mjs';\n"}); + assert.throws(()=>organizationImportedFiles(repo),/packages\/business\/src\/a\.mjs imports \.\.\/\.\.\/organization\/src\/gone\.mjs, which does not exist/); +}); + +test('rename r2 B: a tree without either root throws, so half a package is never walked as a whole one', ()=>{ + assert.throws(()=>organizationImportedFiles(tree({[`${ORG}a.mjs`]:EMPTY},{},{roots:[ORG]})),/ENOENT.*packages\/business\/src/); + assert.throws(()=>organizationImportedFiles(tree({[`${SHIM}a.mjs`]:EMPTY},{},{roots:[SHIM]})),/ENOENT.*packages\/organization\/src/); +}); + +test('rename r2 B: business-imports.mjs is the old name of the same walker', ()=>{ + assert.equal(oldName.businessImportedFiles,organizationImportedFiles); + assert.equal(oldName.organizationImportedFiles,organizationImportedFiles); +}); diff --git a/packages/cli/tests/other-tree.test.mjs b/packages/cli/tests/other-tree.test.mjs index b5fd0f6cb..1420aca07 100644 --- a/packages/cli/tests/other-tree.test.mjs +++ b/packages/cli/tests/other-tree.test.mjs @@ -5,7 +5,7 @@ import { humanTransport } from "../src/transport.mjs"; import { humanEnv, io, tmp } from "./helpers.mjs"; import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; -import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs'; +import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs'; // Row 130 (#1625): the broker accepts the human and tester proofs only from // the tree the bus runs from, so a refusal with human-required or diff --git a/packages/cli/tests/project.test.mjs b/packages/cli/tests/project.test.mjs index 7ba93ba00..2f2cae7c7 100644 --- a/packages/cli/tests/project.test.mjs +++ b/packages/cli/tests/project.test.mjs @@ -5,7 +5,7 @@ import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; import { main, run } from '../src/cli.mjs'; import { fixture, humanEnv, io, tmp, orgFixture } from './helpers.mjs'; -import { writeJson } from '../../business/tests/helpers.mjs'; +import { writeJson } from '../../organization/tests/helpers.mjs'; test('project init/check use declared business repo and never contact the bus', async t => { const root = tmp(t), f = fixture(root); diff --git a/packages/cli/tests/publish-socket.test.mjs b/packages/cli/tests/publish-socket.test.mjs index ec9f238d0..608aa09b4 100644 --- a/packages/cli/tests/publish-socket.test.mjs +++ b/packages/cli/tests/publish-socket.test.mjs @@ -11,7 +11,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { startBroker } from "../../bus/src/runtime.mjs"; -import { businessDoc, rolesCopy, systemFor, writeJson } from "../../business/tests/helpers.mjs"; +import { businessDoc, rolesCopy, systemFor, writeJson } from "../../organization/tests/helpers.mjs"; import { pushWorking } from "../../harness/src/git-tools.mjs"; import { gitEnv } from "../../seat/src/checkout.mjs"; import { workRef } from "../../seat/src/working.mjs"; diff --git a/packages/cli/tests/rename-b-commands.test.mjs b/packages/cli/tests/rename-b-commands.test.mjs new file mode 100644 index 000000000..7c5f87ef0 --- /dev/null +++ b/packages/cli/tests/rename-b-commands.test.mjs @@ -0,0 +1,328 @@ +// Rename design r2, stage B2: the command map. `mosaic org config validate|resolve` is the primary +// spelling of `mosaic business validate|resolve`, and `mosaic org instances|instance|veto` of +// `mosaic business instances|instance|veto`, with the same arguments. Both spellings call one +// implementation: the installed-config service (packages/organization/src/config-command.mjs) and the +// proof-bearing instances command (src/instances.mjs). These golden comparisons pin status, stdout, +// stderr and authorization outcomes across the wrapper, the direct entry points and in-process run(). +// The old spelling stays byte for byte; the new one differs only where the design lets help read +// organization first: the stderr prefix of the config commands and the usage text. +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmodSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { BusError } from "../../bus/src/broker.mjs"; +import { startBroker } from "../../bus/src/runtime.mjs"; +import { businessDoc, rolesCopy, systemConfig, writeJson } from "../../organization/tests/helpers.mjs"; +import { projectFilePath } from "../../organization/src/index.mjs"; +import { canonicalJson, sha256 } from "../../organization/src/util.mjs"; +import { templateRender } from "../../organization/src/org-service.mjs"; +import { BUSINESS_CONFIG, ORG_CONFIG, runConfigCommand } from "../../organization/src/config-command.mjs"; +import * as oldConfigCommand from "../../business/src/config-command.mjs"; +import { run, USAGE } from "../src/cli.mjs"; +import { bootConfig, loadSystem } from "../src/config.mjs"; +import { INSTANCES_USAGE, ORG_INSTANCES_USAGE } from "../src/instances.mjs"; +import { ORG_USAGE } from "../src/org.mjs"; +import { fixture, io, tmp } from "./helpers.mjs"; + +const path = (p) => fileURLToPath(new URL(p, import.meta.url)); +const MOSAIC = path("../../../scripts/mosaic"); +const CLI = path("../src/cli.mjs"); +const ORG_CLI = path("../../organization/src/cli.mjs"); +const OLD_CLI = path("../../business/src/cli.mjs"); +const CONFIG_COMMAND = path("../../organization/src/config-command.mjs"); +const golden = (name) => readFileSync(path(`../../business/tests/fixtures/rename-a/${name}`), "utf8"); + +// The old spelling's stderr as the new spelling prints it: only a usage line changes. Every other +// line, refusals and warnings included, is byte for byte the same. +const OLD_USAGE_LINE = `mosaic business: ${BUSINESS_CONFIG.usage}`; +const asOrgConfig = (stderr) => stderr.replaceAll(OLD_USAGE_LINE, `mosaic org config: ${ORG_CONFIG.usage}`); + +const spawn = (command, args, env, cwd) => { + const p = spawnSync(command, args, { env, cwd, encoding: "utf8" }); + assert.equal(p.error, undefined); + return { status: p.status, stdout: p.stdout, stderr: p.stderr }; +}; + +// An installed organization acme with its stack project file, as the stage A contract test builds it. +function installed(t, edit = () => {}) { + const root = tmp(t, "rb-"); + const config = systemConfig(root), doc = businessDoc(root); + writeJson(join(root, "config", "businesses", "acme.json"), doc); + writeJson(projectFilePath(doc.projects.stack.root), { projectVersion: 1, id: "stack", vars: { "tracker.project": 3 } }); + const env = { PATH: process.env.PATH, HOME: join(root, "home"), TMPDIR: process.env.TMPDIR, LANG: "C.UTF-8", MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesCopy(root) }; + edit({ root, doc, env }); + return { root, doc, env }; +} + +// Every entry point for one argument list. Old: the wrapper, the old executable path and the moved +// executable. New: the wrapper, the direct packages/cli executable and in-process run(). +async function entries(args, env, cwd) { + const x = io(env); + const status = await run(["org", "config", ...args], x, {}); + return { + old: [spawn(MOSAIC, ["business", ...args], env, cwd), spawn(process.execPath, [OLD_CLI, ...args], env, cwd), spawn(process.execPath, [ORG_CLI, ...args], env, cwd)], + now: [spawn(MOSAIC, ["org", "config", ...args], env, cwd), spawn(process.execPath, [CLI, "org", "config", ...args], env, cwd), { status, stdout: x.out.text, stderr: x.err.text }], + }; +} + +async function sameAcrossSpellings(args, env, cwd, status) { + const { old, now } = await entries(args, env, cwd); + const label = args.join(" ") || "(no arguments)"; + for (const r of old.slice(1)) assert.deepEqual(r, old[0], `old entries agree: ${label}`); + for (const r of now.slice(1)) assert.deepEqual(r, now[0], `new entries agree: ${label}`); + assert.equal(old[0].status, status, `${label}: ${old[0].stderr}`); + assert.deepEqual(now[0], { status: old[0].status, stdout: old[0].stdout, stderr: asOrgConfig(old[0].stderr) }, label); + if (status !== 0) assert.equal(now[0].stdout, "", label); + return { old: old[0], now: now[0] }; +} + +test("rename r2 B2: org config validate|resolve has the status, stdout and stderr of business validate|resolve at every entry point", async (t) => { + const { root, env } = installed(t); + const cases = [ + [["validate", "acme"], 0], + [["resolve", "acme", "coder", "--project", "stack"], 0], + [["resolve", "acme", "chief"], 0], + [["validate", "missing"], 4], + [["resolve", "acme", "unknown"], 2], + [["resolve", "acme", "coder", "--project", "nope"], 2], + [[], 4], + [["bogus"], 4], + [["validate"], 4], + [["validate", "acme", "extra"], 4], + [["resolve", "acme"], 4], + [["resolve", "acme", "coder", "--project"], 4], + [["resolve", "acme", "coder", "--project", "stack", "--project", "stack"], 4], + ]; + for (const [args, status] of cases) { + const { old, now } = await sameAcrossSpellings(args, env, root, status); + if (status === 4 && args[1] !== "missing") { + assert.equal(old.stderr, `mosaic business: ${BUSINESS_CONFIG.usage}\n`); + assert.equal(now.stderr, `mosaic org config: ${ORG_CONFIG.usage}\n`); + } + if (status === 0) assert.equal(old.stderr, ""); + } + // The old usage text is byte for byte what `mosaic business` printed before stage B. + assert.equal(BUSINESS_CONFIG.usage, "usage: mosaic business validate | resolve [--project ]"); +}); + +test("rename r2 B2: a credential problem, an expiry warning and a system config problem read the same in both spellings", async (t) => { + const bad = installed(t, ({ root }) => chmodSync(join(root, "secrets", "coder-gitea.token"), 0o644)); + for (const args of [["validate", "acme"], ["resolve", "acme", "coder"]]) { + const { old, now } = await sameAcrossSpellings(args, bad.env, bad.root, 2); + assert.equal(now.stderr, old.stderr, "a refusal is byte for byte the same, unnormalized"); + assert.match(old.stderr, /^mosaic business: .*coder-gitea\.token.*mode 644/m); + assert.match(old.stderr, /credential reference problem\(s\)\n$/); + } + const soon = new Date(Date.now() + 3 * 86400000).toISOString().slice(0, 10); + const warned = installed(t, ({ root, doc }) => { + doc.roles.coder.credentials.vikunja.expires = soon; + writeJson(join(root, "config", "businesses", "acme.json"), doc); + }); + const { old, now } = await sameAcrossSpellings(["resolve", "acme", "coder"], warned.env, warned.root, 0); + assert.equal(now.stderr, old.stderr, "a warning is byte for byte the same, unnormalized"); + assert.match(old.stderr, new RegExp(`^mosaic business: warning: .*expires on ${soon}\\n$`, "m")); + const missing = installed(t, ({ root, env }) => { env.MOSAIC_CONFIG = join(root, "nowhere", "config.json"); }); + const sys = await sameAcrossSpellings(["validate", "acme"], missing.env, missing.root, 3); + assert.match(sys.old.stderr, /^mosaic business: system config problem \(mosaic-config exit 3\): /); + assert.equal(sys.now.stderr, sys.old.stderr, "a system config problem is byte for byte the same, unnormalized"); +}); + +test("rename r2 B2: org config prints the stage A golden v1 bytes, preimage and digest", async (t) => { + const { root, env } = installed(t); + const invoke = (args) => { + const r = spawn(MOSAIC, ["org", "config", ...args], env, root); + assert.deepEqual([r.status, r.stderr], [0, ""], args.join(" ")); + return r.stdout; + }; + const validate = invoke(["validate", "acme"]); + const resolved = invoke(["resolve", "acme", "coder", "--project", "stack"]); + const { digest, ...preimage } = JSON.parse(resolved); + assert.equal(sha256(canonicalJson(preimage)), digest); + for (const [id, expected] of Object.entries(JSON.parse(validate).instances)) { + const { digest: actual, ...record } = JSON.parse(invoke(["resolve", "acme", id])); + assert.equal(sha256(canonicalJson(record)), actual, id); + assert.equal(actual, expected, id); + } + const normalized = (text) => text.split(root).join("").replace(/[a-f0-9]{64}/g, ""); + const preimageText = `${normalized(canonicalJson(preimage))}\n`; + assert.equal(sha256(preimageText.slice(0, -1)), "5fc9d6258f4725b0e260d99e5cf81058a05d21b8ff298bb53653efbbc8013da9"); + assert.equal(normalized(validate), golden("installed-validate.json")); + assert.equal(normalized(resolved), golden("installed-resolve.json")); + assert.equal(preimageText, golden("installed-preimage.json")); + assert.equal(JSON.parse(validate).business, "acme", "the v1 key stays business"); +}); + +test("rename r2 B2: a draft file named like an installed ID: org validate reads the file, org config validate the installed organization", async (t) => { + const { root, env } = installed(t); + const cwd = join(root, "drafts"); + writeJson(join(cwd, "acme"), await templateRender("acme", { fixture: "local" })); + const draft = spawn(MOSAIC, ["org", "validate", "acme"], env, cwd); + assert.deepEqual(draft, { status: 0, stdout: "valid\n", stderr: "" }); + const config = spawn(MOSAIC, ["org", "config", "validate", "acme"], env, cwd); + assert.equal(config.status, 0, config.stderr); + assert.equal(JSON.parse(config.stdout).file, join(root, "config", "businesses", "acme.json")); + assert.deepEqual(config, spawn(MOSAIC, ["business", "validate", "acme"], env, cwd)); + // Each side reads only its own: no draft, and org validate fails though acme is installed; + // no installed acme, and org config validate fails though the draft is in the working directory. + rmSync(join(cwd, "acme")); + const noDraft = spawn(MOSAIC, ["org", "validate", "acme"], env, cwd); + assert.equal(noDraft.status, 2); + assert.match(noDraft.stderr, /^mosaic: cannot read document acme: /); + writeJson(join(cwd, "acme"), await templateRender("acme", { fixture: "local" })); + rmSync(join(root, "config", "businesses", "acme.json")); + const noInstalled = spawn(MOSAIC, ["org", "config", "validate", "acme"], env, cwd); + assert.equal(noInstalled.status, 4); + assert.equal(noInstalled.stdout, ""); + assert.match(noInstalled.stderr, /^mosaic business: .*acme/); + assert.equal(spawn(MOSAIC, ["org", "validate", "acme"], env, cwd).stdout, "valid\n"); +}); + +// A real broker over an installed organization, as instances.test.mjs builds it. Both spellings run +// through run() with the same deps, so the proof-bearing transport and its calls are the same. +async function bus(t) { + const root = tmp(t, "rbi-"); + const f = fixture(root, "acme"); + const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env }); + const b = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization }); + t.after(() => b.close()); + const human = b.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true }); + const calls = []; + const deps = { + system: { dataRoot: f.dataRoot }, + transport: (business) => async (verb, args = {}) => { + calls.push({ business, verb, args }); + try { + return structuredClone(b.broker.request(human, { verb, args })); + } catch (e) { + if (!(e instanceof BusError)) throw e; + const error = new Error(e.code); + error.code = e.code; + throw error; + } + }, + }; + const log = () => readFileSync(join(root, "config", "businesses", ".history", "acme", "changes.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l)); + const mosaic = async (words, env = f.env) => { + const x = io(env), before = calls.length; + const code = await run(words, x, deps); + return { code, out: x.out.text, err: x.err.text, calls: calls.slice(before) }; + }; + const revision = async () => JSON.parse((await mosaic(["business", "instances", "acme", "--json"])).out).revision; + return { root, f, mosaic, revision, log }; +} + +test("rename r2 B2: org instances|instance|veto reads, refuses and writes exactly as business instances|instance|veto", async (t) => { + const s = await bus(t); + for (const args of [["instances", "acme"], ["instances", "acme", "--json"]]) { + const old = await s.mosaic(["business", ...args]), now = await s.mosaic(["org", ...args]); + assert.equal(old.code, 0, old.err); + assert.deepEqual(now, old, args.join(" ")); + assert.deepEqual(now.calls, [{ business: "acme", verb: "instances", args: {} }]); + } + // A refusal from the bus: the same code, reason and exit. + for (const args of [ + ["instance", "add", "acme", "coder", "--definition", "coder", "--revision", await s.revision()], + ["veto", "acme", "keep", "coder", "--revision", "0".repeat(64)], + ["instance", "set", "acme", "nobody", "--no-launch", "--revision", await s.revision()], + ]) { + const old = await s.mosaic(["business", ...args]), now = await s.mosaic(["org", ...args]); + assert.equal(old.code, 2, args.join(" ")); + assert.match(old.err, /^mosaic: [a-z-]+: /); + assert.deepEqual(now, old, args.join(" ")); + } + // An agent marker refuses before any call, in both spellings. + for (const marker of ["MOSAIC_RUN_ID", "CODEX_THREAD_ID"]) { + const env = { ...s.f.env, [marker]: "fixture-agent" }; + const old = await s.mosaic(["business", "instances", "acme"], env), now = await s.mosaic(["org", "instances", "acme"], env); + assert.equal(old.code, 3); + assert.match(old.err, /outside any agent run/); + assert.deepEqual(now, old); + assert.deepEqual(now.calls, []); + } + // A write through the org spelling: the same lines, a log entry naming the human, and the old + // spelling reads it back. + const kept = await s.mosaic(["org", "veto", "acme", "keep", "coder", "--revision", await s.revision()]); + assert.equal(kept.code, 0, kept.err); + const entry = s.log().at(-1); + assert.deepEqual(kept.out.split("\n"), ["business acme: veto keep coder", `revision ${await s.revision()}`, `transaction ${entry.transaction}`, + "no restart: the launcher reads the change at its next launch check", ""]); + assert.equal(entry.actor, "jason"); + assert.deepEqual(kept.calls.map((c) => c.verb), ["veto.set"]); + const vars = writeJson(join(s.root, "vars.json"), { harness: "pi", model: "claude-sonnet-5-5" }); + const changed = await s.mosaic(["org", "instance", "set", "acme", "coder", "--vars", vars, "--no-launch", "--revision", await s.revision()]); + assert.equal(changed.code, 0, changed.err); + assert.match(changed.out, /^business acme: changed coder \(coder\)$/m); + assert.deepEqual(changed.calls.at(-1).args, { revision: changed.calls.at(-1).args.revision, instance: "coder", op: "change", vars: { harness: "pi", model: "claude-sonnet-5-5" }, launch: false }); + const json = JSON.parse((await s.mosaic(["business", "instances", "acme", "--json"])).out); + assert.equal(json.instances.coder.kept, true); + // The same error refusal the old spelling gives after the write: a kept instance refuses delete. + const delOld = await s.mosaic(["business", "instance", "delete", "acme", "coder", "--revision", await s.revision()]); + const delNew = await s.mosaic(["org", "instance", "delete", "acme", "coder", "--revision", await s.revision()]); + assert.equal(delOld.code, 2); + assert.deepEqual(delNew, delOld); +}); + +test("rename r2 B2: org instances never reaches the dev-owner context, and bad arguments print the org usage", async (t) => { + const s = await bus(t); + // This fixture is not a named instance, so the dev-owner context refuses here; org instances runs. + const show = await s.mosaic(["org", "show", "acme"]); + assert.equal(show.code, 2); + assert.match(show.err, /--dev-local-owner requires a named instance/); + const view = await s.mosaic(["org", "instances", "acme"]); + assert.equal(view.code, 0, view.err); + assert.doesNotMatch(view.err, /dev-local-owner/); + for (const args of [["instance"], ["instances"], ["veto", "acme"], ["instances", "acme", "--bogus"], ["instance", "add", "acme", "x"]]) { + const old = await s.mosaic(["business", ...args]), now = await s.mosaic(["org", ...args]); + assert.deepEqual(old, { code: 4, out: "", err: `mosaic: ${INSTANCES_USAGE}\n`, calls: [] }, args.join(" ")); + assert.deepEqual(now, { code: 4, out: "", err: `mosaic: ${ORG_INSTANCES_USAGE}\n`, calls: [] }, args.join(" ")); + } + assert.equal(ORG_INSTANCES_USAGE, INSTANCES_USAGE.replace(/^mosaic business /, "mosaic org ").replaceAll(" | business ", " | org ").replaceAll("BUSINESS", "ORG")); + assert.doesNotMatch(ORG_INSTANCES_USAGE, /business|BUSINESS/); +}); + +test("rename r2 B2: scripts/mosaic routes org instances|instance|veto to the proof-bearing command, not the dev-owner one", (t) => { + const root = tmp(t, "rbw-"); + const env = { PATH: process.env.PATH, HOME: join(root, "home"), LANG: "C.UTF-8", MOSAIC_CONFIG: join(root, "none.json") }; + for (const sub of ["instance", "instances", "veto"]) { + const old = spawn(MOSAIC, ["business", sub], env, root), now = spawn(MOSAIC, ["org", sub], env, root); + assert.deepEqual(old, { status: 4, stdout: "", stderr: `mosaic: ${INSTANCES_USAGE}\n` }, sub); + assert.deepEqual(now, { status: 4, stdout: "", stderr: `mosaic: ${ORG_INSTANCES_USAGE}\n` }, sub); + } + // A real read with an agent marker refuses with the human-shell check in both spellings. + const agent = { ...env, MOSAIC_AGENT_RUN: "1" }; + const old = spawn(MOSAIC, ["business", "instances", "acme"], agent, root), now = spawn(MOSAIC, ["org", "instances", "acme"], agent, root); + assert.equal(old.status, 3); + assert.match(old.stderr, /outside any agent run \(found MOSAIC_AGENT_RUN/); + assert.deepEqual(now, old); + // Without a marker and without a config, both fail the same way, and neither names the dev owner. + const plainOld = spawn(MOSAIC, ["business", "instances", "acme"], env, root), plainNew = spawn(MOSAIC, ["org", "instances", "acme"], env, root); + assert.notEqual(plainOld.status, 0); + assert.deepEqual(plainNew, plainOld); + assert.doesNotMatch(plainNew.stderr, /dev-local-owner/); +}); + +test("rename r2 B2: importing the config service runs nothing and never exits; it returns the code", async (t) => { + const p = spawnSync(process.execPath, ["--input-type=module", "-e", `const m = await import(${JSON.stringify(CONFIG_COMMAND)}); process.stdout.write(Object.keys(m).sort().join(",") + "\\n");`, "validate", "acme"], { encoding: "utf8", env: { PATH: process.env.PATH, LANG: "C.UTF-8" } }); + assert.deepEqual([p.status, p.stdout, p.stderr], [0, "BUSINESS_CONFIG,ORG_CONFIG,runConfigCommand\n", ""]); + assert.equal(oldConfigCommand.runConfigCommand, runConfigCommand); + const root = tmp(t, "rbc-"); + const out = { text: "" }, err = { text: "" }; + const streams = { stdout: { write: (s) => { out.text += s; return true; } }, stderr: { write: (s) => { err.text += s; return true; } } }; + const env = { PATH: process.env.PATH, HOME: root, MOSAIC_CONFIG: join(root, "none.json") }; + assert.equal(runConfigCommand(["validate", "acme"], { ...streams, env, spelling: ORG_CONFIG }), 3); + assert.equal(runConfigCommand([], { ...streams, env }), 4); + assert.equal(out.text, ""); + assert.match(err.text, /^mosaic business: system config problem /); + assert.match(err.text, /\nmosaic business: usage: mosaic business validate \| resolve /); +}); + +test("rename r2 B2: help lists the org spellings first and names the business spelling as the old one", () => { + assert.match(ORG_USAGE, / \| validate FILE \| /, "org validate FILE stays the draft check"); + assert.match(ORG_USAGE, / \| config validate ID \| config resolve ID INSTANCE \[--project ID\]$/); + assert.ok(USAGE.includes(` ${ORG_USAGE}\n`)); + assert.ok(USAGE.includes(` ${ORG_INSTANCES_USAGE}\n`)); + assert.ok(!USAGE.includes(INSTANCES_USAGE)); + assert.match(USAGE, /^ {2}mosaic business validate\|resolve\|instances\|instance\|veto: the old spelling of mosaic org config validate\|resolve and mosaic org instances\|instance\|veto, same arguments$/m); +}); diff --git a/packages/cli/tests/revoke-window.test.mjs b/packages/cli/tests/revoke-window.test.mjs index 48376ac4e..2cd7d5e38 100644 --- a/packages/cli/tests/revoke-window.test.mjs +++ b/packages/cli/tests/revoke-window.test.mjs @@ -15,7 +15,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { launchDoc } from "../../business/tests/helpers.mjs"; +import { launchDoc } from "../../organization/tests/helpers.mjs"; import { BusError } from "../../bus/src/broker.mjs"; import { startBroker } from "../../bus/src/runtime.mjs"; import { Store } from "../../bus/src/store.mjs"; diff --git a/packages/cli/tests/run-evidence.test.mjs b/packages/cli/tests/run-evidence.test.mjs index a07f9c770..6f63e9c23 100644 --- a/packages/cli/tests/run-evidence.test.mjs +++ b/packages/cli/tests/run-evidence.test.mjs @@ -6,7 +6,7 @@ import { createHash } from 'node:crypto'; import { appendFileSync, chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { reserveOrg } from '../../business/src/org-registry.mjs'; +import { reserveOrg } from '../../organization/src/org-registry.mjs'; import { launchLogFile, runsDir } from '../../seat/src/session.mjs'; import { DEV_OWNER } from '../src/dev-local-owner.mjs'; import { runCommand } from '../src/run-evidence.mjs'; diff --git a/packages/cli/tests/session-clear.test.mjs b/packages/cli/tests/session-clear.test.mjs index b56a04d20..b761f8cb1 100644 --- a/packages/cli/tests/session-clear.test.mjs +++ b/packages/cli/tests/session-clear.test.mjs @@ -15,7 +15,7 @@ import { spawn, spawnSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { launchDoc } from "../../business/tests/helpers.mjs"; +import { launchDoc } from "../../organization/tests/helpers.mjs"; import { BusError } from "../../bus/src/broker.mjs"; import { startBroker } from "../../bus/src/runtime.mjs"; import { gitEnv } from "../../seat/src/checkout.mjs"; diff --git a/packages/cli/tests/session-launch.test.mjs b/packages/cli/tests/session-launch.test.mjs index 1232efdba..336e1f28e 100644 --- a/packages/cli/tests/session-launch.test.mjs +++ b/packages/cli/tests/session-launch.test.mjs @@ -17,7 +17,7 @@ import { spawn, spawnSync } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { launchDoc } from "../../business/tests/helpers.mjs"; +import { launchDoc } from "../../organization/tests/helpers.mjs"; import { BusError } from "../../bus/src/broker.mjs"; import { startBroker } from "../../bus/src/runtime.mjs"; import { Store } from "../../bus/src/store.mjs"; diff --git a/packages/cli/tests/stack-instances.test.mjs b/packages/cli/tests/stack-instances.test.mjs index bfa359b54..ff028b5cd 100644 --- a/packages/cli/tests/stack-instances.test.mjs +++ b/packages/cli/tests/stack-instances.test.mjs @@ -12,9 +12,9 @@ import { createHash } from "node:crypto"; import { join, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { LiveSessionGuard } from "../../conversation/src/guard.mjs"; -import { reserveOrg, activateOrg } from "../../business/src/org-registry.mjs"; +import { reserveOrg, activateOrg } from "../../organization/src/org-registry.mjs"; import { hostStatus } from "../src/host.mjs"; -import { businessDoc, rolesCopy, writeJson } from "../../business/tests/helpers.mjs"; +import { businessDoc, rolesCopy, writeJson } from "../../organization/tests/helpers.mjs"; import { REPO_SKILLS } from "../../harness/tests/helpers.mjs"; import { humanEnv, notifyConfig, tmp } from "./helpers.mjs"; diff --git a/packages/cli/tests/stack-release-fixture.mjs b/packages/cli/tests/stack-release-fixture.mjs index 3d773aa4e..492cd11da 100644 --- a/packages/cli/tests/stack-release-fixture.mjs +++ b/packages/cli/tests/stack-release-fixture.mjs @@ -12,7 +12,7 @@ import {tmpdir} from 'node:os'; import {join,dirname,basename} from 'node:path'; import {fileURLToPath,pathToFileURL} from 'node:url'; import {once} from 'node:events'; -import {businessImportedFiles} from './business-imports.mjs'; +import {organizationImportedFiles} from './organization-imports.mjs'; export const ROOT=fileURLToPath(new URL('../../..',import.meta.url)).replace(/\/$/,''); export const NODE=process.execPath; @@ -291,7 +291,8 @@ export const M=git(repo,'rev-parse','HEAD');git(repo,'update-ref','refs/remotes/ export const storeText=(v,froms,to=true)=>`export const SCHEMA_VERSION = '${v}';\nexport const MIGRATIONS = [\n${froms.map((f,i)=>` {\n from: '${f}',\n${to===false?'':` to: '${to===true?froms[i+1]??v:to}',\n`} run() {},\n },\n`).join('')}];\n`; // Plug-in kinds (row 169): C1 and C know the harness kind, as a copy at or past c216f47db does. The // rest have it under another name, as ca15d73e9 has no harness kind. Every copy has the real business -// loader and roles, which the tool imports to read the business file. +// loader (packages/organization/src, and the packages/business/src shims the tool imports, rename r2 +// stage B) and roles, which the tool reads the business file with. export const KINDS_NEW=readFileSync(join(repo,'packages','plugins','src','kinds.mjs'),'utf8'); export const KINDS_OLD=KINDS_NEW.replace('\n harness: Object.freeze({','\n "harness-before-c216f47db": Object.freeze({'); assert.notEqual(KINDS_OLD,KINDS_NEW); @@ -309,10 +310,10 @@ for(const [id,store] of [[F,'// a copy from before SCHEMA_VERSION\n'],[A,storeTe for(const [f,text,mode] of [['scripts/mosaic',script(mosaicStub),0o755],['packages/webui/src/cli.mjs',[B,C1,C].includes(id)?`// fixture Console, never run\n// serve accepts '--tester-port'\n// serve accepts '--proof-header' (row 178)\n${[C1,C].includes(id)?"// serve accepts '--sso' (row 192)\n":''}`:'// fixture Console before tester listener, never run\n',0o644],['packages/bus/src/store.mjs',store,0o644],['packages/bus/src/migrate-schema.mjs',`(${migrateStub})();\n`,0o644],['packages/plugins/src/kinds.mjs',[C1,C].includes(id)?KINDS_NEW:KINDS_OLD,0o644],...ssoFiles(id).map(([f,text])=>[f,text,0o644])]){ mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text,{mode}); } - for(const p of ['packages/business/src','packages/library/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true}); + for(const p of ['packages/organization/src','packages/business/src','packages/library/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true}); // Row 333 R2 (#1831): what the business loader imports from outside its directory (kinds.mjs, which is // already here in its OLD or NEW form, and model-quota.mjs). - for(const p of businessImportedFiles(repo))if(!existsSync(join(d,p)))cpSync(join(repo,p),join(d,p)); + for(const p of organizationImportedFiles(repo))if(!existsSync(join(d,p)))cpSync(join(repo,p),join(d,p)); execFileSync('chmod',['-R','a-w',d]); } for(const [name,fn] of [['systemctl',systemctl],['journalctl',journalctl],['systemd-analyze',systemdAnalyze]])writeFileSync(join(SBIN,name),script(fn),{mode:0o755}); @@ -354,7 +355,7 @@ const markers=/\/(AWAITING-UNITS|FAILED) /; export const kept=(e)=>[...tree(join(e.data,'backups','stack')),...tree(join(e.state,'stack-rollback')).filter((l)=>!markers.test(l)),...posts(e).flatMap((n)=>tree(join(e.data,n))),...tree(COPIES)].map((l)=>l.replace(/ .*/,'')); // The business file (row 169): the committed example made loadable, as acme, with `plugins` when given. -export const EXAMPLE=readFileSync(join(repo,'packages','business','examples','mosaic-stack.example.json'),'utf8'); +export const EXAMPLE=readFileSync(join(repo,'packages','organization','examples','mosaic-stack.example.json'),'utf8'); export function biz(e,plugins){ const b=JSON.parse(EXAMPLE);let n=0; const walk=(o)=>{for(const [k,v] of Object.entries(o)){if(k==='botId')o[k]=++n;else if(v==='YYYY-MM-DD')o[k]='2027-01-01';else if(v&&typeof v==='object')walk(v)}}; diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs index 588e2a9eb..412865d45 100644 --- a/packages/cli/tests/stack-release.test.mjs +++ b/packages/cli/tests/stack-release.test.mjs @@ -2069,7 +2069,7 @@ export class Store { const RCOPIES=join(base,'rcopies');mkdirSync(RCOPIES); for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']],[C1,'3g',['3f']]]){ const d=join(RCOPIES,id); - for(const p of ['scripts/mosaic','scripts/mosaic-config.mjs','packages/cli/src/config.mjs','packages/cli/src/errors.mjs','packages/cli/src/access.mjs','packages/cli/src/org-selector.mjs','packages/cli/src/transport.mjs','packages/bus/src/business.mjs','packages/bus/src/request-store.mjs','packages/bus/src/requirement-catalog.mjs','packages/tasks/src','packages/seat/src/landing.mjs','packages/seat/src/checkout.mjs','packages/processing-control/src','packages/plugins/src/kinds.mjs','packages/business/src','packages/library/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true}); + for(const p of ['scripts/mosaic','scripts/mosaic-config.mjs','packages/cli/src/config.mjs','packages/cli/src/errors.mjs','packages/cli/src/access.mjs','packages/cli/src/org-selector.mjs','packages/cli/src/transport.mjs','packages/bus/src/business.mjs','packages/bus/src/organization-context.mjs','packages/bus/src/request-store.mjs','packages/bus/src/requirement-catalog.mjs','packages/tasks/src','packages/seat/src/landing.mjs','packages/seat/src/checkout.mjs','packages/processing-control/src','packages/plugins/src/kinds.mjs','packages/organization/src','packages/business/src','packages/library/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true}); // Row 333 R2 (#1831): what the business loader imports from outside its directory, model-quota.mjs included. for(const p of businessImportedFiles(repo))if(!existsSync(join(d,p)))cpSync(join(repo,p),join(d,p)); if(id===C1)writeFileSync(join(d,'scripts','mosaic'),`#!/usr/bin/env bash\nexec node "${join(RCOPIES,B,'packages','cli','src','cli.mjs')}" "$@"\n`,{mode:0o755}); diff --git a/packages/cli/tests/tester-reads.test.mjs b/packages/cli/tests/tester-reads.test.mjs index d8d198b6d..3493b29e8 100644 --- a/packages/cli/tests/tester-reads.test.mjs +++ b/packages/cli/tests/tester-reads.test.mjs @@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url"; import { main } from "../src/cli.mjs"; import { BusError } from "../../bus/src/broker.mjs"; import { startBroker } from "../../bus/src/runtime.mjs"; -import { systemFor, writeJson } from "../../business/tests/helpers.mjs"; +import { systemFor, writeJson } from "../../organization/tests/helpers.mjs"; import { fixture, humanEnv, io, tmp, orgFixture } from "./helpers.mjs"; const none = { authority: { withinRole: ["message.send"], crossRole: [] } }; diff --git a/packages/cli/tests/tester-threads.test.mjs b/packages/cli/tests/tester-threads.test.mjs index 492774bbd..994633459 100644 --- a/packages/cli/tests/tester-threads.test.mjs +++ b/packages/cli/tests/tester-threads.test.mjs @@ -14,7 +14,7 @@ import { fileURLToPath } from "node:url"; import { BusError, Broker } from "../../bus/src/broker.mjs"; import { startBroker } from "../../bus/src/runtime.mjs"; import { Store } from "../../bus/src/store.mjs"; -import { systemFor } from "../../business/tests/helpers.mjs"; +import { systemFor } from "../../organization/tests/helpers.mjs"; import { run } from "../src/cli.mjs"; import { CliError } from "../src/errors.mjs"; import { busExit } from "../src/transport.mjs"; diff --git a/packages/cli/tests/thread-launch.test.mjs b/packages/cli/tests/thread-launch.test.mjs index a43fd589c..1abadcce4 100644 --- a/packages/cli/tests/thread-launch.test.mjs +++ b/packages/cli/tests/thread-launch.test.mjs @@ -16,7 +16,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { launchDoc } from "../../business/tests/helpers.mjs"; +import { launchDoc } from "../../organization/tests/helpers.mjs"; import { BusError } from "../../bus/src/broker.mjs"; import { Client } from "../../bus/src/client.mjs"; import { startBroker } from "../../bus/src/runtime.mjs"; diff --git a/packages/cli/tests/thread-revoke.test.mjs b/packages/cli/tests/thread-revoke.test.mjs index dbb3419b6..81c333423 100644 --- a/packages/cli/tests/thread-revoke.test.mjs +++ b/packages/cli/tests/thread-revoke.test.mjs @@ -15,7 +15,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { launchDoc } from "../../business/tests/helpers.mjs"; +import { launchDoc } from "../../organization/tests/helpers.mjs"; import { BusError } from "../../bus/src/broker.mjs"; import { startBroker } from "../../bus/src/runtime.mjs"; import { Store } from "../../bus/src/store.mjs"; diff --git a/packages/cli/tests/trackers-boot.test.mjs b/packages/cli/tests/trackers-boot.test.mjs index e7f50c947..84f75e8dd 100644 --- a/packages/cli/tests/trackers-boot.test.mjs +++ b/packages/cli/tests/trackers-boot.test.mjs @@ -7,7 +7,7 @@ import { join } from "node:path"; import { Client } from "../../bus/src/client.mjs"; import { bootConfig, loadSystem } from "../src/config.mjs"; import { startHost, startTimeOf } from "../src/host.mjs"; -import { writeJson } from "../../business/tests/helpers.mjs"; +import { writeJson } from "../../organization/tests/helpers.mjs"; import { FakeVikunja } from "../../tasks/src/fake.mjs"; import { SCOPES } from "../../tasks/tests/world.mjs"; import { fixture, tmp } from "./helpers.mjs"; diff --git a/packages/control-board/src/serve.mjs b/packages/control-board/src/serve.mjs index 33035eff4..80db8047e 100644 --- a/packages/control-board/src/serve.mjs +++ b/packages/control-board/src/serve.mjs @@ -49,8 +49,8 @@ import { spawnSync } from "node:child_process"; import { scan, markSeen, seenKey, ConfigError, loadRegistrations } from "./scan.mjs"; import { createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs"; import { Refusal } from "../../conversation/src/safe-fs.mjs"; -import { loadSeatSets } from "../../business/src/library-seats.mjs"; -import { configDir } from "../../business/src/business.mjs"; +import { loadSeatSets } from "../../organization/src/library-seats.mjs"; +import { configDir } from "../../organization/src/organization-config.mjs"; const MAX_BODY = 4096; diff --git a/packages/control-board/tests/creative-hidden.test.mjs b/packages/control-board/tests/creative-hidden.test.mjs index 12e16866e..850ad0a1d 100644 --- a/packages/control-board/tests/creative-hidden.test.mjs +++ b/packages/control-board/tests/creative-hidden.test.mjs @@ -9,7 +9,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { conversationResponse, creativeCheck, startServer, REFUSAL_STATUS } from "../src/serve.mjs"; import { conversationId, createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs"; -import { businessDoc, writeJson } from "../../business/tests/helpers.mjs"; +import { businessDoc, writeJson } from "../../organization/tests/helpers.mjs"; import { mappingEntry } from "../../library/tests/helpers.mjs"; // A config directory holding one library business whose seat "bean" is creative, diff --git a/packages/discord/src/cli.mjs b/packages/discord/src/cli.mjs index 61dc1e0af..e291d8c03 100755 --- a/packages/discord/src/cli.mjs +++ b/packages/discord/src/cli.mjs @@ -52,7 +52,7 @@ import { createHash } from "node:crypto"; import { DiscordError } from "./errors.mjs"; import { assertProcessing, processingStatus, ProcessingError } from "../../processing-control/src/state.mjs"; import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity } from "../../processing-control/src/runtimes.mjs"; -import { loadSeatSets } from "../../business/src/library-seats.mjs"; +import { loadSeatSets } from "../../organization/src/library-seats.mjs"; import { defaultConfigPath, loadDataRoot, bindingPath, bindingDataDir, loadBinding, readToken, resolveContextFiles, resolveToolRoots, reloadDiff } from "./binding.mjs"; import { createRest } from "./rest.mjs"; import { createGateway, CONNECTOR_INTENTS } from "./gateway.mjs"; diff --git a/packages/discord/tests/library-seat.test.mjs b/packages/discord/tests/library-seat.test.mjs index a7ac869e5..c7a4d85c8 100644 --- a/packages/discord/tests/library-seat.test.mjs +++ b/packages/discord/tests/library-seat.test.mjs @@ -11,7 +11,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { makeRoot, makeRepo, makeDeployment } from "./helpers.mjs"; import { initializeControl } from "../../processing-control/src/state.mjs"; -import { businessDoc, writeJson } from "../../business/tests/helpers.mjs"; +import { businessDoc, writeJson } from "../../organization/tests/helpers.mjs"; import { mappingEntry } from "../../library/tests/helpers.mjs"; const CLI = fileURLToPath(new URL("../src/cli.mjs", import.meta.url)); diff --git a/packages/harness/src/bundle.mjs b/packages/harness/src/bundle.mjs index 99695a890..65e9c672a 100644 --- a/packages/harness/src/bundle.mjs +++ b/packages/harness/src/bundle.mjs @@ -16,7 +16,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { typedTools, untypedActions } from "./tools.mjs"; import { route as defaultProxyRoute } from "../../plugins/model-provider/cliproxy/index.mjs"; -import { CREATIVE_DEFINITIONS } from "../../business/src/vocabulary.mjs"; +import { CREATIVE_DEFINITIONS } from "../../organization/src/vocabulary.mjs"; export const BUNDLE_VERSION = 1; // The role file's "web" is a grant for the broker's typed web tools, not a harness built-in. The diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs index 5523bf87e..acbd7db2c 100644 --- a/packages/harness/src/runner.mjs +++ b/packages/harness/src/runner.mjs @@ -41,7 +41,7 @@ import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { Client } from "../../bus/src/client.mjs"; import { LENGTH_FAILURE_NOTICE_PREFIX } from "./failure-notice.mjs"; -import { ID_PATTERN } from "../../business/src/vocabulary.mjs"; +import { ID_PATTERN } from "../../organization/src/vocabulary.mjs"; import { firstOutputMarker, firstTurnMarker } from './first-output.mjs'; import { GUARD_CODES, FIXED_TEXTS } from "../../library/src/reply-protocol.mjs"; import { fetchReview, landWork, pushWorking } from "./git-tools.mjs"; diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs index 3a40c682f..84ee01a77 100644 --- a/packages/harness/src/tools.mjs +++ b/packages/harness/src/tools.mjs @@ -11,7 +11,7 @@ // name against this list and forwards the call with its own capability. import { connect } from "node:net"; -import { ACTIONS, ID_PATTERN, THREAD_DEFINITION, CREATIVE_DEFINITIONS } from "../../business/src/vocabulary.mjs"; +import { ACTIONS, ID_PATTERN, THREAD_DEFINITION, CREATIVE_DEFINITIONS } from "../../organization/src/vocabulary.mjs"; const str = (description, extra = {}) => ({ type: "string", description, ...extra }); const obj = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false }); @@ -24,7 +24,7 @@ const REQUEST = str("Original human.input request id from the user's message hea const TASK_PAGE = 50; const TASK_PAGE_MAX = 200; // The keys the broker's `organization.set` accepts (ORGANIZATION_KEYS in packages/bus/src/broker.mjs; -// a test holds both equal to SETTINGS_KEYS in packages/business/src/launch-settings.mjs). Kept here +// a test holds both equal to SETTINGS_KEYS in packages/organization/src/launch-settings.mjs). Kept here // so the session's tool server doesn't load the broker. export const ORGANIZATION_KEYS = Object.freeze(["revision", "limits", "models", "instances", "reset", "assistants"]); // The longest web_search query (SEARCH_MAX_QUERY_CHARS in packages/discord/src/web.mjs, the one diff --git a/packages/harness/tests/bundle.test.mjs b/packages/harness/tests/bundle.test.mjs index 6cd3d9664..106908715 100644 --- a/packages/harness/tests/bundle.test.mjs +++ b/packages/harness/tests/bundle.test.mjs @@ -6,8 +6,8 @@ import { join } from "node:path"; import { BundleError, buildBundle, sessionModel, SOURCES } from "../src/bundle.mjs"; import { HARNESS, REPO, REPO_SKILLS, resolvedFor, scratch } from "./helpers.mjs"; import { decide } from "../src/gate.mjs"; -import { resolveInstance, validateBusinessDocument } from "../../business/src/index.mjs"; -import { businessDoc, REPO_ROLES, systemFor } from "../../business/tests/helpers.mjs"; +import { resolveInstance, validateBusinessDocument } from "../../organization/src/index.mjs"; +import { businessDoc, REPO_ROLES, systemFor } from "../../organization/tests/helpers.mjs"; import { Store } from "../../bus/src/store.mjs"; import { Broker } from "../../bus/src/broker.mjs"; diff --git a/packages/harness/tests/helpers.mjs b/packages/harness/tests/helpers.mjs index 6afc4910f..b99651d15 100644 --- a/packages/harness/tests/helpers.mjs +++ b/packages/harness/tests/helpers.mjs @@ -8,12 +8,12 @@ import { createServer } from "node:net"; import { mkdtempSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { resolveInstance, validateBusinessDocument } from "../../business/src/index.mjs"; -import { businessDoc, REPO_ROLES, systemFor } from "../../business/tests/helpers.mjs"; +import { resolveInstance, validateBusinessDocument } from "../../organization/src/index.mjs"; +import { businessDoc, REPO_ROLES, systemFor } from "../../organization/tests/helpers.mjs"; import { createLoader } from "../../plugins/src/loader.mjs"; -export { REPO } from "../../business/tests/helpers.mjs"; -import { REPO } from "../../business/tests/helpers.mjs"; +export { REPO } from "../../organization/tests/helpers.mjs"; +import { REPO } from "../../organization/tests/helpers.mjs"; // Row 64: the repository's skill directories, as /skills-enabled holds them once activated. export const REPO_SKILLS = join(REPO, "skills"); diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs index 4055ddf50..209436df2 100644 --- a/packages/harness/tests/tools.test.mjs +++ b/packages/harness/tests/tools.test.mjs @@ -4,8 +4,8 @@ import { readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { callTool, ORGANIZATION_KEYS, REPLY_CAP, replyText, typedTools, untypedActions } from "../src/tools.mjs"; import { ORGANIZATION_KEYS as BROKER_ORGANIZATION_KEYS, TASK_PAGE, TASK_PAGE_MAX } from "../../bus/src/broker.mjs"; -import { SETTINGS_KEYS } from "../../business/src/launch-settings.mjs"; -import { ID_PATTERN } from "../../business/src/vocabulary.mjs"; +import { SETTINGS_KEYS } from "../../organization/src/launch-settings.mjs"; +import { ID_PATTERN } from "../../organization/src/vocabulary.mjs"; import { fakeToolSocket, REPO, resolvedFor, scratch } from "./helpers.mjs"; const names = (tools) => tools.map((t) => t.name); diff --git a/packages/library/src/write.mjs b/packages/library/src/write.mjs index 82bb177ae..c1d4054fb 100644 --- a/packages/library/src/write.mjs +++ b/packages/library/src/write.mjs @@ -3,7 +3,7 @@ import { lstatSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { SAFE, git, gitBytes } from '../../project/src/git.mjs'; import { gitEnv, runGit, GIT_LIMITS } from '../../seat/src/checkout.mjs'; -import { ID_PATTERN } from '../../business/src/vocabulary.mjs'; +import { ID_PATTERN } from '../../organization/src/vocabulary.mjs'; import { checkMapping } from './mapping.mjs'; import { classify } from './scope.mjs'; import { supportingPath } from './supporting.mjs'; diff --git a/packages/business/README.md b/packages/organization/README.md similarity index 91% rename from packages/business/README.md rename to packages/organization/README.md index 9838285fd..3ae14f673 100644 --- a/packages/business/README.md +++ b/packages/organization/README.md @@ -1,17 +1,33 @@ -# Business +# Organization -Role definitions, the business and project files, the variable registry and -the resolver (slice 1 row S1, #1518). Brief: `docs/plans/2026-10-04_slice-1.md`, +`@mosaic/organization`: role definitions, the business and project files, the +variable registry and the resolver (slice 1 row S1, #1518). Rename design r2, +stage B moved it here from `packages/business`. That path and `@mosaic/business` +stay as the old names: each module there re-exports the module of the same name +here (`src/business.mjs` re-exports `src/organization-config.mjs`), and its +`src/cli.mjs` runs this package's `src/cli.mjs`. Brief: `docs/plans/2026-10-04_slice-1.md`, row S1. Design: `agents/darkwing/work/slice1-data-model-2026-10-04.md` and its addenda A and B. The package reads files and refuses on any problem. It never writes, and it never opens a token file. ```sh -scripts/mosaic business validate mosaic-stack -scripts/mosaic business resolve mosaic-stack coder --project stack -node --test packages/business/tests/ +scripts/mosaic org config validate mosaic-stack +scripts/mosaic org config resolve mosaic-stack coder --project stack +node --test packages/organization/tests/ ``` +`mosaic business validate|resolve` is the old spelling, with the same +arguments. Both spellings run `runConfigCommand` in `src/config-command.mjs`, +so status, stdout and stderr are the same. The one exception is a usage +error: its line names the spelling you used (`mosaic org config: usage: ...`). +Every other stderr line, refusals and warnings included, starts +`mosaic business: ` in both spellings, so the diagnostics stay byte for byte +what the old command printed. Importing `src/config-command.mjs` runs nothing and never +exits the process; `runConfigCommand(argv, {stdout, stderr, env, spelling})` +returns the exit code. `src/cli.mjs` is the thin executable that the old +spelling runs. `mosaic org validate FILE` is different: it checks a draft file +by path and prints `valid` (packages/cli/README.md). + ## Files | File | Who writes it | Holds | @@ -144,7 +160,7 @@ A write that adds none never calls it. `launch.threadIdleSeconds` (optional, row 121) is how long a thread's session runs with nothing pending and no new message or open before the launcher stops it. Absent means 900 (`THREAD_IDLE_SECONDS` in -`src/business.mjs`). A set value is a positive integer with no upper bound; +`src/organization-config.mjs`). A set value is a positive integer with no upper bound; anything else refuses the file. The key is accepted whether or not the file declares a thread instance. A thread instance is one whose definition is `assistant`. The launcher starts it once per thread, when the thread has a @@ -214,7 +230,7 @@ instance `vars` (`harness`, `model`) as the PM has, unless the system's execution settings cover it. A `launch.by` that names a PM, an assistant or any other instance whose role lacks `role.launch` is refused. A file that still names the PM fails to load with the shipped roles, so edit the file before a copy with -these roles goes live. `packages/business/tests/chief-of-staff.test.mjs` and +these roles goes live. `packages/organization/tests/chief-of-staff.test.mjs` and `assistant.test.mjs` check each role against its approved list and load it through `loadBusiness`; `role.test.mjs` loads every file in `roles/` through the validator. @@ -259,7 +275,7 @@ go. The entries are: "security-reviewer": { "definition": "security-reviewer" } ``` -`packages/business/tests/workflow-roles.test.mjs` checks both against +`packages/organization/tests/workflow-roles.test.mjs` checks both against their approved lists, the arbiter refusal and the proactive line in every contract; `packages/bus/tests/workflow-roles.test.mjs` checks the merge route and the verdict path on the broker. @@ -314,11 +330,11 @@ that narrowed it. ```js import { - loadBusiness, loadProject, systemVars, resolveInstance, classify, + loadOrganization, loadProject, systemVars, resolveInstance, classify, loadRole, validateRoleDocument, checkCredentialRef, -} from "../packages/business/src/index.mjs"; +} from "../packages/organization/src/index.mjs"; -const business = loadBusiness("mosaic-stack", { rolesDir }); // frozen +const business = loadOrganization("mosaic-stack", { rolesDir }); // frozen const project = loadProject(business.projects.stack.root); // frozen const coder = resolveInstance({ system: systemVars(config), business, project, instance: "coder" }); classify(coder, "git.push.working"); // "within" | "cross" | "gated" @@ -342,8 +358,14 @@ included in the resolved digest and the generated session prompt; it does not grant authority. The name is descriptive, not a broker display-name alias; the assistant addresses messages to the exact instance key. -Errors are `BusinessError` with `exitCode` 2 (invalid) or 4 (a required -file missing). The CLI adds 3 for a system config problem and 4 for usage. +Errors are `OrganizationConfigError` with `exitCode` 2 (invalid) or 4 (a +required file missing). `BusinessError` is the same constructor under its old +name, and an error's `name` is still `BusinessError`. `OrganizationError` +(`src/organization.mjs`, the organization writer's error with a `code`) is a +different class. `mosaic org config` adds 3 for a system config problem and 4 +for usage. The old names `loadBusiness`, `validateBusinessDocument` and +`businessFilePath` are the same functions as `loadOrganization`, +`validateOrganizationDocument` and `organizationFilePath`. ## Limits @@ -513,14 +535,15 @@ and whether `add` and `set` accept it. `set` takes: - The whole next document goes through `validateBusinessDocument`, the route checks (`checkRoutes`, under "Routes" above), and each project file's id and instance resolution (`organization-project-mismatch`, `organization-unresolvable-instance`), - the checks `mosaic business validate` runs. + the checks `mosaic org config validate` runs. - The organization writer then refuses a stale revision (`revision-conflict`) and logs the change with the actor. A refusal writes nothing and logs nothing. The answer is the read with the `transaction` and `restart`. `restart` is true after an add or a definition change: the running broker keeps the roles it loaded at start. The writer -doesn't restart anything. The CLI (`mosaic business instance add|set`) and the +doesn't restart anything. The CLI (`mosaic org instance add|set`, or the old +spelling `mosaic business instance add|set`) and the Console Settings page send the patch. ### Organizations (development release) diff --git a/packages/business/examples/mosaic-stack.example.json b/packages/organization/examples/mosaic-stack.example.json similarity index 100% rename from packages/business/examples/mosaic-stack.example.json rename to packages/organization/examples/mosaic-stack.example.json diff --git a/packages/organization/package.json b/packages/organization/package.json new file mode 100644 index 000000000..8bf0a77c2 --- /dev/null +++ b/packages/organization/package.json @@ -0,0 +1,11 @@ +{ + "name": "@mosaic/organization", + "version": "0.1.0", + "private": true, + "description": "Role definitions, organization configuration and project files, variable layers and the resolver (slice 1 row S1).", + "license": "UNLICENSED", + "type": "module", + "engines": { "node": ">=24" }, + "exports": { ".": "./src/index.mjs" }, + "scripts": { "test": "node --test tests/" } +} diff --git a/packages/organization/src/access.mjs b/packages/organization/src/access.mjs new file mode 100644 index 000000000..07be8b55e --- /dev/null +++ b/packages/organization/src/access.mjs @@ -0,0 +1,174 @@ +// The access file, /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 "./organization-config.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); +} diff --git a/packages/organization/src/appearance.mjs b/packages/organization/src/appearance.mjs new file mode 100644 index 000000000..67219c455 --- /dev/null +++ b/packages/organization/src/appearance.mjs @@ -0,0 +1,30 @@ +// 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 } : {}), + }); +} diff --git a/packages/organization/src/cli.mjs b/packages/organization/src/cli.mjs new file mode 100644 index 000000000..40b4468ef --- /dev/null +++ b/packages/organization/src/cli.mjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node +// mosaic business validate +// mosaic business resolve [--project ] +// +// The old spelling's executable (scripts/mosaic business validate|resolve, and the old path +// packages/business/src/cli.mjs, which imports this file). The commands live in config-command.mjs, +// which `mosaic org config validate|resolve` (packages/cli/src/cli.mjs) calls too; this file only +// hands it the process's arguments, streams and environment and sets the exit status. 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, each stderr line prefixed +// `mosaic business: `, exactly as before stage B. +// +// 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 { BUSINESS_CONFIG, runConfigCommand } from "./config-command.mjs"; + +process.exitCode = runConfigCommand(process.argv.slice(2), { + stdout: process.stdout, + stderr: process.stderr, + env: process.env, + spelling: BUSINESS_CONFIG, +}); diff --git a/packages/organization/src/config-command.mjs b/packages/organization/src/config-command.mjs new file mode 100644 index 000000000..87472af2d --- /dev/null +++ b/packages/organization/src/config-command.mjs @@ -0,0 +1,175 @@ +// The installed organization config commands, as one callable service: +// validate ID the installed loader, every role definition it uses, the +// project files under its declared roots, and a stat check +// of every credential reference +// resolve ID INSTANCE [--project ID] one role instance's resolved record +// Both print JSON on stdout and problems on stderr. The v1 serialization (the `business` key, +// `business:` provenance, the canonical preimage and digest) is the stage A contract; it does not +// change with the spelling. +// +// Rename design r2, stage B: `mosaic business validate|resolve` (src/cli.mjs, and the old +// packages/business/src/cli.mjs path that forwards to it) and `mosaic org config validate|resolve` +// (packages/cli/src/cli.mjs) both call runConfigCommand. It never exits the process or runs on +// import: it writes to the streams it is given and returns the exit code. A spelling sets only the +// usage line (its prefix and text). Status, stdout and every other stderr line, refusals and +// warnings included, are byte for byte the same for both: they keep the `mosaic business: ` prefix +// so a caller moving to the new spelling keeps the diagnostic contract. +// +// Exit codes: 0 ok; 2 invalid (organization, 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 { OrganizationConfigError } from "./errors.mjs"; +import { configDir, loadOrganization } from "./organization-config.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)), "..", "..", ".."); + +// The old spelling's prefix and usage are byte for byte what `mosaic business` printed before stage B. +export const BUSINESS_CONFIG = Object.freeze({ + prefix: "mosaic business", + usage: "usage: mosaic business validate | resolve [--project ]", +}); +export const ORG_CONFIG = Object.freeze({ + prefix: "mosaic org config", + usage: "usage: mosaic org config validate ID | resolve ID INSTANCE [--project ID]", +}); + +// Thrown after the message is written; runConfigCommand turns it into the exit code. +class Exit { + constructor(code) { + this.code = code; + } +} + +// argv is [verb, ...args]. Returns the exit code. An error that is not an OrganizationConfigError +// (a bug, not a refusal) is thrown to the caller, as the old executable let it escape. +export function runConfigCommand(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, spelling = BUSINESS_CONFIG } = {}) { + // Diagnostics keep the old prefix in both spellings; only the usage line follows the spelling. + const say = (message) => stderr.write(`${BUSINESS_CONFIG.prefix}: ${message}\n`); + const usage = () => { + stderr.write(`${spelling.prefix}: ${spelling.usage}\n`); + throw new Exit(4); + }; + const fail = (code, message) => { + say(message); + throw new Exit(code); + }; + + const loadSystem = () => { + const proc = spawnSync(process.execPath, [join(REPO, "scripts", "mosaic-config.mjs"), "validate"], { + encoding: "utf8", + maxBuffer: 1024 * 1024, + env, + }); + 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"); + } + }; + const load = (id) => loadOrganization(id, { dir: configDir(env), rolesDir: resolve(env.MOSAIC_ROLES_DIR || join(REPO, "roles")) }); + + // Check credential references; return the problems and warnings. + const checkRefs = (refs, forbiddenRoots) => { + const problems = []; + const warnings = []; + for (const ref of refs) { + const result = checkCredentialRef(ref, { forbiddenRoots, env }); + problems.push(...result.problems); + warnings.push(...result.warnings); + } + return { problems, warnings }; + }; + const forbiddenRoots = (config, organization) => + [REPO, config.dataRoot, ...Object.values(organization.projects).filter((p) => p.kind !== "library").map((p) => p.root)]; + + const validate = (args) => { + if (args.length !== 1) usage(); + const config = loadSystem(); + const system = systemVars(config); + const organization = load(args[0]); + const projects = {}; + const warnings = []; + for (const [id, entry] of Object.entries(organization.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 ${organization.id} declares it as ${id}`); + for (const instance of Object.keys(organization.roles)) resolveInstance({ system, business: organization, project, instance }); + projects[id] = "valid"; + } + const refs = [ + ...Object.values(organization.tracker?.sync?.credentials ?? {}), + ...Object.values(organization.roles).flatMap((r) => Object.values(r.credentials)), + ]; + const checked = checkRefs(refs, forbiddenRoots(config, organization)); + warnings.push(...checked.warnings); + const instances = {}; + for (const instance of Object.keys(organization.roles)) { + instances[instance] = resolveInstance({ system, business: organization, instance }).digest; + } + for (const w of warnings) say(`warning: ${w}`); + if (checked.problems.length > 0) { + for (const p of checked.problems) say(p); + fail(2, `business ${organization.id}: ${checked.problems.length} credential reference problem(s)`); + } + // v1 output: the key stays `business`. + stdout.write(`${JSON.stringify({ business: organization.id, file: organization.file, projects, instances }, null, 2)}\n`); + }; + + const 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) usage(); + project = args[++i]; + } else rest.push(args[i]); + } + if (rest.length !== 2) usage(); + const [organizationId, instance] = rest; + const config = loadSystem(); + const organization = load(organizationId); + let loaded = null; + if (project !== null) { + const entry = Object.hasOwn(organization.projects, project) ? organization.projects[project] : null; + if (!entry) fail(2, `business ${organization.id} declares no project ${JSON.stringify(project)}`); + if (entry.kind !== "library") loaded = loadProject(entry.root); + } + const resolved = resolveInstance({ system: systemVars(config), business: organization, project: loaded, instance }); + const checked = checkRefs(Object.values(resolved.credentials), forbiddenRoots(config, organization)); + for (const w of checked.warnings) say(`warning: ${w}`); + if (checked.problems.length > 0) { + for (const p of checked.problems) say(p); + fail(2, `${organization.id} ${instance}: ${checked.problems.length} credential reference problem(s)`); + } + stdout.write(`${JSON.stringify(resolved, null, 2)}\n`); + }; + + const [verb, ...args] = argv; + try { + if (verb === "validate") validate(args); + else if (verb === "resolve") resolveCommand(args); + else usage(); + return 0; + } catch (error) { + if (error instanceof Exit) return error.code; + if (error instanceof OrganizationConfigError) { + say(error.message); + return error.exitCode; + } + throw error; + } +} diff --git a/packages/organization/src/credentials.mjs b/packages/organization/src/credentials.mjs new file mode 100644 index 000000000..52f95c085 --- /dev/null +++ b/packages/organization/src/credentials.mjs @@ -0,0 +1,125 @@ +// 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 /secrets/agents///, 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. 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 }; +} diff --git a/packages/organization/src/errors.mjs b/packages/organization/src/errors.mjs new file mode 100644 index 000000000..f3ca56fb1 --- /dev/null +++ b/packages/organization/src/errors.mjs @@ -0,0 +1,22 @@ +// Exit codes follow docs/TOOLS.md: 2 invalid data or refused, 4 usage or a +// missing file. Every refusal in this package is an OrganizationConfigError. +// +// Rename design r2 (module map): BusinessError is the same constructor under +// its old name, so instanceof, exitCode and the message hold for both. The +// name property stays "BusinessError": the bus matches refusals by it +// (packages/bus/src/broker.mjs) and an uncaught refusal prints it, so the +// legacy error output does not change. OrganizationError in organization.mjs +// is a different class and stays one. +export class OrganizationConfigError extends Error { + constructor(message, exitCode = 2) { + super(message); + this.name = "BusinessError"; + this.exitCode = exitCode; + } +} + +export const BusinessError = OrganizationConfigError; + +export function refuse(message, exitCode = 2) { + throw new OrganizationConfigError(message, exitCode); +} diff --git a/packages/organization/src/hosts.mjs b/packages/organization/src/hosts.mjs new file mode 100644 index 000000000..3d0d116dc --- /dev/null +++ b/packages/organization/src/hosts.mjs @@ -0,0 +1,86 @@ +// 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]; + }); +} diff --git a/packages/organization/src/index.mjs b/packages/organization/src/index.mjs new file mode 100644 index 000000000..4b22e9521 --- /dev/null +++ b/packages/organization/src/index.mjs @@ -0,0 +1,21 @@ +// @mosaic/organization: role definitions, the organization configuration +// (v1 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. @mosaic/business (packages/business) is the old name and +// re-exports this module; the old export names below are the same values. + +export { OrganizationConfigError, 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, organizationFilePath, validateOrganizationDocument, loadOrganization, + businessFilePath, validateBusinessDocument, loadBusiness, +} from "./organization-config.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"; diff --git a/packages/organization/src/instance-settings.mjs b/packages/organization/src/instance-settings.mjs new file mode 100644 index 000000000..2859c0ce6 --- /dev/null +++ b/packages/organization/src/instance-settings.mjs @@ -0,0 +1,477 @@ +// 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 "./organization-config.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. 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 (organization-config.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; + } + // organization-config.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 }; +} diff --git a/packages/organization/src/launch-settings.mjs b/packages/organization/src/launch-settings.mjs new file mode 100644 index 000000000..be8678e75 --- /dev/null +++ b/packages/organization/src/launch-settings.mjs @@ -0,0 +1,407 @@ +// Shared human settings projection/update (REQ-LIMIT-1), used by the trusted broker. +import { organizationWriter, OrganizationError } from "./organization.mjs"; +import { validateBusinessDocument, businessFilePath } from "./organization-config.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 .. +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: { : { 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 }; +} diff --git a/packages/organization/src/launch.mjs b/packages/organization/src/launch.mjs new file mode 100644 index 000000000..3aa3caa57 --- /dev/null +++ b/packages/organization/src/launch.mjs @@ -0,0 +1,176 @@ +// 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; +} diff --git a/packages/organization/src/library-seats.mjs b/packages/organization/src/library-seats.mjs new file mode 100644 index 000000000..7e6dadeea --- /dev/null +++ b/packages/organization/src/library-seats.mjs @@ -0,0 +1,51 @@ +// 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 "./organization-config.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 }; +} diff --git a/packages/organization/src/migrate-launch.mjs b/packages/organization/src/migrate-launch.mjs new file mode 100644 index 000000000..fd3aa825f --- /dev/null +++ b/packages/organization/src/migrate-launch.mjs @@ -0,0 +1,94 @@ +// Decision 145: pure print-only conversion. System values are supplied by the +// existing read-only config loader; this module never reads credentials or writes. +import { checkModels, checkLimits } from "./launch.mjs"; +import { requireObject, rejectUnknownKeys } from "./util.mjs"; +import { refuse } from "./errors.mjs"; +import { validateBusinessDocument } from "./organization-config.mjs"; + +export function migrateLaunch(document, mapping, file, { rolesDir, system = null }) { + const out = structuredClone(document); + if (!out.launch?.max) { + validateBusinessDocument(out, file, { rolesDir }); + return out; + } + requireObject(mapping, "migration mapping"); + rejectUnknownKeys(mapping, ["models", "families", "selections", "instances"], "migration mapping"); + const models = checkModels(mapping.models); + requireObject(mapping.families, "migration families"); + requireObject(out.launch.max, "old launch.max"); + for (const field of ["instances", "selections"]) { + if (Object.hasOwn(mapping, field)) requireObject(mapping[field], `migration ${field}`); + } + for (const key of Object.keys(mapping.instances ?? {})) { + if (!Object.hasOwn(out.roles, key)) refuse(`unknown migration instance ${key}`); + } + const limits = { models: Object.create(null) }; + const used = new Set(); + for (const [family, count] of Object.entries(out.launch.max)) { + const key = mapping.families[family]; + if (typeof key !== "string" || !Object.hasOwn(models, key)) { + refuse(`unknown migration mapping for family ${family}`); + } + const entry = models[key]; + const tuple = JSON.stringify([entry.provider, entry.model]); + if (used.has(tuple)) refuse("ambiguous migration: two family limits map to the same provider/model"); + used.add(tuple); + limits.models[entry.provider] ??= Object.create(null); + limits.models[entry.provider][entry.model] = count; + } + const checkedLimits = checkLimits(limits); + const previous = new Map(); + for (const [instance, role] of Object.entries(out.roles)) { + const inherited = role.vars?.model === undefined; + let old = role.vars?.model; + let provider = null; + if (inherited) { + old = system?.["execution.model"]; + provider = system?.["execution.provider"]; + if (typeof old !== "string" || !old || typeof provider !== "string" || !provider) { + refuse(`inherited model for ${instance} requires valid system config`); + } + } + previous.set(instance, old); + let selected = mapping.instances?.[instance] ?? mapping.selections?.[old]; + if (selected === undefined) { + const matches = Object.entries(models).filter(([, entry]) => { + return entry.model === old && (!inherited || entry.provider === provider); + }); + if (matches.length !== 1) refuse(`unknown or ambiguous model mapping for ${instance}`); + selected = matches[0][0]; + } + if (typeof selected !== "string" || !Object.hasOwn(models, selected)) { + refuse(`unknown or ambiguous model mapping for ${instance}`); + } + const entry = models[selected]; + if (entry.model !== old || (inherited && entry.provider !== provider)) { + refuse(`migration mapping changes ${instance}'s ${inherited ? "inherited provider/model" : "external model"}`); + } + role.vars = { ...role.vars, model: selected }; + } + for (const instance of [out.launch.by, ...out.launch.instances]) { + const selected = models[out.roles[instance].vars.model]; + const old = previous.get(instance); + const families = Object.keys(out.launch.max).filter(f => old.toLowerCase().includes(f.toLowerCase())); + if (families.length !== 1) refuse(`unknown or ambiguous family mapping for launchable ${instance}`); + const constrained = models[mapping.families[families[0]]]; + if (selected.provider !== constrained.provider || selected.model !== constrained.model) { + refuse(`ambiguous migration mapping for launchable ${instance}`); + } + } + for (const family of Object.keys(out.launch.max)) { + const entry = models[mapping.families[family]]; + for (const [instance, old] of previous) { + if (!old.toLowerCase().includes(family.toLowerCase())) continue; + const actual = models[out.roles[instance].vars.model]; + if (old !== entry.model || actual.provider !== entry.provider) { + refuse(`ambiguous migration: family ${family} spans multiple provider/model pairs`); + } + } + } + const { max, ...launch } = out.launch; + out.launch = { ...launch, models, limits: checkedLimits }; + validateBusinessDocument(out, file, { rolesDir }); + return out; +} diff --git a/packages/organization/src/org-registry.mjs b/packages/organization/src/org-registry.mjs new file mode 100644 index 000000000..06d3378e7 --- /dev/null +++ b/packages/organization/src/org-registry.mjs @@ -0,0 +1,214 @@ +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); } + } + } +} diff --git a/packages/organization/src/org-service.mjs b/packages/organization/src/org-service.mjs new file mode 100644 index 000000000..0278acd99 --- /dev/null +++ b/packages/organization/src/org-service.mjs @@ -0,0 +1,386 @@ +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 './organization-config.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', 'user.email=mosaic@localhost.invalid', '-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; + } + }); +} diff --git a/packages/business/src/org-template.json b/packages/organization/src/org-template.json similarity index 100% rename from packages/business/src/org-template.json rename to packages/organization/src/org-template.json diff --git a/packages/organization/src/organization-config.mjs b/packages/organization/src/organization-config.mjs new file mode 100644 index 000000000..34e0a647a --- /dev/null +++ b/packages/organization/src/organization-config.mjs @@ -0,0 +1,349 @@ +// The organization configuration file, still the v1 business file at +// /businesses/.json (REQ-ROLE-3, note section 2, addendum A +// sections 7 and 8; rename design r2 stage B: names only, same bytes). 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 organizationFilePath(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. 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 validateOrganizationDocument(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/.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 loadOrganization(id, { dir = configDir(), rolesDir }) { + if (!rolesDir) throw new Error("loadBusiness needs rolesDir"); + const file = organizationFilePath(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 validateOrganizationDocument(document, file, { rolesDir }); +} + +// The old names (rename design r2, module map): the same functions, not copies. +export { organizationFilePath as businessFilePath, validateOrganizationDocument as validateBusinessDocument, loadOrganization as loadBusiness }; diff --git a/packages/organization/src/organization.mjs b/packages/organization/src/organization.mjs new file mode 100644 index 000000000..681c10621 --- /dev/null +++ b/packages/organization/src/organization.mjs @@ -0,0 +1,444 @@ +// 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 "./organization-config.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)) }; +} diff --git a/packages/organization/src/plugin-settings.mjs b/packages/organization/src/plugin-settings.mjs new file mode 100644 index 000000000..88c383e57 --- /dev/null +++ b/packages/organization/src/plugin-settings.mjs @@ -0,0 +1,70 @@ +// 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 }; +} diff --git a/packages/organization/src/project-bootstrap.mjs b/packages/organization/src/project-bootstrap.mjs new file mode 100644 index 000000000..db3488865 --- /dev/null +++ b/packages/organization/src/project-bootstrap.mjs @@ -0,0 +1,79 @@ +// 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, /products///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"; +} diff --git a/packages/organization/src/project-remotes.mjs b/packages/organization/src/project-remotes.mjs new file mode 100644 index 000000000..224875ed2 --- /dev/null +++ b/packages/organization/src/project-remotes.mjs @@ -0,0 +1,31 @@ +// 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') }; +} diff --git a/packages/organization/src/project.mjs b/packages/organization/src/project.mjs new file mode 100644 index 000000000..320ec018c --- /dev/null +++ b/packages/organization/src/project.mjs @@ -0,0 +1,36 @@ +// The project file, /.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); +} diff --git a/packages/organization/src/resolve.mjs b/packages/organization/src/resolve.mjs new file mode 100644 index 000000000..09c04ae43 --- /dev/null +++ b/packages/organization/src/resolve.mjs @@ -0,0 +1,117 @@ +// 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 /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"; +} diff --git a/packages/organization/src/role.mjs b/packages/organization/src/role.mjs new file mode 100644 index 000000000..2db4aada5 --- /dev/null +++ b/packages/organization/src/role.mjs @@ -0,0 +1,232 @@ +// Role definitions, roles/.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 +// /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: or write:; 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`)); +} diff --git a/packages/organization/src/selector.mjs b/packages/organization/src/selector.mjs new file mode 100644 index 000000000..e4a94a252 --- /dev/null +++ b/packages/organization/src/selector.mjs @@ -0,0 +1,54 @@ +// Organization selector options (rename design r2, "Selector scopes and parser matrix"). +// scripts/selector.sh is the shell half and keeps the same rules; packages/organization/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; }, + }); +} diff --git a/packages/organization/src/util.mjs b/packages/organization/src/util.mjs new file mode 100644 index 000000000..fa6519d4b --- /dev/null +++ b/packages/organization/src/util.mjs @@ -0,0 +1,109 @@ +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; +} diff --git a/packages/organization/src/vars.mjs b/packages/organization/src/vars.mjs new file mode 100644 index 000000000..409d8c927 --- /dev/null +++ b/packages/organization/src/vars.mjs @@ -0,0 +1,223 @@ +// 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/.json +// project vars in /.mosaic/project.json, then that file's +// roles..vars (still the project layer, applied after) +// agent roles..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}(? 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 /personas//.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 }; +} diff --git a/packages/organization/src/vocabulary.mjs b/packages/organization/src/vocabulary.mjs new file mode 100644 index 000000000..3e6e4d971 --- /dev/null +++ b/packages/organization/src/vocabulary.mjs @@ -0,0 +1,374 @@ +// 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., 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 :. "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]//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; +} diff --git a/packages/business/tests/access-levels.test.mjs b/packages/organization/tests/access-levels.test.mjs similarity index 100% rename from packages/business/tests/access-levels.test.mjs rename to packages/organization/tests/access-levels.test.mjs diff --git a/packages/business/tests/access.test.mjs b/packages/organization/tests/access.test.mjs similarity index 100% rename from packages/business/tests/access.test.mjs rename to packages/organization/tests/access.test.mjs diff --git a/packages/business/tests/agent-identity.test.mjs b/packages/organization/tests/agent-identity.test.mjs similarity index 100% rename from packages/business/tests/agent-identity.test.mjs rename to packages/organization/tests/agent-identity.test.mjs diff --git a/packages/business/tests/appearance.test.mjs b/packages/organization/tests/appearance.test.mjs similarity index 98% rename from packages/business/tests/appearance.test.mjs rename to packages/organization/tests/appearance.test.mjs index 23da86a53..6febd758b 100644 --- a/packages/business/tests/appearance.test.mjs +++ b/packages/organization/tests/appearance.test.mjs @@ -4,7 +4,7 @@ import assert from "node:assert/strict"; import { readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { businessDoc, rolesCopy, tmp as makeRoot, writeJson } from "./helpers.mjs"; -import { validateBusinessDocument } from "../src/business.mjs"; +import { validateBusinessDocument } from "../src/organization-config.mjs"; import { APPEARANCE_MODES, checkAppearance } from "../src/appearance.mjs"; import { pluginSettings } from "../src/plugin-settings.mjs"; import { BusinessError } from "../src/errors.mjs"; diff --git a/packages/business/tests/assistant.test.mjs b/packages/organization/tests/assistant.test.mjs similarity index 100% rename from packages/business/tests/assistant.test.mjs rename to packages/organization/tests/assistant.test.mjs diff --git a/packages/business/tests/case1-business-file.test.mjs b/packages/organization/tests/case1-business-file.test.mjs similarity index 98% rename from packages/business/tests/case1-business-file.test.mjs rename to packages/organization/tests/case1-business-file.test.mjs index 0886d6ef2..5223d353a 100644 --- a/packages/business/tests/case1-business-file.test.mjs +++ b/packages/organization/tests/case1-business-file.test.mjs @@ -101,7 +101,7 @@ test("row 323: a direct Anthropic launch without an account still loads", (t) => test("row 323: real business validate accepts the Case 1 fixture without tracker credentials", (t) => { const x = world(t); configure(x); - const r = spawnSync(process.execPath, [join(REPO, "packages/business/src/cli.mjs"), "validate", "mosaic-stack"], + const r = spawnSync(process.execPath, [join(REPO, "packages/organization/src/cli.mjs"), "validate", "mosaic-stack"], { env: x.env, encoding: "utf8", timeout: 30000 }); assert.equal(r.status, 0, output(r)); assert.equal(JSON.parse(r.stdout).business, "mosaic-stack"); diff --git a/packages/business/tests/case1-project-key.test.mjs b/packages/organization/tests/case1-project-key.test.mjs similarity index 97% rename from packages/business/tests/case1-project-key.test.mjs rename to packages/organization/tests/case1-project-key.test.mjs index 5ccd935b0..66173b49b 100644 --- a/packages/business/tests/case1-project-key.test.mjs +++ b/packages/organization/tests/case1-project-key.test.mjs @@ -28,7 +28,7 @@ function fixture(t, { oldKey = false } = {}) { writeJson(join(root, "config/businesses/mosaic-stack.json"), doc); return { project: JSON.parse(bytes), projectFile, - validate: () => spawnSync(process.execPath, [join(REPO, "packages/business/src/cli.mjs"), "validate", "mosaic-stack"], { + validate: () => spawnSync(process.execPath, [join(REPO, "packages/organization/src/cli.mjs"), "validate", "mosaic-stack"], { env: { ...process.env, HOME: root, MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: REPO_ROLES }, encoding: "utf8", timeout: 30000, }), diff --git a/packages/business/tests/chief-library-credentials.test.mjs b/packages/organization/tests/chief-library-credentials.test.mjs similarity index 100% rename from packages/business/tests/chief-library-credentials.test.mjs rename to packages/organization/tests/chief-library-credentials.test.mjs diff --git a/packages/business/tests/chief-of-staff.test.mjs b/packages/organization/tests/chief-of-staff.test.mjs similarity index 99% rename from packages/business/tests/chief-of-staff.test.mjs rename to packages/organization/tests/chief-of-staff.test.mjs index 9d772a54f..88ce6400b 100644 --- a/packages/business/tests/chief-of-staff.test.mjs +++ b/packages/organization/tests/chief-of-staff.test.mjs @@ -9,7 +9,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { ACTIONS, BusinessError, classify, loadBusiness, loadRole, resolveInstance } from "../src/index.mjs"; -import { THREAD_IDLE_SECONDS } from "../src/business.mjs"; +import { THREAD_IDLE_SECONDS } from "../src/organization-config.mjs"; import { businessDoc, launchDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs"; // Decision 104's list plus role.launch (decision 139) plus role.end and diff --git a/packages/business/tests/cli.test.mjs b/packages/organization/tests/cli.test.mjs similarity index 99% rename from packages/business/tests/cli.test.mjs rename to packages/organization/tests/cli.test.mjs index beb367f8c..a3c399f1e 100644 --- a/packages/business/tests/cli.test.mjs +++ b/packages/organization/tests/cli.test.mjs @@ -6,7 +6,7 @@ import { join } from "node:path"; import { projectFilePath } from "../src/index.mjs"; import { businessDoc, REPO, rolesCopy, systemConfig, tmp, tokenFile, writeJson } from "./helpers.mjs"; -const CLI = join(REPO, "packages", "business", "src", "cli.mjs"); +const CLI = join(REPO, "packages", "organization", "src", "cli.mjs"); test('C2-04: business validate and resolve skip a library before code projects and credential checks', async t => { const { mappingEntry } = await import('../../library/tests/helpers.mjs'); diff --git a/packages/business/tests/credentials.test.mjs b/packages/organization/tests/credentials.test.mjs similarity index 100% rename from packages/business/tests/credentials.test.mjs rename to packages/organization/tests/credentials.test.mjs diff --git a/packages/business/tests/csuite.test.mjs b/packages/organization/tests/csuite.test.mjs similarity index 100% rename from packages/business/tests/csuite.test.mjs rename to packages/organization/tests/csuite.test.mjs diff --git a/packages/business/tests/grant-scope.test.mjs b/packages/organization/tests/grant-scope.test.mjs similarity index 99% rename from packages/business/tests/grant-scope.test.mjs rename to packages/organization/tests/grant-scope.test.mjs index c9e8d7509..69dafcc06 100644 --- a/packages/business/tests/grant-scope.test.mjs +++ b/packages/organization/tests/grant-scope.test.mjs @@ -7,7 +7,7 @@ import { readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { instanceSettings } from "../src/instance-settings.mjs"; import { launchSettings } from "../src/launch-settings.mjs"; -import { loadBusiness } from "../src/business.mjs"; +import { loadBusiness } from "../src/organization-config.mjs"; import { checkGrantScope, grantInScope, grantScopeOf } from "../src/vocabulary.mjs"; import { businessDoc, rolesCopy, systemFor, tmp, writeJson } from "./helpers.mjs"; diff --git a/packages/business/tests/hard-waits.test.mjs b/packages/organization/tests/hard-waits.test.mjs similarity index 100% rename from packages/business/tests/hard-waits.test.mjs rename to packages/organization/tests/hard-waits.test.mjs diff --git a/packages/organization/tests/helpers.mjs b/packages/organization/tests/helpers.mjs new file mode 100644 index 000000000..cca29ef94 --- /dev/null +++ b/packages/organization/tests/helpers.mjs @@ -0,0 +1,108 @@ +// 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 `/secrets`, and a project root under ``. +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}}}; +} diff --git a/packages/business/tests/hosts.test.mjs b/packages/organization/tests/hosts.test.mjs similarity index 100% rename from packages/business/tests/hosts.test.mjs rename to packages/organization/tests/hosts.test.mjs diff --git a/packages/business/tests/instance-credential-transitions.test.mjs b/packages/organization/tests/instance-credential-transitions.test.mjs similarity index 100% rename from packages/business/tests/instance-credential-transitions.test.mjs rename to packages/organization/tests/instance-credential-transitions.test.mjs diff --git a/packages/business/tests/instance-settings.test.mjs b/packages/organization/tests/instance-settings.test.mjs similarity index 99% rename from packages/business/tests/instance-settings.test.mjs rename to packages/organization/tests/instance-settings.test.mjs index 54c22e0e0..88b46759e 100644 --- a/packages/business/tests/instance-settings.test.mjs +++ b/packages/organization/tests/instance-settings.test.mjs @@ -11,7 +11,7 @@ import { instanceSettings } from "../src/instance-settings.mjs"; import { projectFilePath } from "../src/project.mjs"; import { businessDoc, FAR_DATE, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs"; -const CLI = join(REPO, "packages", "business", "src", "cli.mjs"); +const CLI = join(REPO, "packages", "organization", "src", "cli.mjs"); const roots = []; after(() => roots.forEach(root => rmSync(root, { recursive: true, force: true }))); diff --git a/packages/business/tests/launch-settings.test.mjs b/packages/organization/tests/launch-settings.test.mjs similarity index 99% rename from packages/business/tests/launch-settings.test.mjs rename to packages/organization/tests/launch-settings.test.mjs index 7c8a4eb6e..fac64c659 100644 --- a/packages/business/tests/launch-settings.test.mjs +++ b/packages/organization/tests/launch-settings.test.mjs @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import {readFileSync,statSync,writeFileSync,readdirSync,mkdirSync,symlinkSync,rmSync} from 'node:fs'; import {join} from 'node:path'; import {businessDoc,rolesCopy,tmp as makeRoot,writeJson,tokenFile,FAR_DATE} from './helpers.mjs'; -import {validateBusinessDocument} from '../src/business.mjs'; +import {validateBusinessDocument} from '../src/organization-config.mjs'; const roots=[];after(()=>roots.forEach(root=>rmSync(root,{recursive:true,force:true}))); function fixture(){const root=makeRoot();roots.push(root);const rolesDir=rolesCopy(root),doc=businessDoc(root,'acme');for(const r of Object.values(doc.roles))r.vars={...r.vars,model:'sonnet'};doc.roles.pm.vars.model='opus';doc.launch={by:'chief',instances:['coder','reviewer'],models:{opus:{provider:'anthropic',model:'claude-opus-5-5'},sonnet:{provider:'anthropic',model:'claude-sonnet-5-5'}},limits:{models:{anthropic:{'claude-opus-5-5':4,'claude-sonnet-5-5':4}}}};const file=join(root,'businesses/acme.json');writeJson(file,doc);return {root,rolesDir,doc,file};} test('REQ-LIMIT-1 schema: every scope unset or zero, adjustable above four, bad values and unknown models refuse',()=>{ diff --git a/packages/business/tests/library-projects-protected.test.mjs b/packages/organization/tests/library-projects-protected.test.mjs similarity index 100% rename from packages/business/tests/library-projects-protected.test.mjs rename to packages/organization/tests/library-projects-protected.test.mjs diff --git a/packages/business/tests/library-seats.test.mjs b/packages/organization/tests/library-seats.test.mjs similarity index 100% rename from packages/business/tests/library-seats.test.mjs rename to packages/organization/tests/library-seats.test.mjs diff --git a/packages/business/tests/lifecycle-settings.test.mjs b/packages/organization/tests/lifecycle-settings.test.mjs similarity index 99% rename from packages/business/tests/lifecycle-settings.test.mjs rename to packages/organization/tests/lifecycle-settings.test.mjs index 2a80b4e04..e7700255f 100644 --- a/packages/business/tests/lifecycle-settings.test.mjs +++ b/packages/organization/tests/lifecycle-settings.test.mjs @@ -11,7 +11,7 @@ import { OrganizationError, organizationWriter } from "../src/organization.mjs"; import { CREATIVE_DEFINITIONS } from "../src/vocabulary.mjs"; import { businessDoc, FAR_DATE, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs"; -const CLI = join(REPO, "packages", "business", "src", "cli.mjs"); +const CLI = join(REPO, "packages", "organization", "src", "cli.mjs"); const roots = []; after(() => roots.forEach(root => rmSync(root, { recursive: true, force: true }))); diff --git a/packages/business/tests/optional-tracker.test.mjs b/packages/organization/tests/optional-tracker.test.mjs similarity index 96% rename from packages/business/tests/optional-tracker.test.mjs rename to packages/organization/tests/optional-tracker.test.mjs index 97a31719b..265396fbb 100644 --- a/packages/business/tests/optional-tracker.test.mjs +++ b/packages/organization/tests/optional-tracker.test.mjs @@ -34,7 +34,7 @@ test("Q247: real business validate passes without a sync credential reference or rmSync(x.doc.tracker.sync.credentials.vikunja.file); delete x.doc.tracker; writeJson(x.file, x.doc); - const r = spawnSync(process.execPath, [join(REPO, "packages/business/src/cli.mjs"), "validate", "acme"], { + const r = spawnSync(process.execPath, [join(REPO, "packages/organization/src/cli.mjs"), "validate", "acme"], { env: { ...process.env, HOME: x.root, MOSAIC_CONFIG: x.config, MOSAIC_ROLES_DIR: REPO_ROLES }, encoding: "utf8", timeout: 30000, }); diff --git a/packages/business/tests/org-archive-fence.test.mjs b/packages/organization/tests/org-archive-fence.test.mjs similarity index 100% rename from packages/business/tests/org-archive-fence.test.mjs rename to packages/organization/tests/org-archive-fence.test.mjs diff --git a/packages/business/tests/org-archive.test.mjs b/packages/organization/tests/org-archive.test.mjs similarity index 100% rename from packages/business/tests/org-archive.test.mjs rename to packages/organization/tests/org-archive.test.mjs diff --git a/packages/business/tests/org-delete.test.mjs b/packages/organization/tests/org-delete.test.mjs similarity index 100% rename from packages/business/tests/org-delete.test.mjs rename to packages/organization/tests/org-delete.test.mjs diff --git a/packages/business/tests/org-presets.test.mjs b/packages/organization/tests/org-presets.test.mjs similarity index 100% rename from packages/business/tests/org-presets.test.mjs rename to packages/organization/tests/org-presets.test.mjs diff --git a/packages/business/tests/org-registry.test.mjs b/packages/organization/tests/org-registry.test.mjs similarity index 100% rename from packages/business/tests/org-registry.test.mjs rename to packages/organization/tests/org-registry.test.mjs diff --git a/packages/business/tests/org-service.test.mjs b/packages/organization/tests/org-service.test.mjs similarity index 99% rename from packages/business/tests/org-service.test.mjs rename to packages/organization/tests/org-service.test.mjs index 4dd27ca9a..59887f7d5 100644 --- a/packages/business/tests/org-service.test.mjs +++ b/packages/organization/tests/org-service.test.mjs @@ -8,7 +8,7 @@ import { mkdtempSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'n import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createOrg, showOrg, updateOrg, queueInit, queueShow, projectRepoInit, projectRepoShow, notificationShow } from '../src/org-service.mjs'; -import { loadBusiness } from '../src/business.mjs'; +import { loadBusiness } from '../src/organization-config.mjs'; import { REPO_ROLES } from './helpers.mjs'; import { organizationWriter } from '../src/organization.mjs'; import { watch } from 'node:fs'; diff --git a/packages/business/tests/business.test.mjs b/packages/organization/tests/organization-config.test.mjs similarity index 98% rename from packages/business/tests/business.test.mjs rename to packages/organization/tests/organization-config.test.mjs index 636c47e96..5018ab694 100644 --- a/packages/business/tests/business.test.mjs +++ b/packages/organization/tests/organization-config.test.mjs @@ -5,7 +5,7 @@ import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, symlinkSync import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { BusinessError, businessFilePath, configDir, loadBusiness, validateBusinessDocument } from "../src/index.mjs"; -import { THREAD_IDLE_SECONDS } from "../src/business.mjs"; +import { THREAD_IDLE_SECONDS } from "../src/organization-config.mjs"; import { CREATIVE_DEFINITIONS } from "../src/vocabulary.mjs"; import { launchDoc, businessDoc, REPO, REPO_ROLES, rolesCopy, tmp, writeJson } from "./helpers.mjs"; @@ -225,7 +225,7 @@ test("loadBusiness: not a regular file", () => { mkdirSync(join(dir, "businesses", "folder.json"), { recursive: true }); refuses(() => loadBusiness("folder", { dir, rolesDir: REPO_ROLES }), /must be a regular, non-symbolic-link file/, 4); execFileSync("mkfifo", [join(dir, "businesses", "pipe.json")]); - const index = pathToFileURL(join(REPO, "packages", "business", "src", "index.mjs")).href; + const index = pathToFileURL(join(REPO, "packages", "organization", "src", "index.mjs")).href; const child = spawnSync(process.execPath, ["--input-type=module", "-e", ` import { loadBusiness } from ${JSON.stringify(index)}; try { loadBusiness("pipe", { dir: ${JSON.stringify(dir)}, rolesDir: ${JSON.stringify(REPO_ROLES)} }); } @@ -259,7 +259,7 @@ test("names that are Object.prototype properties don't count as declared", () => }); test("the shipped example refuses as written and validates once filled in", () => { - const example = readFileSync(join(REPO, "packages", "business", "examples", "mosaic-stack.example.json"), "utf8"); + const example = readFileSync(join(REPO, "packages", "organization", "examples", "mosaic-stack.example.json"), "utf8"); const root = tmp(); const file = join(root, "businesses", "mosaic-stack.json"); refuses(() => validateBusinessDocument(JSON.parse(example), file, { rolesDir: REPO_ROLES }), /botId must be a positive integer/); @@ -317,7 +317,7 @@ test("row 285: a chief or creative instance without credentials.gitea refuses at }); // Row 285 (C2-08b, brief): a creative instance may carry identity and publish, under the rules at -// business.mjs:155-166, which the grant path reads. The grant itself still refuses it (#giteaRole). +// organization-config.mjs checkRoles (identity and publish), which the grant path reads. The grant itself still refuses it (#giteaRole). test("row 285: a creative instance with identity and publish loads", () => { const identity = { name: "Bean", email: "bean@example.test", gitea: { url: "https://git.example", user: "acme-bean" } }; const business = check((d) => { diff --git a/packages/business/tests/plugin-settings.test.mjs b/packages/organization/tests/plugin-settings.test.mjs similarity index 99% rename from packages/business/tests/plugin-settings.test.mjs rename to packages/organization/tests/plugin-settings.test.mjs index 8e1a41bf2..8813c8c0c 100644 --- a/packages/business/tests/plugin-settings.test.mjs +++ b/packages/organization/tests/plugin-settings.test.mjs @@ -5,7 +5,7 @@ import assert from "node:assert/strict"; import { existsSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { businessDoc, rolesCopy, tmp as makeRoot, writeJson } from "./helpers.mjs"; -import { validateBusinessDocument } from "../src/business.mjs"; +import { validateBusinessDocument } from "../src/organization-config.mjs"; import { pluginSettings } from "../src/plugin-settings.mjs"; import { OrganizationError } from "../src/organization.mjs"; import { BusinessError } from "../src/errors.mjs"; diff --git a/packages/business/tests/project-bootstrap.test.mjs b/packages/organization/tests/project-bootstrap.test.mjs similarity index 100% rename from packages/business/tests/project-bootstrap.test.mjs rename to packages/organization/tests/project-bootstrap.test.mjs diff --git a/packages/business/tests/project-remotes.test.mjs b/packages/organization/tests/project-remotes.test.mjs similarity index 100% rename from packages/business/tests/project-remotes.test.mjs rename to packages/organization/tests/project-remotes.test.mjs diff --git a/packages/business/tests/project-repo.test.mjs b/packages/organization/tests/project-repo.test.mjs similarity index 95% rename from packages/business/tests/project-repo.test.mjs rename to packages/organization/tests/project-repo.test.mjs index e440dfd05..60d4c130d 100644 --- a/packages/business/tests/project-repo.test.mjs +++ b/packages/organization/tests/project-repo.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { join } from 'node:path'; import { businessDoc, REPO_ROLES, tmp } from './helpers.mjs'; -import { validateBusinessDocument } from '../src/business.mjs'; +import { validateBusinessDocument } from '../src/organization-config.mjs'; import { projectRepo } from '../../project/src/index.mjs'; test('optional project repo is preserved and must be a normalized absolute path', () => { const root = tmp(), doc = businessDoc(root), file = join(root, 'businesses/acme.json'); diff --git a/packages/business/tests/project.test.mjs b/packages/organization/tests/project.test.mjs similarity index 100% rename from packages/business/tests/project.test.mjs rename to packages/organization/tests/project.test.mjs diff --git a/packages/business/tests/proxy-launch.test.mjs b/packages/organization/tests/proxy-launch.test.mjs similarity index 100% rename from packages/business/tests/proxy-launch.test.mjs rename to packages/organization/tests/proxy-launch.test.mjs diff --git a/packages/business/tests/record-grants.test.mjs b/packages/organization/tests/record-grants.test.mjs similarity index 100% rename from packages/business/tests/record-grants.test.mjs rename to packages/organization/tests/record-grants.test.mjs diff --git a/packages/organization/tests/rename-b-identity.test.mjs b/packages/organization/tests/rename-b-identity.test.mjs new file mode 100644 index 000000000..fb0e1a17f --- /dev/null +++ b/packages/organization/tests/rename-b-identity.test.mjs @@ -0,0 +1,112 @@ +// Rename design r2, stage B: packages/business moved to packages/organization once, and the old +// package is a set of re-exports. These tests pin the two names to the same values: the error +// constructor, every module's exports, the bus adapter and the old executable path. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { REPO, REPO_ROLES, tmp } from "./helpers.mjs"; +import * as errors from "../src/errors.mjs"; +import * as index from "../src/index.mjs"; +import * as config from "../src/organization-config.mjs"; +import { OrganizationError } from "../src/organization.mjs"; +import * as oldErrors from "../../business/src/errors.mjs"; +import * as oldIndex from "../../business/src/index.mjs"; + +const ORG_SRC = join(REPO, "packages", "organization", "src"); +const OLD_SRC = join(REPO, "packages", "business", "src"); +const load = (dir, file) => import(pathToFileURL(join(dir, file)).href); + +test("rename r2 B: BusinessError is OrganizationConfigError, from both paths", () => { + assert.equal(errors.BusinessError, errors.OrganizationConfigError); + for (const m of [index, oldErrors, oldIndex]) { + assert.equal(m.OrganizationConfigError, errors.OrganizationConfigError); + assert.equal(m.BusinessError, errors.OrganizationConfigError); + } + const e = new errors.BusinessError("acme: bad", 4); + assert.ok(e instanceof errors.OrganizationConfigError); + assert.ok(e instanceof oldErrors.BusinessError); + assert.ok(e instanceof Error); + assert.equal(e.exitCode, 4); + assert.equal(e.message, "acme: bad"); + // The name stays BusinessError: the broker matches on it and the legacy output prints it. + assert.equal(e.name, "BusinessError"); + assert.equal(String(e), "BusinessError: acme: bad"); + assert.equal(new errors.OrganizationConfigError("x").exitCode, 2); + assert.throws(() => errors.refuse("refused here", 3), (thrown) => + thrown instanceof oldErrors.BusinessError && thrown.exitCode === 3 && thrown.message === "refused here" && thrown.name === "BusinessError"); +}); + +test("rename r2 B: OrganizationError stays a different class with a code", () => { + const e = new OrganizationError("organization-definition-refused"); + assert.notEqual(OrganizationError, errors.OrganizationConfigError); + assert.ok(!(e instanceof errors.OrganizationConfigError)); + assert.ok(!(new errors.OrganizationConfigError("x") instanceof OrganizationError)); + assert.equal(e.code, "organization-definition-refused"); + assert.equal(e.message, "organization-definition-refused"); + assert.equal(e.exitCode, undefined); + assert.equal(new OrganizationError("invalid-request", "why").message, "why"); +}); + +test("rename r2 B: the old function names are the same functions, and a refusal reads as before", () => { + assert.equal(config.businessFilePath, config.organizationFilePath); + assert.equal(config.validateBusinessDocument, config.validateOrganizationDocument); + assert.equal(config.loadBusiness, config.loadOrganization); + for (const name of ["businessFilePath", "validateBusinessDocument", "loadBusiness", "organizationFilePath", "validateOrganizationDocument", "loadOrganization", "configDir"]) { + assert.equal(typeof index[name], "function", name); + assert.equal(index[name], config[name], name); + assert.equal(oldIndex[name], config[name], name); + } + assert.throws(() => config.loadOrganization("acme", { dir: tmp() }), { message: "loadBusiness needs rolesDir" }); + const dir = join(tmp(), "config"); + assert.throws(() => config.loadOrganization("acme", { dir, rolesDir: REPO_ROLES }), (e) => + e instanceof errors.BusinessError && e.exitCode === 4 && /acme/.test(e.message)); +}); + +test("rename r2 B: every old module re-exports the moved module's exact values, and nothing else", async () => { + const moved = readdirSync(ORG_SRC).filter((f) => f.endsWith(".mjs") && f !== "cli.mjs").sort(); + const old = readdirSync(OLD_SRC).filter((f) => f.endsWith(".mjs") && f !== "cli.mjs").sort(); + const target = (f) => (f === "business.mjs" ? "organization-config.mjs" : f); + assert.deepEqual(old.map(target).sort(), moved, "one old module per moved module, business.mjs for organization-config.mjs"); + for (const f of old) { + const before = await load(OLD_SRC, f), after = await load(ORG_SRC, target(f)); + assert.deepEqual(Object.keys(before).sort(), Object.keys(after).sort(), f); + for (const k of Object.keys(after)) assert.equal(before[k], after[k], `${f} ${k}`); + assert.equal(before.default, undefined, `${f} has no default export`); + const text = readFileSync(join(OLD_SRC, f), "utf8"); + assert.match(text, new RegExp(`^export \\* from "\\.\\./\\.\\./organization/src/${target(f).replace(".", "\\.")}";$`, "m"), f); + assert.equal(text.split("\n").filter((l) => l && !l.startsWith("//")).length, 1, `${f} holds one re-export and comments`); + } +}); + +test("rename r2 B: the package names", () => { + const json = (p) => JSON.parse(readFileSync(join(REPO, p), "utf8")); + assert.equal(json("packages/organization/package.json").name, "@mosaic/organization"); + assert.equal(json("packages/business/package.json").name, "@mosaic/business"); + assert.deepEqual(json("packages/business/package.json").exports, { ".": "./src/index.mjs" }); +}); + +test("rename r2 B: the bus adapter has both names for one function", async () => { + const bus = await load(join(REPO, "packages", "bus", "src"), "index.mjs"); + const oldBus = await load(join(REPO, "packages", "bus", "src"), "business.mjs"); + const moved = await load(join(REPO, "packages", "bus", "src"), "organization-context.mjs"); + assert.equal(typeof moved.busOrganization, "function"); + assert.equal(oldBus.busBusiness, moved.busOrganization); + assert.equal(bus.busBusiness, moved.busOrganization); + assert.equal(bus.busOrganization, moved.busOrganization); +}); + +test("rename r2 B: the old executable path runs the moved CLI with the same status and output", () => { + const root = tmp(); + const env = { PATH: process.env.PATH, HOME: join(root, "home"), LANG: "C.UTF-8", MOSAIC_CONFIG: join(root, "missing", "config.json") }; + const run = (file, args) => spawnSync(process.execPath, [file, ...args], { encoding: "utf8", env, cwd: root }); + for (const args of [[], ["bogus"], ["validate"], ["resolve", "acme"], ["validate", "acme"], ["resolve", "acme", "chief", "--project", "stack"]]) { + const now = run(join(ORG_SRC, "cli.mjs"), args), before = run(join(OLD_SRC, "cli.mjs"), args); + assert.notEqual(now.status, 0, args.join(" ")); + assert.deepEqual({ status: before.status, signal: before.signal, stdout: before.stdout, stderr: before.stderr }, + { status: now.status, signal: now.signal, stdout: now.stdout, stderr: now.stderr }, args.join(" ")); + assert.match(now.stderr, /^mosaic business: /, args.join(" ")); + } +}); diff --git a/packages/business/tests/reply-limits.test.mjs b/packages/organization/tests/reply-limits.test.mjs similarity index 98% rename from packages/business/tests/reply-limits.test.mjs rename to packages/organization/tests/reply-limits.test.mjs index 63be1e72d..8084c78c7 100644 --- a/packages/business/tests/reply-limits.test.mjs +++ b/packages/organization/tests/reply-limits.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { organizationWriter, setReplyLimits } from '../src/organization.mjs'; -import { validateBusinessDocument } from '../src/business.mjs'; +import { validateBusinessDocument } from '../src/organization-config.mjs'; import { businessDoc, rolesCopy, tmp, writeJson } from './helpers.mjs'; function fixture(t, human = 'jason') { diff --git a/packages/business/tests/resolve.test.mjs b/packages/organization/tests/resolve.test.mjs similarity index 100% rename from packages/business/tests/resolve.test.mjs rename to packages/organization/tests/resolve.test.mjs diff --git a/packages/business/tests/role.test.mjs b/packages/organization/tests/role.test.mjs similarity index 100% rename from packages/business/tests/role.test.mjs rename to packages/organization/tests/role.test.mjs diff --git a/packages/business/tests/selector.test.mjs b/packages/organization/tests/selector.test.mjs similarity index 100% rename from packages/business/tests/selector.test.mjs rename to packages/organization/tests/selector.test.mjs diff --git a/packages/business/tests/spec-review-contracts.test.mjs b/packages/organization/tests/spec-review-contracts.test.mjs similarity index 100% rename from packages/business/tests/spec-review-contracts.test.mjs rename to packages/organization/tests/spec-review-contracts.test.mjs diff --git a/packages/business/tests/token-limit-settings.test.mjs b/packages/organization/tests/token-limit-settings.test.mjs similarity index 99% rename from packages/business/tests/token-limit-settings.test.mjs rename to packages/organization/tests/token-limit-settings.test.mjs index 1b18a8fcd..14f5246fa 100644 --- a/packages/business/tests/token-limit-settings.test.mjs +++ b/packages/organization/tests/token-limit-settings.test.mjs @@ -5,7 +5,7 @@ import assert from 'node:assert/strict'; import {readFileSync,rmSync} from 'node:fs'; import {join} from 'node:path'; import {businessDoc,rolesCopy,tmp as makeRoot,writeJson} from './helpers.mjs'; -import {validateBusinessDocument} from '../src/business.mjs'; +import {validateBusinessDocument} from '../src/organization-config.mjs'; import {launchSettings} from '../src/launch-settings.mjs'; const roots=[];after(()=>roots.forEach(root=>rmSync(root,{recursive:true,force:true}))); diff --git a/packages/business/tests/vars.test.mjs b/packages/organization/tests/vars.test.mjs similarity index 100% rename from packages/business/tests/vars.test.mjs rename to packages/organization/tests/vars.test.mjs diff --git a/packages/business/tests/workflow-roles.test.mjs b/packages/organization/tests/workflow-roles.test.mjs similarity index 100% rename from packages/business/tests/workflow-roles.test.mjs rename to packages/organization/tests/workflow-roles.test.mjs diff --git a/packages/plugins/README.md b/packages/plugins/README.md index c2cf6c031..cd958df1b 100644 --- a/packages/plugins/README.md +++ b/packages/plugins/README.md @@ -220,7 +220,7 @@ refusal through its `log` argument. The Console serves the list at directory on every request and logs a given refusal once per process. Themes are found by listing, not switched on. The organization file picks one -with `appearance.theme` (a name; see `packages/business/src/appearance.mjs`), +with `appearance.theme` (a name; see `packages/organization/src/appearance.mjs`), never with `plugins`. The kind is in the registry, so the `plugins` key accepts a `theme` entry without refusing it, but nothing reads that entry: list or empty, every installed theme stays available. diff --git a/packages/plugins/tests/loader.test.mjs b/packages/plugins/tests/loader.test.mjs index 91fc1e347..1fd971489 100644 --- a/packages/plugins/tests/loader.test.mjs +++ b/packages/plugins/tests/loader.test.mjs @@ -7,8 +7,8 @@ import { chmodSync, mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, unl import { tmpdir } from "node:os"; import { join } from "node:path"; import { LIMIT, LIMIT_V1, USAGE, tree } from "./helpers.mjs"; -import { businessDoc, rolesCopy, tmp as businessRoot, writeJson } from "../../business/tests/helpers.mjs"; -import { validateBusinessDocument } from "../../business/src/business.mjs"; +import { businessDoc, rolesCopy, tmp as businessRoot, writeJson } from "../../organization/tests/helpers.mjs"; +import { validateBusinessDocument } from "../../organization/src/organization-config.mjs"; const GOOD = { "usage-source": ["meter"], "limit-check": ["cap"] }; // What loaded() reports for GOOD: search-source is a kind too, switched off here. diff --git a/packages/seat/src/compactions.mjs b/packages/seat/src/compactions.mjs index 5c20f261c..a4059d9bc 100644 --- a/packages/seat/src/compactions.mjs +++ b/packages/seat/src/compactions.mjs @@ -43,7 +43,7 @@ import { closeSync, constants as fsc, fstatSync, lstatSync, openSync, readFileSync, writeSync } from "node:fs"; import { join } from "node:path"; -import { ID_PATTERN } from "../../business/src/vocabulary.mjs"; +import { ID_PATTERN } from "../../organization/src/vocabulary.mjs"; export const LEDGER = "compactions.jsonl"; const CHUNK = 1 << 20; diff --git a/packages/seat/src/sandbox.mjs b/packages/seat/src/sandbox.mjs index af6d8e0c8..2dd9c5ee1 100644 --- a/packages/seat/src/sandbox.mjs +++ b/packages/seat/src/sandbox.mjs @@ -37,7 +37,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, lstatSync, mkdirSync, readlinkSync, realpathSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { ID_PATTERN } from "../../business/src/vocabulary.mjs"; +import { ID_PATTERN } from "../../organization/src/vocabulary.mjs"; export const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); export const BWRAP = "/usr/bin/bwrap"; @@ -65,7 +65,7 @@ export const RUNNER_FILES = Object.freeze([ "packages/model-proxy/src/validation.mjs", "packages/bus/src/address.mjs", "packages/bus/src/landing.mjs", - "packages/business/src/vocabulary.mjs", + "packages/organization/src/vocabulary.mjs", "packages/plugins/src/kinds.mjs", "packages/library/src/reply-protocol.mjs", "packages/processing-control/src/state.mjs", diff --git a/packages/seat/src/session.mjs b/packages/seat/src/session.mjs index 52206d89b..060542a3e 100644 --- a/packages/seat/src/session.mjs +++ b/packages/seat/src/session.mjs @@ -39,7 +39,7 @@ import { dirname, join, sep } from "node:path"; import { childrenOf, cmdlineOf, startTimeOf } from "./proc.mjs"; import { binds, prepareRun, REPO, sandboxArgv } from "./sandbox.mjs"; import { SeatError } from "./seat.mjs"; -import { ID_PATTERN } from "../../business/src/vocabulary.mjs"; +import { ID_PATTERN } from "../../organization/src/vocabulary.mjs"; export { REPO }; export const RUNNER = join(REPO, "packages", "harness", "src", "runner.mjs"); diff --git a/packages/tasks/src/digest.mjs b/packages/tasks/src/digest.mjs index e0ecf3009..cf3712c69 100644 --- a/packages/tasks/src/digest.mjs +++ b/packages/tasks/src/digest.mjs @@ -1,4 +1,4 @@ -import { canonicalJson, sha256 } from '../../business/src/util.mjs'; +import { canonicalJson, sha256 } from '../../organization/src/util.mjs'; import { BusError } from '../../bus/src/broker.mjs'; // Vikunja writes this for an unset date. export const NULL_DATE = '0001-01-01T00:00:00Z'; diff --git a/packages/tasks/tests/fields.test.mjs b/packages/tasks/tests/fields.test.mjs index 1c2e396d4..d74f9fd2d 100644 --- a/packages/tasks/tests/fields.test.mjs +++ b/packages/tasks/tests/fields.test.mjs @@ -8,7 +8,7 @@ import { world, SCOPES } from './world.mjs'; import { FakeVikunja } from '../src/fake.mjs'; import { loadFields, checkFields, FieldTableError, FIELDS_FILE } from '../src/fields.mjs'; import { TASK_VERBS } from '../../bus/src/broker.mjs'; -import { loadRole } from '../../business/src/index.mjs'; +import { loadRole } from '../../organization/src/index.mjs'; // Row 61 (#1550): fields.json, and the Field table in docs/plans/2026-10-04_slice-1.md, against what // the verbs in src/verbs.mjs do, with the role files as given. The writer layer is read from the // table; whether a write needs a decision, and who diff --git a/packages/webui/README.md b/packages/webui/README.md index 8c9bb89c3..9c32d0963 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -35,8 +35,8 @@ baseline CLI. | Edit or reset launch/assistant limits | `mosaic limits set` / `mosaic limits reset` | Unavailable pending parity | | Rename an instance | `mosaic name` | Unavailable pending parity | | Stop an assistant | No equivalent CLI identified | Unavailable | -| Add, change or delete an instance; launch, regenerate or terminate an agent; change a veto | `mosaic business instance add`, `set`, `delete`, `launch`, `regenerate`, `terminate`; `mosaic business veto` | Unavailable pending parity | -| Change routes or enabled harnesses/providers | `mosaic business instance set`; `mosaic plugins set` | Unavailable pending parity | +| Add, change or delete an instance; launch, regenerate or terminate an agent; change a veto | `mosaic org instance add`, `set`, `delete`, `launch`, `regenerate`, `terminate`; `mosaic org veto` (old spelling `mosaic business instance`, `mosaic business veto`) | Unavailable pending parity | +| Change routes or enabled harnesses/providers | `mosaic org instance set`; `mosaic plugins set` | Unavailable pending parity | | Halt or resume processing | `mosaic halt` / `mosaic resume` | Unavailable pending parity | | Save a project file or revert library content | No equivalent CLI identified | Unavailable | | Change a project remote | No equivalent CLI identified | Unavailable | @@ -672,7 +672,7 @@ mono and the module's own message, redacted by the server. bus host's business: the human, the arbiters as instance and kind ("pm (delivery), cto (technical)"), projects, launch rules, the vars on an allowlist, each role instance with its holder and harness, the - authority of each instance for each action as the business package + authority of each instance for each action as the organization package classifies it (`resolveInstance`, `classify`), and credentials as metadata only: service, account, role, expiry or rotate-by date, and a state worked out from the date alone. A missing or invalid business file @@ -919,7 +919,7 @@ Routes: outcome (`done` or the code), never the routes. Neither route is in `organization.set`'s keys, so the chief of staff can't -reach them (`packages/business/README.md`, "Routes"). +reach them (`packages/organization/README.md`, "Routes"). ## Data and boundaries @@ -976,7 +976,7 @@ imports the store inside its `try`, so a store that fails to load reports forwards the child's text, as `queue-refused`; any other exit is the fixed `read-failed` message "the queue read failed (exit N)", because that stderr may be Node's own, with a `file://` path and a stack. The business -read uses `packages/business` in process. No read writes, calls out or adds a +read uses `packages/organization` in process. No read writes, calls out or adds a dependency. Answers carry `at`; failures are `{ error, message }` with 503 for `not-configured`, `no-bus-host`, `queue-refused` and `read-failed`, 404 for a row that isn't there, 400 for a bad address and 502 otherwise. Settings diff --git a/packages/webui/src/access.mjs b/packages/webui/src/access.mjs index 7f9c16ec9..7f27b324e 100644 --- a/packages/webui/src/access.mjs +++ b/packages/webui/src/access.mjs @@ -4,7 +4,7 @@ // request. A user is live while one grant in the Console's business covers at `viewer`, the same // test as the broker's #userGrants. The broker still decides every verb, project, instance and // thread (D4). Any failure to load, or to name the business, finds nobody (fail closed). -import { covers } from '../../business/src/vocabulary.mjs'; +import { covers } from '../../organization/src/vocabulary.mjs'; export function consoleAccess({ load, business }) { if (typeof load !== 'function' || typeof business !== 'function') throw new TypeError('consoleAccess needs load and business functions'); diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index 513ad3fe8..48782bca2 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -7,15 +7,15 @@ import { organizationConsole } from './organizations.mjs'; import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs'; import { consoleAccess } from './access.mjs'; import { accessContextFor } from '../../cli/src/access.mjs'; -import { accessFilePath, loadAccess } from '../../business/src/access.mjs'; +import { accessFilePath, loadAccess } from '../../organization/src/access.mjs'; import { startTesterSocket } from './tester-socket.mjs'; import { TESTER_CLI } from '../../cli/src/transport.mjs'; import { views } from '../../bus/src/views.mjs'; import { loadSystem, socketPath, orgPaths, REPO, rolesDir } from '../../cli/src/config.mjs'; import { busStatus as orgBusStatus } from '../../cli/src/org-bus.mjs'; -import { listOrgs, resolveOrg } from '../../business/src/org-registry.mjs'; -import { showOrg } from '../../business/src/org-service.mjs'; -import { configDir } from '../../business/src/business.mjs'; +import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs'; +import { showOrg } from '../../organization/src/org-service.mjs'; +import { configDir } from '../../organization/src/organization-config.mjs'; import { consoleReads, redactor } from './reads.mjs'; import { createDevOwner } from './dev-owner.mjs'; import { devOwnerInstance, devLanOptions } from '../../cli/src/dev-local-owner.mjs'; @@ -23,8 +23,8 @@ import { createSso, readSlot } from './sso.mjs'; import { readFileSync, statSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { isAbsolute } from 'node:path'; -import { ID_PATTERN } from '../../business/src/vocabulary.mjs'; -import { selectorScope } from '../../business/src/selector.mjs'; +import { ID_PATTERN } from '../../organization/src/vocabulary.mjs'; +import { selectorScope } from '../../organization/src/selector.mjs'; // The bus verifies the Console's exact same-tree script in /proc, whose argv preserves a // relative shell argument even though Node's process.argv resolves it. Re-exec the same // program with its absolute path before opening anything; preserve PID, ancestry and env so diff --git a/packages/webui/src/org-admin.mjs b/packages/webui/src/org-admin.mjs index e60aa7d2f..8400219fa 100644 --- a/packages/webui/src/org-admin.mjs +++ b/packages/webui/src/org-admin.mjs @@ -31,7 +31,7 @@ const KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/; const MAX_BODY = 64 * 1024; const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/; -// The org-template placeholder (packages/business/src/org-service.mjs templateRender). +// The org-template placeholder (packages/organization/src/org-service.mjs templateRender). export const placeholderRoot = (id, project) => `/__mosaic_org__/${id}/projects/${project}`; // Project fields the server owns. The browser never sees or sends them. const SERVER_PROJECT_KEYS = ['root', 'repo', 'git']; @@ -267,15 +267,15 @@ async function load(spec) { try { return await import(spec); } catch (e) { if (e export function defaultOrgAdmin(env = process.env) { let system = null; return createOrgAdmin({ - service: () => load('../../business/src/org-service.mjs'), - registry: () => load('../../business/src/org-registry.mjs'), + service: () => load('../../organization/src/org-service.mjs'), + registry: () => load('../../organization/src/org-registry.mjs'), bus: () => load('../../cli/src/org-bus.mjs'), // The same context `mosaic org` builds (packages/cli/src/org.mjs): the named // instance from MOSAIC_CONFIG, its data root and its config directory. context: async () => { if (!system) { const { loadSystem } = await import('../../cli/src/config.mjs'); - const { configDir } = await import('../../business/src/business.mjs'); + const { configDir } = await import('../../organization/src/organization-config.mjs'); const { devOwnerInstance } = await import('../../cli/src/dev-local-owner.mjs'); let instance; try { instance = devOwnerInstance(env); } catch { return null; } system = { configDir: configDir(env), dataRoot: loadSystem({ env, instance }).dataRoot, instance }; diff --git a/packages/webui/src/project-remote-http.mjs b/packages/webui/src/project-remote-http.mjs index de7308adf..e5e960667 100644 --- a/packages/webui/src/project-remote-http.mjs +++ b/packages/webui/src/project-remote-http.mjs @@ -1,5 +1,5 @@ // Owner Console transport only. No caller-supplied business, actor or subject. -import { parseGiteaUrl } from '../../business/src/vocabulary.mjs'; +import { parseGiteaUrl } from '../../organization/src/vocabulary.mjs'; export const PROJECT_REMOTE_ROUTE = /^\/api\/project-remotes\/([^/]+)$/; const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/; const one = (req, key) => req.headersDistinct[key]?.length === 1 ? req.headersDistinct[key][0] : null; diff --git a/packages/webui/src/public/agents.js b/packages/webui/src/public/agents.js index 58421a996..7e6bea241 100644 --- a/packages/webui/src/public/agents.js +++ b/packages/webui/src/public/agents.js @@ -6,7 +6,7 @@ and asks for a fresh read. The tester listener offers Launch and Terminate only (D4.1). */ (() => { 'use strict'; - const UNAVAILABLE = 'Agent actions are unavailable here. Use mosaic business instance from the instance CLI.'; + const UNAVAILABLE = 'Agent actions are unavailable here. Use mosaic org instance from the instance CLI.'; const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const POLL_MS = 2000, POLL_LIMIT = 90; const UNKNOWN = 'Outcome unknown. Read the agents again before trying; nothing was retried.'; diff --git a/packages/webui/src/reads.mjs b/packages/webui/src/reads.mjs index f721f5528..1bc69574d 100644 --- a/packages/webui/src/reads.mjs +++ b/packages/webui/src/reads.mjs @@ -3,7 +3,7 @@ // // Queue: rows() in packages/queue (lead decision 83), run as a child like // the bus read, so a slow read never stalls the HTTP server. -// Business: loadBusiness from packages/business, projected to names, vars +// Business: loadBusiness from packages/organization, projected to names, vars // on an allowlist, authority and credential metadata. A credential shows // its service, account, role and date, never a value, file or variable. // Settings: RELEASE, the board origin and the notifier's binding name. @@ -19,10 +19,10 @@ import { spawn } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { BusinessError, loadBusiness, resolveInstance, classify, systemVars } from '../../business/src/index.mjs'; -import { launchSettings } from '../../business/src/launch-settings.mjs'; +import { BusinessError, loadBusiness, resolveInstance, classify, systemVars } from '../../organization/src/index.mjs'; +import { launchSettings } from '../../organization/src/launch-settings.mjs'; import { readSessions } from '../../seat/src/session.mjs'; -import { ACTIONS } from '../../business/src/vocabulary.mjs'; +import { ACTIONS } from '../../organization/src/vocabulary.mjs'; import { readHostState } from '../../cli/src/host.mjs'; import { readBootStatus } from '../../cli/src/boot-status.mjs'; import { readNotifyConfig } from '../../cli/src/notify-config.mjs'; diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 897c89ead..627011698 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -9,7 +9,7 @@ import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from // Row 176, sage-1111: HTTP-only tester refusals; the row-164 transport pin stays untouched. const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403 : coreBusStatus(code); import { ReadError, readStatus, fieldTable } from './reads.mjs'; -import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs'; +import { THREAD_DEFINITIONS } from '../../organization/src/vocabulary.mjs'; import { chatHTTP } from './chat-http.mjs'; import { projectArchiveHTTP } from './project-archive-http.mjs'; import { processingHTTP } from './processing-http.mjs'; @@ -393,15 +393,15 @@ const INSTANCE_KEYS = ['revision', 'instance', 'op', 'definition', 'vars', 'laun // The bus answers a refusal with its code alone, so the reason the page shows is here. const INSTANCE_REASONS = { 'organization-credentials-out-of-scope': 'that definition declares credentials, or the instance holds them. The Console binds no credentials, so that stays a hand edit of the business file', - 'organization-tracker-required': 'that definition uses vikunja and needs a Vikunja bot. The Console names no bot; use mosaic business instance add with --bot and --bot-id', + 'organization-tracker-required': 'that definition uses vikunja and needs a Vikunja bot. The Console names no bot; use mosaic org instance add with --bot and --bot-id', 'organization-tracker-bot-taken': 'another instance already uses that Vikunja bot', 'organization-instance-exists': 'the business already has that instance; change it instead', 'organization-unknown-instance': 'the business has no such instance; add it instead', 'organization-definition-refused': 'this form neither adds nor changes an assistant, and moves no instance to the assistant definition; an assistant changes only in the Assistant section', 'organization-launch-not-configured': 'the business file has no launch block, so launch membership can\'t change', 'organization-no-change': 'the business file already says that', - 'organization-project-mismatch': 'a project file\'s id differs from its key in the business file; mosaic business validate shows it', - 'organization-unresolvable-instance': 'an instance wouldn\'t resolve against a project file; mosaic business validate shows why', + 'organization-project-mismatch': 'a project file\'s id differs from its key in the business file; mosaic org config validate shows it', + 'organization-unresolvable-instance': 'an instance wouldn\'t resolve against a project file; mosaic org config validate shows why', 'revision-conflict': 'the business file changed since you read it. Read the role instances again', }; // Row 194 (#1692, D7): the lifecycle and veto refusals, for the delete op, the Agents routes and a diff --git a/packages/webui/tests/agents.test.mjs b/packages/webui/tests/agents.test.mjs index 92519c536..aa82b0fd7 100644 --- a/packages/webui/tests/agents.test.mjs +++ b/packages/webui/tests/agents.test.mjs @@ -8,7 +8,7 @@ import assert from 'node:assert/strict'; import { request } from 'node:http'; import { mkdtempSync, rmSync, readFileSync } from 'node:fs'; import { runInNewContext } from 'node:vm'; -import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs'; +import { THREAD_DEFINITIONS } from '../../organization/src/vocabulary.mjs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { startServer } from '../src/serve.mjs'; @@ -219,7 +219,7 @@ test('row 194 N5: agents.js is served as JavaScript and index.html loads it once const js = await get(s, '/agents.js'); assert.equal(js.status, 200); assert.match(js.type, /^text\/javascript/); assert.match(js.text, /window\.MosaicAgents = Object\.freeze\(\{ section \}\)/); - assert.match(js.text, /Agent actions are unavailable here\. Use mosaic business instance from the instance CLI\./); + assert.match(js.text, /Agent actions are unavailable here\. Use mosaic org instance from the instance CLI\./); const html = await get(s, '/'); assert.equal(html.text.match(/