Compare commits

...
Author SHA1 Message Date
jason.woltje edec4e1d04 docs: PRD 1.20 draft 2, chief of staff hires at every level (#1873) 2026-11-09 12:14:00 -06:00
jason.woltje 00ef0bd301 docs: record unified workspace direction and PRD 1.20 draft (#1873) 2026-11-09 11:55:44 -06:00
jason.woltje 3c2d8631e5 docs: record owner rulings for organizations 2026-11-09 11:55:31 -06:00
jason.woltje d6ab5047af Merge rename stage A (org-rename-stage-a) into refactor 2026-11-09 11:15:59 -06:00
jason.woltje 87b7a34f1c Merge rename part A2: --org selectors with --business aliases
# Conflicts:
#	packages/cli/tests/org-console.test.mjs
#	packages/webui/src/cli.mjs
2026-11-08 23:04:19 -06:00
jason.woltje a36d45af10 Merge rename part A1: pin v1 organization contracts 2026-11-08 23:04:19 -06:00
jason.woltje 9a52eecffd selector: org-console test asserts the Console child's exact argv
The detached Console is a selector producer: the test reads the child's
/proc cmdline and asserts serve --port 0 --business alpha --dev-local-owner.
2026-11-08 22:35:24 -06:00
jason.woltje 4fa6995c13 selector: --org and --business in land-work
land-work takes --org as --business in one scope. A repeated, conflicting,
missing, empty or flag-shaped value exits 4 before the config is read. The
id keeps its old grammar (none): any other text names businesses/<ID>.json
and a missing file exits 3. A value of --subject or --attribution is never
read as a selector.
2026-11-08 22:35:24 -06:00
jason.woltje 381039fe5f selector: --org and --business in stack-copy check
check takes --org as --business in one scope: each spelling once, both only
with the same value. A repeated, conflicting, missing, empty or flag-shaped
value exits 4 before any unit is read; an id outside check's own grammar
still exits 1. copy and wrapper refuse either spelling with usage, as
before. The sourced-library path used by stack-release.sh does not read
selector.sh; the command path refuses if it cannot.
2026-11-08 22:35:24 -06:00
jason.woltje 1105de280b selector: stack-release rehearsal fixture copies org-selector.mjs
The rehearsal builds fake copies from an explicit file list. f198cfd47 made
packages/cli/src/access.mjs import org-selector.mjs, which the list lacked,
so every rehearsal that ran the copied CLI failed with "Cannot find module"
(17 of 226 tests) from f198cfd47 until this commit. Real copies were not
affected: stack-copy.sh COPY_PATHS takes packages/cli/src whole.
2026-11-08 22:35:24 -06:00
jason.woltje 9e458878df selector: --org and --business in stack-upgrade's args-file scopes
read_args takes --org as --business in the unit flags before -- and in the
serve flags after it, with per-scope duplicate and conflict refusal, and
refuses a file whose serve flags name no selector or another business, as
case2-unit.sh would at the render after A2 stopped the units. The serve
flags are walked with case2-unit.sh's grammar so a value is never read as a
selector. The file is not rewritten; only --copy's value changes, as before.
A checkout without scripts/selector.sh refuses checkout-unreadable.
2026-11-08 22:05:44 -06:00
jason.woltje 226be2e935 selector: --org and --business in case1-accept's three scopes 2026-11-08 21:56:02 -06:00
jason.woltje dbb6909dcd selector: case2-precheck and case2-install refuse when selector.sh cannot be read
Neither script runs under set -e, so a failed source left selector_take
undefined and parsing carried on without the duplicate and conflict rules.
2026-11-08 21:56:02 -06:00
jason.woltje 4b7ddbcb43 selector: --org and --business in case2-unit's outer and serve scopes 2026-11-08 21:39:20 -06:00
jason.woltje 7f882a905d selector: --org and --org-file in case2-install
Rename design r2, "Selector scopes and parser matrix", case2-install row. --org/--business
and --org-file/--business-file are two families, each its own scope: each spelling once, and
a second spelling must carry the same text (file paths compared as given). Missing values
are usage; a flag-shaped or invalid id keeps the business-id refusal; a flag-shaped file, a
repeated spelling and a conflict say why. All exit 4 before anything is read or written.
The persona-import call it spawns still passes --business.
2026-11-08 21:27:37 -06:00
jason.woltje 3baf6f2297 selector: --org and --business in case2 persona-import and precheck
Rename design r2, "Selector scopes and parser matrix". case2-persona-import.mjs takes --org
through selectorScope and case2-precheck.sh through scripts/selector.sh, one scope each. Both
keep their exit codes and their old business-id wording; a repeated or conflicting selector
says why. persona-import now checks the id while parsing, so stage refuses an invalid
selector before it reads anything.

The case2-install fake stack copy carries packages/business/src/selector.mjs, which the real
copy already does (COPY_PATHS takes packages/business/src).
2026-11-08 21:25:49 -06:00
jason.woltje 259302a368 selector: --org and --business in the Console CLI and service parsers
cli.mjs serve and webui-service.sh each take --org as --business in their own
scope through the shared selector rules; the service unit still carries the
canonical --business. A flag-shaped or invalid value keeps each script's old
wording and exit code; a repeated or conflicting selector is a usage error.
2026-11-08 21:20:09 -06:00
jason.woltje f198cfd47b selector: --org and --business in each CLI parser's own scope
Rename design r2, "Selector scopes and parser matrix". packages/business/src/selector.mjs
and scripts/selector.sh carry one rule set: in a parser's scope each spelling appears once,
and a second spelling must carry exactly the same value; a repeated spelling, a missing or
flag-shaped value, an invalid id and unequal values refuse. The file family
(--business-file/--org-file) compares paths as given.

Every CLI parser that took --business now takes --org through orgSelector, and the old
global --org rewrite in main() is gone, so a value another option consumes is never read as
a selector. The id is checked while parsing (OrgError org-invalid, exit 3), so grants admin
set no longer reads the token and proxy init no longer prompts for the key before an
invalid id refuses. access gains the same local id check.
2026-11-08 21:14:11 -06:00
jason.woltje 500418e710 test: pin remaining v1 organization contracts 2026-11-08 20:53:16 -06:00
jason.woltje 7d1ab7cd96 test: pin v1 resolver and command contracts 2026-11-08 20:45:50 -06:00
52 changed files with 2690 additions and 60 deletions

No files matched your search

+2
View File
@@ -6337,3 +6337,5 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that
2026-11-07 UTC | Organizations S1 — implementation phase | Before: ui-dev c8d6d12fe used the instance root for one bus and the Console had no scoped lifecycle commands. The S1 stream charter and boundaries are in /mnt/storage/scratch/mosaic-lead/uidev/org-release/PLAN.md. The queue refused this linked worktree; canonical checkout access was excluded. Verification and unresolved integration boundaries follow in the stream report.
2026-11-07 UTC | Organizations S1 — implementation evidence | Organization-root bus and launcher paths, guarded lifecycle and Console launch/stop were implemented locally. Focused Node 26 tests and host regressions passed; the fresh-organization notifier choice, shared-proxy S2 integration, and remaining instance-root consumers are not complete. Exact commands, inventory, failures and boundaries: /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-s1.md. No push or deployment. Gitea issue creation was excluded by the no-network and no-external-communication limits.
2026-11-09 UTC | Unified workspace (#1873) — direction recorded | Before: the 11-07 rulings had one Console switch between organizations from Settings, tasks only in each org queue, and an agent bound to one organization (REQ-USER-1). After: Jason's 11-09 direction and answers recorded in docs/plans/2026-11-09_unified-workspace-owner-direction.md (all orgs visible at once, per-org task stores with a Vikunja option and an aggregated Kanban, root/org/project agents with one cross-org assistant, system type plug-ins, self-service restart and model switch). PRD 1.20 draft 1 written for his exact-text approval; the PRD itself is unchanged. The unpushed 11-07 rulings record (ad8140065) lands with it.
@@ -0,0 +1,47 @@
# Organizations: owner rulings, 2026-11-07
Recorded by Mos (lead) from Jason's messages of 2026-11-07, around 16:39Z and
16:55Z. These bind the multi-organization design (working files under
`/mnt/storage/scratch/mosaic-lead/uidev/business-design-r*.md`). Corrections
go in a new dated file, not edits here.
## Rulings
1. **Name.** "Business" becomes "Organization" in labels, routes, help and new
CLI verbs now. The internal rename (package `packages/business`, the
`businesses/` config directory, `--business` flags, schema fields) is not
optional. It may wait, but it is a priority follow-up, because mixed names
cause confusion.
2. **Isolation.** Each organization runs its own bus. One Console switches
between them from Settings.
3. **No migration.** No existing business must be migrated. The full setup is
tested from scratch on a fresh instance.
4. **Test order.** An agent runs the full creation end to end with the CLI,
then with the WebUI, before Jason walks through it in the WebUI.
5. **Dev instance authority.** The dragon-lin instance is throwaway and for
development only. Agents may use it, and act as its human for these tests,
without asking. That is why it needs no sign-in. A deployed system keeps
human sign-in and the agent restrictions on actions taken for the user.
6. **WebUI creation.** The WebUI creates an organization with a WYSIWYG editor
of its JSON, starting from a template. Every WebUI action is tied to a CLI
command.
7. **Theme.** Each organization can have its own theme, stored in its file.
Themes are plugins: the choice lists whatever themes are installed.
8. **Proxy.** One model proxy per user by default; that user's organizations
share it, and an organization may opt into its own. On multi-user instances,
sharing between users is by explicit, revocable grant (time-limited,
unlimited, per key). Grants come later.
9. **Organization git.** Each organization has its own canonical git checkout
for its high-level information and queue. A remote is optional when the user
chooses "local only" and accepts the risk.
10. **Project git.** Every project uses git. The user chooses per project
whether it has a remote or is **local only** (a local repository that is
never pushed), for throwaway or temporary work that needs no durable
remote. Jason first said this on 2026-11-06; it was not recorded then.
11. **Access.** JSON access control is acceptable for the MVP. Database-backed
RBAC with tighter security comes later.
12. **Modules.** The system is modules connected through documented API calls.
Each function (for example the model proxy, the OpenMontage plugin, the
creative writing system) is a module with a declared set of calls.
Swapping or disabling a module must not break the core. Adding a module
must never require rewriting the core.
+144
View File
@@ -0,0 +1,144 @@
# PRD version 1.20, draft 2: unified workspace
- Status: draft, not approved. Jason approves the exact text before it
enters `docs/prd/mosaic-stack.md` as version 1.20.
- Source: `docs/plans/2026-11-09_unified-workspace-owner-direction.md`,
Jason's direction and answers of 2026-11-09.
- Drafted by Mos (lead).
Version 1.20 adds the unified workspace: every organization visible at once,
cross-organization views, three levels of agent configuration, per-org task
stores behind the task tracker kind, system type plug-ins and self-service
operation. It amends REQ-ASSIST-1, REQ-USER-1, REQ-TASK-1 and REQ-WEB-3.
## Amended requirements
Each entry gives the 1.19 text that changes and its 1.20 replacement.
### REQ-ASSIST-1
1.19: "Each user has one assistant role per organization."
1.20: Each user has one assistant across all of the organizations they
belong to. It knows the user's organizations, projects and sessions and
keeps one shared memory. It talks with the user and passes each
organization's work to that organization's chief of staff. It doesn't
direct other agents, create or assign tasks, or launch sessions. (The rest
of REQ-ASSIST-1 stands.)
### REQ-USER-1
1.19: "An agent belongs to one organization and reaches only its allowed
projects and threads."
1.20: Users and role-based access control exist at install and
organization level. An agent other than the assistant works in one
organization at a time. Its memory, files and credentials are scoped to that
organization, even when its definition comes from the user's root
configuration. It reaches only that organization's allowed projects and
threads. The assistant is the exception in REQ-ASSIST-1, and it reaches only
organizations its user belongs to.
### REQ-TASK-1
1.19: "Tasks live in Vikunja..."
1.20: Each organization stores its own tasks, through the task tracker
plug-in kind (REQ-PLUG-1). The default module is a store built into Mosaic
Stack. Vikunja is an optional module, and when an organization selects it
the 1.19 rules for Vikunja (bot tokens per role, one writing role per field,
REQ-TASK-2 polling) apply to that organization. Every task tracker module
supports create, read, update, archive and delete, ordering and grouping.
Archiving or removing an organization removes its tasks without touching
another organization's.
### REQ-WEB-3
1.19: "The Console has controls to restart the bus and to reload the
organization file."
1.20: The Console has controls to restart itself, to restart an
organization's bus, to reload the organization file, and to change the model
an agent instance runs on and apply it. Each control is a gated, trailed
action that a human takes. No agent can take it. A reload applies
`launch.instances` changes to running sessions without a manual
`systemctl restart`. On a dev instance (`--dev-local-owner`) the controls
need no sign-in.
## New requirements
### Workspace
- **REQ-WS-1.** The WebUI shows every organization the user belongs to at
all times. A collapsible Organizations item in the sidebar lists them.
There is no organization switcher.
- **REQ-WS-2.** The Console reads across the buses of the user's
organizations to build shared views. Each organization keeps its own bus
as the tenant boundary (REQ-ORG-1). A shared view shows only
organizations the user belongs to.
- **REQ-WS-3.** The main dashboard sums activity, work and metrics across
all of the user's organizations and projects.
- **REQ-WS-4.** Each organization has a dashboard with its docs, every
configurable setting, its KPIs and metrics, and its active agent
sessions.
- **REQ-WS-5.** The projects list covers every organization. It sorts and
filters by organization, status, name and activity. Each project shows
its owning organization by icon, color and avatar.
- **REQ-WS-6.** One inbox holds every item that needs the user, from every
organization, project and session.
- **REQ-WS-7.** One view shows activity across all organizations and
projects. Organizations and projects can be open side by side.
- **REQ-WS-8.** The tasks view aggregates every organization's tasks, with
Kanban and list layouts, filtered by organization and project. It works
the same for every task tracker module.
### Agent levels
- **REQ-AGENT-1.** Agents are configured at three levels: the user's root,
the organization and the project. A project's available agents are the
union of the three.
- **REQ-AGENT-2.** The user can see and change the configuration of agents,
organizations, projects and sessions, and can override any setting at any
time, through the write path (REQ-ROLE-3).
- **REQ-AGENT-3.** The chief of staff hires and fires agents to meet the
user's needs. At the organization and project levels it acts under
REQ-COS-3. At the root level its hire or fire changes the user's root
configuration, which every organization of that user sees, so it reaches
outside the chief's own organization. A root-level hire or fire is a
decision the user approves in the inbox (REQ-DEC-2) before it takes
effect. The user can also change root-level agents directly.
### System types
- **REQ-SYS-1.** System types are a plug-in kind. Its modules include
coding, creative writing, marketing, Mosaic Montage (the OpenMontage
fork) and business management. An organization enables a list of them.
- **REQ-SYS-2.** A project may use any system type its organization
enables, and none is required. An agent working in a project gets the
toolsets of the system types that project uses.
### Accounts
- **REQ-ACCT-1.** A deployed system requires user sign-in (OIDC, Q203). A
user creates organizations and belongs to any number of them.
- **REQ-ACCT-2.** An organization's owner invites another user into it.
The invited user joins with the grant the owner chose (REQ-USER-3).
### Setup
- **REQ-SETUP-1.** Quick Setup leaves its project with its records
initialized.
- **REQ-SETUP-2.** After Quick Setup, the assistant offers an onboarding
interview. It asks for the project's outcome and limits and drafts the
north star and a first PRD. The user approves each through the existing
approval path.
## Open questions for the approval
1. The final name for the Mosaic Montage module. A council of three agents
is producing three suggestions for Jason to choose from.
2. Answered 2026-11-09: "yes, the COS should be able to hire and fire
agents to fulfill the user's needs". REQ-AGENT-3 now lets the chief hire
and fire at every level. Draft 2 keeps one approval step at the root
level because a root change reaches every organization; Jason can drop
that step when he approves the text.
@@ -0,0 +1,253 @@
# Unified workspace: owner direction, 2026-11-09
Recorded by Mos (lead) from Jason's T3 message of 2026-11-09, around 17:50Z,
and his answers to four clarifying questions at about 18:00Z. It binds the
next phase of the organizations work. Corrections go in a new dated file, not
edits here.
## What prompted it
Jason ran Quick Setup on the dragon-lin dev instance (uiorg) to create the
`test3` organization. Two things went wrong:
- The new project showed "This project has no records yet" with an
"Initialize project records" button. Quick Setup should have done that.
The setup receipts show the org, checkout, project repository and bus
steps at 17:19:20Z. The records repository first appears at 17:21:13Z,
from the button press. The fix round reproduces it.
- After he pressed the button, the overview showed the north star template
as raw text: `# North star Describe the outcome this project should
achieve...`. The overview prints the markdown source instead of rendering
it.
He also named a standing problem with how the dev instance is operated.
## Owner text, verbatim
> The Quick Setup should auto-initialize since this didn't ask any
> questions. An onboarding interview to determine the north star and PRD
> would be beneficial here.
>
> I've noticed that we have a perennial issue with needing to restart the
> console, or requiring a sign-in (model proxy), and other limiting behaviors
> that don't seem to make sense for the operating method. We are running
> this as a test instance on my dev workstation (dragon-lin), auth is not
> required for this local testing env., and we need to be capable of rapid
> iteration without undue manual intervention by the user. I also don't see
> where we can trigger any type of restart or relaunch with the available
> config from within the webui. The user should be able to change the
> running model and restart the service from within the system.
>
> Here is what I want:
> - Mosaic Stack system runs
> - Allows user to log in
> - User can create orgs, and be invited to join another user's org
> - User belongs to N organizations
> - All orgs are visible in the interface for the user to see the org,
> project, sessions within each
> - Tasks are overarching for all projects; a central store of tasks
> - Agents can be configured at the root, org, and project level
> - The user is able to see all happenings in the system from one pane
> - Projects are sortable and filterable by org, status, name, activity
> - Inbox contains all items needing the user; all orgs/projects/sessions
> with needs surface here
> - System allows for side-by-side operation of orgs and projects all
> visible in single pane
> - Rather than selecting an organization with a switch or in the settings
> menu, the user sees all orgs at all times.
> - Sidebar collapsible item for Organizations; user clicks to see all info
> about the organzation
> - The Docs are available on the org dashboard
> - All configurables are visible on the org dashboard
> - Org dashboard features KPIs/Metrics for the org
> - Active agent sessions visible on the org dashboard
> - Projects sidebar list indicates the organization owning the project via
> iconography, color, and "avatar" for org
> - Main dashboard for user surfaces sum and cumulative data for all
> orgs/projects, not just one
> - The system is configured to use plugins/modules to allow for various
> system types like coding, creative writing, Mosaic Montage (fork of
> OpenMontage) for social media production, business management
> - Each Project within an org is capable of using multiple system types
> - Example: Org. Distinctly Digital
> - enabled plugins: coding, creative writing, marketing, Mosaic
> Montage/Mosaic Studio (whatever we name this...), business management,
> - Each project within "Distinctly Digital" has access to the plugins,
> but is not required to use all of them
> - Agents configured for the user's root config are available
> - Org agents are available
> - Project agents are available
> - Agents use the toolsets for the plugin/module appropriately
> - Configuration for the agents, orgs, projects, sessions are all
> available for the user to tweak as needed
> - Chief of Staff can hire and fire agents to meet the user's system
> needs
> - User can override settings at any time via the configuration
>
> I'm thinking this information needs to be recorded for durability and we
> need to work toward this implementation.
## Answers to the clarifying questions
1. **Task store.** Verbatim: "We need to keep the tasks separated at an org
level. This allows for archive, instant removal of org tasks without
violating the other orgs. I'd like to see a basic config use per-org,
aggregated store with the option to use Vikunja. Not all users will use
Vikunja. Either way, we need a way to store, retrieve, archive, maintain,
organize, and view all the tasks. If we use Vikunja or a custom-rolled
solution within Mosaic Stack, they all need to surface with Kanban, etc.
for the user to see properly."
2. **Agent scope across orgs.** Verbatim: "The user's main interaction agent
needs to be aware of all orgs, projects, etc. That one interaction agent
uses a shared memory. The other agents are isolated per org."
3. **Multi-user.** Design sign-in, membership and invites now. Build them
in the second phase, after the single-user unified pane.
4. **Order.** Remove the operating friction first, then build the unified
pane.
## Rulings that follow
1. **All orgs at once.** The WebUI shows every organization the user
belongs to at all times. A collapsible Organizations item in the sidebar
lists them, and selecting one opens its dashboard. There is no
organization switcher, in Settings or elsewhere.
2. **Per-org isolation stays.** Each organization keeps its own bus as the
tenant boundary (REQ-ORG-1). The Console reads across the buses of the
user's organizations to build the shared views. Removing or archiving an
organization removes its data, tasks included, without touching another.
3. **Tasks.** Each organization stores its own tasks. The default store is
built into Mosaic Stack. Vikunja is an optional alternative, selected per
organization through the task tracker plug-in kind (REQ-PLUG-1). Both
support store, retrieve, archive, maintain and organize. The WebUI
aggregates every organization's tasks into one view with Kanban and list
layouts, filterable by organization and project.
4. **Agents.** Agents are configured at three levels: the user's root,
the organization and the project. A project's agents are the union of
all three. The user's main interaction agent (the assistant) is the one
exception to isolation. It knows all of the user's organizations,
projects and sessions, and it keeps one shared memory. Every other agent
is isolated to the organization it works in: its memory, files and
credentials are scoped to that organization, even when its definition
comes from the root.
5. **One pane.** One screen shows activity across every organization and
project. Organizations and projects can be open side by side.
6. **Main dashboard.** It sums across all organizations and projects, not
only one.
7. **Org dashboard.** It shows the organization's docs, every configurable
setting, KPIs and metrics, and its active agent sessions.
8. **Projects list.** It sorts and filters by organization, status, name
and activity. Each project shows its owning organization by icon, color
and avatar.
9. **Inbox.** One inbox holds everything that needs the user, from every
organization, project and session.
10. **System types are plug-ins.** Coding, creative writing, marketing,
Mosaic Montage (the OpenMontage fork; final name open) and business
management are modules. An organization enables a set of them. Each of
its projects may use any of the enabled set, and none is required.
Agents get the toolsets of the modules their project uses.
11. **Configuration.** The user can see and change the configuration of
agents, organizations, projects and sessions, and can override any
setting at any time. The chief of staff hires and fires agents to meet
the user's needs, within the existing PRD rules (REQ-ROLE, version
1.7).
12. **Accounts.** The deployed system has user sign-in. Users create
organizations, belong to any number of them, and can be invited into
another user's organization. The dev instance keeps running with no
sign-in (ruling 5 of 2026-11-07).
13. **Operating the dev instance.** Routine operation needs no manual
intervention from the user. From the WebUI the user can restart the
Console and an organization's bus, and change the model an agent runs
on. Dev mode needs no sign-in, including for the model proxy. Agents
still cannot restart the Console; the user can, from the WebUI.
14. **Quick Setup.** Quick Setup leaves a project with its records
initialized. An onboarding interview then helps the user write the
north star and a first PRD.
## What this supersedes or amends
- **2026-11-07 ruling 2.** "One Console switches between them from
Settings" is superseded by ruling 1 above. The per-org bus in the same
ruling stands.
- **2026-11-07 ruling 9.** Each organization's queue lived only in its own
checkout. Ruling 3 above keeps tasks per organization, adds the Vikunja
option through the task tracker kind, and adds the aggregated view.
- **PRD 1.19, REQ-USER-1.** "An agent belongs to one organization" gains
the assistant exception in ruling 4. This needs a PRD version. The draft
is `docs/plans/2026-11-09_prd-1-20-draft.md`, and Jason approves the
exact text before it enters `docs/prd/mosaic-stack.md`.
## Delivery plan
Each phase follows the MVP rule: build, one review round, one fix round, an
agent E2E on a scratch instance, then a deploy to uiorg for Jason.
### Phase F: operating friction (now)
- **F1.** Quick Setup records: reproduce the `test3` gap on a scratch
instance and fix it, so a Quick Setup project opens with live Docs, Files
and north star. Render the north star as markdown on the project
overview.
- **F2.** Onboarding interview: after Quick Setup, the assistant opens a
conversation that asks for the outcome and limits, then drafts
`spec/north-star.md` and a first PRD. The user approves each through the
existing approval path.
- **F3.** Self-service operation: restart the Console and an org bus from
the WebUI, change an agent's model from Settings and apply it, and list
and remove every manual step the dev instance still needs, model proxy
sign-in included.
### Phase U: unified pane
- **U0.** Design samples first. Sample screens for the sidebar with all
orgs, the main dashboard, an org dashboard, the projects list, the inbox,
the task Kanban and side-by-side panes. Jason approves them before the
build (design samples are the acceptance target).
- **U1.** Cross-org read model in the Console: one API that reads every
organization's bus the user belongs to, for projects, sessions, inbox
items, tasks and metrics.
- **U2.** Sidebar and org dashboards. **U3.** Main dashboard, projects list
and inbox. **U4.** Task store: built-in per-org store behind the task
tracker kind, the Vikunja module, and the aggregated Kanban. **U5.**
Side-by-side panes.
### Phase A: agent levels
Root, organization and project agent configuration. The assistant spans
organizations with shared memory, and every other agent is isolated per
organization. The user can override settings, and the chief of staff hires
and fires at each level.
### Phase P: system type plug-ins
A plug-in kind for system types. Organizations enable modules, projects opt
in, and agents receive the modules' toolsets. First modules: coding (the
current behavior), creative writing, then Mosaic Montage, marketing and
business management.
### Phase M: accounts (design now, build second)
OIDC sign-in (OIDC-only per Q203), membership in N organizations, invites
into another user's organization, and access checks on the cross-org read
model. The design lands with Phase U so the read model has membership from
the start.
## Open items
- The final name for the Mosaic Montage module.
- Which "sign-in (model proxy)" step Jason hit. F3 inventories every manual
step on the dev instance and reports it.
## Addendum, 2026-11-09 18:13 UTC
Jason's answers to the two open questions in PRD 1.20 draft 1, verbatim:
> 1. I would like you to use a council of agents to decide on 3 name
> suggestions for this
> 2. yes, the COS should be able to hire and fire agents to fulfill the
> user's needs
Effect: the chief of staff hires and fires agents at the root,
organization and project levels (PRD 1.20 draft 2, REQ-AGENT-3). A council
of three agents proposes three names for the Mosaic Montage module, and
Jason picks one.
+54
View File
@@ -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/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; },
});
}
@@ -0,0 +1 @@
{"business":"acme","contract":"<ROOT>/roles/coder.md","credentials":{"gitea":{"file":"<ROOT>/secrets/coder-gitea.token","rotateBy":"2099-01-01","service":"gitea"},"vikunja":{"expires":"2099-01-01","file":"<ROOT>/secrets/coder-vikunja.token","service":"vikunja"}},"definition":"coder","holder":null,"instance":"coder","launch":null,"limits":{"authority":{"crossRole":["task.scope.change"],"withinRole":["task.update.assigned","git.push.working","review.request","message.send","record.write"]},"network":"none","tools":["read","write","edit","bash","grep","find","ls"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","gitea.baseUrl":"business:acme","harness":"business:acme:roles.coder","limits.network":["business:acme:roles.coder"],"tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"business:acme","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"skills":["ms-unslop","ms-sdlc","ms-file-read","ms-file-write"],"tracker":{"bot":"bot-acme-coder","botId":5},"vars":{"bus.tester":false,"dataRoot":"<ROOT>/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"glm-5.3-flash","execution.provider":"zai","gitea.baseUrl":"https://git.example","harness":"pi","limits.network":"none","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":60,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
@@ -0,0 +1,95 @@
{
"business": "acme",
"project": "stack",
"instance": "coder",
"definition": "coder",
"holder": null,
"contract": "<ROOT>/roles/coder.md",
"vars": {
"tracker.kind": "vikunja",
"tracker.reconcileMinutes": 60,
"tracker.pollSeconds": 60,
"bus.tester": false,
"tracker.descendants": false,
"environment": "development",
"dataRoot": "<ROOT>/data",
"execution.backend": "docker",
"execution.provider": "zai",
"execution.model": "glm-5.3-flash",
"execution.adapter": "mock",
"tracker.baseUrl": "http://127.0.0.1:3456",
"gitea.baseUrl": "https://git.example",
"tracker.project": 3,
"harness": "pi",
"limits.network": "none"
},
"provenance": {
"tracker.kind": "default",
"tracker.reconcileMinutes": "default",
"tracker.pollSeconds": "business:acme",
"bus.tester": "default",
"tracker.descendants": "default",
"environment": "system",
"dataRoot": "system",
"execution.backend": "system",
"execution.provider": "system",
"execution.model": "system",
"execution.adapter": "system",
"tracker.baseUrl": "business:acme",
"gitea.baseUrl": "business:acme",
"tracker.project": "project:stack",
"harness": "business:acme:roles.coder",
"limits.network": [
"business:acme:roles.coder"
]
},
"limits": {
"tools": [
"read",
"write",
"edit",
"bash",
"grep",
"find",
"ls"
],
"network": "none",
"authority": {
"withinRole": [
"task.update.assigned",
"git.push.working",
"review.request",
"message.send",
"record.write"
],
"crossRole": [
"task.scope.change"
]
}
},
"credentials": {
"gitea": {
"service": "gitea",
"file": "<ROOT>/secrets/coder-gitea.token",
"rotateBy": "2099-01-01"
},
"vikunja": {
"service": "vikunja",
"file": "<ROOT>/secrets/coder-vikunja.token",
"expires": "2099-01-01"
}
},
"tracker": {
"bot": "bot-acme-coder",
"botId": 5
},
"launch": null,
"webSource": null,
"skills": [
"ms-unslop",
"ms-sdlc",
"ms-file-read",
"ms-file-write"
],
"digest": "<DIGEST>"
}
@@ -0,0 +1,14 @@
{
"business": "acme",
"file": "<ROOT>/config/businesses/acme.json",
"projects": {
"stack": "valid"
},
"instances": {
"pm": "<DIGEST>",
"cto": "<DIGEST>",
"coder": "<DIGEST>",
"reviewer": "<DIGEST>",
"chief": "<DIGEST>"
}
}
@@ -0,0 +1 @@
{"business":"acme","contract":"/synthetic/roles/coder.md","credentials":{},"definition":"coder","holder":"example","instance":"coder","launch":null,"limits":{"authority":{"crossRole":[],"withinRole":["message.send"]},"network":"none","tools":["read","write"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","limits.network":["business:acme:roles.coder"],"model":"business:acme:roles.coder","tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"project:stack:roles.coder","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"skills":[],"tracker":null,"vars":{"bus.tester":false,"dataRoot":"/synthetic/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"system-model","execution.provider":"zai","limits.network":"none","model":"agent-model","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":15,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
+63
View File
@@ -0,0 +1,63 @@
{
"business": "acme",
"project": "stack",
"instance": "coder",
"definition": "coder",
"holder": "example",
"contract": "/synthetic/roles/coder.md",
"vars": {
"tracker.kind": "vikunja",
"tracker.reconcileMinutes": 60,
"tracker.pollSeconds": 15,
"bus.tester": false,
"tracker.descendants": false,
"environment": "development",
"dataRoot": "/synthetic/data",
"execution.backend": "docker",
"execution.provider": "zai",
"execution.model": "system-model",
"execution.adapter": "mock",
"tracker.baseUrl": "http://127.0.0.1:3456",
"tracker.project": 3,
"model": "agent-model",
"limits.network": "none"
},
"provenance": {
"tracker.kind": "default",
"tracker.reconcileMinutes": "default",
"tracker.pollSeconds": "project:stack:roles.coder",
"bus.tester": "default",
"tracker.descendants": "default",
"environment": "system",
"dataRoot": "system",
"execution.backend": "system",
"execution.provider": "system",
"execution.model": "system",
"execution.adapter": "system",
"tracker.baseUrl": "business:acme",
"tracker.project": "project:stack",
"model": "business:acme:roles.coder",
"limits.network": [
"business:acme:roles.coder"
]
},
"limits": {
"tools": [
"read",
"write"
],
"network": "none",
"authority": {
"withinRole": [
"message.send"
],
"crossRole": []
}
},
"credentials": {},
"tracker": null,
"launch": null,
"webSource": null,
"skills": [],
"digest": "975144f5fcd6989c3393175b1713908c632c04f378b5f28a95cb2f4e552fbefa"
}
@@ -0,0 +1,12 @@
// Synthetic, path-stable resolver input; no file is read by resolveInstance.
export const input = {
system: { environment: 'development', dataRoot: '/synthetic/data', 'execution.backend': 'docker', 'execution.provider': 'zai', 'execution.model': 'system-model', 'execution.adapter': 'mock' },
business: {
id: 'acme', vars: { 'tracker.baseUrl': 'http://127.0.0.1:3456', 'tracker.pollSeconds': 60 },
projects: { stack: { root: '/synthetic/project' } },
roles: { coder: { definition: 'coder', holder: 'example', vars: { model: 'agent-model', 'limits.network': 'none' }, credentials: {}, tracker: null } },
definitions: { coder: { name: 'coder', tools: ['read', 'write'], network: 'api-only', authority: { withinRole: ['message.send'], crossRole: [] }, contractPath: '/synthetic/roles/coder.md', skills: [] } },
},
project: { id: 'stack', file: '/synthetic/project/.mosaic/project.json', vars: { 'tracker.project': 3, 'tracker.pollSeconds': 20 }, roles: { coder: { vars: { 'tracker.pollSeconds': 15 } } } },
instance: 'coder',
};
@@ -0,0 +1,124 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import * as publicApi from '../src/index.mjs';
import * as direct from '../src/business.mjs';
import { BusinessError, refuse } from '../src/errors.mjs';
import { canonicalJson, sha256 } from '../src/util.mjs';
import { validateRecord } from '../../project/src/validate.mjs';
import { businessDoc, REPO_ROLES, tmp } from './helpers.mjs';
import { input } from './fixtures/rename-a/resolver-input.mjs';
const fixture = name => readFileSync(new URL(`./fixtures/rename-a/${name}`, import.meta.url), 'utf8');
test('v1 resolver serialization, canonical preimage and digest are pinned', () => {
const result = publicApi.resolveInstance(input);
assert.equal(`${JSON.stringify(result, null, 2)}\n`, fixture('resolved.json'));
const { digest, ...preimage } = result;
const canonical = fixture('preimage.json');
assert.equal(`${canonicalJson(preimage)}\n`, canonical);
assert.equal(sha256(canonical.slice(0, -1)), digest);
assert.equal(result.provenance['tracker.pollSeconds'], 'project:stack:roles.coder');
assert.equal(result.provenance.model, 'business:acme:roles.coder');
});
test('v1 business public and direct exports share identity and refusal constructor', () => {
for (const name of ['configDir', 'businessFilePath', 'validateBusinessDocument', 'loadBusiness']) {
assert.equal(publicApi[name], direct[name], name);
}
assert.equal(publicApi.BusinessError, BusinessError);
assert.throws(() => refuse('fixture', 4), error => error instanceof publicApi.BusinessError && error.exitCode === 4 && error.message === 'fixture');
});
test('v1 business document rejects missing, mixed and unknown version fields', t => {
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const doc = businessDoc(root);
const check = value => publicApi.validateBusinessDocument(value, join(root, 'businesses', 'acme.json'), { rolesDir: REPO_ROLES });
assert.equal(check(doc).id, 'acme');
for (const change of [d => delete d.businessVersion, d => { d.businessVersion = 2; }, d => { d.businessVersion = '1'; }, d => { d.organizationVersion = 2; }, d => { d.unexpected = true; }]) {
const candidate = structuredClone(doc); change(candidate);
assert.throws(() => check(candidate), BusinessError);
}
});
test('v1 project layout requires its version and business identity exclusively', () => {
const doc = { layoutVersion: 1, business: 'acme', project: 'stack', name: 'Stack', createdAt: '2026-01-01T00:00:00.000Z' };
assert.deepEqual(validateRecord('project', doc), []);
for (const change of [d => delete d.layoutVersion, d => { d.layoutVersion = 2; }, d => { d.layoutVersion = '1'; }, d => delete d.business, d => { d.organization = 'acme'; }, d => { d.unknown = true; }]) {
const candidate = structuredClone(doc); change(candidate);
assert.notDeepEqual(validateRecord('project', candidate), []);
}
});
test('installed business validate and resolve have identical wrapper and direct responses', async t => {
const { spawnSync } = await import('node:child_process');
const { businessDoc, REPO, rolesCopy, systemConfig, tmp, writeJson } = await import('./helpers.mjs');
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const config = systemConfig(root), doc = businessDoc(root);
writeJson(join(root, 'config', 'businesses', 'acme.json'), doc);
writeJson(publicApi.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, MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesCopy(root) };
const directCli = join(REPO, 'packages/business/src/cli.mjs');
const wrapper = join(REPO, 'scripts/mosaic');
const invoke = (command, args) => {
const p = spawnSync(command, args, { env, cwd: root, encoding: 'utf8' });
assert.equal(p.error, undefined);
return { status: p.status, stdout: p.stdout, stderr: p.stderr };
};
for (const args of [['validate', 'acme'], ['resolve', 'acme', 'coder', '--project', 'stack'], ['validate', 'missing'], ['resolve', 'acme', 'unknown']]) {
const expected = invoke(process.execPath, [directCli, ...args]);
const wrapped = invoke(wrapper, ['business', ...args]);
assert.deepEqual(wrapped, expected, args.join(' '));
assert.equal(expected.status, args[1] === 'missing' ? 4 : args[2] === 'unknown' ? 2 : 0);
assert.doesNotMatch(expected.stdout + expected.stderr, /placeholder-not-a-token/);
}
});
test('org validate interprets an installed-ID-shaped argument as a draft filename', async t => {
const { spawnSync } = await import('node:child_process');
const { templateRender } = await import('../src/org-service.mjs');
const { REPO, writeJson } = await import('./helpers.mjs');
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
writeJson(join(root, 'acme'), await templateRender('acme', { fixture: 'local' }));
const env = { PATH: process.env.PATH, HOME: root, TMPDIR: process.env.TMPDIR };
const result = spawnSync(join(REPO, 'scripts/mosaic'), ['org', 'validate', 'acme'], { cwd: root, env, encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, 'valid\n');
assert.equal(result.stderr, '');
});
test('installed wrapper pins v1 validate and resolve output, preimage and digest', async t => {
const { spawnSync } = await import('node:child_process');
const { REPO, rolesCopy, systemConfig, writeJson } = await import('./helpers.mjs');
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const config = systemConfig(root), doc = businessDoc(root);
writeJson(join(root, 'config', 'businesses', 'acme.json'), doc);
writeJson(publicApi.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, MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesCopy(root) };
const invoke = args => {
const result = spawnSync(join(REPO, 'scripts/mosaic'), ['business', ...args], { env, cwd: root, encoding: 'utf8' });
assert.deepEqual([result.status, result.stderr], [0, '']);
return result.stdout;
};
const validate = invoke(['validate', 'acme']);
const parsed = JSON.parse(validate);
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(parsed.instances)) {
const { digest: actual, ...record } = JSON.parse(invoke(['resolve', 'acme', id]));
assert.equal(sha256(canonicalJson(record)), actual);
assert.equal(actual, expected);
}
const normalized = text => text.split(root).join('<ROOT>').replace(/[a-f0-9]{64}/g, '<DIGEST>');
const snapshots = {
'installed-validate.json': normalized(validate),
'installed-resolve.json': normalized(resolved),
'installed-preimage.json': normalized(canonicalJson(preimage)) + '\n',
};
assert.equal(sha256(snapshots['installed-preimage.json'].slice(0, -1)), '5fc9d6258f4725b0e260d99e5cf81058a05d21b8ff298bb53653efbbc8013da9');
for (const [name, actual] of Object.entries(snapshots)) {
assert.equal(actual, fixture(name), name);
}
});
+138
View File
@@ -0,0 +1,138 @@
// The organization selector helpers: one table, run against packages/business/src/selector.mjs and
// scripts/selector.sh through the same small parser, so the two halves keep one set of rules.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { REPO } from './helpers.mjs';
import { ID_PATTERN } from '../src/vocabulary.mjs';
import { SELECTOR_FAMILIES, isSelector, selectorScope } from '../src/selector.mjs';
// A parser with one other value option (--note), positional text and a `--` separator.
function viaJs(family, args) {
const scope = selectorScope({ family, valid: family === 'id' ? id => ID_PATTERN.test(id) : null, refuse: kind => { throw Object.assign(new Error(kind), { kind }); } });
const out = { value: null, note: null, positional: [] };
try {
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (scope.owns(a)) { scope.take(a, args[i + 1]); i++; }
else if (a === '--note') out.note = args[++i] ?? null;
else if (a === '--') { out.positional.push(...args.slice(i + 1)); break; }
else out.positional.push(a);
}
} catch (e) { if (e.kind) return { refused: e.kind }; throw e; }
out.value = scope.value ?? null;
return out;
}
const DRIVER = `
set -euo pipefail
. "$1"; family="$2"; shift 2
refuse() { printf 'refused %s\\n' "$1"; exit 3; }
valid() { [[ "$1" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]]; }
v=-; [ "$family" = id ] && v=valid
note=""; has_note=0; pos=()
while [ $# -gt 0 ]; do
case "$family:$1" in
id:--business|id:--org|file:--business-file|file:--org-file) selector_take s "$family" refuse "$v" "$1" "\${@:2:1}"; shift 2 ;;
*:--note) has_note=1; note="\${2-}"; shift; [ $# -gt 0 ] && shift ;;
*:--) shift; pos+=("$@"); break ;;
*) pos+=("$1"); shift ;;
esac
done
printf 'value %s\\n' "$(selector_value s)"
[ "$has_note" = 1 ] && printf 'note %s\\n' "$note"
for p in "\${pos[@]+"\${pos[@]}"}"; do printf 'pos %s\\n' "$p"; done
`;
function viaShell(family, args) {
const r = spawnSync('bash', ['-c', DRIVER, 'driver', join(REPO, 'scripts/selector.sh'), family, ...args], { encoding: 'utf8' });
if (r.status === 3) return { refused: /^refused (\S+)$/.exec(r.stdout.trim())[1] };
assert.equal(r.status, 0, r.stderr);
const out = { value: null, note: null, positional: [] };
for (const line of r.stdout.split('\n').filter(Boolean)) {
const [, key, rest] = /^(\S+) ?(.*)$/.exec(line);
if (key === 'value') out.value = rest || null;
else if (key === 'note') out.note = rest;
else out.positional.push(rest);
}
return out;
}
const ok = (value, more = {}) => ({ value, note: null, positional: [], ...more });
const ID_TABLE = [
['old alone', ['--business', 'acme'], ok('acme')],
['new alone', ['--org', 'acme'], ok('acme')],
['matching, old first', ['--business', 'acme', '--org', 'acme'], ok('acme')],
['matching, new first', ['--org', 'acme', '--business', 'acme'], ok('acme')],
['unequal, old first', ['--business', 'acme', '--org', 'beta'], { refused: 'differ' }],
['unequal, new first', ['--org', 'acme', '--business', 'beta'], { refused: 'differ' }],
['old repeated', ['--business', 'acme', '--business', 'acme'], { refused: 'repeated' }],
['new repeated', ['--org', 'acme', '--org', 'acme'], { refused: 'repeated' }],
['new repeated after a match', ['--org', 'acme', '--business', 'acme', '--org', 'acme'], { refused: 'repeated' }],
['old missing', ['--business'], { refused: 'missing' }],
['new missing', ['--org'], { refused: 'missing' }],
['new empty', ['--org', ''], { refused: 'missing' }],
['old flag-shaped', ['--business', '--org', 'acme'], { refused: 'flag' }],
['new flag-shaped', ['--org', '-x'], { refused: 'flag' }],
['old invalid', ['--business', 'Acme'], { refused: 'invalid' }],
['new invalid', ['--org', 'acme/../x'], { refused: 'invalid' }],
['equal but invalid', ['--org', 'A', '--business', 'A'], { refused: 'invalid' }],
['option-like value of another option', ['--note', '--org', '--business', 'acme'], ok('acme', { note: '--org' })],
['option-like value, new spelling', ['--note', '--business', '--org', 'acme'], ok('acme', { note: '--business' })],
['text after the separator', ['--org', 'acme', '--', '--business', 'beta', '--org'], ok('acme', { positional: ['--business', 'beta', '--org'] })],
['neither', ['word'], ok(null, { positional: ['word'] })],
];
const FILE_TABLE = [
['old alone', ['--business-file', 'a.json'], ok('a.json')],
['new alone', ['--org-file', 'a.json'], ok('a.json')],
['identical, old first', ['--business-file', 'a.json', '--org-file', 'a.json'], ok('a.json')],
['identical, new first', ['--org-file', 'a.json', '--business-file', 'a.json'], ok('a.json')],
['different paths, old first', ['--business-file', 'a.json', '--org-file', 'b.json'], { refused: 'differ' }],
['different paths, new first', ['--org-file', 'a.json', '--business-file', 'b.json'], { refused: 'differ' }],
// Strings are compared as given, so two spellings of one path differ.
['same file, other spelling', ['--org-file', 'a.json', '--business-file', './a.json'], { refused: 'differ' }],
['new repeated', ['--org-file', 'a.json', '--org-file', 'a.json'], { refused: 'repeated' }],
['old repeated', ['--business-file', 'a.json', '--business-file', 'a.json'], { refused: 'repeated' }],
['new missing', ['--org-file'], { refused: 'missing' }],
['new flag-shaped', ['--org-file', '--business-file', 'a.json'], { refused: 'flag' }],
['an id spelling is data here', ['--org', 'x', '--org-file', 'a.json'], ok('a.json', { positional: ['--org', 'x'] })],
];
for (const [family, table] of [['id', ID_TABLE], ['file', FILE_TABLE]]) {
for (const [name, args, expected] of table) {
test(`${family} selector: ${name}`, () => {
assert.deepEqual(viaJs(family, args), expected, 'selector.mjs');
assert.deepEqual(viaShell(family, args), expected, 'selector.sh');
});
}
}
test('selector families and spellings are fixed', () => {
assert.deepEqual(SELECTOR_FAMILIES.id.spellings, ['--business', '--org']);
assert.deepEqual(SELECTOR_FAMILIES.file.spellings, ['--business-file', '--org-file']);
assert.equal(isSelector('--org'), true);
assert.equal(isSelector('--org-file'), false);
assert.equal(isSelector('--org-file', 'file'), true);
assert.equal(isSelector('--organization'), false);
});
test('a refuse function that returns still refuses, and scopes are separate', () => {
const quiet = selectorScope({ refuse: () => {} });
assert.throws(() => quiet.take('--org', ''), /selector refusal returned/);
assert.throws(() => quiet.take('--org-file', 'x'), TypeError);
const refuse = kind => { throw new Error(kind); };
const outer = selectorScope({ refuse }), child = selectorScope({ refuse });
outer.take('--org', 'acme'); child.take('--org', 'beta');
assert.deepEqual([outer.value, child.value, outer.given], ['acme', 'beta', true]);
assert.equal(selectorScope({ refuse }).given, false);
});
test('the shell helper refuses a misuse and keeps scopes separate', () => {
const run = body => spawnSync('bash', ['-c', `set -u; . "$1"; refuse() { echo "refused $1"; exit 3; }; quiet() { :; }; ${body}`, 'x', join(REPO, 'scripts/selector.sh')], { encoding: 'utf8' });
assert.equal(run('selector_take s id refuse - --org-file a').status, 70);
assert.equal(run('selector_take bad-name id refuse - --org a').status, 70);
assert.equal(run('selector_take s id quiet - --org ""').status, 70);
const two = run('selector_take outer id refuse - --org acme; selector_take child id refuse - --business beta; echo "$(selector_value outer) $(selector_value child) [$(selector_value none)]"');
assert.equal(two.status, 0, two.stderr);
assert.equal(two.stdout, 'acme beta []\n');
});
+5
View File
@@ -13,6 +13,7 @@ import { join, dirname } from "node:path";
import { BusinessError, accessFilePath, checkAccess, configDir, loadAccess, loadBusiness, LEVELS } from "../../business/src/index.mjs";
import { rolesDir } from "./config.mjs";
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
import { refuseInsideAgent } from "./transport.mjs";
export const ACCESS_USAGE = [
@@ -70,14 +71,18 @@ export function accessContextFor(system, env) {
// --tester is refused outright on every verb, before any bus call.
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(why => new CliError(`${why}\n${ACCESS_USAGE}`, 4));
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === "--json" || a === "--tester") out.flags.add(a);
else if (selector.owns(a)) { selector.take(a, args[i + 1]); i++; }
else if (a.startsWith("--")) {
if (i + 1 >= args.length || args[i + 1].startsWith("--")) throw new CliError(`${a} needs one value\n${ACCESS_USAGE}`, 4);
out.values[a] = args[++i];
} else out.positional.push(a);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+7 -8
View File
@@ -40,6 +40,7 @@ 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 { orgSelector } from "./org-selector.mjs";
import { busStart, busStatus, busStop, busRestart, busRecover } from "./org-bus.mjs";
import { consoleStart, consoleStatus, consoleStop } from "./org-console.mjs";
@@ -97,17 +98,22 @@ export const SEAT_USAGE = [
const usage = (why) => new CliError(why ? `${why}\n${USAGE}` : USAGE, 4);
// A verb that takes --business also takes --org, in this parser's scope only, so a value another
// option consumes or positional text is never read as a selector (rename design r2).
function parse(args, { flags = [], values = [] }) {
const out = { positional: [], flags: new Set(), values: {} };
const selector = values.includes("--business") ? orgSelector(usage) : null;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (flags.includes(a)) out.flags.add(a);
if (selector?.owns(a)) { selector.take(a, args[i + 1]); i++; }
else if (flags.includes(a)) out.flags.add(a);
else if (values.includes(a)) {
if (i + 1 >= args.length || Object.hasOwn(out.values, a)) throw usage(`${a} needs one value`);
out.values[a] = args[++i];
} else if (a.startsWith("--")) throw usage(`unknown option ${a}`);
else out.positional.push(a);
}
if (selector?.given) out.values["--business"] = selector.value;
return out;
}
@@ -682,13 +688,6 @@ async function app(io, deps) {
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);
if (argv[0] !== 'org') {
const org = argv.indexOf('--org'), business = argv.indexOf('--business');
if (org !== -1 && business !== -1) {
if (!argv[org + 1] || !argv[business + 1] || argv[org + 1] !== argv[business + 1]) throw usage('--org and --business conflict');
argv = argv.filter((_, i) => i !== org && i !== org + 1);
} else if (org !== -1) argv = argv.map((arg, i) => i === org ? '--business' : arg);
}
const [verb, ...rest] = argv;
if (verb === 'org') return orgCommand(rest, io, deps);
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
+7 -2
View File
@@ -3,6 +3,7 @@
// is the human's; with --tester (Q173) only status and revoke. issue and rotate read the token on
// stdin, never argv, and nothing here prints it: the broker's answer carries the kind, date and state.
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
export const CREDENTIALS_USAGE = "mosaic credentials [status [AGENT]] [--json] [--tester] | issue|rotate AGENT gitea --rotate-by DATE | " +
"issue|rotate AGENT vikunja --expires DATE | issue|rotate AGENT host.ID --rotate-by DATE | " +
@@ -15,15 +16,19 @@ const MAX_TOKEN = 16384;
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(CREDENTIALS_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--business", "--rotate-by", "--expires", "--reason"].includes(arg)) {
if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--rotate-by", "--expires", "--reason"].includes(arg)) {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(CREDENTIALS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(CREDENTIALS_USAGE, 4);
else out.positional.push(arg);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+7 -2
View File
@@ -6,6 +6,7 @@
// scope reads the chief of staff's grant scope and scope set writes it from JSON on stdin; both are
// the human's, and the set is the human's Approve of the change.
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
export const GRANTS_USAGE =
"mosaic grants status gitea [--json] [--tester] [--business ID] | run DECISION [--business ID] | " +
@@ -15,13 +16,16 @@ export const GRANTS_USAGE =
"scope [--business ID] | scope set --revision REV [--business ID] (the scope JSON on stdin)";
const MAX_TOKEN = 16384;
const MAX_SCOPE = 65536;
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by", "--revision"];
const VALUES = ["--reason", "--url", "--account", "--rotate-by", "--revision"];
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(GRANTS_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester" || arg === "--finish") {
if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else if (arg === "--json" || arg === "--tester" || arg === "--finish") {
if (out.flags.has(arg)) throw new CliError(GRANTS_USAGE, 4);
out.flags.add(arg);
} else if (VALUES.includes(arg)) {
@@ -30,6 +34,7 @@ function parse(args) {
} else if (arg.startsWith("--")) throw new CliError(GRANTS_USAGE, 4);
else out.positional.push(arg);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+7 -2
View File
@@ -4,21 +4,26 @@
// reads a key: the answers carry fingerprints only. Row 235 (#1741, r6 section 6, X3) adds revoke, which
// takes an agent's grant on a host away at once, and sweep, which removes the lines a failure left.
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
export const HOSTS_USAGE =
"mosaic hosts [status] [--json] [--tester] [--business ID] | check|remove|sweep HOST [--tester] [--business ID] | revoke AGENT HOST --reason R [--tester] [--business ID]";
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(HOSTS_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (arg === "--business" || arg === "--reason") {
if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (arg === "--reason") {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(HOSTS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(HOSTS_USAGE, 4);
else out.positional.push(arg);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+7 -2
View File
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
import { loadSystem } from "./config.mjs";
import { systemVars } from "../../business/src/index.mjs";
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
import { migrateLaunch } from "../../business/src/migrate-launch.mjs";
// Row 155 (#1649, Q169): a PATCH may carry `assistants`, the assistant's vars, tracker and definition.
@@ -34,15 +35,19 @@ export function formatTokens(tokens) {
export function parseLimits(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(LIMITS_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--file", "--models", "--roles-dir", "--business"].includes(arg)) {
if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--file", "--models", "--roles-dir"].includes(arg)) {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(LIMITS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(LIMITS_USAGE, 4);
else out.positional.push(arg);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+6 -1
View File
@@ -3,6 +3,7 @@ import { configDir } from '../../business/src/business.mjs';
import { createProxyLock } from '../../bus/src/proxy-lock.mjs';
import { loadManagementClient } from '../../model-proxy/src/client.mjs';
import { CliError } from './errors.mjs';
import { orgSelector } from './org-selector.mjs';
import { refuseInsideAgent } from './transport.mjs';
import { fetchPinned } from '../../model-proxy/src/fetch.mjs';
import { validManagementKey, proxyTiming } from '../../model-proxy/src/common.mjs';
@@ -60,11 +61,15 @@ export async function proxyCommand(args, io, { system, callFor, prompt = promptP
}
if (!['fetch', 'init'].includes(verb)) throw failure('proxy-usage', 4);
const parsed = { flags: new Set(), values: {} }; let port;
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => failure('proxy-usage', 4));
for (let i = 0; i < rest.length; i++) {
const flag = rest[i];
if (verb !== 'init' || !['--business', '--port', '--rotate-grace-minutes', '--first-output-seconds'].includes(flag) || parsed.values[flag] !== undefined || !rest[i + 1]) throw failure('proxy-usage', 4);
if (verb === 'init' && selector.owns(flag)) { selector.take(flag, rest[i + 1]); i++; continue; }
if (verb !== 'init' || !['--port', '--rotate-grace-minutes', '--first-output-seconds'].includes(flag) || parsed.values[flag] !== undefined || !rest[i + 1]) throw failure('proxy-usage', 4);
parsed.values[flag] = rest[++i];
}
if (selector.given) parsed.values['--business'] = selector.value;
if (parsed.values['--port'] !== undefined) {
if (!/^[0-9]+$/.test(parsed.values['--port'])) throw failure('proxy-config-invalid');
port = Number(parsed.values['--port']);
+17
View File
@@ -0,0 +1,17 @@
// 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/
// 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';
// usageError(message) returns the parser's usage error for a missing, flag-shaped, repeated or
// conflicting selector.
export function orgSelector(usageError) {
return selectorScope({
valid: id => ID_PATTERN.test(id),
refuse: (kind, why) => { throw kind === 'invalid' ? new OrgError('org-invalid', 'invalid organization id') : usageError(why); },
});
}
+7 -2
View File
@@ -5,20 +5,25 @@
// `tester`) in the organization's changes.jsonl.
import { readFileSync } from "node:fs";
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
export const PLUGINS_USAGE = "mosaic plugins [--business ID] [--json] [--tester] | set --file PATCH [--business ID] [--tester]";
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(PLUGINS_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--file", "--business"].includes(arg)) {
if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (arg === "--file") {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(PLUGINS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(PLUGINS_USAGE, 4);
else out.positional.push(arg);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+6 -1
View File
@@ -7,6 +7,7 @@
// on the tester's own threads (owner `tester`, row 177). The broker's ownership check is the only one.
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 { rolesDir } from "./config.mjs";
@@ -26,15 +27,19 @@ export const THREAD_TALK_USAGE = [
export function parseArgs(args, usage, { flags = [], values = [] } = {}) {
const out = { positional: [], flags: new Set(), values: {} };
const refuse = (why) => new CliError(`${why}\n${usage}`, 4);
// A command that takes --business also takes --org, in this parser's scope (rename design r2).
const selector = values.includes("--business") ? orgSelector(refuse) : null;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (flags.includes(a)) out.flags.add(a);
if (selector?.owns(a)) { selector.take(a, args[i + 1]); i++; }
else if (flags.includes(a)) out.flags.add(a);
else if (values.includes(a)) {
if (i + 1 >= args.length || Object.hasOwn(out.values, a)) throw refuse(`${a} needs one value`);
out.values[a] = args[++i];
} else if (a.startsWith("--")) throw refuse(`unknown option ${a}`);
else out.positional.push(a);
}
if (selector?.given) out.values["--business"] = selector.value;
return out;
}
+5 -1
View File
@@ -1,13 +1,17 @@
import { CliError } from './errors.mjs';
import { orgSelector } from './org-selector.mjs';
export const USAGE_USAGE = 'mosaic usage [--business ID] [--json] [--tester]';
export async function usageCommand(args, io, callFor) {
const parsed = { flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(USAGE_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if ((arg === '--json' || arg === '--tester') && !parsed.flags.has(arg)) parsed.flags.add(arg);
else if (arg === '--business' && args[i + 1] && !args[i + 1].startsWith('--') && !parsed.values[arg]) parsed.values[arg] = args[++i];
else if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else throw new CliError(USAGE_USAGE, 4);
}
if (selector.given) parsed.values['--business'] = selector.value;
const result = await callFor(parsed)('usage', {});
if (parsed.flags.has('--json')) { io.stdout.write(JSON.stringify(result, null, 2) + '\n'); return; }
const counts = row => `input=${row.input} output=${row.output} cacheWrite=${row.cacheWrite} cacheRead=${row.cacheRead} unknown=${row.unknownTurns}`;
+11 -1
View File
@@ -251,7 +251,8 @@ test("an agent caller is refused before the bus is asked", async (t) => {
test("--tester is refused on every verb before the bus is asked", async (t) => {
const s = setup(t);
for (const verb of [["list"], [...GRANT, "--revision", "1"], ["revoke", "--user", "alice", "--revision", "1"]]) {
const args = [...verb, "--business", "acme", "--tester"];
// GRANT already names the business; a repeated --business refuses (rename design r2).
const args = verb.includes("--business") ? [...verb, "--tester"] : [...verb, "--business", "acme", "--tester"];
const err = await refusal(run(args, s.deps), /not --tester/);
assert.equal(err.exitCode, 3);
}
@@ -508,3 +509,12 @@ test("R4 the help keeps the provider-removal order and says what a refused reloa
assert.match(text, /grant and revoke ask the bus to re-read the file after the write\. If the bus refuses, the write still stands \(exit 0\), stderr names the refusal, and the bus's timer applies the change within 30 s\./);
assert.ok(text.indexOf("so do that second") < text.indexOf("re-read the file"), "next to the provider-removal order");
});
test('v1 grant receipt preserves business identity and exact JSON line shape', async t => {
const s = setup(t);
await run([...GRANT, '--revision', '1'], s.deps);
const raw = s.receipts();
const grant = s.read().grants[0].id;
assert.match(grant, /^g-[0-9a-f]{8}$/);
assert.equal(raw, JSON.stringify({ at: '2026-10-20T20:00:00.000Z', verb: 'grant', business: 'acme', user: 'alice', actor: 'human', revisionBefore: 1, revisionAfter: 2, grants: [grant] }) + '\n');
});
+96
View File
@@ -437,6 +437,7 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
if(fault==='inner'||fault==='outer'){
observedScript=join(h.dir,'case1-p10-observed.sh');timingFile=join(h.hc,'probe-uptime');
put(timingFile,'',0o600);put(observedScript,instrumentP10Probe(read(SCRIPT)),0o700);
put(join(h.dir,'selector.sh'),read(join(REPO,'scripts','selector.sh')),0o644);
assert.equal(removeP10Observers(read(observedScript)),read(SCRIPT),'observer reversal equals a fresh shipped script read');
}
const start=performance.now(),r=run(h,timingFile?{ROW299_TIMING_FILE:timingFile}:{},undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
@@ -783,6 +784,101 @@ test("row 195 accept: usage errors and a run without --root as a user exit 2", (
assert.ok(!existsSync(join(h.hc, "calls.log")));
});
// The selector (rename design r2): --org and --business are one option in each of three scopes, the
// script's own arguments and the args file's unit and serve arguments either side of --. A spelling
// may appear once per scope, and the other spelling only with the same value; never counted across --.
const SELECT = { old: ["--business", "mosaic-stack"], new: ["--org", "mosaic-stack"],
both: ["--business", "mosaic-stack", "--org", "mosaic-stack"], swapped: ["--org", "mosaic-stack", "--business", "mosaic-stack"] };
// The args file with outer unit selector tokens and serve selector tokens in ARGS's places.
const argsWith = (outer, serve) => [...ARGS.slice(0, 6), ...outer, ...ARGS.slice(8, 16), "--port", "732", ...serve, "--tester-socket"];
const invocation = (h, sel) => [...sel, "--args", "/etc/mosaic-stack/dev.args", "--claude-pin", CLAUDE_PIN, "--node-pin", NODE_PIN, "--root", h.root];
test("row 195 accept: --org or --business names the business in each of the three scopes, and talk and precheck still get --business", (t) => {
const runs = [["old", "old", "old"], ["new", "new", "new"], ["both", "swapped", "both"], ["swapped", "both", "swapped"],
["new", "old", "new"], ["old", "new", "old"], ["both", "old", "swapped"]];
const seen = [];
for (const [inv, outer, serve] of runs) {
const h = host(t), name = `${inv} ${outer} -- ${serve}`;
put(h.args, argsWith(SELECT[outer], SELECT[serve]).join("\n") + "\n", 0o644);
const r = run(h, {}, invocation(h, SELECT[inv]));
assert.equal(r.status, 0, `${name}: ${r.out}${r.err}`);
assert.deepEqual(failed(r), [], name);
const talk = calls(h, "mosaic").filter((a) => a.startsWith("talk "));
assert.equal(talk.length, 1, name);
assert.match(talk[0], /^talk chief case1-accept-[0-9a-f]{12}: .* --business mosaic-stack --tester --wait 300$/, name);
assert.deepEqual(calls(h, "precheck"), ["--business mosaic-stack --profile factory"], name);
seen.push(r.lines.filter((l) => /^[FP][0-9]+ /.test(l)).map((l) => l.split(" ").slice(0, 3).join(" ")));
}
for (const marks of seen) assert.deepEqual(marks, seen[0]);
});
test("row 195 accept: a bad selector in any scope, or scopes that name different businesses, refuse with exit 2 before any check", (t) => {
const usage = "usage: case1-accept.sh --business ID --args FILE --claude-pin PIN --node-pin vN.N.N\n";
const refusing = (why) => `case1-accept: refusing: ${why}\n`;
const before = (why) => refusing(`/etc/mosaic-stack/dev.args before --: ${why}`);
const after = (why) => refusing(`/etc/mosaic-stack/dev.args after --: ${why}`);
const cases = [
// The script's own arguments.
["invocation unequal", ["--business", "mosaic-stack", "--org", "beta"], null, refusing("--business mosaic-stack and --org beta conflict")],
["invocation unequal, new first", ["--org", "mosaic-stack", "--business", "beta"], null, refusing("--org mosaic-stack and --business beta conflict")],
["invocation old twice", ["--business", "mosaic-stack", "--business", "mosaic-stack"], null, refusing("--business given twice")],
["invocation new twice", ["--org", "mosaic-stack", "--org", "mosaic-stack"], null, refusing("--org given twice")],
["invocation new empty", ["--org", ""], null, usage],
["invocation new flag-shaped", ["--org", "--args"], null, usage],
["invocation new invalid", ["--org", "Mosaic"], null, usage],
["invocation old invalid", ["--business", "a/../b"], null, usage],
// The args file's unit arguments.
["unit unequal", ["--org", "mosaic-stack"], argsWith(["--business", "mosaic-stack", "--org", "beta"], SELECT.old), before("--business mosaic-stack and --org beta conflict")],
["unit unequal, new first", ["--org", "mosaic-stack"], argsWith(["--org", "mosaic-stack", "--business", "beta"], SELECT.old), before("--org mosaic-stack and --business beta conflict")],
["unit old twice", ["--org", "mosaic-stack"], argsWith(["--business", "mosaic-stack", "--business", "mosaic-stack"], SELECT.old), before("--business given twice")],
["unit new twice", ["--org", "mosaic-stack"], argsWith(["--org", "mosaic-stack", "--org", "mosaic-stack"], SELECT.new), before("--org given twice")],
["unit new missing", SELECT.old, [...ARGS.slice(0, 6), ...ARGS.slice(8, 15), "--org"], before("--org needs one value")],
["unit new flag-shaped", SELECT.old, argsWith(["--org"], SELECT.old), before("--org needs one value, not --copy")],
["unit new invalid", SELECT.old, argsWith(["--org", "Mosaic"], SELECT.old), before("--org Mosaic is not valid")],
["unit names another business", SELECT.new, argsWith(["--org", "beta"], SELECT.new), refusing("/etc/mosaic-stack/dev.args is for business beta, not mosaic-stack")],
// --home takes --org as its value, so --business is the unit's one selector and the home refuses.
["unit --home value", SELECT.old, argsWith(["--business", "mosaic-stack"], SELECT.old).map((a, i) => (i === 5 ? "--org" : a)),
refusing("/etc/mosaic-stack/dev.args lacks a plain --user, --home, --business, --node, --claude or --copy")],
// The args file's serve arguments.
["serve unequal", SELECT.old, argsWith(SELECT.old, ["--business", "mosaic-stack", "--org", "beta"]), after("--business mosaic-stack and --org beta conflict")],
["serve unequal, new first", SELECT.old, argsWith(SELECT.old, ["--org", "mosaic-stack", "--business", "beta"]), after("--org mosaic-stack and --business beta conflict")],
["serve old twice", SELECT.old, argsWith(SELECT.old, ["--business", "mosaic-stack", "--business", "mosaic-stack"]), after("--business given twice")],
["serve new twice", SELECT.old, argsWith(SELECT.new, ["--org", "mosaic-stack", "--org", "mosaic-stack"]), after("--org given twice")],
["serve new missing", SELECT.old, [...argsWith(SELECT.old, []), "--org"], after("--org needs one value")],
["serve new flag-shaped", SELECT.old, argsWith(SELECT.old, ["--org"]), after("--org needs one value, not --tester-socket")],
["serve new invalid", SELECT.old, argsWith(SELECT.old, ["--org", "Mosaic"]), after("--org Mosaic is not valid")],
["serve names another business", SELECT.new, argsWith(SELECT.new, ["--org", "beta"]), refusing("serve business differs")],
["serve has no selector", SELECT.old, argsWith(SELECT.old, []), refusing("/etc/mosaic-stack/dev.args needs --business or --org after --")],
// --port takes --org as its value; the serve selector is the --business after it.
["serve --port value", SELECT.old, [...argsWith(SELECT.old, []).slice(0, 17), "--org", "--business", "mosaic-stack", "--tester-socket"],
refusing("/etc/mosaic-stack/dev.args lacks a privileged numeric --port after --")],
];
for (const [name, sel, file, err] of cases) {
const h = host(t);
if (file) put(h.args, file.join("\n") + "\n", 0o644);
const r = run(h, {}, invocation(h, sel));
assert.equal(r.status, 2, `${name}: ${r.out}${r.err}`);
assert.equal(r.err, err, name);
assert.equal(r.out, "", name);
assert.ok(!existsSync(join(h.hc, "calls.log")), name);
}
// A missing value at the end of the script's own arguments is a usage error.
const h = host(t);
const r = run(h, {}, ["--args", "/etc/mosaic-stack/dev.args", "--claude-pin", CLAUDE_PIN, "--node-pin", NODE_PIN, "--root", h.root, "--org"]);
assert.equal(r.status, 2);
assert.equal(r.err, usage);
});
test("row 195 accept: a copy of the script without selector.sh beside it refuses before parsing", (t) => {
const h = host(t), lone = join(h.dir, "lone", "case1-accept.sh");
put(lone, read(SCRIPT), 0o755);
const r = run(h, {}, undefined, lone);
assert.equal(r.status, 2, r.out + r.err);
assert.ok(r.err.endsWith(`case1-accept: refusing: cannot read ${join(h.dir, "lone", "selector.sh")}\n`), r.err);
assert.equal(r.out, "");
assert.ok(!existsSync(join(h.hc, "calls.log")));
});
test("row 195 accept: every in-instance call goes through sudo -u mosaic -H env PATH=<node dir>:...", (t) => {
const h = host(t);
const r = run(h);
+70 -2
View File
@@ -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/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/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"`);
@@ -250,6 +250,64 @@ test("the install refuses root, a business id mismatch, another --home and a con
assert.equal(existsSync(x.business), false);
});
// The selectors (rename design r2): --org/--business and --org-file/--business-file are two
// families, each its own scope. A bad selector refuses with exit 4 before anything is read or written.
const rest = (x) => ["--commit", COMMIT, "--profile", "library", "--personas", x.staging];
test("the install takes --org and --org-file, alone or matching their old spelling in either order", (t) => {
for (const sel of [
(f) => ["--org", "acme", "--org-file", f],
(f) => ["--business", "acme", "--org", "acme", "--business-file", f, "--org-file", f],
(f) => ["--org", "acme", "--business", "acme", "--org-file", f, "--business-file", f],
]) {
const x = world(t);
const r = install(x, { args: [...sel(x.input), ...rest(x)] });
assert.equal(r.code, 0, r.out);
assert.equal(JSON.parse(readFileSync(x.business, "utf8")).id, "acme");
assert.equal(calls(x), `stack-copy copy ${COMMIT} --copies ${join(x.home, ".local", "share", "mosaic-stack", "copies")}\n`);
}
});
test("the install refuses a bad selector in either family before it reads or writes anything", (t) => {
const x = world(t);
const f = x.input, g = `${x.root}/input/../input/acme.json`; // join() would normalize it to f
assert.notEqual(g, f);
const usage = "usage: case2-install.sh --business ID --business-file FILE --commit SHA [--profile factory|library] [--personas DIR] [--home DIR]\n";
const notId = (v) => `case2-install: --business must be a business id: ${v}\n`;
for (const [args, out] of [
[["--business", "acme", "--org", "beta", "--business-file", f], "case2-install: --business acme and --org beta conflict\n"],
[["--org", "acme", "--business", "beta", "--business-file", f], "case2-install: --org acme and --business beta conflict\n"],
[["--business", "acme", "--business", "acme", "--business-file", f], "case2-install: --business given twice\n"],
[["--org", "acme", "--org", "acme", "--business-file", f], "case2-install: --org given twice\n"],
[["--business-file", f, "--org"], usage],
[["--business-file", f, "--business"], usage],
[["--org", "", "--business-file", f], usage],
[["--org", "--business-file", f], notId("--business-file")],
[["--business", "-x", "--business-file", f], notId("-x")],
[["--org", "Acme", "--business-file", f], notId("Acme")],
[["--org", "a.b", "--business-file", f], notId("a.b")],
// The file family compares the text given: the same file named another way still conflicts.
[["--org", "acme", "--business-file", f, "--org-file", g], `case2-install: --business-file ${f} and --org-file ${g} conflict\n`],
[["--org", "acme", "--org-file", g, "--business-file", f], `case2-install: --org-file ${g} and --business-file ${f} conflict\n`],
[["--org", "acme", "--org-file", f, "--org-file", f], "case2-install: --org-file given twice\n"],
[["--org", "acme", "--business-file", f, "--business-file", f], "case2-install: --business-file given twice\n"],
[["--org", "acme", "--org-file"], usage],
[["--org", "acme", "--org-file", ""], usage],
[["--org", "acme", "--org-file", "--commit", COMMIT], "case2-install: --org-file needs one value, not --commit\n"],
]) {
const last = ["--business-file", "--business", "--org", "--org-file"].includes(args.at(-1));
const r = install(x, { args: last ? args : [...args, ...rest(x)] });
assert.equal(r.code, 4, args.join(" "));
assert.equal(r.out, out, args.join(" "));
}
// A selector spelling taken as another option's value is that option's text.
const r = install(x, { args: ["--org", "acme", "--business-file", f, "--commit", "--org", "--profile", "library", "--personas", x.staging] });
assert.equal(r.code, 4, r.out);
assert.equal(r.out, "case2-install: --commit must be a full 40-character commit id: --org\n");
assert.equal(calls(x), "");
assert.equal(existsSync(x.business), false);
});
test("the library install needs --personas", (t) => {
const x = world(t);
const r = install(x, { args: ["--business", "acme", "--business-file", x.input, "--commit", COMMIT] });
@@ -433,7 +491,7 @@ function dependencyFreeClone(t) {
const x = world(t);
const clone = join(x.root, "clone");
mkdirSync(join(clone, "scripts"), { recursive: true });
for (const file of ["case2-install.sh", "case2-persona-import.mjs"]) {
for (const file of ["case2-install.sh", "case2-persona-import.mjs", "selector.sh"]) {
cpSync(join(REPO, "scripts", file), join(clone, "scripts", file));
}
// Isolate the persona step; bootstrap and business validation are covered above.
@@ -463,6 +521,16 @@ test("row 322 F8: library personas install from the copy with no clone node_modu
assert.equal(existsSync(join(x.clone, "node_modules")), false);
});
// Without selector.sh beside it the install refuses before parsing, rather than skip the selector rules.
test("the install refuses when its selector.sh is missing, before it reads or writes anything", (t) => {
const x = dependencyFreeClone(t);
rmSync(join(x.clone, "scripts", "selector.sh"));
const r = x.run();
assert.equal(r.code, 4, r.out);
assert.ok(r.out.endsWith(`case2-install: cannot read ${join(x.clone, "scripts", "selector.sh")}\n`), r.out);
assert.equal(existsSync(x.business), false);
});
test("row 322 F9: importer refusal keeps the error line rather than the Node banner", (t) => {
const x = dependencyFreeClone(t);
const copy = join(x.home, ".local", "share", "mosaic-stack", "copies", COMMIT);
@@ -398,3 +398,62 @@ test("usage: stage needs one of --staging and --dry-run, install takes only its
assert.equal(run(["stage", "--bogus", "x"]).status, 2);
assert.ok(!existsSync(s.staging));
});
// Rename design r2, "Selector scopes and parser matrix": --org and --business are one option in
// this script's own parser, run directly, not through case2-install.sh.
test("install takes --org or --business, alone or matching in either order", (t) => {
for (const selector of [["--business", "case2"], ["--org", "case2"], ["--business", "case2", "--org", "case2"], ["--org", "case2", "--business", "case2"]]) {
const s = scratch(t);
assert.equal(stage(s).status, 0);
const r = run(["install", "--staging", s.staging, "--config-dir", s.configDir, ...selector]);
assert.equal(r.status, 0, `${selector.join(" ")}: ${r.all}`);
assert.deepEqual(readdirSync(join(s.configDir, "personas")), ["case2"], selector.join(" "));
assert.deepEqual(readdirSync(join(s.configDir, "personas", "case2")).sort(), ["bean.md", "chet.md"], selector.join(" "));
}
});
test("a bad selector refuses before the staging directory or the Hermes home is read", (t) => {
const s = scratch(t);
assert.equal(stage(s).status, 0);
for (const [selector, status, err] of [
[["--business", "case2", "--org", "other"], 2, "case2-persona-import: --business case2 and --org other conflict\n"],
[["--org", "case2", "--business", "other"], 2, "case2-persona-import: --org case2 and --business other conflict\n"],
[["--business", "case2", "--business", "case2"], 2, "case2-persona-import: --business given twice\n"],
[["--org", "case2", "--org", "case2"], 2, "case2-persona-import: --org given twice\n"],
[["--org"], 2, "case2-persona-import: --org needs one value\n"],
[["--business"], 2, "case2-persona-import: --business needs one value\n"],
[["--org", ""], 2, "case2-persona-import: --org needs one value\n"],
[["--org", "--dry-run"], 2, "case2-persona-import: --org needs one value, not --dry-run\n"],
[["--org", "-x"], 1, "case2-persona-import: refused: business -x isn't a business id\n"],
[["--org", "Case2"], 1, "case2-persona-import: refused: business Case2 isn't a business id\n"],
[["--business", "../x"], 1, "case2-persona-import: refused: business ../x isn't a business id\n"],
[["--org", "a.b"], 1, "case2-persona-import: refused: business a.b isn't a business id\n"],
]) {
for (const verb of [["install", "--staging", s.staging, "--config-dir", s.configDir], ["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]]) {
rmSync(s.trace, { force: true });
const r = run([...verb, ...selector], { trace: s.trace });
const label = `${verb[0]} ${selector.join(" ")}`;
assert.equal(r.status, status, `${label}: ${r.all}`);
assert.equal(r.err, err, label);
assert.equal(r.out, "", label);
const opened = readFileSync(s.trace, "utf8").split("\n").filter(Boolean).map((l) => l.slice(l.indexOf(" ") + 1));
assert.deepEqual(opened.filter((p) => p.startsWith(s.root)), [], `${label} read nothing of the scratch tree`);
}
}
assert.equal(existsSync(join(s.configDir, "personas")), false);
// The control: the same trace sees install read the staging directory.
rmSync(s.trace, { force: true });
assert.equal(install(s, "case2", { trace: s.trace }).status, 0);
assert.ok(readFileSync(s.trace, "utf8").includes(join(s.staging, "manifest.json")));
});
test("a selector spelling is never another option's value, and stage still ignores a valid selector", (t) => {
const s = scratch(t);
// This grammar refuses any value starting "--", so a selector spelling can't be one.
const r = run(["install", "--staging", "--org", "case2", "--config-dir", s.configDir]);
assert.equal(r.status, 2, r.all);
assert.equal(r.err, "case2-persona-import: --staging needs a value\n");
const st = stage(s, ["--org", "case2"]);
assert.equal(st.status, 0, st.all);
assert.deepEqual(readdirSync(s.staging).sort(), ["bean.md", "chet.md", "manifest.json"]);
});
+113 -4
View File
@@ -107,9 +107,9 @@ const libraryFlags = (h) => ["--port", "443", "--business", "acme", "--public-ho
"--allow-user", "melanie", "--proof-header", "X-Proxy-Proof", "--proof-file", h.proof];
const factoryFlags = () => ["--port", "443", "--business", "acme", "--tester-socket"];
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {} } = {}) {
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {}, outer = ["--business", "acme"], argv } = {}) {
const serve = flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h));
const args = ["--user", "mosaic", "--home", h.home, "--business", "acme", "--copy", copy, "--node", node, "--profile", profile, ...extra, "--", ...serve];
const args = argv ?? ["--user", "mosaic", "--home", h.home, ...outer, "--copy", copy, "--node", node, "--profile", profile, ...extra, "--", ...serve];
const r = spawnSync(UNIT, args, {
encoding: "utf8",
timeout: 30000,
@@ -338,6 +338,71 @@ test("a clean read-only library render writes both units 0644 with the fixed lin
]);
});
// The selector (rename design r2): --org and --business are one option in each of two scopes, the
// script's own flags before -- and the serve flags after it. Nothing is counted across --; the serve
// selector is required, must be the unit's business, and renders as --business.
const SELECTORS = [["--business", "acme"], ["--org", "acme"], ["--business", "acme", "--org", "acme"], ["--org", "acme", "--business", "acme"]];
test("the unit takes --org or --business on either side of --, renders --business once, and re-installs as the same units", (t) => {
const h = hub(t);
assert.equal(unit(h, { profile: "factory", flags: ["--port", "443", "--business", "acme"] }).code, 0);
const before = [read(busFile(h)), read(webuiFile(h))];
assert.deepEqual(lines(before[1], "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve --port 443 --business acme`]);
for (const outer of SELECTORS) {
for (const child of SELECTORS) {
const r = unit(h, { profile: "factory", outer, flags: ["--port", "443", ...child] });
assert.equal(r.code, 0, `${outer.join(" ")} -- ${child.join(" ")}: ${r.err}`);
assert.deepEqual([read(busFile(h)), read(webuiFile(h))], before, `${outer.join(" ")} -- ${child.join(" ")}`);
}
}
// The selector keeps its place among the serve flags.
const g = hub(t);
assert.equal(unit(g, { profile: "factory", outer: ["--org", "acme"], flags: ["--org", "acme", "--port", "443", "--business", "acme"] }).code, 0);
assert.deepEqual(lines(read(webuiFile(g)), "ExecStart"), [`${USR_NODE} ${copyDir(g)}/packages/webui/src/cli.mjs serve --business acme --port 443`]);
});
test("the unit refuses a bad selector in either scope, and an outer and serve pair that differ, before writing a unit", (t) => {
const h = hub(t);
const serve = ["--port", "443", "--business", "acme"];
const usage = "usage: case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS\n";
const head = ["--user", "mosaic", "--home", h.home, "--copy", COPY_A, "--node", USR_NODE, "--profile", "factory"];
for (const [label, argv, code, err] of [
["outer unequal, old first", [...head, "--business", "acme", "--org", "beta", "--", ...serve], 4, "case2-unit: --business acme and --org beta conflict\n"],
["outer unequal, new first", [...head, "--org", "acme", "--business", "beta", "--", ...serve], 4, "case2-unit: --org acme and --business beta conflict\n"],
["outer old repeated", [...head, "--business", "acme", "--business", "acme", "--", ...serve], 4, "case2-unit: --business given twice\n"],
["outer new repeated", [...head, "--org", "acme", "--org", "acme", "--", ...serve], 4, "case2-unit: --org given twice\n"],
["outer missing", [...head, "--org"], 4, usage],
["outer empty", [...head, "--org", "", "--", ...serve], 4, usage],
["outer none", [...head, "--", ...serve], 4, usage],
["outer invalid", [...head, "--org", "Acme", "--", ...serve], 1, "case2-unit: --business must be a business id: Acme\n"],
["outer flag-shaped", [...head, "--business", "-x", "--", ...serve], 1, "case2-unit: --business must be a business id: -x\n"],
["outer takes no separator", [...head, "--org", "--", ...serve], 1, "case2-unit: --business must be a business id: --\n"],
["serve unequal, old first", [...head, "--business", "acme", "--", "--port", "443", "--business", "acme", "--org", "beta"], 1, "case2-unit: serve --business acme and --org beta conflict\n"],
["serve unequal, new first", [...head, "--business", "acme", "--", "--port", "443", "--org", "acme", "--business", "beta"], 1, "case2-unit: serve --org acme and --business beta conflict\n"],
["serve old repeated", [...head, "--business", "acme", "--", "--port", "443", "--business", "acme", "--business", "acme"], 1, "case2-unit: serve flag --business given twice\n"],
["serve new repeated", [...head, "--business", "acme", "--", "--port", "443", "--org", "acme", "--org", "acme"], 1, "case2-unit: serve flag --org given twice\n"],
["serve missing", [...head, "--business", "acme", "--", "--port", "443", "--org"], 1, "case2-unit: serve flag --org needs a value\n"],
["serve empty", [...head, "--business", "acme", "--", "--port", "443", "--org", ""], 1, "case2-unit: serve flag --org needs a value\n"],
["serve none", [...head, "--org", "acme", "--", "--port", "443"], 1, "case2-unit: serve flag --business is required\n"],
["serve invalid", [...head, "--business", "acme", "--", "--port", "443", "--org", "Acme"], 1, "case2-unit: serve --org Acme is not the unit's business acme\n"],
["serve flag-shaped", [...head, "--business", "acme", "--", "--port", "443", "--org", "--tester-socket"], 1, "case2-unit: serve --org --tester-socket is not the unit's business acme\n"],
["outer and serve differ", [...head, "--org", "acme", "--", "--port", "443", "--org", "beta"], 1, "case2-unit: serve --org beta is not the unit's business acme\n"],
["serve and outer differ", [...head, "--org", "beta", "--", "--port", "443", "--business", "acme"], 1, "case2-unit: serve --business acme is not the unit's business beta\n"],
["a selector as --copy's value", ["--user", "mosaic", "--home", h.home, "--business", "acme", "--copy", "--org", "--node", USR_NODE, "--profile", "factory", "--", ...serve], 1, "case2-unit: --copy must be a full 40-character lower-case commit id: --org\n"],
]) {
const r = unit(h, { argv });
assert.equal(r.code, code, `${label}: ${r.err}`);
assert.equal(r.err, err, label);
assert.equal(r.out, "", label);
}
// A selector spelling taken as a library serve flag's value is that flag's text.
const library = libraryFlags(h).map((f) => (f === "books.example" ? "--org" : f));
refuses(unit(h, { flags: library }), /^case2-unit: public host must be a hostname, no port or scheme: --org\n$/);
assert.equal(existsSync(busFile(h)), false);
assert.equal(existsSync(webuiFile(h)), false);
assert.equal(existsSync(h.log), false);
});
test("the read-only library profile explicitly refuses --send before writing a unit", (t) => {
const h = hub(t);
const r = unit(h, { flags: [...libraryFlags(h), "--send"] });
@@ -736,8 +801,8 @@ function pathWithoutClaude(x) {
return `${x.bin}:${usr}`;
}
function precheck(x, { profile = "library", env = {}, path } = {}) {
const r = spawnSync(PRECHECK, ["--business", "acme", "--profile", profile], {
function precheck(x, { profile = "library", env = {}, path, args = ["--business", "acme", "--profile", profile] } = {}) {
const r = spawnSync(PRECHECK, args, {
encoding: "utf8",
timeout: 60000,
env: {
@@ -772,6 +837,50 @@ test("the precheck passes on a good host and prints free memory, without the key
assert.doesNotMatch(r.out, /claude .*fake|marker/);
});
// The selector (rename design r2): --org and --business are one option in the precheck's scope, and a
// bad one refuses before any check line, with the script's usage exit 4.
test("the precheck takes --org or --business, alone or matching in either order, and refuses a bad selector before any check", (t) => {
const x = host(t);
for (const sel of [["--business", "acme"], ["--org", "acme"], ["--business", "acme", "--org", "acme"], ["--org", "acme", "--business", "acme"]]) {
const r = precheck(x, { args: [...sel, "--profile", "library"] });
assert.equal(r.code, 0, `${sel.join(" ")}: ${r.out}`);
assert.match(r.out, /ok {6}config validates/, sel.join(" "));
}
const usage = "usage: case2-precheck.sh --business ID [--profile factory|library] | --business ID --library-status\n";
const notId = (v) => `case2-precheck: --business must be a business id: ${v}\n`;
for (const [args, out] of [
[["--business", "acme", "--org", "beta"], "case2-precheck: --business acme and --org beta conflict\n"],
[["--org", "acme", "--business", "beta"], "case2-precheck: --org acme and --business beta conflict\n"],
[["--business", "acme", "--business", "acme"], "case2-precheck: --business given twice\n"],
[["--org", "acme", "--org", "acme"], "case2-precheck: --org given twice\n"],
[["--org"], usage],
[["--business"], usage],
[["--org", ""], usage],
[["--org", "--profile", "library"], notId("--profile")],
[["--business", "-x"], notId("-x")],
[["--org", "Acme"], notId("Acme")],
[["--org", "acme/../x"], notId("acme/../x")],
]) {
const r = precheck(x, { args });
assert.equal(r.code, 4, args.join(" "));
assert.equal(r.out, out, args.join(" "));
}
// A selector spelling taken as --profile's value is that option's text.
const r = precheck(x, { args: ["--profile", "--org", "--business", "acme"] });
assert.equal(r.code, 4, r.out);
assert.equal(r.out, "case2-precheck: --profile must be factory or library: --org\n");
});
test("the precheck refuses when its selector.sh is missing, before any check", (t) => {
const x = host(t), lone = join(x.root, "lone", "scripts", "case2-precheck.sh");
mkdirSync(dirname(lone), { recursive: true });
cpSync(PRECHECK, lone);
const r = spawnSync(lone, ["--org", "acme"], { encoding: "utf8", env: { PATH: `${x.bin}:/usr/bin:/bin`, HOME: x.home } });
assert.equal(r.status, 4, r.stdout + r.stderr);
assert.equal(r.stdout, "");
assert.ok(r.stderr.endsWith(`case2-precheck: cannot read ${join(x.root, "lone", "scripts", "selector.sh")}\n`), r.stderr);
});
test("the precheck refuses each failed host check", (t) => {
blocked(precheck(host(t), { env: { CASE2_TEST_NODE: "v22.22.2" } }), /REFUSED node v22\.22\.2 is older than 24/);
blocked(precheck(host(t), { env: { CASE2_TEST_GROUPS: `${GID} 998` } }), /REFUSED id -G prints more than one group/);
+32
View File
@@ -11,6 +11,8 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { BusError } from "../../bus/src/broker.mjs";
import { CliError } from "../src/errors.mjs";
import { busExit } from "../src/transport.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { systemFor, writeJson } from "../../business/tests/helpers.mjs";
import { main, run } from "../src/cli.mjs";
@@ -358,3 +360,33 @@ test("row 194 CLI: lifecycle and veto usage errors exit 4 before any bus call",
}
assert.equal(s.calls.length, 0);
});
test('v1 business instances, instance and veto retain broker proof refusals and leave files untouched', async t => {
const s = await setup(t);
const before = readFileSync(s.file, 'utf8');
const reader = s.bus.broker.bindReader({ business: 'acme' });
const calls = [];
const deps = { system: { dataRoot: s.f.dataRoot }, transport: business => async (verb, args) => {
calls.push({ business, verb, args });
try { return structuredClone(s.bus.broker.request(reader, { verb, args })); }
catch (error) { const e = new CliError(error.code, busExit(error.code)); e.code = error.code; throw e; }
} };
const cases = [
[['instances', 'acme', '--json'], 'instances', {}],
[['instance', 'set', 'acme', 'coder', '--launch', '--revision', 'a'.repeat(64)], 'instances.set', { revision: 'a'.repeat(64), instance: 'coder', op: 'change', launch: true }],
[['veto', 'acme', 'keep', 'coder', '--revision', 'a'.repeat(64)], 'veto.set', { revision: 'a'.repeat(64), op: 'keep', name: 'coder' }],
];
for (const [args, verb, request] of cases) {
const x = io(s.f.env);
assert.equal(await run(['business', ...args], x, deps), 3, args.join(' '));
assert.equal(x.out.text, '');
assert.match(x.err.text, /human-required/);
assert.deepEqual(calls.at(-1), { business: 'acme', verb, args: request });
const count = calls.length;
const agent = io({ ...s.f.env, CODEX_THREAD_ID: 'fixture-agent' });
assert.equal(await run(['business', ...args], agent, deps), 3);
assert.match(agent.err.text, /human shell/);
assert.equal(calls.length, count);
}
assert.equal(readFileSync(s.file, 'utf8'), before);
});
+3
View File
@@ -29,6 +29,9 @@ test('detached console reports its live loopback URL and stops', { timeout: 6000
const url = new URL(result.url);
assert.equal(url.hostname, '127.0.0.1');
assert.ok(Number.isSafeInteger(result.pid));
// A selector producer (rename r2): the Console child gets the active organization as --business.
const argv = readFileSync(`/proc/${result.pid}/cmdline`, 'utf8').split('\0').slice(0, -1);
assert.deepEqual(argv, [process.execPath, join(REPO, 'packages/webui/src/cli.mjs'), 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner']);
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(url.port)));
const status = await new Promise((resolve, reject) => get(url, res => { res.resume(); resolve(res.statusCode); }).on('error', reject));
assert.equal(status, 200);
+211
View File
@@ -0,0 +1,211 @@
// The organization selector on every CLI parser that takes --business (rename design r2, "Selector
// scopes and parser matrix"): --org is the same option in that parser's scope. Each entry point runs
// through main() with a stub transport that records the organization callFor binds, then stops; a
// refused selector binds nothing.
import { test } from "node:test";
import assert from "node:assert/strict";
import { main } from "../src/cli.mjs";
import { accessCommand } from "../src/access.mjs";
import { parseLimits } from "../src/limits.mjs";
import { parseArgs } from "../src/thread.mjs";
import { CliError } from "../src/errors.mjs";
import { OrgError } from "../../business/src/org-registry.mjs";
import { io, orgFixture, tmp } from "./helpers.mjs";
const STOP = "selector test stop";
const stop = () => { throw new CliError(STOP, 99); };
function stub(t) {
const h = { bound: [], calls: [], prompted: 0 };
const transport = b => { h.bound.push(b); return async (verb, args) => { h.calls.push({ verb, args }); stop(); }; };
h.deps = {
system: { dataRoot: tmp(t, "sel-") },
transport,
testerTransport: transport,
promptProxyKey: async () => { h.prompted++; stop(); },
};
return h;
}
// What one argv does: reached the bus (or the key prompt), refused, or failed some other way.
async function outcome(t, argv, { tty = false, input } = {}) {
const h = stub(t), term = io();
if (tty) { term.stdin.isTTY = true; term.stdout.isTTY = true; }
if (input !== undefined) term.stdin.end(input);
const seen = { bound: h.bound, calls: h.calls, get prompted() { return h.prompted; }, stdin: term.stdin };
try {
await main(argv, term, h.deps);
return { ...seen, kind: "done" };
} catch (e) {
if (e instanceof CliError && e.message === STOP) return { ...seen, kind: "reached" };
if (e instanceof OrgError) return { ...seen, kind: "org", code: e.code };
if (e instanceof CliError) return { ...seen, kind: "refused", exitCode: e.exitCode, code: e.code, message: e.message };
throw e;
}
}
// The design's per-parser cases. The selector goes after the verb's own arguments.
const CASES = [
["old alone", ["--business", "acme"], "acme"],
["new alone", ["--org", "acme"], "acme"],
["matching, old first", ["--business", "acme", "--org", "acme"], "acme"],
["matching, new first", ["--org", "acme", "--business", "acme"], "acme"],
["unequal, old first", ["--business", "acme", "--org", "beta"], "conflict"],
["unequal, new first", ["--org", "acme", "--business", "beta"], "conflict"],
["old repeated", ["--business", "acme", "--business", "acme"], "usage"],
["new repeated", ["--org", "acme", "--org", "acme"], "usage"],
["old missing", ["--business"], "usage"],
["new missing", ["--org"], "usage"],
["new empty", ["--org", ""], "usage"],
["new flag-shaped", ["--org", "--json"], "usage"],
["old flag-shaped", ["--business", "--org", "acme"], "usage"],
["new invalid", ["--org", "Acme"], "invalid"],
["old invalid", ["--business", "acme/../x"], "invalid"],
];
const ENTRIES = [
["inbox"], ["decide", "d1234567", "yes"], ["tasks"], ["agents"], ["trail", "t1"],
["talk", "pm", "hi"], ["follow"], ["names"], ["name", "coder", "Bob"],
["halt"], ["resume"], ["processing", "status"], ["launches", "off"], ["launches", "on"],
["project", "list"], ["project", "create", "work"], ["threads"], ["thread", "read", "work/t1"], ["session", "list", "work/t1"],
["limits"], ["limits", "set", "--file", "patch.json"], ["usage"], ["plugins"], ["plugins", "set", "--file", "patch.json"],
["credentials", "status"], ["credentials", "delete", "coder", "--reason", "gone"],
["hosts", "status"], ["hosts", "revoke", "coder", "h1", "--reason", "gone"],
["grants", "status", "gitea"], ["grants", "revoke", "coder", "gitea", "--reason", "gone"],
];
function check(r, expected, label) {
if (expected === "acme") {
assert.deepEqual(r.bound, ["acme"], `${label}: ${r.kind} ${r.message ?? r.code ?? ""}`);
return;
}
assert.deepEqual(r.bound, [], `${label} bound nothing`);
assert.equal(r.calls.length, 0, `${label} made no call`);
if (expected === "invalid") {
assert.equal(r.kind, "org", label);
assert.equal(r.code, "org-invalid", label);
} else {
assert.equal(r.kind, "refused", `${label}: ${r.kind}`);
assert.equal(r.exitCode, 4, label);
}
}
for (const base of ENTRIES) {
test(`selector on mosaic ${base.join(" ")}`, async (t) => {
for (const [name, selector, expected] of CASES) {
const r = await outcome(t, [...base, ...selector]);
check(r, expected, `${base.join(" ")} ${name}`);
// The verbs whose usage error carries the reason say which spellings conflict.
if (expected === "conflict" && !["limits", "usage", "plugins", "credentials", "hosts", "grants"].includes(base[0])) assert.match(r.message, /conflict/i, name);
}
});
}
test("selector on mosaic proxy init: the key prompt waits for a valid selector", async (t) => {
for (const [name, selector, expected] of CASES) {
const r = await outcome(t, ["proxy", "init", ...selector], { tty: true });
check(r, expected, `proxy init ${name}`);
assert.equal(r.prompted, expected === "acme" ? 1 : 0, `proxy init ${name} prompted`);
if (r.kind === "refused") assert.equal(r.code, "proxy-usage", name);
}
// fetch takes no selector.
assert.equal((await outcome(t, ["proxy", "fetch", "--org", "acme"])).code, "proxy-usage");
});
test("selector on mosaic access: the human proof binds the selected organization", async (t) => {
for (const verb of [["list"], ["user", "add", "ana"]]) {
for (const [name, selector, expected] of CASES) {
const bound = [], term = io();
const deps = { system: { dataRoot: tmp(t, "sel-") }, transport: b => { bound.push(b); return async () => stop(); }, contextFor: () => { throw new Error("context read before the proof"); } };
let r;
try { await accessCommand([...verb, ...selector], term, deps); r = { kind: "done" }; }
catch (e) {
if (e instanceof CliError && e.message === STOP) r = { kind: "reached" };
else if (e instanceof OrgError) r = { kind: "org", code: e.code };
else if (e instanceof CliError) r = { kind: "refused", exitCode: e.exitCode, message: e.message };
else throw e;
}
check({ ...r, bound, calls: [] }, expected, `access ${verb.join(" ")} ${name}`);
if (expected === "conflict") assert.match(r.message, /conflict/i);
}
}
});
test("a selector token taken as another option's value is that option's text", async (t) => {
const note = await outcome(t, ["decide", "d1234567", "yes", "--note", "--org", "--business", "acme"]);
assert.deepEqual(note.bound, ["acme"]);
const project = await outcome(t, ["threads", "--project", "--org", "--business", "acme"]);
assert.deepEqual(project.bound, ["acme"]);
// The channel's first call reads plugins, so the repo shows in the shared parser's result.
const repo = await outcome(t, ["project", "create", "work", "--repo", "--org", "--business", "acme"]);
assert.deepEqual(repo.bound, ["acme"]);
assert.deepEqual(parseArgs(["work", "--repo", "--org", "--business", "acme"], "u", { values: ["--repo", "--business"] }), { positional: ["work"], flags: new Set(), values: { "--repo": "--org", "--business": "acme" } });
for (const argv of [
["credentials", "delete", "coder", "--reason", "--org", "--business", "acme"],
["hosts", "revoke", "coder", "h1", "--reason", "--business", "--org", "acme"],
["grants", "revoke", "coder", "gitea", "--reason", "--org", "--business", "acme"],
]) {
const r = await outcome(t, argv);
assert.deepEqual(r.bound, ["acme"], argv.join(" "));
assert.equal(r.calls[0].args.reason, argv[argv.indexOf("--reason") + 1], argv.join(" "));
}
// callFor binds before the file is read, so the bound organization and the file name both show.
const plugins = await outcome(t, ["plugins", "set", "--file", "--org", "--business", "acme"]);
assert.deepEqual(plugins.bound, ["acme"]);
assert.match(plugins.message, /--org/);
assert.deepEqual(parseLimits(["set", "--file", "--org", "--business", "acme"]).values, { "--file": "--org", "--business": "acme" });
// proxy init: --port takes --org, so the port check refuses, not the selector.
const port = await outcome(t, ["proxy", "init", "--port", "--org", "--business", "acme"], { tty: true });
assert.equal(port.code, "proxy-config-invalid");
// access keeps its own grammar: an option value may not start with --.
await assert.rejects(accessCommand(["list", "--business", "acme", "--user", "--org"], io(), { transport: stop, contextFor: stop }), /--user needs one value/);
});
test("positional text next to a selector stays positional", async (t) => {
const r = await outcome(t, ["talk", "pm", "--org", "acme", "--business", "acme", "hi"]);
assert.deepEqual(r.bound, ["acme"]);
assert.deepEqual(r.calls[0], { verb: "message.send", args: { to: "pm", body: "hi", class: "REQUEST" } });
});
test("an invalid selector refuses before stdin is read", async (t) => {
const argv = ["grants", "admin", "set", "gitea", "--url", "https://git.example", "--account", "a", "--rotate-by", "2027-01-01"];
const bad = await outcome(t, [...argv, "--org", "Bad"], { input: "tok" });
assert.equal(bad.code, "org-invalid");
assert.ok(bad.stdin.readableLength > 0, "the token is still unread");
const good = await outcome(t, [...argv, "--org", "acme"], { input: "tok" });
assert.equal(good.kind, "reached");
assert.deepEqual(good.bound, ["acme"]);
assert.equal(good.calls[0].args.token, "tok");
});
test("--org is no longer a global option: a verb without a selector refuses it", async (t) => {
for (const argv of [["console", "start", "--org", "acme"], ["bus", "status", "--org", "acme"]]) {
const r = await outcome(t, argv);
assert.equal(r.kind, "refused", argv.join(" "));
assert.match(r.message, /--org/, argv.join(" "));
}
});
test("stop and launches list resolve the selected organization", async (t) => {
const { f } = await orgFixture(t, "acme");
const run = async (argv) => {
const term = io(f.env);
try { await main(argv, term, { system: { dataRoot: f.dataRoot } }); return { kind: "done", out: term.out.text }; }
catch (e) {
if (e instanceof OrgError) return { kind: "org", code: e.code };
if (e instanceof CliError) return { kind: "refused", exitCode: e.exitCode, message: e.message.split("\n")[0] };
throw e;
}
};
for (const [name, selector, expected] of CASES) {
const list = await run(["launches", "list", ...selector]);
if (expected === "acme") assert.deepEqual(list, { kind: "done", out: "no sessions\n" }, name);
else if (expected === "invalid") assert.deepEqual(list, { kind: "org", code: "org-invalid" }, name);
else assert.equal(list.exitCode, 4, name);
}
const reference = await run(["stop", "r1", "--business", "acme"]);
assert.notEqual(reference.kind, "org");
for (const selector of [["--org", "acme"], ["--org", "acme", "--business", "acme"]]) assert.deepEqual(await run(["stop", "r1", ...selector]), reference);
assert.deepEqual(await run(["stop", "r1", "--org", "beta"]), { kind: "org", code: "org-unknown" });
assert.equal((await run(["stop", "r1", "--org", "acme", "--business", "beta"])).exitCode, 4);
});
+55
View File
@@ -362,6 +362,61 @@ test('row 232: check --units external reads the system units, the default reads
}
});
// Rename r2 "Selector scopes and parser matrix": check takes --org as --business, each spelling once, both only
// with one value; a refused selector reads no unit, and --org is refused outside check as --business is.
test('rename r2: check takes --org as --business, once per spelling, and refuses before any unit read',async t=>{
const s=setup(t);assert.equal((await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies])).status,0);
assert.equal((await s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin,'--create'])).status,0);
const copy=join(s.copies,s.id),argv=join(s.root,'argv.log'),base=['check','--copies',s.copies,'--bin',s.bin];
s.fake(`echo "$*" >> '${argv}'; echo 'LoadState=loaded'; echo 'WorkingDirectory=${copy}'; case "$*" in *mosaic-bus*) echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start demo ; }';; *) echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';; esac\n`);
const shows=b=>[`--user show mosaic-bus@${b}.service -p LoadState -p WorkingDirectory -p ExecStart`,'--user show mosaic-webui.service -p LoadState -p WorkingDirectory -p ExecStart'];
for(const sel of [['--business','demo'],['--org','demo'],['--business','demo','--org','demo'],['--org','demo','--business','demo'],['--org','demo','--units','user']]){
const r=await s.run('stack-copy.sh',[...base,...sel]);
assert.equal(r.status,0,sel.join(' ')+r.stderr);assert.equal(r.stdout,`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy}\n`,sel.join(' '));
assert.deepEqual(readFileSync(argv,'utf8').trimEnd().split('\n'),shows('demo'),sel.join(' '));rmSync(argv);
}
// The selector may come before the other options too.
let r=await s.run('stack-copy.sh',['check','--org','demo','--copies',s.copies,'--bin',s.bin]);assert.equal(r.status,0,r.stderr);
assert.deepEqual(readFileSync(argv,'utf8').trimEnd().split('\n'),shows('demo'));rmSync(argv);
for(const [sel,status,message] of [
[['--business','demo','--org','beta'],4,'--business demo and --org beta conflict'],
[['--org','beta','--business','demo'],4,'--org beta and --business demo conflict'],
[['--business','demo','--business','demo'],4,'--business given twice'],
[['--org','demo','--org','demo'],4,'--org given twice'],
[['--org','demo','--business','demo','--org','demo'],4,'--org given twice'],
[['--org'],4,'--org needs one value'],
[['--business'],4,'--business needs one value'],
[['--org',''],4,'--org needs one value'],
[['--org','--units','user'],4,'--org needs one value, not --units'],
[['--business','-x'],4,'--business needs one value, not -x'],
[['--org','Demo'],1,'--org Demo is not valid'],
[['--business','demo_x'],1,'--business demo_x is not valid'],
[['--org','-demo'],4,'--org needs one value, not -demo'],
]){
r=await s.run('stack-copy.sh',[...base,...sel]);
assert.equal(r.status,status,sel.join(' '));assert.equal(r.stderr,`stack-copy: ${message}\n`,sel.join(' '));assert.equal(r.stdout,'',sel.join(' '));
assert.ok(!existsSync(argv),`${sel.join(' ')} reads no unit`);
}
// Positional text after a selector value stays a usage error; --org outside check is one too.
for(const args of [[...base,'--org','demo','beta'],['copy',s.id,'--copies',s.copies,'--org','demo'],['wrapper',s.id,'--copies',s.copies,'--bin',s.bin,'--org','demo'],['copy',s.id,'--business','demo']]){
r=await s.run('stack-copy.sh',args);assert.equal(r.status,4,args.join(' '));assert.match(r.stderr,/^usage: stack-copy\.sh /,args.join(' '));
assert.ok(!existsSync(argv),args.join(' '));
}
});
test('rename r2: check --instance takes --org for its business and still needs one selector',async t=>{
const s=setup(t);assert.equal((await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies])).status,0);
assert.equal((await s.run('bootstrap.sh',['--instance','alpha'])).status,0);
assert.equal((await s.run('stack-copy.sh',['wrapper',s.id,'--instance','alpha','--copies',s.copies,'--bin',s.bin])).status,0);
const copy=join(s.copies,s.id),argv=join(s.root,'argv.log'),base=['check','--instance','alpha','--copies',s.copies,'--bin',s.bin];
s.fake(`echo "$*" >> '${argv}'; echo 'LoadState=loaded';echo 'WorkingDirectory=${copy}';case "$*" in *mosaic-bus-alpha@demo*) echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start demo ; }';; *mosaic-webui-alpha*) echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';; *) exit 1;; esac\n`);
for(const sel of [['--org','demo'],['--business','demo','--org','demo']]){
const r=await s.run('stack-copy.sh',[...base,...sel]);assert.equal(r.status,0,sel.join(' ')+r.stderr);
assert.equal(r.stdout,`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy}\n`,sel.join(' '));
assert.equal(readFileSync(argv,'utf8').split('\n')[0],'--user show [email protected] -p LoadState -p WorkingDirectory -p ExecStart',sel.join(' '));rmSync(argv);
}
const r=await s.run('stack-copy.sh',base);assert.equal(r.status,4);assert.match(r.stderr,/^usage: stack-copy\.sh /);assert.ok(!existsSync(argv));
});
test('Vikunja 614: bundle and checkout copy leave an unreadable caller directory before find',async t=>{
const s=setup(t),cwd=join(s.root,'unreadable');mkdirSync(cwd);
const bundle=join(s.root,'release.bundle');git(s.repo,'bundle','create','-q',bundle,'refactor');
+1 -1
View File
@@ -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/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/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});
// 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});
+105 -3
View File
@@ -9,7 +9,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {execFileSync,spawn,spawnSync} from 'node:child_process';
import {mkdirSync,writeFileSync,readFileSync,existsSync,readdirSync,rmSync,chmodSync,copyFileSync,symlinkSync,statSync} from 'node:fs';
import {mkdirSync,writeFileSync,readFileSync,existsSync,readdirSync,rmSync,chmodSync,copyFileSync,symlinkSync,statSync,realpathSync} from 'node:fs';
import {DatabaseSync} from 'node:sqlite';
import {join,dirname} from 'node:path';
import {once} from 'node:events';
@@ -47,7 +47,7 @@ function fakeSudo(K){
// case2-unit.sh: logs its argv, then installs the copy's units as the fixture's user units (its
// WorkingDirectory under the stack's copies) and starts the optional proxy, bus, then Console,
// as case2-unit.sh does. faults/render-fails-<copy> exits 1 first; faults/render-probe-<copy> (one-shot) first runs
// as case2-unit.sh does. The business is --business's value, or --org's when that is the spelling. faults/render-fails-<copy> exits 1 first; faults/render-probe-<copy> (one-shot) first runs
// probe.sh, a second driver, and records its exit and stderr.
function fakeRender(K){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),cp=process.getBuiltinModule('node:child_process');
@@ -64,7 +64,7 @@ function fakeRender(K){
const sh=(n,...x)=>cp.execFileSync('bash',[path.join(get('--home'),'src','mosaic-stack','scripts',n),...x],{stdio:['ignore','ignore','inherit']});
sh('bus-service.sh','install','--copy',copy,'--copies',copies,...(pre.includes('--pm')?['--pm']:[]),'--claude',get('--claude'));
sh('webui-service.sh','install','--copy',copy,'--copies',copies,...serve);
const names=[`mosaic-bus@${get('--business')}.service`,'mosaic-webui.service'];
const names=[`mosaic-bus@${get(pre.includes('--business')?'--business':'--org')}.service`,'mosaic-webui.service'];
if(pre.includes('--proxy')){
fs.writeFileSync(path.join(process.env.XDG_CONFIG_HOME,'systemd','user','mosaic-proxy.service'),`[Service]\nWorkingDirectory=${copies}/${copy}\nExecStart=${get('--node')} ${copies}/${copy}/packages/model-proxy/src/run.mjs\n`);
names.unshift('mosaic-proxy.service');
@@ -592,6 +592,108 @@ test('#385 R2-F1 controls: library args without --send and a factory args file p
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);
});
// Rename r2, "Selector scopes and parser matrix": --org is the same option as --business before -- (the
// unit's flags) and after it (the serve flags). selArgs moves the business into each scope as given: the
// unit's selectors first, the serve flags' last, the other lines as useed wrote them.
const selArgs=(e,outer,serve)=>{
const l=e.argsText.trimEnd().split('\n'),cut=l.indexOf('--'),drop=(a)=>{a.splice(a.indexOf('--business'),2);return a};
return [...outer,...drop(l.slice(0,cut)),'--',...drop(l.slice(cut+1)),...serve].join('\n')+'\n';
};
test('rename r2: --org or --business names the business before and after --, read_args passes either spelling or both, matched, in either order',async(t)=>{
const e=await useed(t);
// A bundle that does not exist stops the run at its first step after read_args, with nothing stopped.
const gone=join(e.root,'no.bundle');
for(const [outer,serve] of [
[['--org','acme'],['--org','acme']],
[['--business','acme'],['--org','acme']],
[['--org','acme'],['--business','acme']],
[['--business','acme','--org','acme'],['--org','acme','--business','acme']],
[['--org','acme','--business','acme'],['--business','acme','--org','acme']],
]){
const why=`${outer.join(' ')} -- ${serve.join(' ')}`,text=selArgs(e,outer,serve);
writeFileSync(e.args,text);chmodSync(e.args,0o640);
const n=existsSync(e.slog)?slog(e).length:0;
const r=await drive(e,C1,'upgrade','--instance','dev','--bundle',gone,'--sha',C1,...TO);assert.equal(r.code,3,`${why}: ${r.all}`);
assert.deepEqual(slog(e).slice(n).map((x)=>x.split(' ')[0]),['start','fetch','result'],why);
assert.match(slog(e).at(-1),/^result exit=3 fetch-failed /,why);
assert.equal(readFileSync(e.args,'utf8'),text,why);
}
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);
});
test('rename r2: an upgrade from an args file that spells both selectors --org keeps every line but --copy\'s value, and the render gets them as written',async(t)=>{
const e=await useed(t);
e.argsText=selArgs(e,['--org','acme'],['--org','acme']);writeFileSync(e.args,e.argsText);
arm(e,'confirm.sh',`cp ${e.args} ${join(e.sd,'args-at-confirm')}\n`);
const r=await upgrade(e,C1);assert.equal(r.code,0,r.all);
assert.equal(r.out.split('\n').at(-2),`stack-upgrade: upgrade ok: ${C1} is live for acme`);
assert.deepEqual(names(e),[...PRE,...A2,'stage','render','confirm','args','result']);
assert.equal(readFileSync(join(e.sd,'args-at-confirm'),'utf8'),e.argsText);
const after=e.argsText.replace(`--copy\n${B}\n`,`--copy\n${C1}\n`);assert.notEqual(after,e.argsText);
assert.equal(readFileSync(e.args,'utf8'),after);
assert.equal(statSync(e.args).mode&0o777,0o640);assert.deepEqual(readdirSync(e.etc),['dev.args']);
assert.deepEqual(lines(join(e.sd,'render.log')).map((x)=>JSON.parse(x)),[after.trimEnd().split('\n')]);
// The stack's user's steps get the business as a word, never a spelling.
for(const k of ['activate-plan','rehearse','activate','confirm'])assert.ok(JSON.parse(lines(join(e.sd,'sudo.log')).find((x)=>JSON.parse(x).key===k)).cmd.includes('acme'),k);
assert.ok(!lines(join(e.sd,'sudo.log')).some((x)=>JSON.parse(x).cmd.includes('--org')));
assert.deepEqual(live(e),['active','active',C1,C1]);
});
test('rename r2: a bad selector in either scope, or scopes that name different businesses, refuse args-unreadable before anything stops',async(t)=>{
const e=await useed(t),f=e.args;
const l=e.argsText.trimEnd().split('\n'),pre=l.slice(0,l.indexOf('--')).filter((x,i,a)=>x!=='--business'&&a[i-1]!=='--business');
const before=(m)=>`${f} before --: ${m}`,after=(m)=>`${f} after --: ${m}`;
const cases=[
['unit: two spellings, two businesses',selArgs(e,['--business','acme','--org','beta'],['--business','acme']),before('--business acme and --org beta conflict')],
['unit: two businesses, the other order',selArgs(e,['--org','acme','--business','beta'],['--business','acme']),before('--org acme and --business beta conflict')],
['unit: --org twice',selArgs(e,['--org','acme','--org','acme'],['--business','acme']),before('--org given twice')],
['unit: --business twice',selArgs(e,['--business','acme','--business','acme'],['--business','acme']),before('--business given twice')],
['unit: --org at the end',[...pre,'--org'].join('\n')+'\n',before('--org needs one value')],
['unit: --org with an empty line',selArgs(e,['--org',''],['--business','acme']),before('--org needs one value')],
['unit: --org before a flag',selArgs(e,['--org'],['--business','acme']),before('--org needs one value, not --user')],
['unit: --business before --',[...pre,'--business','--','--port',e.port,'--business','acme'].join('\n')+'\n',before('--business needs one value, not --')],
['unit: an invalid business',selArgs(e,['--org','Acme'],['--org','Acme']),before('--org Acme is not valid')],
['unit: --org as --home\'s value is no selector',selArgs(e,['--business','acme'],['--business','acme']).replace(`--home\n${e.env.HOME}\n`,'--home\n--org\n'),`${f} lacks a plain --user, --home, --business, --node or --copy`],
['serve: two spellings, two businesses',selArgs(e,['--business','acme'],['--business','acme','--org','beta']),after('--business acme and --org beta conflict')],
['serve: two businesses, the other order',selArgs(e,['--business','acme'],['--org','beta','--business','acme']),after('--org beta and --business acme conflict')],
['serve: --org twice',selArgs(e,['--business','acme'],['--org','acme','--org','acme']),after('--org given twice')],
['serve: --business twice',selArgs(e,['--org','acme'],['--business','acme','--business','acme']),after('--business given twice')],
['serve: --org at the end',selArgs(e,['--business','acme'],['--org']),after('--org needs one value')],
['serve: --org before a flag',selArgs(e,['--business','acme'],['--org','--sso']),after('--org needs one value, not --sso')],
['serve: an invalid business',selArgs(e,['--business','acme'],['--org','acme_x']),after('--org acme_x is not valid')],
['serve: another business',selArgs(e,['--business','acme'],['--org','beta']),`${f} names business acme before -- and beta after it`],
['serve: another business, unit spelled --org',selArgs(e,['--org','acme'],['--business','beta']),`${f} names business acme before -- and beta after it`],
['serve: no selector',selArgs(e,['--business','acme'],[]),`${f} needs --business or --org after --, as case2-unit.sh does`],
['serve: --business as --allow-user\'s value is no selector',selArgs(e,['--business','acme'],['--allow-user','--business']),`${f} needs --business or --org after --, as case2-unit.sh does`],
['serve: no -- at all',[...pre,'--org','acme'].join('\n')+'\n',`${f} needs --business or --org after --, as case2-unit.sh does`],
['serve: a flag case2-unit.sh does not take',selArgs(e,['--business','acme'],['--business','acme','--tester']),`${f} has --tester after --, which case2-unit.sh does not take`],
];
for(const [why,text,msg] of cases){
writeFileSync(e.args,text);chmodSync(e.args,0o640);
const n=existsSync(e.slog)?slog(e).length:0;
const r=await upgrade(e,C1);assert.equal(r.code,3,`${why}: ${r.all}`);
assert.equal(r.err.split('\n').at(-2),`stack-upgrade: args-unreadable: ${msg} (nothing was stopped)`,why);
assert.deepEqual(slog(e).slice(n).map((x)=>x.split(' ')[0]),['start','result'],why);
assert.equal(readFileSync(e.args,'utf8'),text,why);assert.deepEqual(readdirSync(e.etc),['dev.args'],why);
}
// A rollback reads the same file the same way.
writeFileSync(e.args,cases[0][1]);
const rb=await drive(e,B,'rollback','--instance','dev','--to',B,...TO);assert.equal(rb.code,3,rb.all);
assert.equal(rb.err.split('\n').at(-2),`stack-upgrade: args-unreadable: ${cases[0][2]} (nothing was stopped)`);
assert.ok(!existsSync(join(e.sd,'sudo.log')));assert.ok(!existsSync(join(e.sd,'render.log')));
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);
});
test('rename r2: a checkout without scripts/selector.sh refuses checkout-unreadable before reading the args file',async(t)=>{
const e=await useed(t),d=join(realpathSync(e.root),'lone');
mkdirSync(join(d,'scripts'),{recursive:true});copyFileSync(join(checkout(C1),'scripts','stack-upgrade.sh'),join(d,'scripts','stack-upgrade.sh'));
const r=spawnSync('bash',[join(d,'scripts','stack-upgrade.sh'),'upgrade','--instance','dev','--bundle',BUNDLE,'--sha',C1,...TO],{env:e.denv,encoding:'utf8'});
assert.equal(r.status,3,r.stdout+r.stderr);
assert.equal(r.stderr.split('\n').at(-2),`stack-upgrade: checkout-unreadable: cannot read ${d}/scripts/selector.sh (nothing was stopped)`);
assert.deepEqual(slog(e).map((x)=>x.split(' ')[0]),['start','result']);
assert.ok(!existsSync(join(e.sd,'sudo.log')));assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));argsKept(e);
});
// R3-F1 (#385, PR 1836): a library host rendered by base case2-unit.sh has --send in its installed Console
// unit's ExecStart, not only in its args file. With the args line removed, A2 would stop both units and A4
// and render-old would both refuse the installed unit (check_unit's ExecStart and the full-text match), so
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { initProject, readSpec } from '../src/index.mjs';
const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgsign=false', '-c', 'user.name=fixture', '-c', '[email protected]', '-C', repo, ...args], { encoding: 'utf8' });
const bytes = (repo, rev, path) => git(repo, 'show', `${rev}:${path}`);
test('v1 metadata at each reachable committed revision is checked before reading', async t => {
const root = mkdtempSync(join(tmpdir(), 'revision-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const business = { id: 'acme', human: 'jason', projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') } } };
const ctx = { business, project: 'stack', dataRoot: root };
const { repo, commit: initial } = await initProject(ctx);
const original = bytes(repo, initial, 'project.json');
const meta = JSON.parse(original);
assert.deepEqual({ ...meta, createdAt: '<time>' }, { layoutVersion: 1, business: 'acme', project: 'stack', name: 'stack', createdAt: '<time>' });
assert.match(meta.createdAt, /^\d{4}-\d\d-\d\dT/);
assert.equal(bytes(repo, initial, 'templates/project.json'), readFileSync(new URL('../templates/project.json', import.meta.url), 'utf8'));
const save = (metadata, content) => {
writeFileSync(join(repo, 'project.json'), JSON.stringify(metadata, null, 2) + '\n');
writeFileSync(join(repo, 'spec/north-star.md'), content);
git(repo, 'add', 'project.json', 'spec/north-star.md');
git(repo, 'commit', '-m', 'fixture revision');
return git(repo, 'rev-parse', 'HEAD').trim();
};
const valid = save({ ...meta, name: 'Second' }, '# Second\n');
const badOrg = save({ ...meta, business: 'other' }, '# Wrong org\n');
const badProject = save({ ...meta, project: 'other' }, '# Wrong project\n');
const badMix = save({ ...meta, organization: 'acme' }, '# Mixed\n');
const head = save(meta, '# Head\n');
for (const rev of [initial, valid, head]) assert.equal((await readSpec({ ...ctx, commit: rev })).revision, rev);
assert.equal((await readSpec({ ...ctx, commit: valid })).content, '# Second\n');
for (const rev of [badOrg, badProject, badMix]) await assert.rejects(readSpec({ ...ctx, commit: rev }), { code: 'project-invalid' });
writeFileSync(join(repo, 'project.json'), '{"business":"dirty"}');
assert.equal((await readSpec({ ...ctx, commit: initial })).revision, initial);
assert.equal(bytes(repo, initial, 'project.json'), original);
});
+9 -4
View File
@@ -23,6 +23,7 @@ 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';
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 {
@@ -31,8 +32,15 @@ try {
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
let sendOn = false, ssoOn = false, testerSocket = false, devOwner = false, devBind = null;
const devNames = [];
// --org and --business are one selector in this parser's scope (rename design r2). A flag-shaped
// or invalid value keeps the old wording.
const selector = selectorScope({
valid: id => ID_PATTERN.test(id),
refuse: (kind, why) => { throw new Error(kind === 'invalid' || kind === 'flag' ? 'business must be a business id' : why); },
});
while (args.length) {
const flag = args.shift();
if (selector.owns(flag)) { selector.take(flag, args.shift()); continue; }
// Row 76 (#1570): the one flag without a value.
if (flag === '--send') { sendOn = true; continue; }
// Q175 phase 1 (#1690): the Console signs people in itself, from console.sso in config.json.
@@ -54,10 +62,6 @@ try {
if (!dir) throw new Error('--checkout must be the absolute path of a directory');
options.checkout = value;
}
else if (flag === '--business') {
if (!ID_PATTERN.test(value)) throw new Error('business must be a business id');
options.business = value;
}
// Row 74 (#1568): the preview mode, all three or none, checked before any config is read.
else if (flag === '--public-host') preview.publicHost = value;
else if (flag === '--auth-header') preview.authHeader = value;
@@ -72,6 +76,7 @@ try {
} else if (flag === '--dev-allow-host') devNames.push(value);
else throw new Error(`unknown option: ${flag}`);
}
if (selector.given) options.business = selector.value;
if (devOwner && (ssoOn || testerSocket || Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--dev-local-owner cannot be combined with --sso, preview, proof or --tester-socket');
const lan = devLanOptions({ devOwner, bind: devBind, names: devNames });
const instance = devOwner ? devOwnerInstance() : null;
@@ -0,0 +1,397 @@
{
"business": {
"id": "acme",
"human": "jason",
"arbiters": {
"delivery": "pm",
"technical": "cto"
},
"projects": [
"stack"
],
"actions": [
"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",
"git.push.remote",
"host.sudo",
"review.request",
"review.verdict",
"record.write",
"message.send",
"message.external",
"role.launch",
"role.revoke",
"role.end",
"organization.change",
"instance.name",
"role.hire",
"role.fire",
"credential.mint",
"spend",
"deploy",
"data.delete",
"network.expose",
"account.create",
"policy.change",
"prd.approve",
"decision.resolve.technical",
"library.write"
],
"vars": {
"tracker.baseUrl": "http://127.0.0.1:3456",
"tracker.pollSeconds": 60,
"gitea.baseUrl": "https://git.example"
},
"instances": [
{
"instance": "pm",
"definition": "pm",
"holder": "sage",
"vars": {
"harness": "pi",
"model": "claude-opus-5-5"
},
"authority": {
"task.create": "within",
"project.bootstrap": "gated",
"task.assign": "within",
"task.schedule": "within",
"task.update.assigned": "within",
"task.close": "within",
"task.reassign": "within",
"task.scope.change": "cross",
"task.priority.change": "cross",
"git.push.working": "gated",
"git.push.protected": "gated",
"git.merge.protected": "gated",
"git.push.remote": "gated",
"host.sudo": "gated",
"review.request": "gated",
"review.verdict": "gated",
"record.write": "within",
"message.send": "within",
"message.external": "gated",
"role.launch": "gated",
"role.revoke": "gated",
"role.end": "gated",
"organization.change": "gated",
"instance.name": "gated",
"role.hire": "gated",
"role.fire": "gated",
"credential.mint": "gated",
"spend": "gated",
"deploy": "gated",
"data.delete": "gated",
"network.expose": "gated",
"account.create": "gated",
"policy.change": "gated",
"prd.approve": "gated",
"decision.resolve.technical": "gated",
"library.write": "gated"
},
"refused": null
},
{
"instance": "cto",
"definition": "cto",
"holder": "darkwing",
"vars": {
"harness": "claude-code"
},
"authority": {
"task.create": "gated",
"project.bootstrap": "gated",
"task.assign": "gated",
"task.schedule": "gated",
"task.update.assigned": "within",
"task.close": "gated",
"task.reassign": "gated",
"task.scope.change": "cross",
"task.priority.change": "gated",
"git.push.working": "gated",
"git.push.protected": "gated",
"git.merge.protected": "gated",
"git.push.remote": "gated",
"host.sudo": "gated",
"review.request": "within",
"review.verdict": "gated",
"record.write": "within",
"message.send": "within",
"message.external": "gated",
"role.launch": "gated",
"role.revoke": "gated",
"role.end": "gated",
"organization.change": "gated",
"instance.name": "gated",
"role.hire": "gated",
"role.fire": "gated",
"credential.mint": "gated",
"spend": "gated",
"deploy": "gated",
"data.delete": "gated",
"network.expose": "gated",
"account.create": "gated",
"policy.change": "gated",
"prd.approve": "gated",
"decision.resolve.technical": "within",
"library.write": "gated"
},
"refused": null
},
{
"instance": "coder",
"definition": "coder",
"holder": null,
"vars": {
"harness": "pi",
"limits.network": "none"
},
"authority": {
"task.create": "gated",
"project.bootstrap": "gated",
"task.assign": "gated",
"task.schedule": "gated",
"task.update.assigned": "within",
"task.close": "gated",
"task.reassign": "gated",
"task.scope.change": "cross",
"task.priority.change": "gated",
"git.push.working": "within",
"git.push.protected": "gated",
"git.merge.protected": "gated",
"git.push.remote": "gated",
"host.sudo": "gated",
"review.request": "within",
"review.verdict": "gated",
"record.write": "within",
"message.send": "within",
"message.external": "gated",
"role.launch": "gated",
"role.revoke": "gated",
"role.end": "gated",
"organization.change": "gated",
"instance.name": "gated",
"role.hire": "gated",
"role.fire": "gated",
"credential.mint": "gated",
"spend": "gated",
"deploy": "gated",
"data.delete": "gated",
"network.expose": "gated",
"account.create": "gated",
"policy.change": "gated",
"prd.approve": "gated",
"decision.resolve.technical": "gated",
"library.write": "gated"
},
"refused": null
},
{
"instance": "reviewer",
"definition": "reviewer",
"holder": null,
"vars": {},
"authority": {
"task.create": "gated",
"project.bootstrap": "gated",
"task.assign": "gated",
"task.schedule": "gated",
"task.update.assigned": "within",
"task.close": "gated",
"task.reassign": "gated",
"task.scope.change": "gated",
"task.priority.change": "gated",
"git.push.working": "gated",
"git.push.protected": "gated",
"git.merge.protected": "gated",
"git.push.remote": "gated",
"host.sudo": "gated",
"review.request": "gated",
"review.verdict": "within",
"record.write": "within",
"message.send": "within",
"message.external": "gated",
"role.launch": "gated",
"role.revoke": "gated",
"role.end": "gated",
"organization.change": "gated",
"instance.name": "gated",
"role.hire": "gated",
"role.fire": "gated",
"credential.mint": "gated",
"spend": "gated",
"deploy": "gated",
"data.delete": "gated",
"network.expose": "gated",
"account.create": "gated",
"policy.change": "gated",
"prd.approve": "gated",
"decision.resolve.technical": "gated",
"library.write": "gated"
},
"refused": null
},
{
"instance": "chief",
"definition": "chief-of-staff",
"holder": null,
"vars": {},
"authority": {
"task.create": "gated",
"project.bootstrap": "gated",
"task.assign": "gated",
"task.schedule": "gated",
"task.update.assigned": "gated",
"task.close": "gated",
"task.reassign": "gated",
"task.scope.change": "gated",
"task.priority.change": "gated",
"git.push.working": "gated",
"git.push.protected": "gated",
"git.merge.protected": "gated",
"git.push.remote": "gated",
"host.sudo": "gated",
"review.request": "gated",
"review.verdict": "gated",
"record.write": "within",
"message.send": "within",
"message.external": "gated",
"role.launch": "within",
"role.revoke": "gated",
"role.end": "within",
"organization.change": "within",
"instance.name": "within",
"role.hire": "within",
"role.fire": "within",
"credential.mint": "gated",
"spend": "gated",
"deploy": "gated",
"data.delete": "gated",
"network.expose": "gated",
"account.create": "gated",
"policy.change": "gated",
"prd.approve": "gated",
"decision.resolve.technical": "gated",
"library.write": "gated"
},
"refused": null
}
],
"launch": {
"by": "chief",
"instances": [
"coder",
"reviewer"
],
"models": {
"claude-opus-5-5": {
"provider": "zai",
"model": "claude-opus-5-5"
},
"claude-sonnet-5-5": {
"provider": "zai",
"model": "claude-sonnet-5-5"
},
"glm-5.3-flash": {
"provider": "zai",
"model": "glm-5.3-flash"
}
},
"limits": {
"models": {
"zai": {
"claude-opus-5-5": 2,
"claude-sonnet-5-5": 4
}
}
},
"threadIdleSeconds": 900
},
"credentials": [
{
"service": "gitea",
"account": null,
"role": "pm",
"dateKind": "rotateBy",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "vikunja",
"account": "bot-acme-pm",
"role": "pm",
"dateKind": "expires",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "gitea",
"account": null,
"role": "cto",
"dateKind": "rotateBy",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "vikunja",
"account": "bot-acme-cto",
"role": "cto",
"dateKind": "expires",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "gitea",
"account": null,
"role": "coder",
"dateKind": "rotateBy",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "vikunja",
"account": "bot-acme-coder",
"role": "coder",
"dateKind": "expires",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "gitea",
"account": null,
"role": "reviewer",
"dateKind": "rotateBy",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "vikunja",
"account": "bot-acme-reviewer",
"role": "reviewer",
"dateKind": "expires",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "vikunja",
"account": "bot-acme-sync",
"role": "tracker sync",
"dateKind": "expires",
"date": "2099-01-01",
"state": "valid"
}
],
"appearance": null
},
"at": "2026-10-10T12:00:00.000Z"
}
@@ -0,0 +1,67 @@
// The organization selector on `cli.mjs serve` (rename design r2, "Selector scopes and parser
// matrix"): --org and --business are one option in serve's scope. serve.mjs and bus.mjs are
// stand-ins loaded through a hook, so nothing listens and no bus is called; the bus stand-in
// records the organization the Console would bind.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmp, fixture } from '../../cli/tests/helpers.mjs';
const CLI = new URL('../src/cli.mjs', import.meta.url).pathname;
function serve(t, args) {
const root = tmp(t, 'sel-'), f = fixture(root), log = join(root, 'bound.jsonl'), hook = join(root, 'hooks.mjs');
writeFileSync(log, '');
const serveSource = "export const DEFAULT_BOARD='http://127.0.0.1:9';export const previewOptions=()=>null;export const proofOptions=()=>null;export async function startServer(){return {address:()=>({port:0}),close:()=>{}};}";
const busSource = `import {appendFileSync} from 'node:fs';export class BusReadError extends Error{};export const busCaller=o=>{appendFileSync(${JSON.stringify(log)},JSON.stringify({business:o.business})+'\\n');return async()=>[];};export const organizationConsole=()=>{throw Error('organizationConsole outside dev-owner')};export const busBusiness=()=>{throw Error('busBusiness outside --sso')};export const userCaller=()=>{throw Error('userCaller outside --sso')};export const consoleCall=()=>{throw Error('consoleCall outside --sso')};`;
writeFileSync(hook, `import {registerHooks} from 'node:module';registerHooks({load(url,context,next){
if(url.endsWith('/webui/src/serve.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(serveSource)}};
if(url.endsWith('/webui/src/bus.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(busSource)}};
return next(url,context);
}});`);
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', ...args], { env: { ...f.env, NODE_OPTIONS: `--import=${hook}` }, encoding: 'utf8', timeout: 10000 });
return { ...r, bound: readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l).business) };
}
const accepted = (r, name) => {
assert.equal(r.status, 0, `${name}: ${r.stderr}`);
assert.deepEqual(r.bound, ['acme'], name);
assert.match(r.stdout, /^Bus: acme$/m, name);
};
const refused = (r, pattern, name) => {
assert.equal(r.status, 2, `${name}: ${r.stdout}${r.stderr}`);
assert.match(r.stderr, pattern, name);
assert.deepEqual(r.bound, [], `${name} bound nothing`);
};
test('serve takes --org or --business, alone or matching in either order', t => {
for (const args of [['--business', 'acme'], ['--org', 'acme'], ['--business', 'acme', '--org', 'acme'], ['--org', 'acme', '--business', 'acme']]) accepted(serve(t, args), args.join(' '));
});
test('serve refuses unequal, repeated, missing, flag-shaped and invalid selectors before any read', t => {
for (const [args, pattern] of [
[['--business', 'acme', '--org', 'beta'], /^refused: --business acme and --org beta conflict/],
[['--org', 'acme', '--business', 'beta'], /^refused: --org acme and --business beta conflict/],
[['--business', 'acme', '--business', 'acme'], /^refused: --business given twice/],
[['--org', 'acme', '--org', 'acme'], /^refused: --org given twice/],
[['--org'], /^refused: --org needs one value/],
[['--business'], /^refused: --business needs one value/],
[['--org', ''], /^refused: --org needs one value/],
[['--org', '--send'], /^refused: business must be a business id/],
[['--business', '-x'], /^refused: business must be a business id/],
[['--org', 'Acme'], /^refused: business must be a business id/],
[['--org', 'acme/../x'], /^refused: business must be a business id/],
]) refused(serve(t, args), pattern, args.join(' '));
});
test('a selector spelling taken as another option\'s value is that option\'s text', t => {
const r = serve(t, ['--board', '--org', '--business', 'acme']);
accepted(r, 'board');
assert.match(r.stdout, /^Board: --org$/m);
// Without a selector the Console keeps the running host's business.
const none = serve(t, []);
assert.equal(none.status, 0, none.stderr);
assert.deepEqual(none.bound, [null]);
});
@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:net';
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { businessDoc, REPO, REPO_ROLES, writeJson } from '../../business/tests/helpers.mjs';
import { consoleReads } from '../src/reads.mjs';
import { startServer } from '../src/serve.mjs';
import { humanCall, consoleCall } from '../src/bus.mjs';
const fixed = Date.parse('2026-10-10T12:00:00.000Z');
const golden = new URL('./fixtures/rename-a/business-response.json', import.meta.url);
test('v1 /api/business response preserves its complete JSON shape and redaction', async t => {
const root = mkdtempSync(join(tmpdir(), 'wire-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const configDir = join(root, 'config'), dataRoot = join(root, 'data');
const doc = businessDoc(root);
writeJson(join(configDir, 'businesses/acme.json'), doc);
const system = { configVersion: 1, environment: 'development', dataRoot, execution: { backend: 'docker', provider: 'zai', model: 'glm-5.3-flash', adapter: 'mock' } };
const reads = consoleReads({ root: REPO, configDir, rolesDir: REPO_ROLES, system, business: 'acme', now: () => fixed });
const server = await startServer({ port: 0, reads });
t.after(() => server.close());
const response = await fetch(`http://127.0.0.1:${server.address().port}/api/business`);
assert.equal(response.status, 200);
const body = await response.text();
assert.doesNotMatch(body, /placeholder-not-a-token|secret|"file"|"botId"/);
assert.deepEqual(JSON.parse(body), JSON.parse(readFileSync(golden, 'utf8')));
});
test('v1 Console human and owner reads send their exact business-bound IPC envelopes', async t => {
const root = mkdtempSync(join(tmpdir(), 'ipc-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const socket = join(root, 's');
const received = [];
const server = createServer(conn => {
let input = '';
conn.on('data', chunk => {
input += chunk;
if (!input.includes('\n')) return;
received.push(JSON.parse(input));
conn.end('{"ok":true,"result":{"answer":"fixture"}}\n');
});
});
await new Promise((resolve, reject) => server.listen(socket, resolve).once('error', reject));
t.after(() => server.close());
const env = { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR };
const human = humanCall({ socket, business: 'acme', env });
assert.deepEqual(await human('instances', {}), { answer: 'fixture' });
assert.deepEqual(await human('limits', {}), { answer: 'fixture' });
const owner = consoleCall({ socket, dataRoot: root, business: 'acme', env });
assert.deepEqual(await owner('request.draft.console', { request: 'r1' }, { subject: { provider: 'local', iss: 'fixture', sub: 'owner' } }), { answer: 'fixture' });
for (const [i, verb] of ['instances', 'limits'].entries()) {
const { human: proof, ...rest } = received[i];
assert.deepEqual(rest, { verb, args: {} });
assert.deepEqual(Object.keys(proof), ['business', 'pid', 'startTime', 'nonce']);
assert.equal(proof.business, 'acme');
assert.ok(Number.isInteger(proof.pid));
assert.match(String(proof.startTime), /^\d+$/);
assert.match(proof.nonce, /^[a-f0-9]{64}$/);
}
const { console: proof, ...rest } = received[2];
assert.deepEqual(rest, { verb: 'request.draft.console', args: { request: 'r1' } });
assert.deepEqual(Object.keys(proof), ['business', 'provider', 'iss', 'sub', 'pid', 'startTime', 'nonce']);
assert.deepEqual([proof.business, proof.provider, proof.iss, proof.sub], ['acme', 'local', 'fixture', 'owner']);
assert.ok(Number.isInteger(proof.pid));
assert.match(String(proof.startTime), /^\d+$/);
assert.match(proof.nonce, /^[a-f0-9]{64}$/);
});
+46
View File
@@ -258,6 +258,52 @@ test('a value the unit cannot carry is refused, for every flag', async (t) => {
}
});
// Rename design r2, "Selector scopes and parser matrix": --org and --business are one selector in
// the script's flags, and the unit always carries the canonical --business.
test('render takes --org or --business, alone or matching in either order, and writes --business', async (t) => {
const dir = tmp(t);
const want = `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7332 --business acme`;
for (const args of [['--business', 'acme'], ['--org', 'acme'], ['--business', 'acme', '--org', 'acme'], ['--org', 'acme', '--business', 'acme']]) {
const r = await run(dir, ['render', ...args]);
assert.equal(r.status, 0, `${args.join(' ')}: ${r.stderr}`);
assert.equal(execStart(r.stdout), want, args.join(' '));
}
// A selector spelling another flag consumes is that flag's value, so the board check refuses it.
const board = await run(dir, ['render', '--board', '--org', '--business', 'acme']);
assert.equal(board.status, 1);
assert.match(board.stderr, /board/);
});
test('render, install, uninstall and status refuse a bad selector before anything is read or written', async (t) => {
const dir = tmp(t);
const unitDir = join(dir, 'units');
const usageText = (await run(dir, [])).stderr;
for (const [args, status, stderr] of [
[['--business', 'acme', '--org', 'beta'], 4, 'webui-service: --business acme and --org beta conflict\n'],
[['--org', 'acme', '--business', 'beta'], 4, 'webui-service: --org acme and --business beta conflict\n'],
[['--business', 'acme', '--business', 'acme'], 4, 'webui-service: --business given twice\n'],
[['--org', 'acme', '--org', 'acme'], 4, 'webui-service: --org given twice\n'],
[['--org'], 4, usageText],
[['--business'], 4, usageText],
[['--org', ''], 4, usageText],
[['--org', '--send'], 1, 'webui-service: business must be a business id: --send\n'],
[['--business', '-x'], 1, 'webui-service: business must be a business id: -x\n'],
[['--org', 'Acme'], 1, 'webui-service: business must be a business id: Acme\n'],
[['--org', 'a'.repeat(65)], 1, `webui-service: business must be a business id: ${'a'.repeat(65)}\n`],
[['--org', 'acme/../x'], 1, 'webui-service: business must be a business id: acme/../x\n'],
]) {
for (const verb of [['render'], ['install', ...INSTALL, '--dir', unitDir, '--no-reload'], ['uninstall', '--dir', unitDir, '--no-reload'], ['status', '--dir', unitDir]]) {
const r = await run(dir, [...verb, ...args]);
const label = `${verb[0]} ${args.join(' ')}`;
assert.equal(r.status, status, `${label}: ${r.stderr}`);
assert.equal(r.stdout, '', label);
assert.equal(r.stderr, stderr, label);
}
}
assert.equal(existsSync(unitDir), false, 'nothing was installed');
assert.equal(existsSync(join(dir, '.quiet', 'calls.txt')), false, 'systemctl was never called');
});
test('the rendered unit names none of the human proof agent markers and sets no agent variable', async (t) => {
const dir = tmp(t);
const source = readFileSync(join(REPO, 'packages', 'bus', 'src', 'human.mjs'), 'utf8');
+20 -4
View File
@@ -32,6 +32,12 @@
# Exit 0 when every F check passes, 1 when one fails, 2 on usage or a refusal (a bad args file, or
# --root misused). A P check never changes the exit.
#
# --org ID is the same option as --business ID, in three scopes: this script's own arguments, the args
# file's unit arguments before --, and its serve arguments after it. In each scope a spelling may appear
# once, and the other spelling must carry the same value; the args file needs a selector in both of its
# scopes, and all three must name the same business. The talk and precheck calls still pass --business,
# which every copy takes.
#
# Test seam, for packages/cli/tests/case1-accept.test.mjs only. Root takes none:
# --root DIR prefixes the args file, /etc, /proc, the stack's home, the host check and node, and
# reads the account database from DIR/etc/passwd and DIR/etc/group instead of getent
@@ -39,10 +45,15 @@ set -uo pipefail
usage() { echo "usage: case1-accept.sh --business ID --args FILE --claude-pin PIN --node-pin vN.N.N" >&2; exit 2; }
refuse() { echo "case1-accept: refusing: $*" >&2; exit 2; }
# selector.sh defines functions only, from this script's own root-owned checkout; parsing runs no program.
case "${BASH_SOURCE[0]}" in */*) SELECTOR_SH="${BASH_SOURCE[0]%/*}/selector.sh" ;; *) SELECTOR_SH=./selector.sh ;; esac
. "$SELECTOR_SH" || refuse "cannot read $SELECTOR_SH"
business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]*$ ]]; }
invocation_refused() { case "$1" in missing|flag|invalid) usage ;; *) refuse "$2" ;; esac; }
BUSINESS="" ARGS_FILE="" CLAUDE_PIN="" NODE_PIN="" ROOT=""
while [ $# -gt 0 ]; do
case "$1" in
--business) [ $# -ge 2 ] && [[ "$2" =~ ^[a-z0-9][a-z0-9-]*$ ]] || usage; BUSINESS="$2"; shift 2 ;;
--business|--org) selector_take invocation id invocation_refused business_id_ok "$1" "${@:2:1}"; BUSINESS="$2"; shift 2 ;;
--args) [ $# -ge 2 ] && [[ "$2" =~ ^/[A-Za-z0-9/._+-]+$ ]] || usage; ARGS_FILE="$2"; shift 2 ;;
--claude-pin) [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage; CLAUDE_PIN="$2"; shift 2 ;;
--node-pin) [ $# -ge 2 ] && [[ "$2" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage; NODE_PIN="$2"; shift 2 ;;
@@ -67,14 +78,18 @@ st="$(stat -c '%u %a' -- "$f")" || refuse "cannot stat $ARGS_FILE"
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) ||
refuse "$ARGS_FILE must be $(id -un)'s with no group or other write (it is uid ${st% *}, mode ${st#* })"
mapfile -t ARGS < "$f" || refuse "cannot read $ARGS_FILE"
unit_refused() { refuse "$ARGS_FILE before --: $2"; }
serve_refused() { refuse "$ARGS_FILE after --: $2"; }
S_USER="" S_HOME="" A_BUSINESS="" NODE="" COPY_ID="" CLAUDE="" PROFILE="" PORT="" TESTER_SOCKET=0
copies=0 past=""
copies=0 past="" served=""
for ((i = 0; i < ${#ARGS[@]}; i++)); do
a="${ARGS[i]}" v="${ARGS[i + 1]:-}"
if [ -n "$past" ]; then
case "$a" in
--port) PORT="$v"; i=$((i + 1)) ;;
--business) [ "$v" = "$BUSINESS" ] || refuse "serve business differs"; i=$((i + 1)) ;;
--business|--org)
selector_take serve id serve_refused business_id_ok "$a" "$v"
[ "$v" = "$BUSINESS" ] || refuse "serve business differs"; served=1; i=$((i + 1)) ;;
--tester-socket) TESTER_SOCKET=$((TESTER_SOCKET + 1)) ;;
--tester-port) refuse "case1-accept.sh retires tester TCP; use --tester-socket" ;;
*) refuse "unknown serve flag $a" ;;
@@ -86,7 +101,7 @@ for ((i = 0; i < ${#ARGS[@]}; i++)); do
--pm) ;;
--user) S_USER="$v"; i=$((i + 1)) ;;
--home) S_HOME="$v"; i=$((i + 1)) ;;
--business) A_BUSINESS="$v"; i=$((i + 1)) ;;
--business|--org) selector_take unit id unit_refused business_id_ok "$a" "$v"; A_BUSINESS="$v"; i=$((i + 1)) ;;
--node) NODE="$v"; i=$((i + 1)) ;;
--claude) CLAUDE="$v"; i=$((i + 1)) ;;
--profile) PROFILE="$v"; i=$((i + 1)) ;;
@@ -95,6 +110,7 @@ for ((i = 0; i < ${#ARGS[@]}; i++)); do
esac
done
[ "$copies" = 1 ] || refuse "$ARGS_FILE needs exactly one --copy before --, not $copies"
[ -n "$served" ] || refuse "$ARGS_FILE needs --business or --org after --"
[[ "$S_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] && [[ "$S_HOME" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$A_BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] &&
[[ "$NODE" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$COPY_ID" =~ ^[0-9a-f]{40}$ ]] && [[ "$CLAUDE" =~ ^/[A-Za-z0-9/._+@-]+$ ]] ||
refuse "$ARGS_FILE lacks a plain --user, --home, --business, --node, --claude or --copy"
+25 -2
View File
@@ -5,6 +5,10 @@
# case2-install.sh --business ID --business-file FILE --commit SHA
# [--profile factory|library] [--personas DIR] [--home DIR]
#
# --org ID is the same option as --business ID, and --org-file FILE the same
# as --business-file FILE. They are two families: each spelling once, and a
# second spelling of a family must carry the same text.
#
# Steps, in order: refuse root; scripts/bootstrap.sh; stack-copy.sh copy
# SHA, whose npm ci installs the pinned harness; for library, the persona
# import's install from the staging DIR; the business file at 0600; for
@@ -39,11 +43,30 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
STACK_COPY="${CASE2_STACK_COPY:-$REPO/scripts/stack-copy.sh}"
usage() { echo 'usage: case2-install.sh --business ID --business-file FILE --commit SHA [--profile factory|library] [--personas DIR] [--home DIR]' >&2; exit 4; }
# shellcheck source=scripts/selector.sh
. "$REPO/scripts/selector.sh" || { echo "case2-install: cannot read $REPO/scripts/selector.sh" >&2; exit 4; }
# The selectors (rename design r2): the id family and the file family are separate scopes. A
# missing value is a usage error; a flag-shaped or invalid id is the business-id refusal below; a
# flag-shaped file, a repeated spelling and a conflict say why. All exit 4, as before.
id_refused() {
case "$1" in
missing) usage ;;
flag|invalid) echo "case2-install: --business must be a business id: $BUSINESS" >&2; exit 4 ;;
*) echo "case2-install: $2" >&2; exit 4 ;;
esac
}
file_refused() {
case "$1" in
missing) usage ;;
*) echo "case2-install: $2" >&2; exit 4 ;;
esac
}
business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]{0,63}$ ]]; }
PROFILE=library BUSINESS='' BFILE='' COMMIT='' PERSONAS='' HOME_ARG="${HOME:-}"
while [ $# -gt 0 ]; do
case "$1" in
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; shift 2 ;;
--business-file) [ $# -ge 2 ] || usage; BFILE="$2"; shift 2 ;;
--business|--org) BUSINESS="${2-}"; selector_take id id id_refused business_id_ok "$1" "${@:2:1}"; shift 2 ;;
--business-file|--org-file) selector_take file file file_refused - "$1" "${@:2:1}"; BFILE="$2"; shift 2 ;;
--commit) [ $# -ge 2 ] || usage; COMMIT="$2"; shift 2 ;;
--profile) [ $# -ge 2 ] || usage; PROFILE="$2"; shift 2 ;;
--personas) [ $# -ge 2 ] || usage; PERSONAS="$2"; shift 2 ;;
+15 -1
View File
@@ -5,6 +5,7 @@
// case2-persona-import.mjs stage --hermes-root DIR --pair PROFILE=NAME --pair PROFILE=NAME
// (--staging DIR | --dry-run)
// case2-persona-import.mjs install --staging DIR --config-dir DIR --business ID
// (--org ID is the same option)
//
// stage runs as whoever can read her Hermes home. Per profile it reads SOUL.md, the persona Hermes
// loads (agent/prompt_builder.py:1829-1855 at df5700ebe), and config.yaml for the per-channel prompt
@@ -31,6 +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";
// 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
@@ -67,9 +69,21 @@ class Usage extends Error {}
function args(argv) {
const out = { pairs: [] };
// --org and --business are one selector in these options (rename design r2). A missing value or
// one starting "--" is a usage error, as before; an id outside this script's grammar is its
// refusal, now while parsing; a repeated or conflicting selector is a usage error.
let given;
const selector = selectorScope({
valid: (id) => BUSINESS_ID.test(id),
refuse: (kind, why) => {
if (kind === "invalid" || (kind === "flag" && !given.startsWith("--"))) throw new Refusal(`business ${given} isn't a business id`);
throw new Usage(why);
},
});
for (let i = 0; i < argv.length; i++) {
const flag = argv[i];
if (flag === "--dry-run") { out.dryRun = true; continue; }
if (selector.owns(flag)) { given = argv[i + 1]; selector.take(flag, given); i++; continue; }
const value = argv[i + 1];
if (value === undefined || value.startsWith("--")) throw new Usage(`${flag} needs a value`);
i++;
@@ -77,9 +91,9 @@ function args(argv) {
else if (flag === "--hermes-root") out.hermesRoot = value;
else if (flag === "--staging") out.staging = value;
else if (flag === "--config-dir") out.configDir = value;
else if (flag === "--business") out.business = value;
else throw new Usage(`unknown option ${flag}`);
}
if (selector.given) out.business = selector.value;
return out;
}
+16 -1
View File
@@ -7,6 +7,8 @@
# case2-precheck.sh --business ID [--profile factory|library]
# case2-precheck.sh --business ID --library-status
#
# --org ID is the same option as --business ID.
#
# Checks: the config and business file validate; `sudo -n true` fails and
# `id -G` prints one group; bus.tester is off for library and on for factory,
# the explicit notifier config is valid at 0600 in a private 0700 directory;
@@ -53,11 +55,24 @@ WEBUI_UNIT="$UNIT_DIR/mosaic-webui.service"
MARKER_TOKEN_CEILING=2000
usage() { echo 'usage: case2-precheck.sh --business ID [--profile factory|library] | --business ID --library-status' >&2; exit 4; }
# shellcheck source=scripts/selector.sh
. "$REPO/scripts/selector.sh" || { echo "case2-precheck: cannot read $REPO/scripts/selector.sh" >&2; exit 4; }
# --org and --business are one selector here (rename design r2). A missing value is a usage error,
# a flag-shaped or invalid one the business-id refusal below, and a repeated or conflicting one
# says why; all exit 4, as before.
selector_refused() {
case "$1" in
missing) usage ;;
flag|invalid) echo "case2-precheck: --business must be a business id: $BUSINESS" >&2; exit 4 ;;
*) echo "case2-precheck: $2" >&2; exit 4 ;;
esac
}
business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]]; }
PROFILE=library PROFILE_SET=0 BUSINESS='' STATUS=0
while [ $# -gt 0 ]; do
case "$1" in
--profile) [ $# -ge 2 ] || usage; PROFILE="$2"; PROFILE_SET=1; shift 2 ;;
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; shift 2 ;;
--business|--org) BUSINESS="${2-}"; selector_take args id selector_refused business_id_ok "$1" "${@:2:1}"; shift 2 ;;
--library-status) STATUS=1; shift ;;
*) usage ;;
esac
+43 -6
View File
@@ -9,6 +9,12 @@
# [--profile factory|library] [--proxy] [--pm] [--claude PATH]
# -- SERVE FLAGS
#
# --org ID is the same option as --business ID (rename design r2), in each of
# two scopes: this script's own flags before --, and the serve flags after it.
# Each scope takes each spelling once, and a second spelling the same value;
# nothing is counted across --. The serve flags must name the selector, it must
# be the unit's business, and the unit renders it as --business ID.
#
# Root runs it from a root-owned checkout of the reviewed commit outside
# <home>, never from the release, because <user> can write the release. It
# writes the bus and Console units (and the proxy unit under --proxy) under
@@ -77,14 +83,35 @@ abs_plain() {
[ "$1" = / ] || [ "${1%/}" = "$1" ] || die "$2 must not end in /: $1"
}
# shellcheck source=scripts/selector.sh
. "$REPO/scripts/selector.sh"
business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]]; }
# The outer selector: a missing value is a usage error, a flag-shaped or invalid one the
# business-id refusal, and a repeated or conflicting one a usage error that says why.
outer_refused() {
case "$1" in
missing) usage ;;
flag|invalid) die "--business must be a business id: $BUSINESS" ;;
*) echo "case2-unit: $2" >&2; exit 4 ;;
esac
}
# The serve selector: every refusal names the serve flag, as the other serve flags do.
serve_refused() {
case "$1" in
missing) die "serve flag $SERVE_NAME needs a value" ;;
flag|invalid) die "serve $SERVE_NAME $SERVE_VALUE is not the unit's business $BUSINESS" ;;
*) die "serve $2" ;;
esac
}
PROFILE=library USER_NAME='' HOME_DIR='' BUSINESS='' COPY_ID='' NODE='' PROXY=0 PM=0 CLAUDE='' CLAUDE_SET=0
while [ $# -gt 0 ]; do
case "$1" in
--profile|--user|--home|--business|--copy|--node|--claude)
--business|--org) BUSINESS="${2-}"; selector_take outer id outer_refused business_id_ok "$1" "${@:2:1}"; shift 2 ;;
--profile|--user|--home|--copy|--node|--claude)
[ $# -ge 2 ] || usage
case "$1" in
--profile) PROFILE="$2" ;; --user) USER_NAME="$2" ;; --home) HOME_DIR="$2" ;;
--business) BUSINESS="$2" ;; --copy) COPY_ID="$2" ;; --node) NODE="$2" ;;
--copy) COPY_ID="$2" ;; --node) NODE="$2" ;;
--claude) CLAUDE="$2"; CLAUDE_SET=1 ;;
esac
shift 2 ;;
@@ -346,7 +373,7 @@ tester_location() {
serve_flags() {
local -A seen=()
local flags='' name value
local port='' public_host='' auth_header='' allow_user='' proof_header='' proof_file='' tester_socket=0 sso=0
local port='' public_host='' auth_header='' allow_user='' proof_header='' proof_file='' tester_socket=0 sso=0 selected=0
local i=0 n=${#SERVE[@]}
while [ "$i" -lt "$n" ]; do
name="${SERVE[$i]}"
@@ -357,7 +384,7 @@ serve_flags() {
--board) die "refusing --board: Case 2 runs no control board" ;;
--sso) [ "$PROFILE" = factory ] || die "refusing --sso: the Console signs people in through the proxy, with the preview set and the proof pair (Q175 phase 2 is a follow-up)" ;;
--send) [ "$PROFILE" = factory ] || die "the library profile is read-only; refusing --send until Case 2 supports --sso (#412)" ;;
--port|--business) ;;
--port|--business|--org) ;;
--public-host|--auth-header|--allow-user|--proof-header|--proof-file)
[ "$PROFILE" = library ] || die "the factory profile refuses $name: its host has no proxy" ;;
*) die "unknown serve flag: $name" ;;
@@ -369,10 +396,20 @@ serve_flags() {
if [ "$name" = --send ]; then flags="$flags --send"; i=$((i + 1)); continue; fi
[ $((i + 1)) -lt "$n" ] || die "serve flag $name needs a value"
value="${SERVE[$((i + 1))]}"
if [ "$name" = --business ] || [ "$name" = --org ]; then
# One selector in the serve scope, bound to the unit's business, rendered once
# as --business where it first appears.
SERVE_NAME="$name" SERVE_VALUE="$value"
selector_take serve id serve_refused business_id_ok "$name" "$value"
[ "$value" = "$BUSINESS" ] || die "serve $name $value is not the unit's business $BUSINESS"
[ "$selected" = 1 ] || flags="$flags --business $value"
selected=1
i=$((i + 2))
continue
fi
case "$name" in
--port) port="$value"
[[ "$port" =~ ^[1-9][0-9]{0,3}$ ]] && [ "$port" -le 1023 ] || die "--port must be 1..1023, so only the unit's capability can bind it: $port" ;;
--business) [ "$value" = "$BUSINESS" ] || die "serve --business $value is not the unit's business $BUSINESS" ;;
--public-host) public_host="$value"
case "$value" in [A-Za-z0-9]*) ;; *) die "public host must be a hostname, no port or scheme: $value" ;; esac
case "$value" in *[!A-Za-z0-9.-]*) die "public host must be a hostname, no port or scheme: $value" ;; esac ;;
@@ -392,7 +429,7 @@ serve_flags() {
local required=(--port --business)
[ "$PROFILE" = factory ] || required+=(--public-host --auth-header --allow-user --proof-header --proof-file)
for name in "${required[@]}"; do
[ -n "${seen[$name]:-}" ] || die "serve flag $name is required"
[ -n "${seen[$name]:-}" ] || { [ "$name" = --business ] && [ -n "${seen[--org]:-}" ]; } || die "serve flag $name is required"
done
if [ "$PROFILE" = factory ]; then
# #415: only the paired owner sign-in and writes. No preview header or
+8 -1
View File
@@ -6,6 +6,10 @@
# [--subject TEXT] [--attribution LINE] [--business ID]
# [--project ID]
#
# --org ID is --business ID (rename r2): each spelling once, both only with one value. A missing,
# empty or flag-shaped value exits 4 before the config is read. The id keeps its old grammar: any
# other text names businesses/<ID>.json, and a missing file exits 3.
#
# Checks, in order; each refusal names its reason on stderr and exits 2:
# bus-schema the bus database's schema isn't the Store's
# decision-not-found no decision with that id and one raise in the business
@@ -48,6 +52,9 @@ REPO="$(cd "$SCRIPT_DIR/.." && pwd)"
usage() { echo "usage: land-work.sh <decision-id> [--base refactor] [--dry-run] [--subject TEXT] [--attribution LINE] [--business ID] [--project ID]" >&2; exit 4; }
fail() { echo "land-work: $1" >&2; exit "${2:-1}"; }
refuse() { echo "land-work: refused: $1${2:+: $2}" >&2; exit 2; }
# shellcheck source=scripts/selector.sh
. "$SCRIPT_DIR/selector.sh" || fail "cannot read $SCRIPT_DIR/selector.sh" 4
lw_selector_refused() { fail "$2" 4; }
DECISION=""; BASE="refactor"; DRY_RUN="no"; SUBJECT=""; ATTRIBUTION=""
BUSINESS=""; PROJECT=""
@@ -57,7 +64,7 @@ while [ $# -gt 0 ]; do
--dry-run) DRY_RUN="yes"; shift ;;
--subject) [ $# -ge 2 ] || usage; SUBJECT="$2"; shift 2 ;;
--attribution) [ $# -ge 2 ] || usage; ATTRIBUTION="$2"; shift 2 ;;
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; shift 2 ;;
--business|--org) selector_take args id lw_selector_refused - "$1" "${@:2:1}"; BUSINESS="$2"; shift 2 ;;
--project) [ $# -ge 2 ] || usage; PROJECT="$2"; shift 2 ;;
-*) usage ;;
*) [ -z "$DECISION" ] || usage; DECISION="$1"; shift ;;
+52
View File
@@ -0,0 +1,52 @@
# Sourced, not run: organization selector options, the shell half of
# packages/business/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
# (file). A scope is one parser's own options, so the caller's own loop decides which tokens are
# selectors; text after a separator or a value another option consumes is never passed here. In a
# scope each spelling may appear once, and a second spelling must carry exactly the same value.
#
# selector_take SCOPE FAMILY REFUSE VALID FLAG [VALUE]
# SCOPE [a-z0-9_]+, the scope's name; each keeps its own spellings and value
# FAMILY id or file
# REFUSE a function called as REFUSE KIND MESSAGE that must exit; KIND is missing, flag,
# repeated, invalid or differ, so each script keeps its exit codes and wording
# VALID a predicate called as VALID VALUE, or - when the caller checks the value itself
# FLAG the spelling given
# VALUE absent when the argument list ended there; pass "${@:2:1}" from a loop on "$@"
# selector_value SCOPE prints the scope's value, nothing when none was given
#
# A missing or empty value, one starting with "-", one VALID refuses, a repeated spelling and two
# spellings with different values all refuse. File values are compared as given, not as paths.
selector__bug() { echo "selector.sh: $*" >&2; exit 70; }
selector_take() {
[ $# -ge 5 ] && [ $# -le 6 ] || selector__bug "selector_take takes 5 or 6 arguments, not $#"
local scope="$1" family="$2" refuse="$3" valid="$4" flag="$5"
[[ "$scope" =~ ^[a-z0-9_]+$ ]] || selector__bug "bad scope name $scope"
case "$family:$flag" in
id:--business|id:--org|file:--business-file|file:--org-file) ;;
*) selector__bug "$flag is not a $family selector" ;;
esac
local seen_name="SELECTOR_SEEN_$scope" value_name="SELECTOR_VALUE_$scope"
local seen="${!seen_name-}" current="${!value_name-}" first
case " $seen " in *" $flag "*) "$refuse" repeated "$flag given twice"; selector__bug "$refuse returned" ;; esac
if [ $# -lt 6 ] || [ -z "$6" ]; then "$refuse" missing "$flag needs one value"; selector__bug "$refuse returned"; fi
local value="$6"
case "$value" in -*) "$refuse" flag "$flag needs one value, not $value"; selector__bug "$refuse returned" ;; esac
if [ "$valid" != - ] && ! "$valid" "$value"; then "$refuse" invalid "$flag $value is not valid"; selector__bug "$refuse returned"; fi
first="${seen%% *}"
if [ -n "$seen" ] && [ "$current" != "$value" ]; then
"$refuse" differ "$first $current and $flag $value conflict"; selector__bug "$refuse returned"
fi
printf -v "$seen_name" '%s' "${seen:+$seen }$flag"
printf -v "$value_name" '%s' "$value"
}
selector_value() {
[[ "${1-}" =~ ^[a-z0-9_]+$ ]] || selector__bug "bad scope name ${1-}"
local value_name="SELECTOR_VALUE_$1"
printf '%s' "${!value_name-}"
}
+6 -1
View File
@@ -3,6 +3,7 @@
# copy creates files only; wrapper is a separately Mos-gated live step.
# wrapper and check take --instance NAME (row 190): the units mosaic-bus-NAME@ and mosaic-webui-NAME
# and the wrapper $BIN/mosaic-NAME, which sets NAME's MOSAIC_CONFIG. check needs --business for NAME.
# check takes --org as --business (rename r2): each spelling once, and both only with one value.
# Row 232: check --units external reads root's system units. copy --bundle makes the copy from a git
# bundle on a host with no checkout, and wrapper --create writes the first wrapper on a new host.
set -euo pipefail
@@ -15,10 +16,13 @@ MARKER='# mosaic-cli wrapper (Q81, agents/sage/work/q81/mosaic-cli.sh)'
usage() { echo 'usage: stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree] | copy --bundle FILE SHA [--copies DIR] | wrapper ID [--instance NAME] [--copies DIR] [--bin DIR] [--create] | check [--instance NAME] [--copies DIR] [--bin DIR] [--business ID] [--units user|external]' >&2; exit "${1:-4}"; }
die() { echo "stack-copy: $*" >&2; exit 1; }
plain_path() { case "$1" in /*) ;; *) die "$2 must be an absolute path: $1" ;; esac; case "$1" in *[!A-Za-z0-9/._+-]*) die "$2 contains a character the unit cannot carry: $1" ;; esac; }
# Selector refusals: a bad shape is a usage error (4); an invalid id exits 1, as check's own test does.
copy_selector_refused() { case "$1" in invalid) die "$2" ;; *) echo "stack-copy: $2" >&2; exit 4 ;; esac; }
copy_business_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]*$ ]]; }
parse() { while [ $# -gt 0 ]; do case "$1" in
--copies) [ $# -ge 2 ] || usage; COPIES="$2"; shift 2;;
--bin) [ "$CMD" != copy ] && [ $# -ge 2 ] || usage; BIN="$2";shift 2;;
--business) [ "$CMD" = check ] && [ $# -ge 2 ] || usage; BUSINESS="$2";BUSINESS_SET=1;shift 2;;
--business|--org) [ "$CMD" = check ] || usage; selector_take check id copy_selector_refused copy_business_ok "$1" "${@:2:1}"; BUSINESS="$2";BUSINESS_SET=1;shift 2;;
--allow-worktree) [ "$CMD" = copy ] && [ -z "$BUNDLE" ] || usage; ALLOW_WORKTREE=1;shift;;
--units) [ "$CMD" = check ] && [ $# -ge 2 ] || usage; case "$2" in user|external) UNITS="$2";; *) usage;; esac; shift 2;;
--create) [ "$CMD" = wrapper ] || usage; CREATE=1;shift;;
@@ -179,6 +183,7 @@ check() {
}
# Sourced (stack-release.sh): define the functions, run nothing.
[ "${BASH_SOURCE[0]}" = "$0" ] || return 0
. "$REPO/scripts/selector.sh" || die "cannot read $REPO/scripts/selector.sh"
. "$REPO/scripts/instance.sh"
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
+36 -7
View File
@@ -12,10 +12,13 @@
# Root runs it from a root-owned checkout of the reviewed commit, never from a copy, because the
# stack's user can write the copies. The unit values come from /etc/mosaic-stack/<instance>.args,
# which I2's install writes: one case2-unit.sh argument per line, the serve flags after `--`. Only
# --copy changes, and the file changes only after confirm passes. The stack's user runs its steps
# from its own clone, <home>/src/mosaic-stack, through sudo. Root reads none of that user's output
# but exit codes and the rehearsal's BACKUP_STORE line (sage-1653), which it copies into its step
# log after a match against a fixed pattern and decides nothing on.
# --copy changes, and the file changes only after confirm passes. --org is the same option as
# --business in each of the file's two scopes, case2-unit.sh's own flags before `--` and the serve
# flags after it: each scope takes each spelling once, a second spelling only with the same value,
# and both scopes must name the business, the same one (rename design r2). The stack's user runs
# its steps from its own clone, <home>/src/mosaic-stack, through sudo. Root reads none of that user's
# output but exit codes and the rehearsal's BACKUP_STORE line (sage-1653), which it copies into its
# step log after a match against a fixed pattern and decides nothing on.
#
# --no-rehearsal is for a stack whose log records no backup. The rehearsal runs either way: exit 3
# from it (no-backup) goes on only with the flag, and exit 0 with the flag refuses
@@ -119,6 +122,12 @@ logl "start rc=0 argv:$(printf ' %q' "$0" "${ARGV[@]}")"
# --- The args file and the stack's user ---
# The selector scopes: selector.sh defines functions only, from this root-owned checkout, as RENDER is.
. "$REPO/scripts/selector.sh" || refuse checkout-unreadable "cannot read $REPO/scripts/selector.sh"
args_business_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]*$ ]]; }
args_outer_refused() { refuse args-unreadable "$ETC/$INSTANCE.args before --: $2"; }
args_serve_refused() { refuse args-unreadable "$ETC/$INSTANCE.args after --: $2"; }
# I2's file: one case2-unit.sh argument per line, owned by root with no group or other write. Sets
# ARGS (the lines), S_USER, S_HOME, BUSINESS, NODE, ARGS_COPY, ARGS_PROFILE and ARGS_PROXY, and the paths derived
# from them.
@@ -126,8 +135,12 @@ logl "start rc=0 argv:$(printf ' %q' "$0" "${ARGV[@]}")"
# --profile wins, as there), and the current one refuses it. A2 would stop both units, and A4 and
# render-old would both fail on the same args, so a library file holding --send after -- refuses
# here, before anything stops. The file is never rewritten; the operator removes the line.
# Rename r2: case2-unit.sh also refuses a file whose serve flags name no selector or another
# business, so that refuses here too. The serve flags are read with case2-unit.sh's grammar, which
# says which of them take a value, so a value is never taken for a selector; a token that grammar
# lacks refuses, as the render would.
read_args() {
local f="$ETC/$INSTANCE.args" st i copies=0 v profile=library
local f="$ETC/$INSTANCE.args" st i sep copies=0 v profile=library served
[ -f "$f" ] && [ ! -L "$f" ] || refuse args-unreadable "no regular file $f"
st="$(stat -c '%u %a' "$f")" || refuse args-unreadable "cannot stat $f"
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) || refuse args-unreadable "$f must be $(id -un)'s with no group or other write (it is uid ${st% *}, mode ${st#* })"
@@ -138,10 +151,13 @@ read_args() {
--) break ;;
--pm) ;;
--proxy) ARGS_PROXY=1 ;;
--user|--home|--business|--copy|--node|--profile|--claude)
--business|--org)
selector_take outer id args_outer_refused args_business_ok "${ARGS[i]}" "${ARGS[i + 1]:-}"
BUSINESS="${ARGS[i + 1]}"; i=$((i + 1)) ;;
--user|--home|--copy|--node|--profile|--claude)
v="${ARGS[i + 1]:-}"
case "${ARGS[i]}" in
--user) S_USER="$v" ;; --home) S_HOME="$v" ;; --business) BUSINESS="$v" ;; --node) NODE="$v" ;;
--user) S_USER="$v" ;; --home) S_HOME="$v" ;; --node) NODE="$v" ;;
--copy) ARGS_COPY="$v"; copies=$((copies + 1)) ;;
--profile) profile="$v" ;;
esac
@@ -149,6 +165,7 @@ read_args() {
*) refuse args-unreadable "$f has ${ARGS[i]} before --, which case2-unit.sh does not take" ;;
esac
done
sep=$i
[ "$copies" = 1 ] || refuse args-unreadable "$f needs exactly one --copy before --, not $copies"
ARGS_PROFILE="$profile"
if [ "$profile" = library ]; then
@@ -160,6 +177,18 @@ read_args() {
[[ "$S_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] && [[ "$S_HOME" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] &&
[[ "$NODE" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$ARGS_COPY" =~ ^[0-9a-f]{40}$ ]] ||
refuse args-unreadable "$f lacks a plain --user, --home, --business, --node or --copy"
for ((i = sep + 1; i < ${#ARGS[@]}; i++)); do
case "${ARGS[i]}" in
--business|--org)
selector_take serve id args_serve_refused args_business_ok "${ARGS[i]}" "${ARGS[i + 1]:-}"; i=$((i + 1)) ;;
--port|--public-host|--auth-header|--allow-user|--proof-header|--proof-file) i=$((i + 1)) ;;
--tester-socket|--sso|--send) ;;
*) refuse args-unreadable "$f has ${ARGS[i]} after --, which case2-unit.sh does not take" ;;
esac
done
served="$(selector_value serve)"
[ -n "$served" ] || refuse args-unreadable "$f needs --business or --org after --, as case2-unit.sh does"
[ "$served" = "$BUSINESS" ] || refuse args-unreadable "$f names business $BUSINESS before -- and $served after it"
COPIES_DIR="$S_HOME/.local/share/mosaic-stack/copies"
CLONE="$S_HOME/src/mosaic-stack"
SR="$CLONE/scripts/stack-release.sh"
+40
View File
@@ -242,6 +242,46 @@ cp "$SANDBOX/c/args/businesses/fx.json" "$SANDBOX/c/nobus/businesses/"
sed "s#$SANDBOX/c/args/data#$SANDBOX/c/nobus/data#" "$SANDBOX/c/args/config.json" > "$SANDBOX/c/nobus/config.json"
expect nobus 3 "" "$D"
# ---- rename r2: --org is --business, each spelling once ----
# Against a missing config: exit 4 and the message mean the parse refused before any read (3 otherwise).
sel() { # want-rc want-stderr args...
local want="$1" text="$2" rc=0 err
shift 2
err="$(MOSAIC_CONFIG="$SANDBOX/missing.json" TMPDIR="$SANDBOX/c/args/tmp" bash scripts/land-work.sh "$@" 2>&1 >/dev/null)" || rc=$?
check "selector: $* exits $want: $text (got $rc: $err)" "$([ "$rc" -eq "$want" ] && [ "$err" = "$text" ]; echo $?)"
}
sel 4 "land-work: --business fx and --org other conflict" "$D" --business fx --org other
sel 4 "land-work: --org other and --business fx conflict" "$D" --org other --business fx
sel 4 "land-work: --business given twice" "$D" --business fx --business fx
sel 4 "land-work: --org given twice" "$D" --org fx --org fx
sel 4 "land-work: --org given twice" "$D" --org fx --business fx --org fx
sel 4 "land-work: --org needs one value" "$D" --org
sel 4 "land-work: --business needs one value" "$D" --business ""
sel 4 "land-work: --org needs one value, not --dry-run" "$D" --org --dry-run
sel 4 "land-work: --business needs one value, not -fx" "$D" --business -fx
USAGE="$(bash scripts/land-work.sh 2>&1)"
sel 4 "$USAGE" --org fx
sel 4 "$USAGE" "$D" --org fx extra
# A value another option takes is text, not a selector: the config read is what refuses.
rc=0; err="$(MOSAIC_CONFIG="$SANDBOX/missing.json" TMPDIR="$SANDBOX/c/args/tmp" bash scripts/land-work.sh "$D" --subject --org 2>&1 >/dev/null)" || rc=$?
check "selector: --subject --org is a subject, and the config read refuses (got $rc)" \
"$([ "$rc" -eq 3 ] && [ "${err##*$'\n'}" = "land-work: system config is missing or invalid" ]; echo $?)"
# Each spelling alone and both together name the business file read.
# land-work never had an id grammar and keeps none, so text that is not an id is a missing file too.
for args in "--business other" "--org other" "--org other --business other" "--org Other" "--business ../other"; do
# shellcheck disable=SC2086
expect args 3 "" "$D" $args
check "selector: $args names businesses/${args##* }.json" \
"$([ "$(tail -n1 "$SANDBOX/c/args/err")" = "land-work: no business file: $SANDBOX/c/args/businesses/${args##* }.json" ]; echo $?)"
done
expect args 2 decision-not-found "d0000000-0000-4000-8000-00000000000f" --org fx
expect args 2 decision-not-found "d0000000-0000-4000-8000-00000000000f" --business fx --org fx
expect args 2 decision-not-found "d0000000-0000-4000-8000-00000000000f" --org fx --business fx
setup dry-org ok 42 "$W_OK"
expect dry-org 0 "" "$D" --dry-run --org fx
grep -q "no ref was written" "$SANDBOX/c/dry-org/out"
check "selector: --org fx runs the dry run" "$?"
# ---- the bus database ----
setup schema-table ok 42 "$W_OK"
node -e 'const {DatabaseSync}=require("node:sqlite"); const db=new DatabaseSync(process.argv[1]); db.exec("CREATE TABLE extra(x)"); db.close()' \
+14 -1
View File
@@ -83,6 +83,8 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in"
# shellcheck source=scripts/instance.sh
. "$REPO/scripts/instance.sh"
# shellcheck source=scripts/selector.sh
. "$REPO/scripts/selector.sh"
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
if [ "${1:-}" = copy ] && [ -n "$INSTANCE_FLAG" ]; then
@@ -99,6 +101,17 @@ USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-workt
die() { echo "webui-service: $*" >&2; exit 1; }
usage() { echo "$USAGE" >&2; exit 4; }
# --org and --business are one selector in these flags (rename design r2). A missing value is a
# usage error and a flag-shaped or invalid one the business-id refusal serve_flags makes, worded
# as before; a repeated or conflicting selector is a usage error that says why.
selector_refused() {
case "$1" in
missing) usage ;;
flag|invalid) die "business must be a business id: $BUSINESS" ;;
*) echo "webui-service: $2" >&2; exit 4 ;;
esac
}
business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]]; }
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
@@ -135,7 +148,7 @@ parse_flags() {
--port) [ $# -ge 2 ] || usage; PORT="$2"; PORT_SET=1; SERVE_SET=1; shift 2 ;;
--tester-socket) TESTER_SOCKET=1; SERVE_SET=1; shift ;;
--tester-port) [ $# -ge 2 ] || usage; TESTER_PORT="$2"; TESTER_SET=1; SERVE_SET=1; shift 2 ;;
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; SERVE_SET=1; shift 2 ;;
--business|--org) BUSINESS="${2-}"; selector_take flags id selector_refused business_id_ok "$1" "${@:2:1}"; SERVE_SET=1; shift 2 ;;
--board) [ $# -ge 2 ] || usage; BOARD="$2"; SERVE_SET=1; shift 2 ;;
--public-host) [ $# -ge 2 ] || usage; PUBLIC_HOST="$2"; SERVE_SET=1; shift 2 ;;
--auth-header) [ $# -ge 2 ] || usage; AUTH_HEADER="$2"; SERVE_SET=1; shift 2 ;;