From a8d9eecf654dacc47120f5bdf8a18c2cd12421cc Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 9 Nov 2026 11:47:48 -0600 Subject: [PATCH 1/3] organization: move packages/business to packages/organization (rename r2 stage B1) git mv packages/business -> packages/organization (@mosaic/organization). The old package stays as one-line re-export shims of each moved module, with src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs forwarding to the moved executable. BusinessError is OrganizationConfigError (one constructor, name kept BusinessError); OrganizationError stays distinct. The bus adapter gains organization-context.mjs (busOrganization), with business.mjs (busBusiness) as the same function. Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure walker (organization-imports.mjs, both roots required) move to the new path. v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage A contract tests stay where they were and pass as before. --- packages/bus/src/broker.mjs | 2 +- packages/bus/src/business.mjs | 29 +- packages/bus/src/credential-verbs.mjs | 2 +- packages/bus/src/credentials.mjs | 2 +- packages/bus/src/gitea-grant.mjs | 4 +- packages/bus/src/host-grant.mjs | 6 +- packages/bus/src/index.mjs | 1 + packages/bus/src/organization-context.mjs | 28 + packages/bus/src/request-bootstrap.mjs | 6 +- packages/bus/src/request-host.mjs | 2 +- packages/bus/src/request-store.mjs | 4 +- packages/bus/src/runtime.mjs | 30 +- .../bus/tests/bootstrap-origin-probe.test.mjs | 6 +- packages/bus/tests/broker.test.mjs | 8 +- packages/bus/tests/chief-of-staff.test.mjs | 12 +- packages/bus/tests/citation-guard.test.mjs | 4 +- packages/bus/tests/citation-helpers.mjs | 2 +- packages/bus/tests/citation-runtime.test.mjs | 4 +- packages/bus/tests/creative.test.mjs | 2 +- packages/bus/tests/credential-verbs.test.mjs | 2 +- .../bus/tests/daily-quota-runtime.test.mjs | 2 +- packages/bus/tests/dev-local-owner.test.mjs | 2 +- packages/bus/tests/grant-host.test.mjs | 2 +- .../bus/tests/grant-scope-runtime.test.mjs | 2 +- packages/bus/tests/grant-scope.test.mjs | 4 +- packages/bus/tests/hard-waits.test.mjs | 2 +- packages/bus/tests/host-run.test.mjs | 4 +- packages/bus/tests/instances-verb.test.mjs | 2 +- packages/bus/tests/launch-notice.test.mjs | 2 +- .../bus/tests/library-credential.test.mjs | 2 +- packages/bus/tests/lifecycle-revoke.test.mjs | 10 +- packages/bus/tests/lifecycle-verbs.test.mjs | 10 +- packages/bus/tests/limits-runtime.test.mjs | 2 +- packages/bus/tests/limits.test.mjs | 4 +- packages/bus/tests/model-catalogue.test.mjs | 4 +- .../tests/model-hire-ownership-fixture.mjs | 4 +- .../bus/tests/model-historical-ipc.test.mjs | 8 +- packages/bus/tests/model-keys.test.mjs | 12 +- packages/bus/tests/model-orphan-hire.test.mjs | 10 +- packages/bus/tests/model-pending-fixture.mjs | 10 +- packages/bus/tests/model-restart.test.mjs | 10 +- packages/bus/tests/model-review-fixtures.mjs | 10 +- .../bus/tests/model-revocation-fixture.mjs | 12 +- ...test.mjs => organization-context.test.mjs} | 12 +- .../bus/tests/p5-key-compatibility.test.mjs | 6 +- packages/bus/tests/p5-key-lineage.test.mjs | 4 +- packages/bus/tests/p5-runtime.test.mjs | 2 +- packages/bus/tests/p5-telemetry-fixture.mjs | 2 +- packages/bus/tests/plugins-verb.test.mjs | 6 +- packages/bus/tests/project-runtime.test.mjs | 4 +- .../provider-materialization-guard.test.mjs | 2 +- .../bus/tests/provider-repoint-guard.test.mjs | 2 +- packages/bus/tests/publish.test.mjs | 4 +- packages/bus/tests/reload.test.mjs | 10 +- packages/bus/tests/request-bootstrap.test.mjs | 6 +- packages/bus/tests/routes-verb.test.mjs | 8 +- packages/bus/tests/tester-writes.test.mjs | 4 +- packages/bus/tests/web.test.mjs | 8 +- packages/bus/tests/workflow-roles.test.mjs | 8 +- packages/business/package.json | 2 +- packages/business/src/access.mjs | 176 +------ packages/business/src/appearance.mjs | 32 +- packages/business/src/business.mjs | 347 +------------ packages/business/src/cli.mjs | 142 +----- packages/business/src/credentials.mjs | 127 +---- packages/business/src/errors.mjs | 15 +- packages/business/src/hosts.mjs | 88 +--- packages/business/src/index.mjs | 18 +- packages/business/src/instance-settings.mjs | 479 +----------------- packages/business/src/launch-settings.mjs | 409 +-------------- packages/business/src/launch.mjs | 178 +------ packages/business/src/library-seats.mjs | 53 +- packages/business/src/migrate-launch.mjs | 96 +--- packages/business/src/org-registry.mjs | 216 +------- packages/business/src/org-service.mjs | 388 +------------- packages/business/src/organization.mjs | 446 +--------------- packages/business/src/plugin-settings.mjs | 72 +-- packages/business/src/project-bootstrap.mjs | 81 +-- packages/business/src/project-remotes.mjs | 33 +- packages/business/src/project.mjs | 38 +- packages/business/src/resolve.mjs | 119 +---- packages/business/src/role.mjs | 234 +-------- packages/business/src/selector.mjs | 56 +- packages/business/src/util.mjs | 111 +--- packages/business/src/vars.mjs | 225 +------- packages/business/src/vocabulary.mjs | 376 +------------- packages/business/tests/helpers.mjs | 112 +--- packages/cli/live/request-lifecycle.mjs | 2 +- packages/cli/src/access.mjs | 2 +- packages/cli/src/cli.mjs | 4 +- packages/cli/src/config.mjs | 8 +- packages/cli/src/dev-local-owner.mjs | 2 +- packages/cli/src/launcher.mjs | 10 +- packages/cli/src/limits.mjs | 4 +- packages/cli/src/model-proxy.mjs | 2 +- packages/cli/src/org-bus.mjs | 2 +- packages/cli/src/org-console.mjs | 2 +- packages/cli/src/org-selector.mjs | 8 +- packages/cli/src/org.mjs | 6 +- packages/cli/src/project.mjs | 4 +- packages/cli/src/run-evidence.mjs | 4 +- packages/cli/src/thread.mjs | 2 +- packages/cli/tests/access-boot.test.mjs | 2 +- packages/cli/tests/boot-status.test.mjs | 2 +- packages/cli/tests/business-imports.mjs | 150 +----- packages/cli/tests/business-imports.test.mjs | 291 ----------- packages/cli/tests/case2-install.test.mjs | 2 +- packages/cli/tests/channel-fixture.mjs | 4 +- packages/cli/tests/cli.test.mjs | 2 +- .../cli/tests/compactions-launch.test.mjs | 2 +- packages/cli/tests/config.test.mjs | 2 +- .../cli/tests/federation-acceptance.test.mjs | 2 +- packages/cli/tests/fixtures/request-agent.mjs | 4 +- packages/cli/tests/git-socket.test.mjs | 2 +- packages/cli/tests/helpers.mjs | 4 +- packages/cli/tests/host.test.mjs | 2 +- packages/cli/tests/idle-stop-race.test.mjs | 2 +- packages/cli/tests/launch-limits.test.mjs | 2 +- .../tests/launcher-final-race-probe.test.mjs | 2 +- packages/cli/tests/launcher.test.mjs | 2 +- packages/cli/tests/lifecycle.test.mjs | 2 +- packages/cli/tests/limits.test.mjs | 2 +- packages/cli/tests/optional-tracker.test.mjs | 4 +- packages/cli/tests/org-archive-race.test.mjs | 4 +- packages/cli/tests/org-bus.test.mjs | 4 +- packages/cli/tests/org-delete.test.mjs | 2 +- packages/cli/tests/org-recovery.test.mjs | 4 +- packages/cli/tests/organization-imports.mjs | 153 ++++++ .../cli/tests/organization-imports.test.mjs | 323 ++++++++++++ packages/cli/tests/other-tree.test.mjs | 2 +- packages/cli/tests/project.test.mjs | 2 +- packages/cli/tests/publish-socket.test.mjs | 2 +- packages/cli/tests/revoke-window.test.mjs | 2 +- packages/cli/tests/run-evidence.test.mjs | 2 +- packages/cli/tests/session-clear.test.mjs | 2 +- packages/cli/tests/session-launch.test.mjs | 2 +- packages/cli/tests/stack-instances.test.mjs | 4 +- packages/cli/tests/stack-release-fixture.mjs | 11 +- packages/cli/tests/stack-release.test.mjs | 2 +- packages/cli/tests/tester-reads.test.mjs | 2 +- packages/cli/tests/tester-threads.test.mjs | 2 +- packages/cli/tests/thread-launch.test.mjs | 2 +- packages/cli/tests/thread-revoke.test.mjs | 2 +- packages/cli/tests/trackers-boot.test.mjs | 2 +- packages/control-board/src/serve.mjs | 4 +- .../tests/creative-hidden.test.mjs | 2 +- packages/discord/src/cli.mjs | 2 +- packages/discord/tests/library-seat.test.mjs | 2 +- packages/harness/src/bundle.mjs | 2 +- packages/harness/src/runner.mjs | 2 +- packages/harness/src/tools.mjs | 4 +- packages/harness/tests/bundle.test.mjs | 4 +- packages/harness/tests/helpers.mjs | 8 +- packages/harness/tests/tools.test.mjs | 4 +- packages/library/src/write.mjs | 2 +- packages/{business => organization}/README.md | 0 .../examples/mosaic-stack.example.json | 0 packages/organization/package.json | 11 + packages/organization/src/access.mjs | 174 +++++++ packages/organization/src/appearance.mjs | 30 ++ packages/organization/src/cli.mjs | 139 +++++ packages/organization/src/credentials.mjs | 125 +++++ packages/organization/src/errors.mjs | 22 + packages/organization/src/hosts.mjs | 86 ++++ packages/organization/src/index.mjs | 21 + .../organization/src/instance-settings.mjs | 477 +++++++++++++++++ packages/organization/src/launch-settings.mjs | 407 +++++++++++++++ packages/organization/src/launch.mjs | 176 +++++++ packages/organization/src/library-seats.mjs | 51 ++ packages/organization/src/migrate-launch.mjs | 94 ++++ packages/organization/src/org-registry.mjs | 214 ++++++++ packages/organization/src/org-service.mjs | 386 ++++++++++++++ .../src/org-template.json | 0 .../organization/src/organization-config.mjs | 349 +++++++++++++ packages/organization/src/organization.mjs | 444 ++++++++++++++++ packages/organization/src/plugin-settings.mjs | 70 +++ .../organization/src/project-bootstrap.mjs | 79 +++ packages/organization/src/project-remotes.mjs | 31 ++ packages/organization/src/project.mjs | 36 ++ packages/organization/src/resolve.mjs | 117 +++++ packages/organization/src/role.mjs | 232 +++++++++ packages/organization/src/selector.mjs | 54 ++ packages/organization/src/util.mjs | 109 ++++ packages/organization/src/vars.mjs | 223 ++++++++ packages/organization/src/vocabulary.mjs | 374 ++++++++++++++ .../tests/access-levels.test.mjs | 0 .../tests/access.test.mjs | 0 .../tests/agent-identity.test.mjs | 0 .../tests/appearance.test.mjs | 2 +- .../tests/assistant.test.mjs | 0 .../tests/case1-business-file.test.mjs | 2 +- .../tests/case1-project-key.test.mjs | 2 +- .../tests/chief-library-credentials.test.mjs | 0 .../tests/chief-of-staff.test.mjs | 2 +- .../tests/cli.test.mjs | 2 +- .../tests/credentials.test.mjs | 0 .../tests/csuite.test.mjs | 0 .../tests/grant-scope.test.mjs | 2 +- .../tests/hard-waits.test.mjs | 0 packages/organization/tests/helpers.mjs | 108 ++++ .../tests/hosts.test.mjs | 0 .../instance-credential-transitions.test.mjs | 0 .../tests/instance-settings.test.mjs | 2 +- .../tests/launch-settings.test.mjs | 2 +- .../tests/library-projects-protected.test.mjs | 0 .../tests/library-seats.test.mjs | 0 .../tests/lifecycle-settings.test.mjs | 2 +- .../tests/optional-tracker.test.mjs | 2 +- .../tests/org-archive-fence.test.mjs | 0 .../tests/org-archive.test.mjs | 0 .../tests/org-delete.test.mjs | 0 .../tests/org-presets.test.mjs | 0 .../tests/org-registry.test.mjs | 0 .../tests/org-service.test.mjs | 2 +- .../tests/organization-config.test.mjs} | 8 +- .../tests/plugin-settings.test.mjs | 2 +- .../tests/project-bootstrap.test.mjs | 0 .../tests/project-remotes.test.mjs | 0 .../tests/project-repo.test.mjs | 2 +- .../tests/project.test.mjs | 0 .../tests/proxy-launch.test.mjs | 0 .../tests/record-grants.test.mjs | 0 .../tests/rename-b-identity.test.mjs | 112 ++++ .../tests/reply-limits.test.mjs | 2 +- .../tests/resolve.test.mjs | 0 .../tests/role.test.mjs | 0 .../tests/selector.test.mjs | 0 .../tests/spec-review-contracts.test.mjs | 0 .../tests/token-limit-settings.test.mjs | 2 +- .../tests/vars.test.mjs | 0 .../tests/workflow-roles.test.mjs | 0 packages/plugins/tests/loader.test.mjs | 4 +- packages/seat/src/compactions.mjs | 2 +- packages/seat/src/sandbox.mjs | 4 +- packages/seat/src/session.mjs | 2 +- packages/tasks/src/digest.mjs | 2 +- packages/tasks/tests/fields.test.mjs | 2 +- packages/webui/src/access.mjs | 2 +- packages/webui/src/cli.mjs | 12 +- packages/webui/src/org-admin.mjs | 8 +- packages/webui/src/project-remote-http.mjs | 2 +- packages/webui/src/reads.mjs | 8 +- packages/webui/src/serve.mjs | 2 +- packages/webui/tests/agents.test.mjs | 2 +- packages/webui/tests/dev-local-owner.test.mjs | 2 +- .../tests/helpers/w15-project-remote-real.mjs | 10 +- packages/webui/tests/instances.test.mjs | 2 +- packages/webui/tests/org-admin.test.mjs | 6 +- .../webui/tests/org-archive-browser.test.mjs | 4 +- packages/webui/tests/org-bus.test.mjs | 4 +- .../webui/tests/org-delete-browser.test.mjs | 4 +- .../webui/tests/org-list-privacy.test.mjs | 4 +- .../tests/org-list-refresh-browser.test.mjs | 4 +- .../webui/tests/org-quick-browser.test.mjs | 4 +- packages/webui/tests/org-reads.test.mjs | 4 +- .../tests/org-request-status-browser.test.mjs | 2 +- packages/webui/tests/reads-fixture.mjs | 2 +- packages/webui/tests/reads.test.mjs | 4 +- packages/webui/tests/routes-browser.test.mjs | 2 +- packages/webui/tests/routes.test.mjs | 2 +- packages/webui/tests/tester-fixture.mjs | 4 +- packages/webui/tests/views.test.mjs | 2 +- scripts/case2-persona-import.mjs | 2 +- scripts/case2-precheck.sh | 4 +- scripts/case2/launch.mjs | 2 +- scripts/mosaic | 7 +- scripts/mosaic-task.mjs | 4 +- scripts/selector.sh | 2 +- scripts/stack-copy.sh | 5 +- scripts/stack-release.sh | 4 +- 270 files changed, 5678 insertions(+), 5418 deletions(-) create mode 100644 packages/bus/src/organization-context.mjs rename packages/bus/tests/{business.test.mjs => organization-context.test.mjs} (61%) delete mode 100644 packages/cli/tests/business-imports.test.mjs create mode 100644 packages/cli/tests/organization-imports.mjs create mode 100644 packages/cli/tests/organization-imports.test.mjs rename packages/{business => organization}/README.md (100%) rename packages/{business => organization}/examples/mosaic-stack.example.json (100%) create mode 100644 packages/organization/package.json create mode 100644 packages/organization/src/access.mjs create mode 100644 packages/organization/src/appearance.mjs create mode 100644 packages/organization/src/cli.mjs create mode 100644 packages/organization/src/credentials.mjs create mode 100644 packages/organization/src/errors.mjs create mode 100644 packages/organization/src/hosts.mjs create mode 100644 packages/organization/src/index.mjs create mode 100644 packages/organization/src/instance-settings.mjs create mode 100644 packages/organization/src/launch-settings.mjs create mode 100644 packages/organization/src/launch.mjs create mode 100644 packages/organization/src/library-seats.mjs create mode 100644 packages/organization/src/migrate-launch.mjs create mode 100644 packages/organization/src/org-registry.mjs create mode 100644 packages/organization/src/org-service.mjs rename packages/{business => organization}/src/org-template.json (100%) create mode 100644 packages/organization/src/organization-config.mjs create mode 100644 packages/organization/src/organization.mjs create mode 100644 packages/organization/src/plugin-settings.mjs create mode 100644 packages/organization/src/project-bootstrap.mjs create mode 100644 packages/organization/src/project-remotes.mjs create mode 100644 packages/organization/src/project.mjs create mode 100644 packages/organization/src/resolve.mjs create mode 100644 packages/organization/src/role.mjs create mode 100644 packages/organization/src/selector.mjs create mode 100644 packages/organization/src/util.mjs create mode 100644 packages/organization/src/vars.mjs create mode 100644 packages/organization/src/vocabulary.mjs rename packages/{business => organization}/tests/access-levels.test.mjs (100%) rename packages/{business => organization}/tests/access.test.mjs (100%) rename packages/{business => organization}/tests/agent-identity.test.mjs (100%) rename packages/{business => organization}/tests/appearance.test.mjs (98%) rename packages/{business => organization}/tests/assistant.test.mjs (100%) rename packages/{business => organization}/tests/case1-business-file.test.mjs (98%) rename packages/{business => organization}/tests/case1-project-key.test.mjs (97%) rename packages/{business => organization}/tests/chief-library-credentials.test.mjs (100%) rename packages/{business => organization}/tests/chief-of-staff.test.mjs (99%) rename packages/{business => organization}/tests/cli.test.mjs (99%) rename packages/{business => organization}/tests/credentials.test.mjs (100%) rename packages/{business => organization}/tests/csuite.test.mjs (100%) rename packages/{business => organization}/tests/grant-scope.test.mjs (99%) rename packages/{business => organization}/tests/hard-waits.test.mjs (100%) create mode 100644 packages/organization/tests/helpers.mjs rename packages/{business => organization}/tests/hosts.test.mjs (100%) rename packages/{business => organization}/tests/instance-credential-transitions.test.mjs (100%) rename packages/{business => organization}/tests/instance-settings.test.mjs (99%) rename packages/{business => organization}/tests/launch-settings.test.mjs (99%) rename packages/{business => organization}/tests/library-projects-protected.test.mjs (100%) rename packages/{business => organization}/tests/library-seats.test.mjs (100%) rename packages/{business => organization}/tests/lifecycle-settings.test.mjs (99%) rename packages/{business => organization}/tests/optional-tracker.test.mjs (96%) rename packages/{business => organization}/tests/org-archive-fence.test.mjs (100%) rename packages/{business => organization}/tests/org-archive.test.mjs (100%) rename packages/{business => organization}/tests/org-delete.test.mjs (100%) rename packages/{business => organization}/tests/org-presets.test.mjs (100%) rename packages/{business => organization}/tests/org-registry.test.mjs (100%) rename packages/{business => organization}/tests/org-service.test.mjs (99%) rename packages/{business/tests/business.test.mjs => organization/tests/organization-config.test.mjs} (98%) rename packages/{business => organization}/tests/plugin-settings.test.mjs (99%) rename packages/{business => organization}/tests/project-bootstrap.test.mjs (100%) rename packages/{business => organization}/tests/project-remotes.test.mjs (100%) rename packages/{business => organization}/tests/project-repo.test.mjs (95%) rename packages/{business => organization}/tests/project.test.mjs (100%) rename packages/{business => organization}/tests/proxy-launch.test.mjs (100%) rename packages/{business => organization}/tests/record-grants.test.mjs (100%) create mode 100644 packages/organization/tests/rename-b-identity.test.mjs rename packages/{business => organization}/tests/reply-limits.test.mjs (98%) rename packages/{business => organization}/tests/resolve.test.mjs (100%) rename packages/{business => organization}/tests/role.test.mjs (100%) rename packages/{business => organization}/tests/selector.test.mjs (100%) rename packages/{business => organization}/tests/spec-review-contracts.test.mjs (100%) rename packages/{business => organization}/tests/token-limit-settings.test.mjs (99%) rename packages/{business => organization}/tests/vars.test.mjs (100%) rename packages/{business => organization}/tests/workflow-roles.test.mjs (100%) 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/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/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..bbda3f7a8 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -31,7 +31,7 @@ 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,7 +39,7 @@ 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"; 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/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..9a44b77b2 100644 --- a/packages/cli/src/org.mjs +++ b/packages/cli/src/org.mjs @@ -1,7 +1,7 @@ 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'; 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/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 100% rename from packages/business/README.md rename to packages/organization/README.md 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..c61331250 --- /dev/null +++ b/packages/organization/src/cli.mjs @@ -0,0 +1,139 @@ +#!/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 "./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)), "..", "..", ".."); +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; +} 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/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/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 ef8ce3f9b..ecec96d0b 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -7,23 +7,23 @@ 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'; import { createSso, readSlot } from './sso.mjs'; import { statSync } from 'node:fs'; 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'; const args = process.argv.slice(2); const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]'; try { 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/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..19f712a10 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'; diff --git a/packages/webui/tests/agents.test.mjs b/packages/webui/tests/agents.test.mjs index 92519c536..3af8a11ab 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'; diff --git a/packages/webui/tests/dev-local-owner.test.mjs b/packages/webui/tests/dev-local-owner.test.mjs index 6fd927d2f..180a847ab 100644 --- a/packages/webui/tests/dev-local-owner.test.mjs +++ b/packages/webui/tests/dev-local-owner.test.mjs @@ -99,7 +99,7 @@ test('dev owner resolves a decision through the real Console wrapper and bus', { const { spawn } = await import('node:child_process'); const { fixture, notifyConfig } = await import('../../cli/tests/helpers.mjs'); const { loadSystem, bootConfig } = await import('../../cli/src/config.mjs'); - const { createOrg } = await import('../../business/src/org-service.mjs'); + const { createOrg } = await import('../../organization/src/org-service.mjs'); const { Store } = await import('../../bus/src/store.mjs'); const { Broker } = await import('../../bus/src/broker.mjs'); const root=mkdtempSync(join(tmpdir(),'o-')); diff --git a/packages/webui/tests/helpers/w15-project-remote-real.mjs b/packages/webui/tests/helpers/w15-project-remote-real.mjs index 5bf73ceb3..7afeb31e6 100644 --- a/packages/webui/tests/helpers/w15-project-remote-real.mjs +++ b/packages/webui/tests/helpers/w15-project-remote-real.mjs @@ -1,10 +1,10 @@ import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -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 { busBusiness } from '../../../bus/src/business.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 { busOrganization } from '../../../bus/src/organization-context.mjs'; import { startBroker } from '../../../bus/src/runtime.mjs'; import { serve } from '../../../bus/src/server.mjs'; import { Client } from '../../../bus/src/client.mjs'; @@ -22,7 +22,7 @@ export async function realRemoteFixture(t, mutate = () => {}) { const form = () => { const business = loadBusiness('demo', { dir: root, rolesDir }); const resolved = Object.fromEntries(Object.keys(business.roles).map(instance => [instance, resolveInstance({ system, business, instance })])); - return busBusiness(business, resolved); + return busOrganization(business, resolved); }; const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo: form() }, organization: { dir: root, rolesDir, system } }); let runtime = await start(); diff --git a/packages/webui/tests/instances.test.mjs b/packages/webui/tests/instances.test.mjs index 6fb80147f..e4eb3c061 100644 --- a/packages/webui/tests/instances.test.mjs +++ b/packages/webui/tests/instances.test.mjs @@ -18,7 +18,7 @@ import { fixture, tmp } from '../../cli/tests/helpers.mjs'; import { bootConfig, loadSystem } from '../../cli/src/config.mjs'; import { createSsoSession } from './helpers/sso-session.mjs'; -const BUSINESS_CLI = fileURLToPath(new URL('../../business/src/cli.mjs', import.meta.url)); +const BUSINESS_CLI = fileURLToPath(new URL('../../organization/src/cli.mjs', import.meta.url)); const REV = 'a'.repeat(64); const view = (extra = {}) => ({ business: 'acme', revision: 'b'.repeat(64), launch: true, instances: { coder: { definition: 'coder', vars: {}, launched: true, launcher: false, protected: false } }, definitions: {}, ...extra }); diff --git a/packages/webui/tests/org-admin.test.mjs b/packages/webui/tests/org-admin.test.mjs index 5aa8ac772..f68b6c4b7 100644 --- a/packages/webui/tests/org-admin.test.mjs +++ b/packages/webui/tests/org-admin.test.mjs @@ -382,8 +382,8 @@ test('the Settings page ships the organization assets', async t => { }); test('against the real C2 service: create, set up, resume and refuse an overwrite', async t => { - const service = await import('../../business/src/org-service.mjs'); - const registry = await import('../../business/src/org-registry.mjs'); + const service = await import('../../organization/src/org-service.mjs'); + const registry = await import('../../organization/src/org-registry.mjs'); const { mkdtempSync, mkdirSync, existsSync } = await import('node:fs'); const { join } = await import('node:path'); const { tmpdir } = await import('node:os'); const root = mkdtempSync(join(tmpdir(), 'oa')); const configDir = join(root, 'config'), dataRoot = join(root, 'data'); @@ -422,7 +422,7 @@ test('against the real C2 service: create, set up, resume and refuse an overwrit }); test('preset routes expose installed choices and refuse invalid queries', async t => { - const service = await import('../../business/src/org-service.mjs'); + const service = await import('../../organization/src/org-service.mjs'); const admin = createOrgAdmin({ service, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) }); const c = await console_(t, { orgAdmin: admin }); const presets = await c.call('GET', '/api/org-presets'); diff --git a/packages/webui/tests/org-archive-browser.test.mjs b/packages/webui/tests/org-archive-browser.test.mjs index 0c9bcdabe..2167a2ffd 100644 --- a/packages/webui/tests/org-archive-browser.test.mjs +++ b/packages/webui/tests/org-archive-browser.test.mjs @@ -7,8 +7,8 @@ import { startServer } from '../src/serve.mjs'; import { createDevOwner } from '../src/dev-owner.mjs'; import { createOrgAdmin } from '../src/org-admin.mjs'; import { organizationConsole } from '../src/organizations.mjs'; -import * as service from '../../business/src/org-service.mjs'; -import * as registry from '../../business/src/org-registry.mjs'; +import * as service from '../../organization/src/org-service.mjs'; +import * as registry from '../../organization/src/org-registry.mjs'; import { orgPaths } from '../../cli/src/config.mjs'; import { busStopForDelete } from '../../cli/src/org-bus.mjs'; import { browser } from './browser.mjs'; diff --git a/packages/webui/tests/org-bus.test.mjs b/packages/webui/tests/org-bus.test.mjs index e1d53aa7e..28bf48769 100644 --- a/packages/webui/tests/org-bus.test.mjs +++ b/packages/webui/tests/org-bus.test.mjs @@ -23,8 +23,8 @@ test('adapter uses the registry and owner mapping for two scratch organizations' const { mkdtempSync, mkdirSync, rmSync } = await import('node:fs'); const { tmpdir } = await import('node:os'); const { join } = await import('node:path'); - const { createOrg, showOrg, templateRender } = await import('../../business/src/org-service.mjs'); - const { listOrgs, resolveOrg } = await import('../../business/src/org-registry.mjs'); + const { createOrg, showOrg, templateRender } = await import('../../organization/src/org-service.mjs'); + const { listOrgs, resolveOrg } = await import('../../organization/src/org-registry.mjs'); const root = mkdtempSync(join(tmpdir(), 'org-console-')); t.after(() => rmSync(root, { recursive: true, force: true })); const ctx = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test' }; diff --git a/packages/webui/tests/org-delete-browser.test.mjs b/packages/webui/tests/org-delete-browser.test.mjs index 125e9ff65..5fde11a3a 100644 --- a/packages/webui/tests/org-delete-browser.test.mjs +++ b/packages/webui/tests/org-delete-browser.test.mjs @@ -6,8 +6,8 @@ import { tmpdir } from 'node:os'; import { startServer } from '../src/serve.mjs'; import { createDevOwner } from '../src/dev-owner.mjs'; import { createOrgAdmin } from '../src/org-admin.mjs'; -import * as service from '../../business/src/org-service.mjs'; -import * as registry from '../../business/src/org-registry.mjs'; +import * as service from '../../organization/src/org-service.mjs'; +import * as registry from '../../organization/src/org-registry.mjs'; import { browser } from './browser.mjs'; import { close } from './fixture.mjs'; import { busStopForDelete } from '../../cli/src/org-bus.mjs'; diff --git a/packages/webui/tests/org-list-privacy.test.mjs b/packages/webui/tests/org-list-privacy.test.mjs index 6f8c4c17a..50cb56477 100644 --- a/packages/webui/tests/org-list-privacy.test.mjs +++ b/packages/webui/tests/org-list-privacy.test.mjs @@ -5,8 +5,8 @@ import assert from 'node:assert/strict'; import * as fs from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { createOrg, showOrg, templateRender } from '../../business/src/org-service.mjs'; -import { listOrgs, resolveOrg } from '../../business/src/org-registry.mjs'; +import { createOrg, showOrg, templateRender } from '../../organization/src/org-service.mjs'; +import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs'; import { organizationConsole } from '../src/organizations.mjs'; import { startServer } from '../src/serve.mjs'; import { createDevOwner } from '../src/dev-owner.mjs'; diff --git a/packages/webui/tests/org-list-refresh-browser.test.mjs b/packages/webui/tests/org-list-refresh-browser.test.mjs index a77100076..466347416 100644 --- a/packages/webui/tests/org-list-refresh-browser.test.mjs +++ b/packages/webui/tests/org-list-refresh-browser.test.mjs @@ -5,8 +5,8 @@ import assert from 'node:assert/strict'; import * as fs from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { createOrg, showOrg, templateRender } from '../../business/src/org-service.mjs'; -import { listOrgs, resolveOrg } from '../../business/src/org-registry.mjs'; +import { createOrg, showOrg, templateRender } from '../../organization/src/org-service.mjs'; +import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs'; import { organizationConsole } from '../src/organizations.mjs'; import { startServer } from '../src/serve.mjs'; import { createDevOwner } from '../src/dev-owner.mjs'; diff --git a/packages/webui/tests/org-quick-browser.test.mjs b/packages/webui/tests/org-quick-browser.test.mjs index acbe43efc..dcd322d9f 100644 --- a/packages/webui/tests/org-quick-browser.test.mjs +++ b/packages/webui/tests/org-quick-browser.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { startServer } from '../src/serve.mjs'; import { createDevOwner } from '../src/dev-owner.mjs'; import { createOrgAdmin } from '../src/org-admin.mjs'; -import * as service from '../../business/src/org-service.mjs'; +import * as service from '../../organization/src/org-service.mjs'; import { browser } from './browser.mjs'; import { close } from './fixture.mjs'; @@ -26,7 +26,7 @@ test('setup paths and preset controls are visible', { timeout: 45000 }, async t for (const scenario of ['bus-refused', 'project-refused', 'project-unknown', 'success']) test(`quick setup creates real repositories: ${scenario}`, { timeout: 45000 }, async t => { const { mkdtempSync, mkdirSync, rmSync, existsSync } = await import('node:fs'); const { tmpdir } = await import('node:os'); const { join } = await import('node:path'); - const registry = await import('../../business/src/org-registry.mjs'); + const registry = await import('../../organization/src/org-registry.mjs'); const dir = mkdtempSync(join(tmpdir(), 'quick-')); const configDir = join(dir, 'config'), dataRoot = join(dir, 'data'); mkdirSync(configDir, { mode: 0o700 }); mkdirSync(dataRoot, { mode: 0o700 }); let starts = 0; diff --git a/packages/webui/tests/org-reads.test.mjs b/packages/webui/tests/org-reads.test.mjs index 57ce478f7..bb3796600 100644 --- a/packages/webui/tests/org-reads.test.mjs +++ b/packages/webui/tests/org-reads.test.mjs @@ -5,9 +5,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { consoleReads } from '../src/reads.mjs'; import { recordBootRefused, recordBootOk } from '../../cli/src/boot-status.mjs'; -import { createOrg, templateRender, showOrg, archiveOrg } from '../../business/src/org-service.mjs'; +import { createOrg, templateRender, showOrg, archiveOrg } from '../../organization/src/org-service.mjs'; import { busStopForDelete } from '../../cli/src/org-bus.mjs'; -import { listOrgs, resolveOrg } from '../../business/src/org-registry.mjs'; +import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs'; import { organizationConsole } from '../src/organizations.mjs'; test('admission errors are visible while a healthy organization remains usable', async t => { diff --git a/packages/webui/tests/org-request-status-browser.test.mjs b/packages/webui/tests/org-request-status-browser.test.mjs index 33698e81d..c34fa23cb 100644 --- a/packages/webui/tests/org-request-status-browser.test.mjs +++ b/packages/webui/tests/org-request-status-browser.test.mjs @@ -10,7 +10,7 @@ import { mkdirSync, readFileSync, renameSync, chmodSync, writeFileSync } from 'n import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { tmp, fixture } from '../../cli/tests/helpers.mjs'; -import { createOrg } from '../../business/src/org-service.mjs'; +import { createOrg } from '../../organization/src/org-service.mjs'; import { orgPaths } from '../../cli/src/config.mjs'; import { DEV_OWNER } from '../../cli/src/dev-local-owner.mjs'; import { browser } from './browser.mjs'; diff --git a/packages/webui/tests/reads-fixture.mjs b/packages/webui/tests/reads-fixture.mjs index 539a1ba6c..2e3b8593d 100644 --- a/packages/webui/tests/reads-fixture.mjs +++ b/packages/webui/tests/reads-fixture.mjs @@ -11,7 +11,7 @@ import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } f import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { scratchRepo, genesisCommitted, mapText, MAP_ROWS } from '../../queue/tests/helpers.mjs'; -import { businessDoc, writeJson } from '../../business/tests/helpers.mjs'; +import { businessDoc, writeJson } from '../../organization/tests/helpers.mjs'; export const SRC = join(import.meta.dirname, '..', 'src'); export const SNOWFLAKES = ['123456789012345678', '223456789012345678', '323456789012345678', '423456789012345678', '523456789012345678']; diff --git a/packages/webui/tests/reads.test.mjs b/packages/webui/tests/reads.test.mjs index cdcb84a8d..850f57ed4 100644 --- a/packages/webui/tests/reads.test.mjs +++ b/packages/webui/tests/reads.test.mjs @@ -1,4 +1,4 @@ -import { launchDoc } from "../../business/tests/helpers.mjs"; +import { launchDoc } from "../../organization/tests/helpers.mjs"; // Row 54 (#1543): the Queue, Business and Settings reads over the seeded // fixture in reads-fixture.mjs. No response may carry a secret, an id or // the temporary directory. @@ -10,7 +10,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { startServer } from '../src/serve.mjs'; import { consoleReads, credentialState, queueReader, redactor } from '../src/reads.mjs'; -import { writeJson, REPO_ROLES } from '../../business/tests/helpers.mjs'; +import { writeJson, REPO_ROLES } from '../../organization/tests/helpers.mjs'; import { SRC, SNOWFLAKES, NOTE_SHOWN, day, queueRepo, seeded, clean } from './reads-fixture.mjs'; import { FIELDS_FILE } from '../../tasks/src/fields.mjs'; diff --git a/packages/webui/tests/routes-browser.test.mjs b/packages/webui/tests/routes-browser.test.mjs index 9782a306a..0f4d4ca9f 100644 --- a/packages/webui/tests/routes-browser.test.mjs +++ b/packages/webui/tests/routes-browser.test.mjs @@ -14,7 +14,7 @@ import {BusReadError} from '../src/bus.mjs'; import {close} from './fixture.mjs'; import {BusError} from '../../bus/src/broker.mjs'; import {startBroker} from '../../bus/src/runtime.mjs'; -import {systemFor} from '../../business/tests/helpers.mjs'; +import {systemFor} from '../../organization/tests/helpers.mjs'; import {fixture,tmp} from '../../cli/tests/helpers.mjs'; import {createSsoSession} from './helpers/sso-session.mjs'; diff --git a/packages/webui/tests/routes.test.mjs b/packages/webui/tests/routes.test.mjs index fd6aa8144..fe87c5f1a 100644 --- a/packages/webui/tests/routes.test.mjs +++ b/packages/webui/tests/routes.test.mjs @@ -11,7 +11,7 @@ import { startServer } from '../src/serve.mjs'; import { BusReadError } from '../src/bus.mjs'; import { BusError } from '../../bus/src/broker.mjs'; import { startBroker } from '../../bus/src/runtime.mjs'; -import { systemFor } from '../../business/tests/helpers.mjs'; +import { systemFor } from '../../organization/tests/helpers.mjs'; import { fixture, tmp } from '../../cli/tests/helpers.mjs'; import { createSsoSession } from './helpers/sso-session.mjs'; diff --git a/packages/webui/tests/tester-fixture.mjs b/packages/webui/tests/tester-fixture.mjs index 4d06a4620..25dbec0ab 100644 --- a/packages/webui/tests/tester-fixture.mjs +++ b/packages/webui/tests/tester-fixture.mjs @@ -5,8 +5,8 @@ import { execFileSync } from 'node:child_process'; import { DatabaseSync } from 'node:sqlite'; import { fixture, tmp } from '../../cli/tests/helpers.mjs'; import { loadSystem, bootConfig } from '../../cli/src/config.mjs'; -import { loadBusiness } from '../../business/src/index.mjs'; -import { writeJson } from '../../business/tests/helpers.mjs'; +import { loadBusiness } from '../../organization/src/index.mjs'; +import { writeJson } from '../../organization/tests/helpers.mjs'; import { initProject } from '../../project/src/index.mjs'; import { startBroker } from '../../bus/src/runtime.mjs'; import { Broker, BusError } from '../../bus/src/broker.mjs'; diff --git a/packages/webui/tests/views.test.mjs b/packages/webui/tests/views.test.mjs index eafd72172..40e22047a 100644 --- a/packages/webui/tests/views.test.mjs +++ b/packages/webui/tests/views.test.mjs @@ -14,7 +14,7 @@ import { browser } from './browser.mjs'; import { close } from './fixture.mjs'; import { startServer } from '../src/serve.mjs'; import { consoleReads, ReadError } from '../src/reads.mjs'; -import { writeJson, REPO_ROLES } from '../../business/tests/helpers.mjs'; +import { writeJson, REPO_ROLES } from '../../organization/tests/helpers.mjs'; import { SRC, NOTE_SHOWN, queueRepo, seeded, clean } from './reads-fixture.mjs'; const PROBE = 'window.__stubReady=true;window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));window.requests=[];window.bodies=[];const f=window.fetch;window.fetch=async(u,o={})=>{requests.push([(o.method||"GET").toUpperCase(),String(u)]);const r=await f(u,o);bodies.push(await r.clone().text());return r};' diff --git a/scripts/case2-persona-import.mjs b/scripts/case2-persona-import.mjs index cd8fe8c5a..6152997df 100755 --- a/scripts/case2-persona-import.mjs +++ b/scripts/case2-persona-import.mjs @@ -32,7 +32,7 @@ import { createHash } from "node:crypto"; import { join } from "node:path"; import { parse as parseYaml } from "yaml"; import { PERSONA_CHARS, personaTextFault } from "../packages/harness/src/bundle.mjs"; -import { selectorScope } from "../packages/business/src/selector.mjs"; +import { selectorScope } from "../packages/organization/src/selector.mjs"; // The tool names registered in hermes-agent's tools/*.py at df5700ebe (literal `name="..."` in // registry.register calls; the MCP and plugin tools get their names at run time), plus Hermes and diff --git a/scripts/case2-precheck.sh b/scripts/case2-precheck.sh index 26fe54e90..cb2693dcc 100755 --- a/scripts/case2-precheck.sh +++ b/scripts/case2-precheck.sh @@ -97,7 +97,7 @@ if [ "$STATUS" = 1 ]; then import { lstatSync } from "node:fs"; const [repo, id] = process.argv.slice(1); const { loadSystem, rolesDir } = await import(`${repo}/packages/cli/src/config.mjs`); -const { loadBusiness } = await import(`${repo}/packages/business/src/index.mjs`); +const { loadBusiness } = await import(`${repo}/packages/organization/src/index.mjs`); const { libraryStatus, mirrorPath } = await import(`${repo}/packages/library/src/index.mjs`); const say = (...f) => console.log(f.join("\t")); // A library error code is a fixed [a-z-] string; anything else prints as one name. @@ -183,7 +183,7 @@ facts="$(cd "$REPO" && node --input-type=module -e ' import { readFileSync } from "node:fs"; const [repo, id] = process.argv.slice(1); const { loadSystem, rolesDir } = await import(`${repo}/packages/cli/src/config.mjs`); -const { businessFilePath, configDir, loadBusiness, resolveInstance, systemVars } = await import(`${repo}/packages/business/src/index.mjs`); +const { businessFilePath, configDir, loadBusiness, resolveInstance, systemVars } = await import(`${repo}/packages/organization/src/index.mjs`); const say = (...f) => console.log(f.join("\t")); let system; try { system = loadSystem(); } catch (e) { say("CONFIG", "bad", e.message.split("\n")[0]); process.exit(0); } diff --git a/scripts/case2/launch.mjs b/scripts/case2/launch.mjs index d29709067..f5250949e 100644 --- a/scripts/case2/launch.mjs +++ b/scripts/case2/launch.mjs @@ -6,7 +6,7 @@ import { BusError, writeGrantEvent } from '../../packages/bus/src/broker.mjs'; import { Store } from '../../packages/bus/src/store.mjs'; import { startBroker } from '../../packages/bus/src/runtime.mjs'; import { grantDecision } from '../../packages/bus/tests/host-fixtures.mjs'; -import { launchDoc } from '../../packages/business/tests/helpers.mjs'; +import { launchDoc } from '../../packages/organization/tests/helpers.mjs'; import { fakeHarnesses, fixture } from '../../packages/cli/tests/helpers.mjs'; import { bootConfig, loadSystem } from '../../packages/cli/src/config.mjs'; import { createLauncher } from '../../packages/cli/src/launcher.mjs'; diff --git a/scripts/mosaic b/scripts/mosaic index 35c22208f..be3ab0556 100755 --- a/scripts/mosaic +++ b/scripts/mosaic @@ -3,7 +3,7 @@ # with registration for the control board. See packages/seat/README.md. # `mosaic queue `: the work queue. See packages/queue/README.md. # `mosaic business `: business files and role instances. See -# packages/business/README.md. +# packages/organization/README.md. # `mosaic inbox|decide|tasks|agents|trail|talk|stop|launches` and # `mosaic bus start|stop|status`: the human commands, managed sessions and # the bus host. See packages/cli/README.md. @@ -27,12 +27,13 @@ if [ "${1:-}" = queue ]; then fi if [ "${1:-}" = business ]; then # Row 165 (#1662): the human's role instance commands need the human proof, - # so they run in packages/cli. validate and resolve stay in packages/business. Row 194 adds veto. + # so they run in packages/cli. validate and resolve run in packages/organization (the old + # packages/business path is a shim of it, rename r2 stage B). Row 194 adds veto. case "${2:-}" in instances|instance|veto) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;; esac shift - exec node "$REPO/packages/business/src/cli.mjs" "$@" + exec node "$REPO/packages/organization/src/cli.mjs" "$@" fi # `mosaic library survey`: names and counts from a writing repo, never its text # (packages/library/README.md). Other library verbs are bus verbs. diff --git a/scripts/mosaic-task.mjs b/scripts/mosaic-task.mjs index bb945dace..d6d35a87d 100755 --- a/scripts/mosaic-task.mjs +++ b/scripts/mosaic-task.mjs @@ -34,7 +34,7 @@ import process from "node:process"; import { randomBytes } from "node:crypto"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { BusinessError, validateRoleDocument } from "../packages/business/src/index.mjs"; +import { BusinessError, validateRoleDocument } from "../packages/organization/src/index.mjs"; import { RunsError, isRunId, listRunRecords, readRunRecord } from "../packages/runs/src/index.mjs"; import { initializeControl, assertProcessing } from "../packages/processing-control/src/state.mjs"; import { runContainer } from "../packages/processing-control/src/containers.mjs"; @@ -254,7 +254,7 @@ function validateTask(document, file) { // Role contract (M18, slice 1 row S1): seat-declared role authority. The // tools array is a ceiling - seats may narrow it, never escalate past it. // Version 2 adds a contract, an authority map and credential needs; -// packages/business validates both versions. Unknown keys refuse, name must +// packages/organization validates both versions. Unknown keys refuse, name must // match the filename, wrong document kind refuses. function validateRole(document, file) { try { diff --git a/scripts/selector.sh b/scripts/selector.sh index b4840f14c..01f1fb8f6 100644 --- a/scripts/selector.sh +++ b/scripts/selector.sh @@ -1,5 +1,5 @@ # Sourced, not run: organization selector options, the shell half of -# packages/business/src/selector.mjs (rename design r2, "Selector scopes and parser matrix"). A +# packages/organization/src/selector.mjs (rename design r2, "Selector scopes and parser matrix"). A # script in the same checkout sources it; it defines functions only and runs no other program. # # --org and --business are one option family (id), and --org-file and --business-file are another diff --git a/scripts/stack-copy.sh b/scripts/stack-copy.sh index a2cc21718..2918e3725 100755 --- a/scripts/stack-copy.sh +++ b/scripts/stack-copy.sh @@ -33,7 +33,10 @@ copy_root() { [[ "$ID" =~ ^[0-9a-f]{40}$ ]] || die '--copy must be a full 40-cha COPY_PATHS=( scripts/mosaic scripts/mosaic-config.mjs scripts/case2-persona-import.mjs adapters roles RELEASE package.json package-lock.json - packages/bus/src packages/bus/schema.sql packages/business/src packages/cli/src packages/project + packages/bus/src packages/bus/schema.sql packages/cli/src packages/project + # Rename r2 stage B: packages/organization/src is the implementation. packages/business/src + # holds the old-path shims that @mosaic/business callers and the stack-release helper import. + packages/organization/src packages/business/src packages/conversation/src packages/discord/src packages/discord/bin packages/discord/extension packages/harness/src packages/seat/src packages/tasks/src packages/tasks/fields.json packages/tasks/requirements.json packages/processing-control diff --git a/scripts/stack-release.sh b/scripts/stack-release.sh index c4b5be1d3..beb959c85 100755 --- a/scripts/stack-release.sh +++ b/scripts/stack-release.sh @@ -363,7 +363,9 @@ check_target() { # harness prints plugins.harness as JSON, or `none`, from the copy's loader # launchby prints launch.by, the PM's instance, from a plain JSON read (row 232); it imports # only business.mjs, for the business file's path -# A failure exits 1 and prints its first line. +# A failure exits 1 and prints its first line. The path stays packages/business/src/business.mjs +# (rename r2 stage B): a copy from before the rename has only that file, and a newer copy keeps it +# as a shim of packages/organization/src/organization-config.mjs, so one path reads both. PLUGINS_JS=' const {readFileSync}=require("node:fs");const {pathToFileURL}=require("node:url"); const [mode,copy,id,kind]=process.argv.slice(1); -- 2.54.0 From 13c75b699f147aaeb660286cb706e544a49e6437 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 9 Nov 2026 12:00:29 -0600 Subject: [PATCH 2/3] cli, organization: org config and org instances commands, old business spelling kept (rename r2 stage B2) mosaic org config validate|resolve runs runConfigCommand (packages/organization/src/config-command.mjs), the callable service the old mosaic business validate|resolve executable now calls too. The service returns the exit code, never exits the process and does nothing on import. The spelling sets only the stderr prefix and the usage text; status and stdout are byte-identical, and the v1 output keeps the business key. mosaic org instances|instance|veto takes the same arguments as mosaic business instances|instance|veto and runs the same proof-bearing instances command. It is dispatched before orgCommand, so it never builds the dev-local-owner context. mosaic org validate FILE is unchanged. Help, the UI labels for agent actions and instance reasons, and the current docs now use the org spelling and the packages/organization paths. Two webui test regexes follow the label text. packages/cli/tests/ rename-b-commands.test.mjs compares the two spellings golden for golden on status, stdout, stderr and authorization outcomes, and covers a draft file named like an installed ID. --- docs/TOOLS.md | 22 +- docs/design/IMPLEMENTING.md | 2 +- docs/guides/host-run-sheet.md | 6 +- packages/bus/README.md | 6 +- packages/business/src/config-command.mjs | 2 + packages/cli/README.md | 45 ++- packages/cli/src/cli.mjs | 26 +- packages/cli/src/errors.mjs | 11 + packages/cli/src/instances.mjs | 19 ++ packages/cli/src/org.mjs | 2 +- packages/cli/tests/rename-b-commands.test.mjs | 323 ++++++++++++++++++ packages/organization/README.md | 53 ++- packages/organization/src/cli.mjs | 145 +------- packages/organization/src/config-command.mjs | 169 +++++++++ packages/plugins/README.md | 2 +- packages/webui/README.md | 10 +- packages/webui/src/public/agents.js | 2 +- packages/webui/src/serve.mjs | 6 +- packages/webui/tests/agents.test.mjs | 2 +- packages/webui/tests/instances.test.mjs | 2 +- scripts/mosaic | 6 +- 21 files changed, 670 insertions(+), 191 deletions(-) create mode 100644 packages/business/src/config-command.mjs create mode 100644 packages/cli/tests/rename-b-commands.test.mjs create mode 100644 packages/organization/src/config-command.mjs diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 3e8b8b5a3..df583d2fc 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 and stdout. The +validate/resolve stderr lines start with the spelling you used +(`mosaic org config: ` or `mosaic business: `), and a usage error prints that +spelling's usage. `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/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/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/src/cli.mjs b/packages/cli/src/cli.mjs index bbda3f7a8..7c9769577 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -21,11 +21,11 @@ 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"; @@ -46,6 +46,7 @@ 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,22 @@ 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, prefixed +// `mosaic org config: `, 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 +729,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 +760,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/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/org.mjs b/packages/cli/src/org.mjs index 9a44b77b2..405873c79 100644 --- a/packages/cli/src/org.mjs +++ b/packages/cli/src/org.mjs @@ -6,7 +6,7 @@ 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/tests/rename-b-commands.test.mjs b/packages/cli/tests/rename-b-commands.test.mjs new file mode 100644 index 000000000..d9d07beac --- /dev/null +++ b/packages/cli/tests/rename-b-commands.test.mjs @@ -0,0 +1,323 @@ +// 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: each line's prefix and the usage text. +const asOrgConfig = (stderr) => stderr.replaceAll(BUSINESS_CONFIG.usage, ORG_CONFIG.usage).replace(/^mosaic business: /gm, "mosaic org config: "); + +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 } = await sameAcrossSpellings(args, bad.env, bad.root, 2); + 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 } = await sameAcrossSpellings(["resolve", "acme", "coder"], warned.env, warned.root, 0); + 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\): /); +}); + +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 org config: .*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 org config: 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/organization/README.md b/packages/organization/README.md index 9838285fd..563d972a7 100644 --- a/packages/organization/README.md +++ b/packages/organization/README.md @@ -1,17 +1,31 @@ -# 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 and stdout are the same. Only stderr differs: each line starts +`mosaic org config: ` or `mosaic business: `, and the usage line names the +spelling you used. 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 +158,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 +228,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 +273,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 +328,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 +356,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 +533,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/organization/src/cli.mjs b/packages/organization/src/cli.mjs index c61331250..40b4468ef 100644 --- a/packages/organization/src/cli.mjs +++ b/packages/organization/src/cli.mjs @@ -2,138 +2,23 @@ // 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. +// 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 { 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 "./organization-config.mjs"; -import { loadProject, projectFilePath } from "./project.mjs"; -import { checkCredentialRef } from "./credentials.mjs"; -import { systemVars, resolveInstance } from "./resolve.mjs"; +import { BUSINESS_CONFIG, runConfigCommand } from "./config-command.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; -} +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..b72c96beb --- /dev/null +++ b/packages/organization/src/config-command.mjs @@ -0,0 +1,169 @@ +// 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 +// prefix of each stderr line and the usage text; status, stdout and every message after the prefix +// are the same for both. +// +// 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 } = {}) { + const say = (message) => stderr.write(`${spelling.prefix}: ${message}\n`); + 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) fail(4, spelling.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) fail(4, spelling.usage); + project = args[++i]; + } else rest.push(args[i]); + } + if (rest.length !== 2) fail(4, spelling.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 fail(4, spelling.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/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/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/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/serve.mjs b/packages/webui/src/serve.mjs index 19f712a10..627011698 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.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 3af8a11ab..aa82b0fd7 100644 --- a/packages/webui/tests/agents.test.mjs +++ b/packages/webui/tests/agents.test.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(/